fix: reconcile ambiguous KIS order submissions
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
In-memory adapters; deterministic paper double drives the full async path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from trex_core.adapters.broker.paper import PaperBroker
|
||||
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
|
||||
from trex_core.application.broker_port import FillStatus, OrderAck
|
||||
from trex_core.application.execution import ExecutionService
|
||||
from trex_core.application.intents import BuyIntent, IntentService, SellIntent
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
@@ -19,9 +21,12 @@ def _wire(broker, max_active_slots=None):
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo, instance_key=INST)
|
||||
intents = IntentService(
|
||||
svc, repo, InMemoryIntentStore(),
|
||||
svc,
|
||||
repo,
|
||||
InMemoryIntentStore(),
|
||||
resolve_symbol=SYMBOLS.get,
|
||||
instance_key=INST, max_active_slots=max_active_slots,
|
||||
instance_key=INST,
|
||||
max_active_slots=max_active_slots,
|
||||
)
|
||||
execution = ExecutionService(svc, repo, broker, instance_key=INST)
|
||||
repo.reconcile_universe(INST, SYMBOLS) # permanent asset slots
|
||||
@@ -29,9 +34,7 @@ def _wire(broker, max_active_slots=None):
|
||||
|
||||
|
||||
def _buy(intents, iid="b1", pid="PF-1", qty=10):
|
||||
return intents.submit_buy(
|
||||
BuyIntent(intent_id=iid, portfolio_id=pid, qty=qty, entry_price=1000)
|
||||
)
|
||||
return intents.submit_buy(BuyIntent(intent_id=iid, portfolio_id=pid, qty=qty, entry_price=1000))
|
||||
|
||||
|
||||
def test_buy_lifecycle_to_holding():
|
||||
@@ -48,13 +51,93 @@ def test_buy_dispatch_is_idempotent_no_double_order():
|
||||
broker = PaperBroker(fill_after_polls=5) # stays accepted (not filled) across ticks
|
||||
repo, svc, intents, execution = _wire(broker)
|
||||
sid = _buy(intents).slot_id
|
||||
execution.run_once() # dispatch (1 order) + poll(accepted)
|
||||
execution.run_once() # dispatch (1 order) + poll(accepted)
|
||||
bid1 = repo.fetch_slot(sid).broker_order_id
|
||||
execution.run_once() # must NOT re-dispatch
|
||||
execution.run_once() # must NOT re-dispatch
|
||||
bid2 = repo.fetch_slot(sid).broker_order_id
|
||||
assert bid1 == bid2 and len(broker._orders) == 1 # exactly one broker order
|
||||
|
||||
|
||||
def test_unknown_submit_outcome_stays_dispatched_without_retry():
|
||||
class UnknownOutcomeBroker(PaperBroker):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.submit_count = 0
|
||||
self.cancel_count = 0
|
||||
|
||||
def submit_order(self, req):
|
||||
self.submit_count += 1
|
||||
return OrderAck(
|
||||
accepted=True,
|
||||
broker_order_id=f"submission-unknown:{req.slot_id}",
|
||||
reason="submit_outcome_unknown:ReadTimeout",
|
||||
)
|
||||
|
||||
def poll_fill(self, broker_order_id, *, side, symbol):
|
||||
return FillStatus(state="unknown")
|
||||
|
||||
def cancel_order(self, broker_order_id, *, side, symbol, qty, venue="KRX"):
|
||||
self.cancel_count += 1
|
||||
return False
|
||||
|
||||
broker = UnknownOutcomeBroker()
|
||||
repo, svc, intents, execution = _wire(broker)
|
||||
sid = _buy(intents).slot_id
|
||||
|
||||
first = execution.run_once()
|
||||
second = execution.run_once()
|
||||
|
||||
slot = repo.fetch_slot(sid)
|
||||
assert first.dispatched == 1
|
||||
assert second.dispatched == 0
|
||||
assert broker.submit_count == 1
|
||||
assert broker.cancel_count == 0
|
||||
assert slot.status is SlotState.PENDING_BUY
|
||||
assert slot.broker_order_id == f"submission-unknown:{sid}"
|
||||
|
||||
|
||||
def test_unknown_submit_resolution_is_persisted_before_restart_reconcile():
|
||||
class ResolvedOutcomeBroker(PaperBroker):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.submit_count = 0
|
||||
self.poll_count = 0
|
||||
|
||||
def submit_order(self, req):
|
||||
self.submit_count += 1
|
||||
return OrderAck(
|
||||
accepted=True,
|
||||
broker_order_id=f"submission-unknown:{req.slot_id}",
|
||||
reason="submit_outcome_unknown:ReadTimeout",
|
||||
)
|
||||
|
||||
def poll_fill(self, broker_order_id, *, side, symbol):
|
||||
self.poll_count += 1
|
||||
if self.poll_count == 1:
|
||||
return FillStatus(
|
||||
state="accepted",
|
||||
raw={"resolved_broker_order_id": "91258:0012345678"},
|
||||
)
|
||||
return FillStatus(
|
||||
state="filled",
|
||||
filled_qty=10,
|
||||
avg_price=1000,
|
||||
execution_id="resolved-fill-1",
|
||||
)
|
||||
|
||||
broker = ResolvedOutcomeBroker()
|
||||
repo, svc, intents, execution = _wire(broker)
|
||||
sid = _buy(intents).slot_id
|
||||
|
||||
execution.run_once()
|
||||
assert repo.fetch_slot(sid).broker_order_id == "91258:0012345678"
|
||||
|
||||
execution.reconcile()
|
||||
slot = repo.fetch_slot(sid)
|
||||
assert slot.status is SlotState.HOLDING
|
||||
assert broker.submit_count == 1
|
||||
|
||||
|
||||
def test_buy_reject_rolls_back_to_inactive():
|
||||
repo, svc, intents, execution = _wire(PaperBroker(reject_symbols={"005931"}))
|
||||
sid = _buy(intents).slot_id
|
||||
@@ -156,8 +239,7 @@ def test_sell_skipped_when_broker_flat():
|
||||
sid = _buy(intents).slot_id
|
||||
execution.run_once() # holding (broker balance 10)
|
||||
broker._orders.clear() # simulate divergence: broker is actually flat
|
||||
svc.transit_slot(sid, SlotState.HOLDING, SlotState.PENDING_SELL,
|
||||
reason="r", actor="t")
|
||||
svc.transit_slot(sid, SlotState.HOLDING, SlotState.PENDING_SELL, reason="r", actor="t")
|
||||
r = execution.run_once()
|
||||
assert r.sell_skipped_no_balance == 1
|
||||
assert r.dispatched == 0
|
||||
@@ -172,8 +254,7 @@ def test_skipped_sell_does_not_block_other_dispatches():
|
||||
sid = _buy(intents, iid="b1", pid="PF-1").slot_id
|
||||
execution.run_once() # PF-1 holding
|
||||
broker._orders.clear() # broker flat for PF-1's symbol
|
||||
svc.transit_slot(sid, SlotState.HOLDING, SlotState.PENDING_SELL,
|
||||
reason="r", actor="t")
|
||||
svc.transit_slot(sid, SlotState.HOLDING, SlotState.PENDING_SELL, reason="r", actor="t")
|
||||
d2 = _buy(intents, iid="b2", pid="PF-2") # a buy behind the stuck sell
|
||||
r = execution.run_once()
|
||||
assert r.sell_skipped_no_balance == 1
|
||||
@@ -185,10 +266,10 @@ def test_reconcile_after_crash_completes_fill_without_redispatch():
|
||||
broker = PaperBroker(fill_after_polls=1)
|
||||
repo, svc, intents, execution = _wire(broker)
|
||||
sid = _buy(intents).slot_id
|
||||
execution.run_once() # dispatch + poll1 (accepted, not filled) — simulates crash here
|
||||
execution.run_once() # dispatch + poll1 (accepted, not filled) — simulates crash here
|
||||
assert repo.fetch_slot(sid).status is SlotState.PENDING_BUY
|
||||
orders_before = len(broker._orders)
|
||||
execution.reconcile() # restart: re-poll -> fills, no new order
|
||||
execution.reconcile() # restart: re-poll -> fills, no new order
|
||||
slot = repo.fetch_slot(sid)
|
||||
assert slot.status is SlotState.HOLDING
|
||||
assert len(broker._orders) == orders_before # reconcile did not submit a new order
|
||||
@@ -197,7 +278,8 @@ def test_reconcile_after_crash_completes_fill_without_redispatch():
|
||||
def test_capacity_full_blocks_further_buys():
|
||||
"""dcy-020/REQ-059: at M active slots further buys reject capacity_full."""
|
||||
repo, svc, intents, execution = _wire(
|
||||
PaperBroker(fill_after_polls=0), max_active_slots=2,
|
||||
PaperBroker(fill_after_polls=0),
|
||||
max_active_slots=2,
|
||||
)
|
||||
assert _buy(intents, iid="b0", pid="PF-0").status == "admitted"
|
||||
assert _buy(intents, iid="b1", pid="PF-1").status == "admitted"
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
"""KIS-compatible virtual HTTP recovery tests for ambiguous order submission."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from trex_core.adapters.broker.kis import KISBroker
|
||||
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
|
||||
from trex_core.application.execution import ExecutionService
|
||||
from trex_core.application.intents import BuyIntent, IntentService
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
KST = ZoneInfo("Asia/Seoul")
|
||||
INSTANCE = "virtual-reliability"
|
||||
PORTFOLIO = "virtual-005930"
|
||||
SYMBOL = "005930"
|
||||
|
||||
|
||||
class _ExchangeState:
|
||||
def __init__(self, *, first_attempt_reaches_exchange: bool) -> None:
|
||||
self.first_attempt_reaches_exchange = first_attempt_reaches_exchange
|
||||
self.submit_attempts = 0
|
||||
self.orders: list[dict[str, str]] = []
|
||||
self.fill_orders = False
|
||||
|
||||
def submit(self, body: dict[str, str]) -> tuple[bool, dict[str, object]]:
|
||||
self.submit_attempts += 1
|
||||
order = {
|
||||
"pdno": body["PDNO"],
|
||||
"sll_buy_dvsn_cd": "02",
|
||||
"ord_qty": body["ORD_QTY"],
|
||||
"ord_unpr": body["ORD_UNPR"],
|
||||
"ord_dt": datetime.now(KST).strftime("%Y%m%d"),
|
||||
"ord_tmd": datetime.now(KST).strftime("%H%M%S"),
|
||||
"ord_gno_brno": "91258",
|
||||
"odno": f"{self.submit_attempts:010d}",
|
||||
"tot_ccld_qty": "0",
|
||||
"avg_prvs": "0",
|
||||
"cncl_yn": "N",
|
||||
}
|
||||
reached = self.submit_attempts > 1 or self.first_attempt_reaches_exchange
|
||||
if reached:
|
||||
self.orders.append(order)
|
||||
return reached, {
|
||||
"rt_cd": "0",
|
||||
"output": {
|
||||
"KRX_FWDG_ORD_ORGNO": order["ord_gno_brno"],
|
||||
"ODNO": order["odno"],
|
||||
},
|
||||
}
|
||||
|
||||
def daily_orders(self) -> dict[str, object]:
|
||||
rows = []
|
||||
for order in self.orders:
|
||||
row = dict(order)
|
||||
if self.fill_orders:
|
||||
row["tot_ccld_qty"] = row["ord_qty"]
|
||||
row["avg_prvs"] = row["ord_unpr"]
|
||||
rows.append(row)
|
||||
return {"rt_cd": "0", "output1": rows}
|
||||
|
||||
def balance(self) -> dict[str, object]:
|
||||
quantity = sum(int(order["ord_qty"]) for order in self.orders) if self.fill_orders else 0
|
||||
rows = [{"pdno": SYMBOL, "hldg_qty": str(quantity)}] if quantity else []
|
||||
return {"rt_cd": "0", "output1": rows}
|
||||
|
||||
|
||||
def _serve(state: _ExchangeState):
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *_args):
|
||||
return
|
||||
|
||||
def do_POST(self):
|
||||
length = int(self.headers.get("content-length", "0"))
|
||||
body = json.loads(self.rfile.read(length))
|
||||
reached, payload = state.submit(body)
|
||||
if state.submit_attempts == 1:
|
||||
time.sleep(0.08)
|
||||
self._reply(payload if reached or state.submit_attempts > 1 else payload)
|
||||
|
||||
def do_GET(self):
|
||||
if self.path.startswith("/uapi/domestic-stock/v1/trading/inquire-balance"):
|
||||
self._reply(state.balance())
|
||||
else:
|
||||
self._reply(state.daily_orders())
|
||||
|
||||
def _reply(self, payload):
|
||||
raw = json.dumps(payload).encode()
|
||||
try:
|
||||
self.send_response(200)
|
||||
self.send_header("content-type", "application/json")
|
||||
self.send_header("content-length", str(len(raw)))
|
||||
self.end_headers()
|
||||
self.wfile.write(raw)
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
pass
|
||||
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
return server
|
||||
|
||||
|
||||
def _broker(server: ThreadingHTTPServer) -> KISBroker:
|
||||
broker = KISBroker(
|
||||
"12345678-01",
|
||||
app_key="virtual-key",
|
||||
app_secret="virtual-secret",
|
||||
base_url=f"http://127.0.0.1:{server.server_port}",
|
||||
timeout=0.03,
|
||||
allow_live=True,
|
||||
)
|
||||
broker._headers = lambda _tr_id: {} # virtual exchange needs no credentials
|
||||
return broker
|
||||
|
||||
|
||||
def _wire(broker: KISBroker):
|
||||
repo = InMemorySlotRepository()
|
||||
slots = SlotService(repo, instance_key=INSTANCE)
|
||||
intents = IntentService(
|
||||
slots,
|
||||
repo,
|
||||
InMemoryIntentStore(),
|
||||
resolve_symbol={PORTFOLIO: SYMBOL}.get,
|
||||
instance_key=INSTANCE,
|
||||
)
|
||||
repo.reconcile_universe(INSTANCE, {PORTFOLIO: SYMBOL})
|
||||
execution = ExecutionService(slots, repo, broker, instance_key=INSTANCE)
|
||||
decision = intents.submit_buy(
|
||||
BuyIntent(
|
||||
intent_id="virtual-buy-1",
|
||||
portfolio_id=PORTFOLIO,
|
||||
qty=2,
|
||||
entry_price=238_500,
|
||||
)
|
||||
)
|
||||
return repo, slots, intents, execution, decision.slot_id
|
||||
|
||||
|
||||
def test_timeout_after_exchange_accept_resolves_without_duplicate_and_survives_restart():
|
||||
state = _ExchangeState(first_attempt_reaches_exchange=True)
|
||||
server = _serve(state)
|
||||
try:
|
||||
repo, slots, _intents, execution, slot_id = _wire(_broker(server))
|
||||
|
||||
execution.run_once()
|
||||
pending = repo.fetch_slot(slot_id)
|
||||
assert pending.status is SlotState.PENDING_BUY
|
||||
assert pending.broker_order_id == "91258:0000000001"
|
||||
assert state.submit_attempts == 1
|
||||
assert len(state.orders) == 1
|
||||
|
||||
state.fill_orders = True
|
||||
restarted = ExecutionService(slots, repo, _broker(server), instance_key=INSTANCE)
|
||||
restarted.reconcile()
|
||||
|
||||
holding = repo.fetch_slot(slot_id)
|
||||
assert holding.status is SlotState.HOLDING
|
||||
assert holding.qty == 2
|
||||
assert holding.avg_price == 238_500
|
||||
assert state.submit_attempts == 1
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
|
||||
def test_timeout_before_exchange_accept_retries_only_after_confirmed_absence(monkeypatch):
|
||||
state = _ExchangeState(first_attempt_reaches_exchange=False)
|
||||
state.fill_orders = True
|
||||
server = _serve(state)
|
||||
monkeypatch.setattr(
|
||||
"trex_core.adapters.broker.kis._SUBMISSION_LOOKUP_GRACE_SECONDS",
|
||||
-1.0,
|
||||
)
|
||||
try:
|
||||
repo, _slots, intents, execution, slot_id = _wire(_broker(server))
|
||||
|
||||
execution.run_once()
|
||||
assert repo.fetch_slot(slot_id).status is SlotState.INACTIVE
|
||||
assert state.submit_attempts == 1
|
||||
assert state.orders == []
|
||||
|
||||
retry = intents.submit_buy(
|
||||
BuyIntent(
|
||||
intent_id="virtual-buy-2",
|
||||
portfolio_id=PORTFOLIO,
|
||||
qty=2,
|
||||
entry_price=238_500,
|
||||
)
|
||||
)
|
||||
assert retry.status == "admitted"
|
||||
execution.run_once()
|
||||
holding = repo.fetch_slot(slot_id)
|
||||
assert holding.status is SlotState.HOLDING
|
||||
assert holding.qty == 2
|
||||
assert state.submit_attempts == 2
|
||||
assert len(state.orders) == 1
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
Reference in New Issue
Block a user