From 9cdd12b41efebe6a53834cb04a43a1fbbbfc1b3c Mon Sep 17 00:00:00 2001 From: shuim Date: Wed, 23 Sep 2026 10:53:13 +0900 Subject: [PATCH] =?UTF-8?q?fix(P1):=20MDD=20=EC=B6=95=EC=86=8C=20=EC=88=98?= =?UTF-8?q?=EB=9F=89=20=EB=B0=98=EC=98=81=C2=B7ActiveTrade=20=EC=A0=84?= =?UTF-8?q?=EB=9E=B5=EB=AA=85=20=ED=95=84=EC=88=98=ED=99=94=C2=B7=EC=8A=A4?= =?UTF-8?q?=EC=9C=99=20=EB=A7=A4=EB=8F=84=EC=97=90=EC=84=9C=20DT=20?= =?UTF-8?q?=EB=B3=B4=EC=9C=A0=EB=B6=84=20=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mdd_manager: 강제 축소가 TradeHistory/remaining_pct 만 갱신하고 실제 수량은 어디도 줄이지 않아 다음 청산이 축소 전 수량으로 매도했음 → _reflect_reduction()으로 Portfolio·DT 인메모리 반영, ActiveTrade.quantity 도 잔량으로 갱신 (두 경로 모두) - swing/exit_service: close/upsert 에 strategy_name 미전달 시 db_worker 가 최신 active 행(=DT)을 닫거나 트레일링 고점을 덮어씀 → 항상 전략명 명시 - trade_executor._execute_sell: Portfolio 행(스윙+DT 합계)을 그대로 매도 대상으로 삼아 DT 보유분까지 팔았음 → 엔진 보유 수량을 제외한 자기 전략 보유분만 대상, upsert 에 전략명 - portfolio.sync: DT 태깅이 해외 루프에만 적용돼 국내 DT 매수분이 swing 으로 저장되면 스윙 엔진이 손절 → 국내에도 적용. _dt_qty 정의를 output1 블록 밖으로 올려 NameError 방지 Co-Authored-By: Claude Opus 5 (1M context) --- backend/app/api/endpoints/portfolio.py | 31 +++++++++------ backend/app/services/mdd_manager.py | 39 +++++++++++++++++++ backend/app/services/swing/exit_service.py | 13 ++++++- backend/app/services/trade_executor.py | 24 ++++++++++++ backend/tests/test_p1_audit_fixes.py | 44 ++++++++++++++++++++++ 5 files changed, 138 insertions(+), 13 deletions(-) create mode 100644 backend/tests/test_p1_audit_fixes.py diff --git a/backend/app/api/endpoints/portfolio.py b/backend/app/api/endpoints/portfolio.py index 1d0d035..b3a642c 100644 --- a/backend/app/api/endpoints/portfolio.py +++ b/backend/app/api/endpoints/portfolio.py @@ -521,6 +521,18 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) # 디버깅: KIS API 전체 응답 로깅 logger.info(f"🔍 KIS API balance response keys: {balance_data.keys() if balance_data else 'None'}") + # 데이트레이딩 엔진이 보유 중인 종목 → strategy_type='daytrading' 태깅용. + # (국내 output1 블록 안에서 정의하면 해외 루프에서 NameError → sync 전체 실패) + try: + from app.services.daytrading.position_manager import day_trading_state as _dts + _dt_qty = { + code: int(pos.get("quantity", 0) or 0) + for code, pos in _dts.positions.items() + } + except Exception: + _dt_qty = {} + _dt_codes = set(_dt_qty) + # Balance API 오류 시 기존 DB 보유종목을 삭제하지 않고 캐시 유지 if not balance_data or balance_data.get('rt_cd') != '0': error_msg = balance_data.get('msg1', 'Unknown error') if balance_data else 'No response' @@ -609,17 +621,6 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) _realtime_price_map = {} # ────────────────────────────────────────────────────────────────── - # 데이트레이딩 엔진이 보유 중인 종목 → strategy_type='daytrading' 태깅용 - try: - from app.services.daytrading.position_manager import day_trading_state as _dts - _dt_qty = { - code: int(pos.get("quantity", 0) or 0) - for code, pos in _dts.positions.items() - } - _dt_codes = set(_dt_qty) - except Exception: - _dt_qty, _dt_codes = {}, set() - for item in balance_data["output1"]: stock_code = item.get("pdno") @@ -682,6 +683,9 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) h.profit_loss = profit_loss h.profit_loss_rate = profit_loss_rate h.market = "KRX" + # 전량이 DT 포지션일 때만 태깅(혼재 시 스윙 청산 대상 유지) + if stock_code in _dt_codes and quantity <= _dt_qty.get(stock_code, 0): + h.strategy_type = "daytrading" new_holdings.append(h) else: new_holding = Portfolio( @@ -693,6 +697,11 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) profit_loss=profit_loss, profit_loss_rate=profit_loss_rate, market="KRX", + strategy_type=( + "daytrading" + if quantity <= _dt_qty.get(stock_code, 0) + else "swing" + ), ) db.add(new_holding) new_holdings.append(new_holding) diff --git a/backend/app/services/mdd_manager.py b/backend/app/services/mdd_manager.py index e2fed8d..d95b9c7 100644 --- a/backend/app/services/mdd_manager.py +++ b/backend/app/services/mdd_manager.py @@ -329,8 +329,13 @@ class MDDManager: await enqueue_active_trade_upsert({ "stock_code": stock_code, "remaining_pct_multiply": sell_ratio, + "quantity": remaining_qty, # 잔량 갱신(미갱신 시 다음 청산이 과다매도) }) + # [2026-09-23] 축소 체결분을 Portfolio·DT 인메모리에도 반영. + # 누락 시 다음 청산이 축소 전 수량으로 매도한다(과다매도). + await _reflect_reduction(stock_code, sell_qty, current_price, remaining_qty) + results.append({ "stock_code": stock_code, "stock_name": stock_name, @@ -490,8 +495,13 @@ class MDDManager: "user_id": user_id, "stock_code": stock_code, "remaining_pct_multiply": sell_ratio, + "quantity": remaining_qty, }) + # [2026-09-23] 축소 체결분을 Portfolio·DT 인메모리에도 반영. + # 누락 시 다음 청산이 축소 전 수량으로 매도한다(과다매도). + await _reflect_reduction(stock_code, sell_qty, current_price, remaining_qty) + results.append({ "stock_code": stock_code, "stock_name": stock_name, @@ -530,3 +540,32 @@ class MDDManager: # 싱글톤 인스턴스 mdd_manager = MDDManager() + + +async def _reflect_reduction( + stock_code: str, sell_qty: int, price: float, remaining_qty: int +) -> None: + """MDD 축소 체결을 Portfolio 캐시/DB 와 데이트레이딩 인메모리 포지션에 반영. + + ActiveTrade 잔량은 호출측에서 upsert 하므로 여기서는 Portfolio 와 인메모리만 다룬다. + """ + try: + from app.services.portfolio_cache_bridge import reflect_sell + await reflect_sell(stock_code, sell_qty, price, remaining_qty=remaining_qty) + except Exception as e: + logger.warning(f"[MDD] Portfolio 반영 실패 ({stock_code}): {e}") + + try: + from app.services.daytrading.position_manager import day_trading_state + async with day_trading_state.trade_lock: + pos = day_trading_state.positions.get(stock_code) + if pos: + _left = max(0, int(pos.get("quantity", 0) or 0) - sell_qty) + if _left <= 0: + day_trading_state.remove_position(stock_code) + logger.warning(f"[MDD] DT 포지션 전량 축소: {stock_code}") + else: + pos["quantity"] = _left + logger.warning(f"[MDD] DT 포지션 잔량 갱신: {stock_code} → {_left}주") + except Exception as e: + logger.warning(f"[MDD] DT 인메모리 반영 실패 ({stock_code}): {e}") diff --git a/backend/app/services/swing/exit_service.py b/backend/app/services/swing/exit_service.py index 3b0e2f8..eda8896 100644 --- a/backend/app/services/swing/exit_service.py +++ b/backend/app/services/swing/exit_service.py @@ -200,6 +200,9 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]: # ── ActiveTrade 조회 (분할 청산 상태) ──────────────────── active_trade = await get_swing_active_trade(db, stock_code) + # ActiveTrade upsert/close 시 항상 전략명을 명시해야 같은 종목의 DT 행을 + # 잘못 건드리지 않는다(db_worker 는 전략명이 없으면 최신 행을 매칭). + _at_strategy = (active_trade.strategy_name if active_trade else None) or "swing" remaining_pct = float(active_trade.remaining_pct) if active_trade and active_trade.remaining_pct is not None else 1.0 trailing_high = float(active_trade.highest_since_entry) if active_trade and active_trade.highest_since_entry else None @@ -209,12 +212,13 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]: from app.services.db_worker import enqueue_active_trade_upsert await enqueue_active_trade_upsert({ "stock_code": stock_code, + "strategy_name": _at_strategy, # DT 행 오염 방지 "highest_since_entry": trailing_high, }) # ── 전략별 청산 분기 ──────────────────────────────────── exit_signal = None - _strategy = active_trade.strategy_name if active_trade else "swing" + _strategy = _at_strategy if domestic: try: @@ -341,13 +345,18 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]: # 전량 청산이면 ActiveTrade 종료, 분할 매도면 remaining_pct 갱신 if sell_qty >= quantity: - await enqueue_close_active_trade(stock_code, strategy_name=None) + # strategy_name 을 비우면 db_worker 가 '가장 최근 active 행'을 닫는다 + # → 같은 종목에 DT 행이 있으면 그쪽이 닫히고 스윙 행은 영구 잔존. + await enqueue_close_active_trade( + stock_code, strategy_name=_strategy or "swing" + ) else: # 분할 매도 후 remaining_pct 갱신 (반복 트리거 방지) new_remaining = max(0.0, remaining_pct - sell_pct) from app.services.db_worker import enqueue_active_trade_upsert _at_data = { "stock_code": stock_code, + "strategy_name": _strategy or "swing", "remaining_pct": new_remaining, # 잔량도 함께 갱신 — 누락 시 재기동 복원이 원래 수량으로 되살아나 # 보유량보다 많이 매도한다(2026-09-23 DT LG씨엔에스 사례와 동일 패턴) diff --git a/backend/app/services/trade_executor.py b/backend/app/services/trade_executor.py index 6aa9bdb..08867f3 100644 --- a/backend/app/services/trade_executor.py +++ b/backend/app/services/trade_executor.py @@ -1134,7 +1134,30 @@ async def _execute_sell( """ # [CLAUDE.md 5.2] 분할매도: sell_pct에 따라 매도 수량 결정 + # [2026-09-23] Portfolio 행은 stock_code UNIQUE 라 같은 종목의 스윙+데이트레이딩 + # 수량이 합산돼 있다. 행 전체를 대상으로 잡으면 다른 전략 보유분까지 매도된다 + # → 데이트레이딩 엔진이 들고 있는 수량은 제외한 '이 전략의 보유분'만 대상으로 한다. total_qty = holding.quantity + _strategy_tag = stock.get("strategy_type", "swing") + if _strategy_tag != "daytrading": + try: + from app.services.daytrading.position_manager import day_trading_state as _dts + _dt_pos = _dts.positions.get(stock_code) + _dt_qty = int((_dt_pos or {}).get("quantity", 0) or 0) + except Exception: + _dt_qty = 0 + if _dt_qty > 0: + _own = max(0, total_qty - _dt_qty) + if _own <= 0: + logger.info( + f"[SELL] {stock_name}: 보유 {total_qty}주 전량이 데이트레이딩 포지션 — 스윙 청산 스킵" + ) + return {"action": "SKIP", "reason": "owned_by_daytrading", "stock": stock_name} + logger.info( + f"[SELL] {stock_name}: 보유 {total_qty}주 중 DT {_dt_qty}주 제외 → {_own}주 대상" + ) + total_qty = _own + quantity = max(1, int(total_qty * sell_pct)) # 최소 1주 if sell_pct >= 1.0: quantity = total_qty # 전량매도 시 잔여분 없도록 @@ -1255,6 +1278,7 @@ async def _execute_sell( new_remaining = max(0.0, remaining_pct - sell_pct) await enqueue_active_trade_upsert({ "stock_code": stock_code, + "strategy_name": stock.get("strategy_type", "swing"), "remaining_pct": new_remaining, "quantity": max(0, total_qty - quantity), # 잔량 갱신(과다매도 방지) "highest_since_entry": current_price, diff --git a/backend/tests/test_p1_audit_fixes.py b/backend/tests/test_p1_audit_fixes.py new file mode 100644 index 0000000..6c3a122 --- /dev/null +++ b/backend/tests/test_p1_audit_fixes.py @@ -0,0 +1,44 @@ +"""감사 P1 수정 검증: MDD 수량 반영 / 전략명 명시 / 국내 DT 태깅.""" +import inspect + +import pytest + + +def test_mdd_reduction_reflects_quantities(): + """MDD 축소는 ActiveTrade·Portfolio·DT 인메모리 수량을 모두 줄여야 한다.""" + from app.services import mdd_manager as m + src = inspect.getsource(m) + assert src.count("_reflect_reduction(stock_code, sell_qty") == 2, "두 축소 경로 모두 반영 필요" + assert '"quantity": remaining_qty' in src + helper = inspect.getsource(m._reflect_reduction) + assert "reflect_sell" in helper and "day_trading_state" in helper + assert "remove_position" in helper and 'pos["quantity"] = _left' in helper + + +def test_swing_exit_always_passes_strategy_name(): + """전략명을 비우면 db_worker 가 최신 active 행(=DT)을 닫거나 덮어쓴다.""" + from app.services.swing import exit_service as se + src = inspect.getsource(se) + assert "strategy_name=None" not in src, "close 호출에 전략명 누락" + assert 'strategy_name=_strategy or "swing"' in src + assert '"strategy_name": _at_strategy' in src # 트레일링 고점 upsert + assert '"strategy_name": _strategy or "swing"' in src # 분할매도 upsert + + +def test_executor_sell_excludes_daytrading_shares(): + """Portfolio 행은 스윙+DT 합계 → 스윙 청산이 DT 보유분까지 팔면 안 된다.""" + from app.services import trade_executor as te + src = inspect.getsource(te._execute_sell) + assert "day_trading_state" in src and "_dt_qty" in src + assert "owned_by_daytrading" in src + assert 'total_qty = _own' in src + assert '"strategy_name": stock.get("strategy_type", "swing")' in src + + +def test_sync_dt_tagging_applies_to_domestic_and_survives_missing_output1(): + from app.api.endpoints import portfolio as pf + src = inspect.getsource(pf.sync_portfolio) + # 국내 신규/기존 행 + 해외 = 태깅 지점 4곳 + assert src.count('quantity <= _dt_qty.get(stock_code, 0)') == 4 + # 정의가 output1 블록보다 앞이어야 NameError 가 없다 + assert src.index("_dt_codes = set(_dt_qty)") < src.index('if "output1" in balance_data')