fix: reconcile ambiguous KIS order submissions

This commit is contained in:
jordan
2026-07-28 11:17:06 +09:00
parent b72bf9f394
commit 24c42d1c92
11 changed files with 1420 additions and 191 deletions
+222 -14
View File
@@ -3,10 +3,12 @@
Unit tests run always; the sandbox integration test runs only when KIS
모의투자 credentials are present (TREX_KIS_BROKER_* env / .env).
"""
from __future__ import annotations
import os
import httpx
import pytest
from trex_core.adapters.broker.kis import KISBroker
@@ -25,21 +27,35 @@ def test_account_split_and_mock_tr_ids():
def test_live_base_url_requires_allow_live():
with pytest.raises(RuntimeError):
KISBroker("12345678-01", app_key="k", app_secret="s",
base_url="https://openapi.koreainvestment.com:9443")
KISBroker(
"12345678-01",
app_key="k",
app_secret="s",
base_url="https://openapi.koreainvestment.com:9443",
)
# gated on explicitly
b = KISBroker("12345678-01", app_key="k", app_secret="s",
base_url="https://openapi.koreainvestment.com:9443", allow_live=True)
b = KISBroker(
"12345678-01",
app_key="k",
app_secret="s",
base_url="https://openapi.koreainvestment.com:9443",
allow_live=True,
)
assert b.name() == "kis-live" and b._tr_submit("buy") == "TTTC0012U"
def _kis_env():
keys = ("TREX_KIS_BROKER_BASE_URL", "TREX_KIS_BROKER_APP_KEY",
"TREX_KIS_BROKER_APP_SECRET", "TREX_KIS_BROKER_ACCOUNT_REF")
keys = (
"TREX_KIS_BROKER_BASE_URL",
"TREX_KIS_BROKER_APP_KEY",
"TREX_KIS_BROKER_APP_SECRET",
"TREX_KIS_BROKER_ACCOUNT_REF",
)
if not all(os.environ.get(k) for k in keys):
# try .env via dotenv
try:
from dotenv import load_dotenv, find_dotenv
load_dotenv(find_dotenv(usecwd=True), override=False)
except ImportError:
pass
@@ -68,8 +84,11 @@ def test_sandbox_token_and_submit():
assert isinstance(token, str) and len(token) > 0
# limit buy 1 share at a low price (sandbox, no real money). Reuses the cached token.
ack = b.submit_order(OrderRequest(slot_id="t", side="buy", symbol="005930", qty=1,
limit_price=10000, order_type="00"))
ack = b.submit_order(
OrderRequest(
slot_id="t", side="buy", symbol="005930", qty=1, limit_price=10000, order_type="00"
)
)
assert ack.raw, "expected a KIS response payload"
if ack.accepted:
assert ack.broker_order_id
@@ -83,8 +102,11 @@ def test_get_balance_rejects_failure_instead_of_false_empty(monkeypatch):
monkeypatch.setattr(b, "_headers", lambda _tr: {})
class Response:
def raise_for_status(self): pass
def json(self): return {"rt_cd": "1", "output1": []}
def raise_for_status(self):
pass
def json(self):
return {"rt_cd": "1", "output1": []}
monkeypatch.setattr("trex_core.adapters.broker.kis.httpx.get", lambda *a, **kw: Response())
with pytest.raises(BrokerBalanceError, match="broker_rejected"):
@@ -96,8 +118,11 @@ def test_get_balance_accepts_validated_empty_balance(monkeypatch):
monkeypatch.setattr(b, "_headers", lambda _tr: {})
class Response:
def raise_for_status(self): pass
def json(self): return {"rt_cd": "0", "output1": []}
def raise_for_status(self):
pass
def json(self):
return {"rt_cd": "0", "output1": []}
monkeypatch.setattr("trex_core.adapters.broker.kis.httpx.get", lambda *a, **kw: Response())
assert b.get_balance() == []
@@ -105,7 +130,9 @@ def test_get_balance_accepts_validated_empty_balance(monkeypatch):
def test_get_balance_sanitizes_token_issuance_failure(monkeypatch):
b = KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL)
monkeypatch.setattr(b, "_headers", lambda _tr: (_ for _ in ()).throw(KeyError("access_token-secret")))
monkeypatch.setattr(
b, "_headers", lambda _tr: (_ for _ in ()).throw(KeyError("access_token-secret"))
)
with pytest.raises(BrokerBalanceError) as raised:
b.get_balance()
assert raised.value.code == "token_issuance_failed"
@@ -118,7 +145,9 @@ def test_get_available_cash_uses_cash_only_amount(monkeypatch):
monkeypatch.setattr(b, "get_current_price", lambda _symbol: None)
class Response:
def raise_for_status(self): pass
def raise_for_status(self):
pass
def json(self):
return {
"rt_cd": "0",
@@ -127,3 +156,182 @@ def test_get_available_cash_uses_cash_only_amount(monkeypatch):
monkeypatch.setattr("trex_core.adapters.broker.kis.httpx.get", lambda *a, **kw: Response())
assert b.get_available_cash() == 695_591
def test_submit_transport_failure_blocks_automatic_duplicate(monkeypatch):
b = KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL)
monkeypatch.setattr(b, "_headers", lambda _tr: {})
monkeypatch.setattr(
"trex_core.adapters.broker.kis.httpx.post",
lambda *a, **kw: (_ for _ in ()).throw(httpx.ReadTimeout("timed out")),
)
ack = b.submit_order(
OrderRequest(
slot_id="slot-1",
side="buy",
symbol="005930",
qty=4,
limit_price=232_000,
)
)
assert ack.accepted is True
assert ack.broker_order_id.startswith("submission-unknown:")
assert ack.reason == "submit_outcome_unknown:ReadTimeout"
assert (
b.cancel_order(
ack.broker_order_id,
side="buy",
symbol="005930",
qty=4,
)
is False
)
def test_submit_gateway_5xx_blocks_automatic_duplicate(monkeypatch):
b = KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL)
monkeypatch.setattr(b, "_headers", lambda _tr: {})
class Response:
status_code = 502
def json(self):
return {"error": "upstream_unavailable"}
monkeypatch.setattr(
"trex_core.adapters.broker.kis.httpx.post",
lambda *a, **kw: Response(),
)
ack = b.submit_order(
OrderRequest(
slot_id="slot-2",
side="buy",
symbol="005930",
qty=4,
limit_price=231_500,
)
)
assert ack.accepted is True
assert ack.broker_order_id.startswith("submission-unknown:")
assert ack.reason == "submit_outcome_unknown:http_502"
def test_submit_unknown_is_resolved_by_unique_daily_order(monkeypatch):
b = KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL)
monkeypatch.setattr(b, "_headers", lambda _tr: {})
monkeypatch.setattr(
"trex_core.adapters.broker.kis.httpx.post",
lambda *a, **kw: (_ for _ in ()).throw(httpx.ReadTimeout("timed out")),
)
ack = b.submit_order(
OrderRequest(
slot_id="slot-resolve",
side="buy",
symbol="005930",
qty=4,
limit_price=232_000,
)
)
from trex_core.adapters.broker.kis import _decode_submission_unknown
submitted = _decode_submission_unknown(ack.broker_order_id)["submitted_at"]
from datetime import datetime
at = datetime.fromisoformat(submitted)
row = {
"pdno": "005930",
"sll_buy_dvsn_cd": "02",
"ord_qty": "4",
"ord_unpr": "232000",
"ord_dt": at.strftime("%Y%m%d"),
"ord_tmd": at.strftime("%H%M%S"),
"ord_gno_brno": "91258",
"odno": "0012345678",
"tot_ccld_qty": "0",
"cncl_yn": "N",
}
class Response:
status_code = 200
def json(self):
return {"rt_cd": "0", "output1": [row]}
monkeypatch.setattr(
"trex_core.adapters.broker.kis.httpx.get",
lambda *a, **kw: Response(),
)
status = b.poll_fill(ack.broker_order_id, side="buy", symbol="005930")
assert status.state == "accepted"
assert status.raw["resolved_broker_order_id"] == "91258:0012345678"
def test_submit_unknown_confirmed_absent_becomes_retryable_reject(monkeypatch):
b = KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL)
monkeypatch.setattr(b, "_headers", lambda _tr: {})
monkeypatch.setattr(
"trex_core.adapters.broker.kis.httpx.post",
lambda *a, **kw: (_ for _ in ()).throw(httpx.ReadTimeout("timed out")),
)
ack = b.submit_order(
OrderRequest(
slot_id="slot-absent",
side="buy",
symbol="005930",
qty=4,
limit_price=232_000,
)
)
class Response:
status_code = 200
def json(self):
return {"rt_cd": "0", "output1": []}
monkeypatch.setattr(
"trex_core.adapters.broker.kis.httpx.get",
lambda *a, **kw: Response(),
)
monkeypatch.setattr(
"trex_core.adapters.broker.kis._SUBMISSION_LOOKUP_GRACE_SECONDS",
-1.0,
)
status = b.poll_fill(ack.broker_order_id, side="buy", symbol="005930")
assert status.state == "rejected"
assert status.raw == {"reason": "submission_confirmed_absent"}
def test_fill_poll_transport_failure_uses_backoff(monkeypatch):
b = KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL)
monkeypatch.setattr(b, "_headers", lambda _tr: {})
clock = [100.0]
monkeypatch.setattr("trex_core.adapters.broker.kis.monotonic", lambda: clock[0])
calls = []
def fail(*args, **kwargs):
calls.append((args, kwargs))
raise httpx.ConnectTimeout("timed out")
monkeypatch.setattr("trex_core.adapters.broker.kis.httpx.get", fail)
first = b.poll_fill("91258:1234", side="buy", symbol="005930")
immediate = b.poll_fill("91258:1234", side="buy", symbol="005930")
assert first.state == "unknown"
assert immediate.state == "accepted"
assert immediate.raw == {"reason": "fill_poll_backoff"}
assert len(calls) == 1
clock[0] += 31
again = b.poll_fill("91258:1234", side="buy", symbol="005930")
assert again.state == "unknown"
assert len(calls) == 2
+18 -6
View File
@@ -4,6 +4,7 @@ inquire-daily-ccld marks a cancelled order with cncl_yn="Y". These tests stub
the HTTP layer (token pre-seeded, httpx.get faked) and assert the FillStatus
mapping for full cancel / partial-fill-then-cancel / not-cancelled.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
@@ -17,7 +18,8 @@ ODNO = "0000123456"
def _broker():
b = KISBroker(
"12345678-01",
app_key="k", app_secret="s",
app_key="k",
app_secret="s",
base_url="https://openapivts.koreainvestment.com:29443", # mock endpoint
)
b._token = "tok"
@@ -36,14 +38,22 @@ class _Resp:
def _fake_get(payload):
def _get(*a, **k):
return _Resp(payload)
return _get
def _row(**kw):
base = {"odno": ODNO, "ord_qty": "10", "tot_ccld_qty": "0",
"avg_prvs": "0", "cncl_yn": "N", "ord_dt": "20260608", "ord_gno_brno": "06010"}
base = {
"odno": ODNO,
"ord_qty": "10",
"tot_ccld_qty": "0",
"avg_prvs": "0",
"cncl_yn": "N",
"ord_dt": "20260608",
"ord_gno_brno": "06010",
}
base.update(kw)
return {"output1": [base]}
return {"rt_cd": "0", "output1": [base]}
def test_full_external_cancel_maps_to_cancelled(monkeypatch):
@@ -60,7 +70,8 @@ def test_zero_fill_not_cancelled_stays_accepted(monkeypatch):
def test_partial_fill_then_cancel_flags_partial_cancel(monkeypatch):
monkeypatch.setattr(
kis_mod.httpx, "get",
kis_mod.httpx,
"get",
_fake_get(_row(ord_qty="10", tot_ccld_qty="4", avg_prvs="70000", cncl_yn="Y")),
)
fs = _broker().poll_fill(f"06010:{ODNO}", side="sell", symbol="005930")
@@ -72,7 +83,8 @@ def test_partial_fill_then_cancel_flags_partial_cancel(monkeypatch):
def test_full_fill_unaffected_by_cancel_flag(monkeypatch):
monkeypatch.setattr(
kis_mod.httpx, "get",
kis_mod.httpx,
"get",
_fake_get(_row(ord_qty="10", tot_ccld_qty="10", avg_prvs="70000", cncl_yn="N")),
)
fs = _broker().poll_fill(f"06010:{ODNO}", side="sell", symbol="005930")