diff --git a/backend/app/api/endpoints/portfolio.py b/backend/app/api/endpoints/portfolio.py index 821684e..63b274f 100644 --- a/backend/app/api/endpoints/portfolio.py +++ b/backend/app/api/endpoints/portfolio.py @@ -796,6 +796,32 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) # 이뤄져야 하므로(Portfolio SELECT → UPDATE/DELETE → commit) 큐로 분리가 어려움. # sync_portfolio()는 API 엔드포인트(get_db 의존성) 경로에서만 호출되므로 # 실시간 콜백/스케줄러 직접 호출과는 성격이 다름. + # ActiveTrade 잔량 정합 — 같은 종목에 active 행이 1개뿐일 때만 실제 보유량으로 교정. + # (분할매도·수동매도 누락으로 DB 잔량이 실제와 달라지면 다음 청산이 과다매도) + try: + _held = {h.stock_code: h.quantity for h in new_holdings if h.quantity > 0} + _at_rows = (await db.execute( + select(ActiveTrade).where(ActiveTrade.status == "active") + )).scalars().all() + _by_code: dict[str, list] = {} + for _row in _at_rows: + _by_code.setdefault(_row.stock_code, []).append(_row) + for _code, _rows in _by_code.items(): + if len(_rows) != 1: + continue # 스윙+DT 혼재 등 → 자동 교정하지 않음 + _row = _rows[0] + _actual = _held.get(_code, 0) + if _actual == 0: + _row.status = "closed" + logger.info(f"🧹 ActiveTrade 정리: {_code} 보유 없음 → closed") + elif int(_row.quantity or 0) != _actual: + logger.info( + f"🧹 ActiveTrade 잔량 교정: {_code} {_row.quantity} → {_actual}" + ) + _row.quantity = _actual + except Exception as _rec_err: + logger.warning(f"ActiveTrade 정합 실패(무시): {_rec_err}") + await db.commit() portfolio_cache.holdings = [h for h in new_holdings if h.quantity > 0] await portfolio_cache.cache_summary(balance_data, overseas_balance_data) # 국내·해외 잔고 전달 @@ -1528,6 +1554,7 @@ class ActiveTradeStatusUpdateRequest(BaseModel): reason: Optional[str] = "" updated_at: Optional[str] = None remaining_pct: Optional[float] = None # 부분 매도 후 잔여 비율 (예: 0.5). 제공 시 ActiveTrade.remaining_pct 갱신 + quantity: Optional[int] = None # 실제 보유 수량으로 교정 (DB 잔량 드리프트 정정용) @router.patch("/active-trades/{stock_code}/status") @@ -1594,6 +1621,15 @@ async def update_active_trade_status( if request.remaining_pct is not None: active_trade.remaining_pct = Decimal(str(round(request.remaining_pct, 4))) + if request.quantity is not None: + if request.quantity < 0: + raise HTTPException(status_code=400, detail="quantity는 0 이상이어야 합니다") + logger.info( + f"[TradeMonitor API] {stock_code} ActiveTrade 수량 교정: " + f"{active_trade.quantity} → {request.quantity}" + ) + active_trade.quantity = request.quantity + db.add(active_trade) await db.commit() await db.refresh(active_trade) diff --git a/backend/tests/test_bugfixes_20260923.py b/backend/tests/test_bugfixes_20260923.py index 4a45789..071ae6f 100644 --- a/backend/tests/test_bugfixes_20260923.py +++ b/backend/tests/test_bugfixes_20260923.py @@ -136,3 +136,21 @@ def test_manual_sell_closes_active_trade(): src = inspect.getsource(pf.sell_stock) assert "_enq_close_at(stock_code" in src assert '"quantity": original_holding_qty - quantity' in src + + +def test_sync_reconciles_active_trade_quantity(): + """동기화가 ActiveTrade 잔량을 실제 보유량으로 정합해야 드리프트가 누적되지 않는다.""" + import inspect + from app.api.endpoints import portfolio as pf + src = inspect.getsource(pf.sync_portfolio) + assert "ActiveTrade 잔량 교정" in src + assert "len(_rows) != 1" in src, "스윙+DT 혼재 종목은 자동 교정 대상에서 제외해야 함" + assert "_row.status = \"closed\"" in src, "보유 없음 → closed 처리 누락" + + +def test_active_trade_patch_supports_quantity(): + import inspect + from app.api.endpoints import portfolio as pf + assert "quantity" in pf.ActiveTradeStatusUpdateRequest.model_fields + src = inspect.getsource(pf.update_active_trade_status) + assert "active_trade.quantity = request.quantity" in src