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:
@@ -20,6 +20,7 @@ strategy_type='swing' 포지션만 처리하도록 분리한 서비스.
|
||||
- 매도 주문: safe_place_order() 사용 (세마포어+분산락 보호)
|
||||
"""
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
@@ -41,6 +42,10 @@ from app.core.json_helpers import _dumps
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 종목별 마지막 청산 시각(epoch) — 큐 지연으로 같은 분할매도가 반복되는 것을 막는 인메모리 가드
|
||||
_recent_exits: dict[str, float] = {}
|
||||
EXIT_COOLDOWN_SEC = 180
|
||||
|
||||
|
||||
def _check_momentum_exit(
|
||||
current_price: float,
|
||||
@@ -160,6 +165,7 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]:
|
||||
|
||||
try:
|
||||
# strategy_type='swing' 포지션만 조회
|
||||
_now_ts = time.time()
|
||||
holdings = await get_swing_holdings(db)
|
||||
if not holdings:
|
||||
logger.debug("[SwingExit] 스윙 보유 종목 없음")
|
||||
@@ -285,6 +291,17 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]:
|
||||
if not exit_signal:
|
||||
continue
|
||||
|
||||
# [2026-09-23] 상태 저장(remaining_pct·Portfolio)이 db_worker 큐 경유라
|
||||
# 큐 지연·DLQ 시 다음 사이클이 옛 값을 읽어 같은 분할매도를 반복한다.
|
||||
# 인메모리 쿨다운으로 직전 청산 직후 재매도를 차단한다.
|
||||
_last_exit = _recent_exits.get(stock_code)
|
||||
if _last_exit is not None and (_now_ts - _last_exit) < EXIT_COOLDOWN_SEC:
|
||||
logger.info(
|
||||
f"[SwingExit] {stock_code}: 직전 청산 {int(_now_ts - _last_exit)}초 전 "
|
||||
f"— 쿨다운({EXIT_COOLDOWN_SEC}초) 중 스킵"
|
||||
)
|
||||
continue
|
||||
|
||||
# ── 매도 수량 결정 (소량 분할 방지) ─────────────────────
|
||||
sell_pct = getattr(exit_signal, "sell_pct", 1.0)
|
||||
if sell_pct < 1.0 and quantity <= 3:
|
||||
@@ -339,6 +356,8 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]:
|
||||
"strategy_type": "swing",
|
||||
})
|
||||
|
||||
_recent_exits[stock_code] = time.time()
|
||||
|
||||
# 포트폴리오 화면 즉시 반영 (Portfolio 수량 차감 + 캐시)
|
||||
from app.services.portfolio_cache_bridge import reflect_sell
|
||||
await reflect_sell(stock_code, sell_qty, latest_price, remaining_qty=quantity - sell_qty)
|
||||
|
||||
Reference in New Issue
Block a user