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

@@ -500,13 +500,24 @@ class KoreaInvestmentAPI:
async with self.semaphore:
return await self._make_request_with_retry(method, endpoint, tr_id, extra_headers=extra_headers, **kwargs)
# 주문 계열 엔드포인트 — 재전송 시 중복 체결 위험이 있어 내부 재시도를 하지 않는다.
# (상위 trade_executor._place_order_with_retry 가 체결 확인 후 재시도를 판단한다)
_NON_IDEMPOTENT_ENDPOINTS = (
"/uapi/domestic-stock/v1/trading/order-cash",
"/uapi/domestic-stock/v1/trading/order-rvsecncl",
"/uapi/overseas-stock/v1/trading/order",
)
async def _make_request_with_retry(self, method: str, endpoint: str, tr_id: str, extra_headers: Optional[Dict[str, str]] = None, **kwargs) -> (Dict[str, Any], Any):
"""재시도 로직을 포함한 실제 요청 실행"""
"""재시도 로직을 포함한 실제 요청 실행 (주문 엔드포인트는 재시도 없음)"""
start_time = time.time()
retry_count = 0
last_exception = None
for attempt in range(self.max_retries + 1):
# 주문은 1회만 전송 — 타임아웃/5xx 에도 재전송하지 않는다(중복 주문 방지)
_max_retries = 0 if endpoint in self._NON_IDEMPOTENT_ENDPOINTS else self.max_retries
for attempt in range(_max_retries + 1):
try:
# 쓰로틀링: 최소 요청 간격 보장
# _throttle_lock으로 "간격 계산 + sleep + last_request_time 갱신"을 직렬화하여
@@ -556,9 +567,9 @@ class KoreaInvestmentAPI:
# 서버 오류 (5xx, 429) — 재시도
elif response.status_code in [429, 500, 502, 503, 504]:
retry_count += 1
if attempt < self.max_retries:
if attempt < _max_retries:
delay = self.retry_delay * (self.retry_backoff ** attempt) + random.uniform(0, 0.3)
logger.warning(f"⏳ API 재시도 {attempt + 1}/{self.max_retries}: {response.status_code}, {delay:.1f}초 후")
logger.warning(f"⏳ API 재시도 {attempt + 1}/{_max_retries}: {response.status_code}, {delay:.1f}초 후")
await asyncio.sleep(delay)
continue
else:
@@ -577,9 +588,9 @@ class KoreaInvestmentAPI:
retry_count += 1
last_exception = e
if attempt < self.max_retries:
if attempt < _max_retries:
delay = self.retry_delay * (self.retry_backoff ** attempt) + random.uniform(0, 0.3)
logger.warning(f"⏳ API 타임아웃, {delay:.1f}초 후 재시도 ({attempt + 1}/{self.max_retries})")
logger.warning(f"⏳ API 타임아웃, {delay:.1f}초 후 재시도 ({attempt + 1}/{_max_retries})")
await asyncio.sleep(delay)
continue
else:
@@ -1092,6 +1103,46 @@ class KoreaInvestmentAPI:
logger.error(f"Error fetching balance: {e}")
raise
async def get_todays_orders(self, stock_code: str = "") -> list[dict]:
"""당일 주문체결 내역 조회 (inquire-daily-ccld).
주문 전송 후 응답을 못 받았을 때 '실제로 접수됐는지' 확인해 중복 주문을 막는 용도.
실전: TTTC8001R / 모의: VTTC8001R. 실패 시 빈 리스트.
"""
try:
account_number_prefix = self.account_number[:8]
account_number_suffix = self.account_number[8:]
is_virtual = "vts" in self.base_url.lower()
today = datetime.now().strftime("%Y%m%d")
result, _ = await self._make_request(
"GET",
"/uapi/domestic-stock/v1/trading/inquire-daily-ccld",
"VTTC8001R" if is_virtual else "TTTC8001R",
params={
"CANO": account_number_prefix,
"ACNT_PRDT_CD": account_number_suffix,
"INQR_STRT_DT": today,
"INQR_END_DT": today,
"SLL_BUY_DVSN_CD": "00",
"INQR_DVSN": "00",
"PDNO": stock_code or "",
"CCLD_DVSN": "00",
"ORD_GNO_BRNO": "",
"ODNO": "",
"INQR_DVSN_3": "00",
"INQR_DVSN_1": "",
"CTX_AREA_FK100": "",
"CTX_AREA_NK100": "",
},
)
if result.get("rt_cd") != "0":
return []
out = result.get("output1") or []
return out if isinstance(out, list) else [out]
except Exception as e:
logger.debug(f"[get_todays_orders] 조회 실패: {e}")
return []
async def get_buyable_cash(self, stock_code: str = "005930") -> float:
"""주문가능현금(ord_psbl_cash) 조회 — 매수가능조회 API.