fix(P2): 주문 멱등성·취소 보호·다중행·v2/수동매도 정합

- korea_investment_api: 주문 계열 엔드포인트는 내부 재시도 금지(_NON_IDEMPOTENT_ENDPOINTS).
  상위 _place_order_with_retry 와 중첩돼 타임아웃 시 동일 주문 최대 12회 전송 가능했음.
  get_todays_orders() 추가.
- trade_executor: 예외 후 재시도 전에 당일 주문내역으로 접수 여부 확인(_find_recent_order)
  → 접수됐으면 재주문하지 않고 기존 주문 반환.
- daytrading/exit_service: 주문 체결 이후 상태 반영을 _finalize_exit 로 분리하고
  asyncio.shield 로 보호 — 스케줄러 단계 타임아웃이 이 구간을 취소하면 브로커는 팔았는데
  인메모리는 그대로라 다음 사이클이 재매도.
- swing/position_manager: active 행이 2개면 MultipleResultsFound 로 해당 종목 청산이
  통째로 스킵됐음(손절 미작동) → 최신 행 사용.
- swing/exit_service: 큐 지연·DLQ 시 같은 분할매도가 반복되지 않도록 180초 인메모리 쿨다운.
- portfolio_v2: 부분매도 ActiveTrade 잔량 갱신 + 전략명 매핑(daytrading→day_trade,
  us_swing→us_mean_reversion) — 매칭 실패로 active 행이 영구 잔존했음.
- portfolio.sell_stock: safe_place_order 경유(세마포어·분산락) + DT 인메모리 잔량 반영.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-23 10:57:40 +09:00
parent 9cdd12b41e
commit 8971a648e2
8 changed files with 296 additions and 14 deletions

View File

@@ -485,18 +485,35 @@ async def sell_stock_v2(
sell_history_payload["user_id"] = ctx.user_id
await enqueue_trade_history(sell_history_payload)
# Portfolio.strategy_type('daytrading'/'us_swing')과 ActiveTrade.strategy_name
# ('day_trade'/'us_mean_reversion')은 값 체계가 달라 그대로 넘기면 매칭에 실패해
# active 행이 영구 잔존한다(2026-09-23).
_AT_STRATEGY = {"daytrading": "day_trade", "us_swing": "us_mean_reversion"}
_at_name = _AT_STRATEGY.get(holding.strategy_type or "swing", holding.strategy_type or "swing")
if quantity >= original_holding_qty:
await db.delete(holding)
if ctx.is_legacy:
# M3: legacy 스케줄러/기존 `/portfolio/sell`과 동일하게 user_id 없는 close 사용
# (ActiveTrade.user_id == owner 필터로 NULL 행을 못 찾아 유령 포지션으로
# 남는 결함 방지).
await enqueue_close_active_trade(stock_code, strategy_name=holding.strategy_type)
await enqueue_close_active_trade(stock_code, strategy_name=_at_name)
else:
await enqueue_close_active_trade_v2(ctx.user_id, stock_code, strategy_name=holding.strategy_type)
await enqueue_close_active_trade_v2(ctx.user_id, stock_code, strategy_name=_at_name)
else:
holding.quantity -= quantity
holding.current_price = Decimal(str(executed_price))
# 부분매도 시 ActiveTrade 잔량 갱신 — 누락 시 다음 청산이 매도 전 수량으로 판다
from app.services.db_worker import enqueue_active_trade_upsert as _enq_at
_at_payload = {
"stock_code": stock_code,
"strategy_name": _at_name,
"quantity": original_holding_qty - quantity,
"current_price": float(executed_price),
}
if not ctx.is_legacy:
_at_payload["user_id"] = ctx.user_id
await _enq_at(_at_payload)
await db.commit()
logger.info(f"✅ [v2] user_id={ctx.user_id} 수동 매도 완료: {stock_name} {quantity}주 @ {executed_price:,.0f}원")