Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 26 additions & 11 deletions api/kis_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@ class KISTokenExpiredError(Exception):
"""KIS API 401 응답(토큰 만료) 시 사용. CircuitBreaker 실패로 누적하지 않음."""


class KISOrderResponseUnknown(Exception):
"""비멱등(주문 제출) 요청에서 응답을 받지 못한 네트워크 오류.

주문 바이트가 브로커에 도달했을 수 있어 체결 여부가 불명한 상태다.
재전송하면 이중 체결 위험이 있으므로, 상위(재시도 래퍼)가 이 예외를 받으면
재전송하지 않고 reconcile(미체결 조회·잔고 대조) 경로로 넘겨야 한다.
"""


class KISApi:
"""
한국투자증권 Open API 래퍼
Expand Down Expand Up @@ -400,12 +409,13 @@ def _request(
breaker.on_failure()
if not idempotent:
# 주문 제출처럼 비멱등 요청은 응답 유실 시 재전송하면 이중 체결 위험.
# 재시도하지 않고 빈 응답을 돌려 상위 reconcile/미체결 조회가 판단하게 한다.
# 재시도하지 않고, 체결 여부 불명 예외를 던져 상위 재시도 래퍼가
# 재전송 대신 reconcile/미체결 조회 경로로 분기하게 한다.
logger.error(
"비멱등 요청 네트워크 오류 — 재전송하지 않고 중단(이중 체결 방지): {} - {}",
path, type(e).__name__,
)
return {}
raise KISOrderResponseUnknown(f"{path}: {type(e).__name__}") from e
wait = self._backoff_with_jitter(attempt, base=2.0)
logger.warning(
"연결/SSL 오류, {:.1f}초 후 재시도 ({}/{}) - 경로: {} - {} (누적: {}회)",
Expand All @@ -419,7 +429,7 @@ def _request(
logger.error(
"비멱등 요청 타임아웃 — 재전송하지 않고 중단(이중 체결 방지): {}", path,
)
return {}
raise KISOrderResponseUnknown(f"{path}: Timeout")
wait = self._backoff_with_jitter(attempt)
logger.warning("요청 타임아웃, {:.1f}초 후 재시도 ({}/{}) - 경로: {}", wait, attempt, max_retries, path)
time.sleep(wait)
Expand All @@ -430,7 +440,7 @@ def _request(
logger.error(
"비멱등 요청 실패 — 재전송하지 않고 중단(이중 체결 방지): {} - {}", path, e,
)
return {}
raise KISOrderResponseUnknown(f"{path}: {type(e).__name__}") from e
logger.error("요청 실패: {} - {}", path, e)
time.sleep(self._backoff_with_jitter(attempt, base=0.5))

Expand Down Expand Up @@ -1442,13 +1452,18 @@ def place_overseas_order(
"ORD_DVSN": "00",
}

data = self._request(
"POST",
"/uapi/overseas-stock/v1/trading/order",
tr_id,
body=body,
idempotent=False, # 주문 제출: 응답 유실 시 재전송 금지(이중 체결 방지)
)
try:
data = self._request(
"POST",
"/uapi/overseas-stock/v1/trading/order",
tr_id,
body=body,
idempotent=False, # 주문 제출: 응답 유실 시 재전송 금지(이중 체결 방지)
)
except KISOrderResponseUnknown as exc:
# 응답 유실(체결 여부 불명) — 재전송하지 않고 실패로 처리(이중 체결 방지).
logger.error("해외주문 응답 유실 — 재전송 금지, 체결 여부 불명: {} {} ({})", symb, sd, exc)
return None
if data and data.get("rt_cd") == "0":
logger.info("해외주문 성공 {} {} {}주 @ {}", sd, symb, qty, unpr)
out = data.get("output")
Expand Down
53 changes: 51 additions & 2 deletions core/order_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from loguru import logger

from config.config_loader import Config
from api.kis_api import KISApi
from api.kis_api import KISApi, KISOrderResponseUnknown
from core.risk_manager import RiskManager
from database.repositories import (
save_trade, save_position, delete_position, reduce_position,
Expand All @@ -32,6 +32,12 @@
def _log_op_event(*a, **kw): pass


# 비멱등 주문의 응답 유실(체결 여부 불명) 표식.
# 재시도 래퍼가 KISOrderResponseUnknown을 받으면 이 표식을 돌려주고,
# 호출부는 재전송 대신 reconcile 대기 결과로 분기한다(이중 체결 방지).
ORDER_RESPONSE_UNKNOWN = object()


class OrderExecutor:
"""
주문 실행기
Expand Down Expand Up @@ -910,6 +916,8 @@ def _execute_buy_impl(
symbol=symbol, action="BUY", price=price, quantity=quantity,
strategy=strategy, signal_score=signal_score, reason=reason,
)
if order_result is ORDER_RESPONSE_UNKNOWN:
return self._unknown_response_result(order, "BUY")
if order_result is None:
order.transition(OrderStatus.REJECTED, reason="KIS API 3회 재시도 실패")
OrderGuard.clear(symbol)
Expand Down Expand Up @@ -1328,6 +1336,8 @@ def _execute_sell_impl(
symbol=symbol, action="SELL", price=price, quantity=sell_qty,
strategy=strategy, signal_score=signal_score, reason=reason,
)
if order_result is ORDER_RESPONSE_UNKNOWN:
return self._unknown_response_result(order, "SELL")
if order_result is None:
order.transition(OrderStatus.REJECTED, reason="KIS API 3회 재시도 실패")
OrderGuard.clear(symbol)
Expand Down Expand Up @@ -1790,6 +1800,35 @@ def _mark_partial_live_execution(self, order, execution: dict) -> None:
fill_price=fill_price,
)

def _unknown_response_result(self, order, action: str) -> dict:
"""비멱등 주문의 응답이 유실돼 체결 여부가 불명한 경우의 결과.

주문이 브로커에 접수됐을 수 있으므로:
- 재전송하지 않는다(이중 체결 방지). 호출 전에 이미 재시도 래퍼에서 중단됨.
- 주문을 SUBMITTED 상태로 유지한다(미완료 주문으로 남아 다음 시도의
persistent open-order 차단이 같은 종목 중복 주문을 막는다).
- OrderGuard를 clear하지 않는다(TTL 동안 추가 중복 차단).
- 장부(거래·포지션)는 반영하지 않고, requires_reconcile로 표시해
다음 KIS↔DB 동기화에서 실제 체결분을 대조하게 한다.
"""
self._persist_order_record(order)
logger.warning(
"실전 주문 응답 유실 — 접수 여부 불명, 장부 반영 보류·reconcile 대기: {} {} order_id={} status={}",
action, order.symbol, order.order_id, order.status.value,
)
return {
"success": False,
"reason": "실전 주문 응답이 유실돼 체결 여부를 확인할 수 없습니다. 재전송하지 않고 reconcile을 대기합니다.",
"symbol": order.symbol,
"action": action,
"mode": self.mode,
"order_pending": True,
"requires_reconcile": True,
"response_unknown": True,
"order_id": order.order_id,
"order_status": order.status.value,
}

def _pending_live_execution_result(
self,
*,
Expand Down Expand Up @@ -2063,7 +2102,17 @@ def _execute_with_retry(
모든 재시도 실패 시 dead-letter 테이블에 저장하여 주문 누락을 방지합니다.
"""
for attempt in range(1, self.MAX_RETRIES + 1):
result = order_func(*args)
try:
result = order_func(*args)
except KISOrderResponseUnknown as exc:
# 응답 유실 — 주문이 브로커에 접수됐을 수 있어 체결 여부 불명.
# 재전송하면 이중 체결 위험이므로 즉시 중단하고 reconcile 대기로 넘긴다.
# dead-letter에 넣지 않는다(접수됐을 수 있어 '주문 누락'이 아님).
logger.error(
"주문 응답 유실 — 재전송 금지(이중 체결 방지), reconcile 대기: {} {} ({})",
action or "?", symbol or "?", exc,
)
return ORDER_RESPONSE_UNKNOWN
if result is not None:
if attempt > 1:
logger.info("주문 성공 ({}회째 시도)", attempt)
Expand Down
64 changes: 64 additions & 0 deletions tests/test_executor_state_machine.py
Original file line number Diff line number Diff line change
Expand Up @@ -864,3 +864,67 @@ def get_order_execution_after_order(self, symbol, order_output):
finally:
session.close()
OrderGuard.clear("005387")

def test_live_buy_lost_response_does_not_resubmit_and_requires_reconcile(self):
"""비멱등 주문의 응답 유실 시 재전송하지 않고(이중 체결 방지) reconcile 대기로 남긴다.

브로커가 주문을 받았으나 응답이 유실되면(타임아웃/연결 리셋) KISApi가
KISOrderResponseUnknown을 던진다. 재시도 래퍼는 이를 받아 재전송하지 않고,
주문은 SUBMITTED·OrderGuard 유지로 중복을 막으며 장부 반영을 보류해야 한다.
"""
from core.order_guard import OrderGuard
from api.kis_api import KISOrderResponseUnknown
from database.models import TradeHistory, get_session
from database.repositories import get_open_order_records, get_position

class LostResponseKIS:
def __init__(self):
self.buy_calls = 0

def has_unfilled_orders(self, symbol):
return False

def buy_order(self, symbol, quantity, price):
self.buy_calls += 1
raise KISOrderResponseUnknown("order-cash: ConnectionError")

OrderGuard.clear("005931")
kis = LostResponseKIS()
executor = self._prepare_live_executor(self._make_executor(), kis)

result = executor.execute_buy(
symbol="005931",
price=60_000,
capital=10_000_000,
available_cash=10_000_000,
signal_score=2.0,
reason="live lost response test",
strategy="scoring",
)

# 1) 재전송 금지: buy_order는 정확히 1회만 호출돼야 한다(재시도 없음).
assert kis.buy_calls == 1, f"재전송 발생: buy_order {kis.buy_calls}회 호출"
# 2) 결과는 실패가 아니라 reconcile 대기 신호.
assert result["success"] is False
assert result["order_pending"] is True
assert result["requires_reconcile"] is True
assert result["response_unknown"] is True
# 3) 주문은 SUBMITTED로 남아 다음 시도의 persistent open-order 차단이 중복을 막는다.
assert result["order_status"] == OrderStatus.SUBMITTED.value
orders = [o for o in executor.order_book._orders.values() if o.symbol == "005931"]
assert orders[-1].status == OrderStatus.SUBMITTED
# 4) OrderGuard는 유지(추가 중복 차단), 장부(포지션·거래)는 미반영.
assert OrderGuard.has_pending("005931")
assert get_position("005931", account_key="test_sm") is None
open_records = get_open_order_records(
symbol="005931", account_key="test_sm", mode="live",
)
assert len(open_records) == 1
assert open_records[0]["status"] == OrderStatus.SUBMITTED.value

session = get_session()
try:
assert session.query(TradeHistory).filter(TradeHistory.symbol == "005931").count() == 0
finally:
session.close()
OrderGuard.clear("005931")
17 changes: 9 additions & 8 deletions tests/test_kis_order_idempotency.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
"""KIS _request 비멱등(주문) 재시도 안전성 회귀 테스트.

핵심: 주문 제출(POST)은 응답을 못 받은 네트워크 오류에서 재전송하면 이중 체결이
난다. idempotent=False면 한 번만 보내고 빈 응답을 돌려 상위 reconcile가 판단해야 한다.
난다. idempotent=False면 한 번만 보내고, 체결 여부 불명 예외(KISOrderResponseUnknown)를
던져 상위 재시도 래퍼가 재전송 대신 reconcile 경로로 분기하게 한다.
"""
import time
from unittest.mock import patch

import pytest
import requests

from api.kis_api import KISApi
from api.kis_api import KISApi, KISOrderResponseUnknown
from api.circuit_breaker import get_breaker


Expand Down Expand Up @@ -49,7 +50,7 @@ def _reset_breaker():


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

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

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

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


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

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

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

assert result == {}
assert calls["post"] == 1


Expand Down
Loading