From b933e237774947e19cbf768e3bd7b2ff7461cec0 Mon Sep 17 00:00:00 2001 From: shuim Date: Wed, 23 Sep 2026 10:36:00 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EB=B6=84=ED=95=A0=EB=A7=A4=EB=8F=84=20?= =?UTF-8?q?=EC=9E=94=EB=9F=89=20=EB=AF=B8=EA=B0=B1=EC=8B=A0=20=E2=80=94=20?= =?UTF-8?q?=EC=8A=A4=EC=9C=99=C2=B7=EA=B5=AD=EB=82=B4=20executor=C2=B7?= =?UTF-8?q?=EB=AF=B8=EA=B5=AD=20=EA=B2=BD=EB=A1=9C=20=EB=8F=99=EC=9D=BC=20?= =?UTF-8?q?=ED=8C=A8=ED=84=B4=20+=20=EC=88=98=EB=8F=99=EB=A7=A4=EB=8F=84?= =?UTF-8?q?=20ActiveTrade=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DT에서 찾은 '분할매도 후 ActiveTrade.quantity 미갱신' 패턴이 3곳 더 있었음: - us_market_scheduler: total_qty = trade.quantity 를 매도량 산정에 직접 사용 → 분할매도 후 다음 사이클이 원래 수량 기준으로 팔아 과다매도(US_TRADING_ENABLED=true 상태) - swing/exit_service, trade_executor._execute_sell: remaining_pct만 갱신 또한 수동 매도(/portfolio/sell)가 ActiveTrade를 전혀 정리하지 않아 청산된 종목이 active로 잔존(삼아알미늄·LG 등) → 재매수 시 옛 remaining_pct가 살아나 1차 익절이 건너뛰어짐. 전량 매도 시 close, 분할 시 잔량 갱신으로 수정. Co-Authored-By: Claude Opus 5 (1M context) --- backend/app/api/endpoints/portfolio.py | 19 +++++++++++ backend/app/services/swing/exit_service.py | 3 ++ backend/app/services/trade_executor.py | 1 + backend/app/services/us_market_scheduler.py | 3 ++ backend/tests/test_bugfixes_20260923.py | 38 +++++++++++++++++++++ 5 files changed, 64 insertions(+) diff --git a/backend/app/api/endpoints/portfolio.py b/backend/app/api/endpoints/portfolio.py index b6f7078..821684e 100644 --- a/backend/app/api/endpoints/portfolio.py +++ b/backend/app/api/endpoints/portfolio.py @@ -1457,6 +1457,25 @@ async def sell_stock( await db.commit() + # ActiveTrade 정리 — 누락 시 유령 active 행이 남아, 같은 종목을 다시 매수했을 때 + # 옛 remaining_pct(예: 0.50)가 살아나 1차 익절이 건너뛰어진다. + # (2026-09-23 확인: 삼아알미늄·LG 등 수동 청산 종목이 active 상태로 잔존) + from app.services.db_worker import ( + enqueue_close_active_trade as _enq_close_at, + enqueue_active_trade_upsert as _enq_at_upsert, + ) + try: + if quantity >= original_holding_qty: + await _enq_close_at(stock_code, strategy_name=None) + else: + await _enq_at_upsert({ + "stock_code": stock_code, + "quantity": original_holding_qty - quantity, + "current_price": float(executed_price), + }) + except Exception as _at_err: + logger.warning(f"[SELL] ActiveTrade 정리 실패 {stock_code}: {_at_err}") + # ✅ 인메모리 캐시 즉시 반영 (KIS 재동기화 전에도 화면에서 즉시 제거) if quantity >= original_holding_qty: portfolio_cache.holdings = [ diff --git a/backend/app/services/swing/exit_service.py b/backend/app/services/swing/exit_service.py index 3a69a4f..3b0e2f8 100644 --- a/backend/app/services/swing/exit_service.py +++ b/backend/app/services/swing/exit_service.py @@ -349,6 +349,9 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]: _at_data = { "stock_code": stock_code, "remaining_pct": new_remaining, + # 잔량도 함께 갱신 — 누락 시 재기동 복원이 원래 수량으로 되살아나 + # 보유량보다 많이 매도한다(2026-09-23 DT LG씨엔에스 사례와 동일 패턴) + "quantity": quantity - sell_qty, "highest_since_entry": latest_price if latest_price > (trailing_high or 0) else trailing_high, } if active_trade is None: diff --git a/backend/app/services/trade_executor.py b/backend/app/services/trade_executor.py index e3dd2f6..6aa9bdb 100644 --- a/backend/app/services/trade_executor.py +++ b/backend/app/services/trade_executor.py @@ -1256,6 +1256,7 @@ async def _execute_sell( await enqueue_active_trade_upsert({ "stock_code": stock_code, "remaining_pct": new_remaining, + "quantity": max(0, total_qty - quantity), # 잔량 갱신(과다매도 방지) "highest_since_entry": current_price, }) except Exception as _at_err: diff --git a/backend/app/services/us_market_scheduler.py b/backend/app/services/us_market_scheduler.py index 3ea33b2..740bd6e 100644 --- a/backend/app/services/us_market_scheduler.py +++ b/backend/app/services/us_market_scheduler.py @@ -399,6 +399,9 @@ async def _monitor_us_exits(exchange_rate: float = _DEFAULT_FX_RATE) -> List[Dic "stock_code": ticker, "strategy_name": "us_mean_reversion", "remaining_pct": round(new_remaining, 2), + # 잔량 갱신 — 누락 시 다음 사이클이 원래 수량 기준으로 매도량을 + # 계산해 보유량보다 많이 판다(total_qty = trade.quantity) + "quantity": max(0, total_qty - sell_qty), "current_price": current_price, }) diff --git a/backend/tests/test_bugfixes_20260923.py b/backend/tests/test_bugfixes_20260923.py index 8f67504..4a45789 100644 --- a/backend/tests/test_bugfixes_20260923.py +++ b/backend/tests/test_bugfixes_20260923.py @@ -98,3 +98,41 @@ async def test_dt_partial_exit_updates_active_trade_quantity(): at = [u for u in upserts if u.get("stock_code") == "064400"] assert at and at[-1]["quantity"] == 4, f"ActiveTrade 잔량 갱신 누락: {upserts}" day_trading_state.positions.clear() + + +@pytest.mark.asyncio +async def test_swing_partial_exit_persists_remaining_quantity(): + """스윙 분할매도도 ActiveTrade.quantity를 갱신해야 한다(DT와 동일 패턴).""" + import inspect + from app.services.swing import exit_service as ses + src = inspect.getsource(ses) + block = src[src.index("분할 매도 후 remaining_pct 갱신"):] + block = block[:block.index("await enqueue_active_trade_upsert(_at_data)")] + assert '"quantity": quantity - sell_qty' in block, "스윙 분할매도에 잔량 갱신 누락" + + +def test_us_partial_exit_persists_remaining_quantity(): + """미국 스윙도 동일 — total_qty = trade.quantity 를 읽으므로 갱신이 없으면 과다매도.""" + import inspect + from app.services import us_market_scheduler as us + src = inspect.getsource(us) + i = src.index('"strategy_name": "us_mean_reversion",\n "remaining_pct"') + block = src[i:i + 500] + assert '"quantity": max(0, total_qty - sell_qty)' in block, "미국 분할매도에 잔량 갱신 누락" + + +def test_domestic_executor_partial_exit_persists_quantity(): + import inspect + from app.services import trade_executor as te + src = inspect.getsource(te) + i = src.index('"remaining_pct": new_remaining,\n "quantity"') + assert 'max(0, total_qty - quantity)' in src[i:i + 200] + + +def test_manual_sell_closes_active_trade(): + """수동 전량 매도는 ActiveTrade를 closed 로 만들어야 유령 행이 남지 않는다.""" + import inspect + from app.api.endpoints import portfolio as pf + src = inspect.getsource(pf.sell_stock) + assert "_enq_close_at(stock_code" in src + assert '"quantity": original_holding_qty - quantity' in src