|
14 | 14 | from loguru import logger |
15 | 15 |
|
16 | 16 | from config.config_loader import Config |
17 | | -from api.kis_api import KISApi |
| 17 | +from api.kis_api import KISApi, KISOrderResponseUnknown |
18 | 18 | from core.risk_manager import RiskManager |
19 | 19 | from database.repositories import ( |
20 | 20 | save_trade, save_position, delete_position, reduce_position, |
|
32 | 32 | def _log_op_event(*a, **kw): pass |
33 | 33 |
|
34 | 34 |
|
| 35 | +# 비멱등 주문의 응답 유실(체결 여부 불명) 표식. |
| 36 | +# 재시도 래퍼가 KISOrderResponseUnknown을 받으면 이 표식을 돌려주고, |
| 37 | +# 호출부는 재전송 대신 reconcile 대기 결과로 분기한다(이중 체결 방지). |
| 38 | +ORDER_RESPONSE_UNKNOWN = object() |
| 39 | + |
| 40 | + |
35 | 41 | class OrderExecutor: |
36 | 42 | """ |
37 | 43 | 주문 실행기 |
@@ -910,6 +916,8 @@ def _execute_buy_impl( |
910 | 916 | symbol=symbol, action="BUY", price=price, quantity=quantity, |
911 | 917 | strategy=strategy, signal_score=signal_score, reason=reason, |
912 | 918 | ) |
| 919 | + if order_result is ORDER_RESPONSE_UNKNOWN: |
| 920 | + return self._unknown_response_result(order, "BUY") |
913 | 921 | if order_result is None: |
914 | 922 | order.transition(OrderStatus.REJECTED, reason="KIS API 3회 재시도 실패") |
915 | 923 | OrderGuard.clear(symbol) |
@@ -1328,6 +1336,8 @@ def _execute_sell_impl( |
1328 | 1336 | symbol=symbol, action="SELL", price=price, quantity=sell_qty, |
1329 | 1337 | strategy=strategy, signal_score=signal_score, reason=reason, |
1330 | 1338 | ) |
| 1339 | + if order_result is ORDER_RESPONSE_UNKNOWN: |
| 1340 | + return self._unknown_response_result(order, "SELL") |
1331 | 1341 | if order_result is None: |
1332 | 1342 | order.transition(OrderStatus.REJECTED, reason="KIS API 3회 재시도 실패") |
1333 | 1343 | OrderGuard.clear(symbol) |
@@ -1790,6 +1800,35 @@ def _mark_partial_live_execution(self, order, execution: dict) -> None: |
1790 | 1800 | fill_price=fill_price, |
1791 | 1801 | ) |
1792 | 1802 |
|
| 1803 | + def _unknown_response_result(self, order, action: str) -> dict: |
| 1804 | + """비멱등 주문의 응답이 유실돼 체결 여부가 불명한 경우의 결과. |
| 1805 | +
|
| 1806 | + 주문이 브로커에 접수됐을 수 있으므로: |
| 1807 | + - 재전송하지 않는다(이중 체결 방지). 호출 전에 이미 재시도 래퍼에서 중단됨. |
| 1808 | + - 주문을 SUBMITTED 상태로 유지한다(미완료 주문으로 남아 다음 시도의 |
| 1809 | + persistent open-order 차단이 같은 종목 중복 주문을 막는다). |
| 1810 | + - OrderGuard를 clear하지 않는다(TTL 동안 추가 중복 차단). |
| 1811 | + - 장부(거래·포지션)는 반영하지 않고, requires_reconcile로 표시해 |
| 1812 | + 다음 KIS↔DB 동기화에서 실제 체결분을 대조하게 한다. |
| 1813 | + """ |
| 1814 | + self._persist_order_record(order) |
| 1815 | + logger.warning( |
| 1816 | + "실전 주문 응답 유실 — 접수 여부 불명, 장부 반영 보류·reconcile 대기: {} {} order_id={} status={}", |
| 1817 | + action, order.symbol, order.order_id, order.status.value, |
| 1818 | + ) |
| 1819 | + return { |
| 1820 | + "success": False, |
| 1821 | + "reason": "실전 주문 응답이 유실돼 체결 여부를 확인할 수 없습니다. 재전송하지 않고 reconcile을 대기합니다.", |
| 1822 | + "symbol": order.symbol, |
| 1823 | + "action": action, |
| 1824 | + "mode": self.mode, |
| 1825 | + "order_pending": True, |
| 1826 | + "requires_reconcile": True, |
| 1827 | + "response_unknown": True, |
| 1828 | + "order_id": order.order_id, |
| 1829 | + "order_status": order.status.value, |
| 1830 | + } |
| 1831 | + |
1793 | 1832 | def _pending_live_execution_result( |
1794 | 1833 | self, |
1795 | 1834 | *, |
@@ -2063,7 +2102,17 @@ def _execute_with_retry( |
2063 | 2102 | 모든 재시도 실패 시 dead-letter 테이블에 저장하여 주문 누락을 방지합니다. |
2064 | 2103 | """ |
2065 | 2104 | for attempt in range(1, self.MAX_RETRIES + 1): |
2066 | | - result = order_func(*args) |
| 2105 | + try: |
| 2106 | + result = order_func(*args) |
| 2107 | + except KISOrderResponseUnknown as exc: |
| 2108 | + # 응답 유실 — 주문이 브로커에 접수됐을 수 있어 체결 여부 불명. |
| 2109 | + # 재전송하면 이중 체결 위험이므로 즉시 중단하고 reconcile 대기로 넘긴다. |
| 2110 | + # dead-letter에 넣지 않는다(접수됐을 수 있어 '주문 누락'이 아님). |
| 2111 | + logger.error( |
| 2112 | + "주문 응답 유실 — 재전송 금지(이중 체결 방지), reconcile 대기: {} {} ({})", |
| 2113 | + action or "?", symbol or "?", exc, |
| 2114 | + ) |
| 2115 | + return ORDER_RESPONSE_UNKNOWN |
2067 | 2116 | if result is not None: |
2068 | 2117 | if attempt > 1: |
2069 | 2118 | logger.info("주문 성공 ({}회째 시도)", attempt) |
|
0 commit comments