fix: reconcile ambiguous KIS order submissions
This commit is contained in:
@@ -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