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:
@@ -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}원")
|
||||
|
||||
Reference in New Issue
Block a user