Compare commits

...

2 Commits

Author SHA1 Message Date
8456b00dfa Merge branch 'dev' into main — ActiveTrade 정합 2026-09-23 10:38:26 +09:00
ee4f8afa8b 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>
2026-09-23 10:38:24 +09:00
2 changed files with 54 additions and 0 deletions

View File

@@ -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)

View File

@@ -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