Skip to content

Commit 7e986a2

Browse files
authored
Merge pull request #393 from easygap/fix/order-lost-response-no-resubmit
fix: 비멱등 주문 응답 유실 시 재전송 금지 — live 이중 체결 위험 차단
2 parents f546574 + 0634602 commit 7e986a2

4 files changed

Lines changed: 150 additions & 21 deletions

File tree

api/kis_api.py

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,15 @@ class KISTokenExpiredError(Exception):
2626
"""KIS API 401 응답(토큰 만료) 시 사용. CircuitBreaker 실패로 누적하지 않음."""
2727

2828

29+
class KISOrderResponseUnknown(Exception):
30+
"""비멱등(주문 제출) 요청에서 응답을 받지 못한 네트워크 오류.
31+
32+
주문 바이트가 브로커에 도달했을 수 있어 체결 여부가 불명한 상태다.
33+
재전송하면 이중 체결 위험이 있으므로, 상위(재시도 래퍼)가 이 예외를 받으면
34+
재전송하지 않고 reconcile(미체결 조회·잔고 대조) 경로로 넘겨야 한다.
35+
"""
36+
37+
2938
class KISApi:
3039
"""
3140
한국투자증권 Open API 래퍼
@@ -400,12 +409,13 @@ def _request(
400409
breaker.on_failure()
401410
if not idempotent:
402411
# 주문 제출처럼 비멱등 요청은 응답 유실 시 재전송하면 이중 체결 위험.
403-
# 재시도하지 않고 빈 응답을 돌려 상위 reconcile/미체결 조회가 판단하게 한다.
412+
# 재시도하지 않고, 체결 여부 불명 예외를 던져 상위 재시도 래퍼가
413+
# 재전송 대신 reconcile/미체결 조회 경로로 분기하게 한다.
404414
logger.error(
405415
"비멱등 요청 네트워크 오류 — 재전송하지 않고 중단(이중 체결 방지): {} - {}",
406416
path, type(e).__name__,
407417
)
408-
return {}
418+
raise KISOrderResponseUnknown(f"{path}: {type(e).__name__}") from e
409419
wait = self._backoff_with_jitter(attempt, base=2.0)
410420
logger.warning(
411421
"연결/SSL 오류, {:.1f}초 후 재시도 ({}/{}) - 경로: {} - {} (누적: {}회)",
@@ -419,7 +429,7 @@ def _request(
419429
logger.error(
420430
"비멱등 요청 타임아웃 — 재전송하지 않고 중단(이중 체결 방지): {}", path,
421431
)
422-
return {}
432+
raise KISOrderResponseUnknown(f"{path}: Timeout")
423433
wait = self._backoff_with_jitter(attempt)
424434
logger.warning("요청 타임아웃, {:.1f}초 후 재시도 ({}/{}) - 경로: {}", wait, attempt, max_retries, path)
425435
time.sleep(wait)
@@ -430,7 +440,7 @@ def _request(
430440
logger.error(
431441
"비멱등 요청 실패 — 재전송하지 않고 중단(이중 체결 방지): {} - {}", path, e,
432442
)
433-
return {}
443+
raise KISOrderResponseUnknown(f"{path}: {type(e).__name__}") from e
434444
logger.error("요청 실패: {} - {}", path, e)
435445
time.sleep(self._backoff_with_jitter(attempt, base=0.5))
436446

@@ -1442,13 +1452,18 @@ def place_overseas_order(
14421452
"ORD_DVSN": "00",
14431453
}
14441454

1445-
data = self._request(
1446-
"POST",
1447-
"/uapi/overseas-stock/v1/trading/order",
1448-
tr_id,
1449-
body=body,
1450-
idempotent=False, # 주문 제출: 응답 유실 시 재전송 금지(이중 체결 방지)
1451-
)
1455+
try:
1456+
data = self._request(
1457+
"POST",
1458+
"/uapi/overseas-stock/v1/trading/order",
1459+
tr_id,
1460+
body=body,
1461+
idempotent=False, # 주문 제출: 응답 유실 시 재전송 금지(이중 체결 방지)
1462+
)
1463+
except KISOrderResponseUnknown as exc:
1464+
# 응답 유실(체결 여부 불명) — 재전송하지 않고 실패로 처리(이중 체결 방지).
1465+
logger.error("해외주문 응답 유실 — 재전송 금지, 체결 여부 불명: {} {} ({})", symb, sd, exc)
1466+
return None
14521467
if data and data.get("rt_cd") == "0":
14531468
logger.info("해외주문 성공 {} {} {}주 @ {}", sd, symb, qty, unpr)
14541469
out = data.get("output")

core/order_executor.py

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from loguru import logger
1515

1616
from config.config_loader import Config
17-
from api.kis_api import KISApi
17+
from api.kis_api import KISApi, KISOrderResponseUnknown
1818
from core.risk_manager import RiskManager
1919
from database.repositories import (
2020
save_trade, save_position, delete_position, reduce_position,
@@ -32,6 +32,12 @@
3232
def _log_op_event(*a, **kw): pass
3333

3434

35+
# 비멱등 주문의 응답 유실(체결 여부 불명) 표식.
36+
# 재시도 래퍼가 KISOrderResponseUnknown을 받으면 이 표식을 돌려주고,
37+
# 호출부는 재전송 대신 reconcile 대기 결과로 분기한다(이중 체결 방지).
38+
ORDER_RESPONSE_UNKNOWN = object()
39+
40+
3541
class OrderExecutor:
3642
"""
3743
주문 실행기
@@ -910,6 +916,8 @@ def _execute_buy_impl(
910916
symbol=symbol, action="BUY", price=price, quantity=quantity,
911917
strategy=strategy, signal_score=signal_score, reason=reason,
912918
)
919+
if order_result is ORDER_RESPONSE_UNKNOWN:
920+
return self._unknown_response_result(order, "BUY")
913921
if order_result is None:
914922
order.transition(OrderStatus.REJECTED, reason="KIS API 3회 재시도 실패")
915923
OrderGuard.clear(symbol)
@@ -1328,6 +1336,8 @@ def _execute_sell_impl(
13281336
symbol=symbol, action="SELL", price=price, quantity=sell_qty,
13291337
strategy=strategy, signal_score=signal_score, reason=reason,
13301338
)
1339+
if order_result is ORDER_RESPONSE_UNKNOWN:
1340+
return self._unknown_response_result(order, "SELL")
13311341
if order_result is None:
13321342
order.transition(OrderStatus.REJECTED, reason="KIS API 3회 재시도 실패")
13331343
OrderGuard.clear(symbol)
@@ -1790,6 +1800,35 @@ def _mark_partial_live_execution(self, order, execution: dict) -> None:
17901800
fill_price=fill_price,
17911801
)
17921802

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+
17931832
def _pending_live_execution_result(
17941833
self,
17951834
*,
@@ -2063,7 +2102,17 @@ def _execute_with_retry(
20632102
모든 재시도 실패 시 dead-letter 테이블에 저장하여 주문 누락을 방지합니다.
20642103
"""
20652104
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
20672116
if result is not None:
20682117
if attempt > 1:
20692118
logger.info("주문 성공 ({}회째 시도)", attempt)

tests/test_executor_state_machine.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -864,3 +864,67 @@ def get_order_execution_after_order(self, symbol, order_output):
864864
finally:
865865
session.close()
866866
OrderGuard.clear("005387")
867+
868+
def test_live_buy_lost_response_does_not_resubmit_and_requires_reconcile(self):
869+
"""비멱등 주문의 응답 유실 시 재전송하지 않고(이중 체결 방지) reconcile 대기로 남긴다.
870+
871+
브로커가 주문을 받았으나 응답이 유실되면(타임아웃/연결 리셋) KISApi가
872+
KISOrderResponseUnknown을 던진다. 재시도 래퍼는 이를 받아 재전송하지 않고,
873+
주문은 SUBMITTED·OrderGuard 유지로 중복을 막으며 장부 반영을 보류해야 한다.
874+
"""
875+
from core.order_guard import OrderGuard
876+
from api.kis_api import KISOrderResponseUnknown
877+
from database.models import TradeHistory, get_session
878+
from database.repositories import get_open_order_records, get_position
879+
880+
class LostResponseKIS:
881+
def __init__(self):
882+
self.buy_calls = 0
883+
884+
def has_unfilled_orders(self, symbol):
885+
return False
886+
887+
def buy_order(self, symbol, quantity, price):
888+
self.buy_calls += 1
889+
raise KISOrderResponseUnknown("order-cash: ConnectionError")
890+
891+
OrderGuard.clear("005931")
892+
kis = LostResponseKIS()
893+
executor = self._prepare_live_executor(self._make_executor(), kis)
894+
895+
result = executor.execute_buy(
896+
symbol="005931",
897+
price=60_000,
898+
capital=10_000_000,
899+
available_cash=10_000_000,
900+
signal_score=2.0,
901+
reason="live lost response test",
902+
strategy="scoring",
903+
)
904+
905+
# 1) 재전송 금지: buy_order는 정확히 1회만 호출돼야 한다(재시도 없음).
906+
assert kis.buy_calls == 1, f"재전송 발생: buy_order {kis.buy_calls}회 호출"
907+
# 2) 결과는 실패가 아니라 reconcile 대기 신호.
908+
assert result["success"] is False
909+
assert result["order_pending"] is True
910+
assert result["requires_reconcile"] is True
911+
assert result["response_unknown"] is True
912+
# 3) 주문은 SUBMITTED로 남아 다음 시도의 persistent open-order 차단이 중복을 막는다.
913+
assert result["order_status"] == OrderStatus.SUBMITTED.value
914+
orders = [o for o in executor.order_book._orders.values() if o.symbol == "005931"]
915+
assert orders[-1].status == OrderStatus.SUBMITTED
916+
# 4) OrderGuard는 유지(추가 중복 차단), 장부(포지션·거래)는 미반영.
917+
assert OrderGuard.has_pending("005931")
918+
assert get_position("005931", account_key="test_sm") is None
919+
open_records = get_open_order_records(
920+
symbol="005931", account_key="test_sm", mode="live",
921+
)
922+
assert len(open_records) == 1
923+
assert open_records[0]["status"] == OrderStatus.SUBMITTED.value
924+
925+
session = get_session()
926+
try:
927+
assert session.query(TradeHistory).filter(TradeHistory.symbol == "005931").count() == 0
928+
finally:
929+
session.close()
930+
OrderGuard.clear("005931")

tests/test_kis_order_idempotency.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
"""KIS _request 비멱등(주문) 재시도 안전성 회귀 테스트.
22
33
핵심: 주문 제출(POST)은 응답을 못 받은 네트워크 오류에서 재전송하면 이중 체결이
4-
난다. idempotent=False면 한 번만 보내고 빈 응답을 돌려 상위 reconcile가 판단해야 한다.
4+
난다. idempotent=False면 한 번만 보내고, 체결 여부 불명 예외(KISOrderResponseUnknown)를
5+
던져 상위 재시도 래퍼가 재전송 대신 reconcile 경로로 분기하게 한다.
56
"""
67
import time
78
from unittest.mock import patch
89

910
import pytest
1011
import requests
1112

12-
from api.kis_api import KISApi
13+
from api.kis_api import KISApi, KISOrderResponseUnknown
1314
from api.circuit_breaker import get_breaker
1415

1516

@@ -49,7 +50,7 @@ def _reset_breaker():
4950

5051

5152
def test_order_post_not_resubmitted_on_timeout():
52-
"""idempotent=False: Timeout 시 재전송하지 않고 1회 POST 후 빈 응답."""
53+
"""idempotent=False: Timeout 시 재전송하지 않고 1회 POST 후 체결 불명 예외."""
5354
api = _make_api()
5455
calls = {"post": 0}
5556

@@ -59,14 +60,14 @@ def fake_post(*a, **kw):
5960

6061
with patch("api.kis_api.requests.post", side_effect=fake_post), \
6162
patch("api.kis_api.requests.get", side_effect=AssertionError("should not GET")):
62-
result = api._request("POST", "/order", "TR", body={"x": 1}, idempotent=False)
63+
with pytest.raises(KISOrderResponseUnknown):
64+
api._request("POST", "/order", "TR", body={"x": 1}, idempotent=False)
6365

64-
assert result == {}
6566
assert calls["post"] == 1 # 단 한 번만 제출(재전송 없음)
6667

6768

6869
def test_order_post_not_resubmitted_on_connection_error():
69-
"""idempotent=False: ConnectionError(응답 유실 가능)도 재전송 금지."""
70+
"""idempotent=False: ConnectionError(응답 유실 가능)도 재전송 금지하고 체결 불명 예외."""
7071
api = _make_api()
7172
calls = {"post": 0}
7273

@@ -75,9 +76,9 @@ def fake_post(*a, **kw):
7576
raise requests.exceptions.ConnectionError("RST")
7677

7778
with patch("api.kis_api.requests.post", side_effect=fake_post):
78-
result = api._request("POST", "/order", "TR", body={"x": 1}, idempotent=False)
79+
with pytest.raises(KISOrderResponseUnknown):
80+
api._request("POST", "/order", "TR", body={"x": 1}, idempotent=False)
7981

80-
assert result == {}
8182
assert calls["post"] == 1
8283

8384

0 commit comments

Comments
 (0)