fix: ActiveTrade 잔량 자동 정합 + PATCH 수량 교정 지원
수동/분할 매도 누락으로 ActiveTrade 잔량이 실제 보유량과 어긋나면 다음 청산이
보유량보다 많이 매도한다. 동기화 시 종목당 active 행이 1개뿐일 때 실제 보유량으로
교정하고, 보유가 없으면 closed 처리(스윙+DT 혼재 종목은 자동 교정 제외).
운영 교정용으로 PATCH /portfolio/active-trades/{code}/status 에 quantity 필드 추가.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -796,6 +796,32 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
|
|||||||
# 이뤄져야 하므로(Portfolio SELECT → UPDATE/DELETE → commit) 큐로 분리가 어려움.
|
# 이뤄져야 하므로(Portfolio SELECT → UPDATE/DELETE → commit) 큐로 분리가 어려움.
|
||||||
# sync_portfolio()는 API 엔드포인트(get_db 의존성) 경로에서만 호출되므로
|
# 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()
|
await db.commit()
|
||||||
portfolio_cache.holdings = [h for h in new_holdings if h.quantity > 0]
|
portfolio_cache.holdings = [h for h in new_holdings if h.quantity > 0]
|
||||||
await portfolio_cache.cache_summary(balance_data, overseas_balance_data) # 국내·해외 잔고 전달
|
await portfolio_cache.cache_summary(balance_data, overseas_balance_data) # 국내·해외 잔고 전달
|
||||||
@@ -1528,6 +1554,7 @@ class ActiveTradeStatusUpdateRequest(BaseModel):
|
|||||||
reason: Optional[str] = ""
|
reason: Optional[str] = ""
|
||||||
updated_at: Optional[str] = None
|
updated_at: Optional[str] = None
|
||||||
remaining_pct: Optional[float] = None # 부분 매도 후 잔여 비율 (예: 0.5). 제공 시 ActiveTrade.remaining_pct 갱신
|
remaining_pct: Optional[float] = None # 부분 매도 후 잔여 비율 (예: 0.5). 제공 시 ActiveTrade.remaining_pct 갱신
|
||||||
|
quantity: Optional[int] = None # 실제 보유 수량으로 교정 (DB 잔량 드리프트 정정용)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/active-trades/{stock_code}/status")
|
@router.patch("/active-trades/{stock_code}/status")
|
||||||
@@ -1594,6 +1621,15 @@ async def update_active_trade_status(
|
|||||||
if request.remaining_pct is not None:
|
if request.remaining_pct is not None:
|
||||||
active_trade.remaining_pct = Decimal(str(round(request.remaining_pct, 4)))
|
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)
|
db.add(active_trade)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(active_trade)
|
await db.refresh(active_trade)
|
||||||
|
|||||||
@@ -136,3 +136,21 @@ def test_manual_sell_closes_active_trade():
|
|||||||
src = inspect.getsource(pf.sell_stock)
|
src = inspect.getsource(pf.sell_stock)
|
||||||
assert "_enq_close_at(stock_code" in src
|
assert "_enq_close_at(stock_code" in src
|
||||||
assert '"quantity": original_holding_qty - quantity' 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
|
||||||
|
|||||||
Reference in New Issue
Block a user