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

@@ -1477,11 +1477,15 @@ async def sell_stock(
original_avg_price = float(holding.average_price or 0)
# 매도 주문 실행
order_result = await korea_investment_api.place_order(
# [2026-09-23] safe_place_order 경유 — 종목별 세마포어 + Redis 분산락으로
# 스윙/데이트레이딩 자동 청산과 동시에 매도되는 것을 막는다.
from app.services.trade_executor import safe_place_order
order_result = await safe_place_order(
stock_code=stock_code,
order_type='SELL',
quantity=quantity,
price=price # None이면 시장가
price=price, # None이면 시장가
is_domestic=True,
)
if not order_result or order_result.get('rt_cd') != '0':
@@ -1547,6 +1551,23 @@ async def sell_stock(
except Exception as _at_err:
logger.warning(f"[SELL] ActiveTrade 정리 실패 {stock_code}: {_at_err}")
# 데이트레이딩 엔진이 같은 종목을 보유 중이면 인메모리 잔량도 줄인다.
# (누락 시 엔진이 매도된 수량을 그대로 들고 있다고 믿고 다음 사이클에 재매도)
try:
from app.services.daytrading.position_manager import day_trading_state as _dts
async with _dts.trade_lock:
_dt_pos = _dts.positions.get(stock_code)
if _dt_pos:
_left = max(0, int(_dt_pos.get("quantity", 0) or 0) - quantity)
if _left <= 0:
_dts.remove_position(stock_code)
logger.info(f"[SELL] DT 포지션 제거(수동 매도): {stock_code}")
else:
_dt_pos["quantity"] = _left
logger.info(f"[SELL] DT 포지션 잔량 갱신(수동 매도): {stock_code} → {_left}주")
except Exception as _dt_err:
logger.warning(f"[SELL] DT 인메모리 반영 실패 {stock_code}: {_dt_err}")
# ✅ 인메모리 캐시 즉시 반영 (KIS 재동기화 전에도 화면에서 즉시 제거)
if quantity >= original_holding_qty:
portfolio_cache.holdings = [