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:
@@ -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 = [
|
||||
|
||||
@@ -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