"""스윙 포지션 관리 -- DB 조회 전용 strategy_type='swing' 필터를 적용해 데이트레이딩 포지션과 완전히 분리. 모든 함수는 읽기 전용 -- 쓰기는 db_worker.py의 큐를 통해서만 수행. DB 테이블 매핑: - Portfolio: 보유 종목 (strategy_type 컬럼으로 swing/daytrading 구분) - ActiveTrade: 활성 매매 (strategy_name 컬럼으로 구분, 분할 청산 상태 추적) 사용 위치: - exit_service.py: 청산 대상 포지션 조회 - entry_service.py: 포지션 한도 체크 - scheduler.py: 스윙 스케줄러 사이클 """ import logging from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.models.models import Portfolio, ActiveTrade # [§8.5 항목3] DT 제외 필터는 db_worker 를 정본으로 공유한다. # (db_worker 는 서비스 모듈을 모듈 수준에서 import 하지 않으므로 순환 참조 없음) from app.services.db_worker import DT_STRATEGY_NAMES, not_dt_strategy_clause # noqa: F401 logger = logging.getLogger(__name__) # 스윙 전략 식별자 -- Portfolio.strategy_type과 매칭 STRATEGY_TYPE = "swing" async def get_swing_holdings(db: AsyncSession) -> list: """strategy_type='swing'인 보유 종목 목록 반환. Portfolio 테이블에서 수량 > 0이고 strategy_type='swing'인 레코드 조회. Args: db: AsyncSession 인스턴스 Returns: list[Portfolio]: 스윙 보유 종목 ORM 객체 목록 """ result = await db.execute( select(Portfolio).where( Portfolio.quantity > 0, Portfolio.strategy_type == STRATEGY_TYPE, ) ) holdings = result.scalars().all() # [2026-10-09 하드닝 R1] 어느 청산 엔진에도 속하지 않는 보유분 탐지(경보 전용). # 경보는 리스크 가드가 아니므로 실패해도 청산 대상 반환을 막지 않는다(청산 우선 §8.1-11). try: await detect_strategy_tag_mismatch_v2(db) except Exception as e: logger.warning(f"[SwingPM] 전략 태그 불일치 탐지 실패(청산 대상 조회는 계속): {e}") return holdings # ── [2026-10-09 하드닝 R1] Portfolio 태그 ↔ ActiveTrade 전략명 불일치 탐지 ───────── # 사례: 105560 3주가 Portfolio.strategy_type='daytrading' + active ActiveTrade # strategy_name='synced'(비-DT). get_swing_holdings 는 'swing' 태그만 보므로 스윙 청산이 # 보지 않고, DT 엔진은 인메모리 포지션만 관리하므로(당일 매수분은 EOD 청산) 잔여 수량에는 # **손절이 걸려 있지 않다**. 자동 편입(스윙 청산 대상으로 승격)은 과다매도·소유권 규칙 # (trade_executor._sell_ownership_block_reason_v2) 충돌 위험이 있어 하지 않고, error 경보로 # 운영자가 KIS 실보유를 확인하게 한다. def find_strategy_tag_mismatches_v2( portfolio_rows, active_rows, dt_inmemory_qty: dict | None = None, ) -> list[dict]: """순수 함수 — DT 태그 Portfolio 행 중 비-DT active ActiveTrade 가 걸려 있고, DT 인메모리 수량이 보유 수량을 덮지 못하는(= 관리 주체 없는 수량이 있는) 종목. Args: portfolio_rows: Portfolio 유사 객체(stock_code, quantity, strategy_type) active_rows: status='active' ActiveTrade 유사 객체(stock_code, strategy_name, id) dt_inmemory_qty: {stock_code: DT 인메모리 보유 수량} Returns: [{stock_code, portfolio_qty, portfolio_tag, dt_inmemory_qty, unmanaged_qty, active_trade_ids, active_strategy_names}] """ dt_mem = dt_inmemory_qty or {} non_dt_active: dict[str, list] = {} for a in active_rows or []: name = getattr(a, "strategy_name", None) if name in DT_STRATEGY_NAMES: continue non_dt_active.setdefault(getattr(a, "stock_code", None), []).append(a) out: list[dict] = [] for p in portfolio_rows or []: if getattr(p, "strategy_type", None) not in DT_STRATEGY_NAMES: continue try: qty = int(getattr(p, "quantity", 0) or 0) except (TypeError, ValueError): qty = 0 if qty <= 0: continue code = getattr(p, "stock_code", None) rows = non_dt_active.get(code) if not rows: continue try: mem = max(0, int(dt_mem.get(code, 0) or 0)) except (TypeError, ValueError): mem = 0 unmanaged = qty - min(mem, qty) if unmanaged <= 0: continue out.append({ "stock_code": code, "portfolio_qty": qty, "portfolio_tag": getattr(p, "strategy_type", None), "dt_inmemory_qty": mem, "unmanaged_qty": unmanaged, "active_trade_ids": [getattr(r, "id", None) for r in rows], "active_strategy_names": [getattr(r, "strategy_name", None) for r in rows], }) return out def _dt_inmemory_qty_snapshot_v2() -> dict: """DT 인메모리 보유 수량 스냅샷(읽기 전용). DT 상태를 못 읽으면 빈 dict — 그 경우 DT 태그 수량 전부를 '관리 주체 없음'으로 보아 경보 쪽으로 기운다.""" try: from app.services.daytrading.position_manager import day_trading_state return { code: int((pos or {}).get("quantity", 0) or 0) for code, pos in dict(day_trading_state.positions).items() } except Exception as e: logger.warning(f"[SwingPM] DT 인메모리 수량 조회 실패 — 빈 스냅샷으로 판정: {e}") return {} async def detect_strategy_tag_mismatch_v2(db: AsyncSession) -> list[dict]: """DB SELECT 2회(쓰기 없음) + DT 인메모리 대조 → 불일치 종목을 error 로그(종목별 레이트리밋). 반환값은 테스트·상태 조회용. 자동 수정은 하지 않는다(운영자 확인 필요). """ p_res = await db.execute( select(Portfolio).where( Portfolio.quantity > 0, Portfolio.strategy_type.in_(DT_STRATEGY_NAMES), ) ) p_rows = p_res.scalars().all() if not p_rows: return [] codes = [getattr(p, "stock_code", None) for p in p_rows] a_res = await db.execute( select(ActiveTrade).where( ActiveTrade.status == "active", ActiveTrade.stock_code.in_(codes), not_dt_strategy_clause(ActiveTrade.strategy_name), ) ) a_rows = a_res.scalars().all() found = find_strategy_tag_mismatches_v2(p_rows, a_rows, _dt_inmemory_qty_snapshot_v2()) if found: from app.core.log_throttle import should_log for m in found: ok, sup = should_log(f"swing_pm:tag_mismatch:{m['stock_code']}") if not ok: continue logger.error( f"[SwingPM] 🚨 {m['stock_code']}: Portfolio 태그 '{m['portfolio_tag']}' " f"{m['portfolio_qty']}주인데 비-DT active ActiveTrade " f"{m['active_strategy_names']}(id={m['active_trade_ids']}) 존재, DT 인메모리 " f"{m['dt_inmemory_qty']}주 → {m['unmanaged_qty']}주가 스윙·DT 어느 청산 엔진에도 " f"속하지 않아 손절 없음. KIS 실보유 확인 후 태그/ActiveTrade 수동 정리 필요" + (f" (+{sup}건 억제)" if sup else "") ) return found async def get_swing_active_trades(db: AsyncSession) -> list: """활성 스윙 ActiveTrade 목록 반환. DT 계열(DT_STRATEGY_NAMES) 제외 조건으로 데이트레이딩 포지션만 걸러낸다. 분할 청산 진행 상태(remaining_pct), 최고가(highest_since_entry) 포함. [2026-09-27 §8.5 항목3] 기존 `strategy_name != "day_trade"` 는 SQL 3값 논리로 strategy_name IS NULL 인 legacy 스윙 행까지 함께 제외했고, 'daytrading' 전략명은 반대로 통과시켰다 → not_dt_strategy_clause() 로 통일. Args: db: AsyncSession 인스턴스 Returns: list[ActiveTrade]: 활성 스윙 매매 ORM 객체 목록 """ result = await db.execute( select(ActiveTrade).where( ActiveTrade.status == "active", not_dt_strategy_clause(ActiveTrade.strategy_name), ) ) return result.scalars().all() async def get_swing_active_trade(db: AsyncSession, stock_code: str) -> ActiveTrade | None: """특정 종목의 스윙 ActiveTrade 반환. 분할 청산 상태(remaining_pct)와 최고가(highest_since_entry) 조회용. 청산 로직에서 전략별 분기(모멘텀/평균회귀)에도 사용. Args: db: AsyncSession 인스턴스 stock_code: 종목 코드 Returns: ActiveTrade | None: 활성 매매 ORM 객체, 없으면 None """ # [2026-09-23] scalar_one_or_none() 은 행이 2개 이상이면 MultipleResultsFound 를 던지고, # 호출측 except 가 이를 삼켜 해당 종목의 청산(손절 포함)이 통째로 스킵됐다. # db_worker legacy 경로와 동일하게 가장 최근 행을 사용한다. # [2026-09-27 §8.5 항목3] strategy_name IS NULL 인 legacy 스윙 행이 # `!= "day_trade"` 로 탈락하던 문제 + 'daytrading' 누락을 동시에 수정. result = await db.execute( select(ActiveTrade) .where( ActiveTrade.stock_code == stock_code, ActiveTrade.status == "active", not_dt_strategy_clause(ActiveTrade.strategy_name), ) .order_by(ActiveTrade.id.desc()) .limit(1) ) return result.scalars().first() async def count_swing_positions(db: AsyncSession) -> int: """현재 스윙 보유 종목 수 반환. swing/entry_service.MAX_SWING_POSITIONS(현재 20) 한도 체크에 사용. Args: db: AsyncSession 인스턴스 Returns: int: 스윙 보유 종목 수 """ from sqlalchemy import func result = await db.execute( select(func.count(Portfolio.id)).where( Portfolio.quantity > 0, Portfolio.strategy_type == STRATEGY_TYPE, ) ) return result.scalar_one() or 0 # ── [2026-10-08] 스윙 전략 진입가 (Portfolio 평단 혼합 방지) ────────────────── # Portfolio 행은 stock_code UNIQUE 라 같은 종목의 스윙+DT 수량·평단이 합산된다(§3.5). # KIS pchs_avg_pric 는 이동평균법이라 DT 가 전량 매도한 뒤에도 혼합 평단이 그대로 남는다 # (예: 스윙 5주@68,000 + DT 8주@66,900 → 67,323 — DT 청산 후에도 67,323). # 따라서 스윙 손익·손절은 **스윙 소유 ActiveTrade.entry_price** 를 기준으로 한다. # ActiveTrade 가 오래된 유령 행이면 진입가가 엉뚱할 수 있으므로 Portfolio 평단과의 괴리가 # 이 비율을 넘으면 **Portfolio 평단(KIS 실제 매입 평단)** 을 쓰고 warning 을 남긴다. # # [2026-10-09 하드닝 R1 / 검증 1회차 V1a M-2] 이전에는 괴리 > 30% 면 "높은 값"을 썼다. # 유령 entry 100,000 / 실평단 50,000 / 현재가 52,000 이면 -48% 로 판정돼 **허위 전량 손절** # + loss_tracker 허위 손실(3연패 → 42일 진입 금지)이 났다. 30% 괴리는 DT 혼합 평단으로는 # 만들어지기 어렵고(혼합 평단은 두 매입가 사이에 있다) 유령/오염 행일 가능성이 압도적이므로, # 실제 돈이 걸린 KIS 평단을 기준으로 한다. 반대 방향(유령 entry 가 낮은 경우)도 KIS 평단이 # 높으므로 손절이 늦춰지지 않는다 — 계좌 기준 손실이 그대로 손절선에 반영된다. SWING_ENTRY_PRICE_MAX_DEVIATION = 0.30 def resolve_swing_entry_price_v2( active_trade_entry_price, portfolio_avg_price, stock_code: str = "", ) -> tuple[float, str]: """스윙 손익·손절 판정용 진입가 (순수 함수 — DB·네트워크 없음). Returns: (price, basis) — basis: "active_trade" : 스윙 ActiveTrade.entry_price (정상) "portfolio_avg_deviation" : 두 값 괴리 > SWING_ENTRY_PRICE_MAX_DEVIATION → Portfolio 평단(KIS 실제 매입 평단). ActiveTrade 는 유령 의심. (2026-10-09 이전의 "conservative_max"=max(두 값) 는 폐기 — 유령 고가 entry 로 허위 전량 손절을 냈다) "portfolio_avg" : ActiveTrade 진입가 없음 → Portfolio 평단(혼합 가능) 폴백 "none" : 둘 다 없음(0) — 호출부가 판정을 건너뛴다 """ try: at = float(active_trade_entry_price or 0) except (TypeError, ValueError): at = 0.0 try: pa = float(portfolio_avg_price or 0) except (TypeError, ValueError): pa = 0.0 if at > 0 and pa > 0: dev = abs(at / pa - 1.0) if dev <= SWING_ENTRY_PRICE_MAX_DEVIATION + 1e-9: # 부동소수 경계(정확히 30%) 포함 return at, "active_trade" from app.core.log_throttle import warn_throttled warn_throttled( logger, f"swing_entry_price:{stock_code}", f"[SwingEntryPrice] {stock_code}: ActiveTrade 진입가 {at:,.2f} 와 Portfolio 평단 " f"{pa:,.2f} 괴리 {dev:.1%} > {SWING_ENTRY_PRICE_MAX_DEVIATION:.0%} — 유령/오염 행 의심, " f"KIS 실제 평단 {pa:,.2f} 사용(ActiveTrade 수동 확인 필요)" ) return pa, "portfolio_avg_deviation" if at > 0: return at, "active_trade" if pa > 0: return pa, "portfolio_avg" return 0.0, "none" # ── [2026-10-09 하드닝 R1] 스윙 SELL 의 출처(시스템 진입 vs 동기화 유입) 분리 ───────── # 운영 실측: swing SELL 중 선행 스윙 BUY 가 없는 매도 35건(pnl +62,789) vs 스윙 BUY 이후 # 매도 30건(pnl -166,885). 유입분이 sync 로 'swing' 태그를 받아 청산되면 전략 성과에 섞여 # 시스템 진입 신호의 품질이 가려진다. 통계 API 가 쓸 수 있도록 **원장 순수량** 기준으로 # 매도 수량을 system/inflow 로 배분하는 순수 함수를 둔다(DB·네트워크 없음). _SELL_ORIGIN_EXCLUDED_STATUSES = frozenset({"FAILED", "CANCELLED", "CANCELED", "REJECTED"}) def _row_get_v2(row, key, default=None): if isinstance(row, dict): return row.get(key, default) return getattr(row, key, default) def split_swing_sells_by_origin_v2(trades) -> list[dict]: """스윙 TradeHistory 행들을 시간순으로 훑어 각 SELL 의 수량을 출처별로 나눈다. 규칙: 종목별 '시스템 보유' = 누적 스윙 BUY 체결수량 − 이미 system 으로 배분된 SELL 수량. SELL 은 시스템 보유분부터 소진하고(FIFO 무관, 수량만), 초과분은 inflow(동기화 유입)로 본다. strategy_type 이 swing(또는 NULL) 이 아닌 행, 실패·취소·거부 행은 무시한다. Returns: SELL 마다 {id, stock_code, created_at, sell_qty, system_qty, inflow_qty, origin: "system"|"inflow"|"mixed"} (입력 시간순) """ rows = [] for idx, t in enumerate(trades or []): st = _row_get_v2(t, "strategy_type") if st not in (None, STRATEGY_TYPE): continue status = str(_row_get_v2(t, "status") or "").upper() if status in _SELL_ORIGIN_EXCLUDED_STATUSES: continue side = str(_row_get_v2(t, "order_type") or "").upper() if side not in ("BUY", "SELL"): continue q = _row_get_v2(t, "executed_quantity") if q is None: q = _row_get_v2(t, "order_quantity") try: q = int(q or 0) except (TypeError, ValueError): q = 0 if q <= 0: continue rows.append((_row_get_v2(t, "created_at"), idx, side, q, t)) # created_at 없는 행은 입력 순서를 유지(idx 보조키) rows.sort(key=lambda r: (r[0] is None, r[0] if r[0] is not None else 0, r[1])) held: dict[str, int] = {} out: list[dict] = [] for created_at, _idx, side, q, t in rows: code = _row_get_v2(t, "stock_code") if side == "BUY": held[code] = held.get(code, 0) + q continue sys_q = min(q, held.get(code, 0)) held[code] = held.get(code, 0) - sys_q inflow_q = q - sys_q origin = "system" if inflow_q == 0 else ("inflow" if sys_q == 0 else "mixed") out.append({ "id": _row_get_v2(t, "id"), "stock_code": code, "created_at": created_at, "sell_qty": q, "system_qty": sys_q, "inflow_qty": inflow_q, "origin": origin, }) return out