feat(execution): reconcile timed-out orders safely

This commit is contained in:
Hermes Agent
2026-08-14 15:15:52 +09:00
parent 482b00e54d
commit 147873cb5d
21 changed files with 801 additions and 87 deletions
+43 -7
View File
@@ -307,6 +307,7 @@ class KISBroker:
reason=f"submit_outcome_unknown:{type(exc).__name__}",
venue=actual_venue,
raw={"error_class": type(exc).__name__},
submitted_at=submitted_at,
)
if getattr(resp, "status_code", 200) >= 500:
return OrderAck(
@@ -315,6 +316,7 @@ class KISBroker:
reason=f"submit_outcome_unknown:http_{resp.status_code}",
venue=actual_venue,
raw={"http_status": resp.status_code},
submitted_at=submitted_at,
)
if str(payload.get("rt_cd")) == "0":
out = payload.get("output") or {}
@@ -326,6 +328,7 @@ class KISBroker:
broker_order_id=bid,
venue=actual_venue,
raw=payload,
submitted_at=submitted_at,
)
return OrderAck(
accepted=False,
@@ -362,7 +365,15 @@ class KISBroker:
"EXCG_ID_DVSN_CD": "ALL",
}
def poll_fill(self, broker_order_id: str, *, side: str, symbol: str) -> FillStatus:
def poll_fill(
self,
broker_order_id: str,
*,
side: str,
symbol: str,
order_date: Optional[str] = None,
submitted_at: Optional[datetime] = None,
) -> FillStatus:
if broker_order_id.startswith(_SUBMISSION_UNKNOWN_PREFIX):
return self._poll_unknown_submission(broker_order_id, side=side, symbol=symbol)
now = monotonic()
@@ -375,8 +386,8 @@ class KISBroker:
# open duplicate KIS inquiries for the same order.
self._fill_poll_not_before[broker_order_id] = now + _FILL_POLL_MIN_INTERVAL_SECONDS
odno = broker_order_id.split(":")[-1]
today = datetime.now(tz=_KST).strftime("%Y%m%d")
tr_id, params = self._daily_fill_request(today, order_number=odno)
inquiry_date = order_date or self._clock().astimezone(_KST).strftime("%Y%m%d")
tr_id, params = self._daily_fill_request(inquiry_date, order_number=odno)
try:
resp = httpx.get(
f"{self._base_url}/uapi/domestic-stock/v1/trading/inquire-daily-ccld",
@@ -410,11 +421,36 @@ class KISBroker:
None,
)
if row is None:
return FillStatus(
state="accepted",
raw={"reason": "order_not_visible_in_fill_inquiry"},
visible_grace = (
submitted_at is None
or (self._clock().astimezone(timezone.utc) - submitted_at.astimezone(timezone.utc))
.total_seconds()
< _SUBMISSION_LOOKUP_GRACE_SECONDS
)
return self._fill_status_from_row(row, side=side, today=today, fallback_odno=odno)
return FillStatus(
state="accepted" if visible_grace else "unknown",
raw={
"reason": (
"order_visibility_grace"
if visible_grace
else "order_not_visible_unresolved"
),
"order_visible": False,
"inquiry_date": inquiry_date,
},
)
status = self._fill_status_from_row(
row, side=side, today=inquiry_date, fallback_odno=odno
)
return replace(
status,
raw={
**(status.raw if isinstance(status.raw, dict) else {}),
"order_visible": True,
"order_open_confirmed": status.state in {"accepted", "partially_filled"},
"inquiry_date": inquiry_date,
},
)
def _poll_unknown_submission(
self,
+18 -2
View File
@@ -10,8 +10,10 @@ from __future__ import annotations
import json
import os
from datetime import datetime
from pathlib import Path
from threading import Lock
from typing import Optional
from trex_core.application.broker_port import (
BalanceItem,
@@ -126,8 +128,22 @@ class OwnershipAwareBroker:
def submit_order(self, req: OrderRequest) -> OrderAck:
return self._delegate.submit_order(req)
def poll_fill(self, broker_order_id: str, *, side: str, symbol: str) -> FillStatus:
result = self._delegate.poll_fill(broker_order_id, side=side, symbol=symbol)
def poll_fill(
self,
broker_order_id: str,
*,
side: str,
symbol: str,
order_date: Optional[str] = None,
submitted_at: Optional[datetime] = None,
) -> FillStatus:
result = self._delegate.poll_fill(
broker_order_id,
side=side,
symbol=symbol,
order_date=order_date,
submitted_at=submitted_at,
)
if result.execution_id and result.filled_qty > 0 and (
result.is_filled or result.is_partial_cancel
):
+22 -3
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import threading
from decimal import Decimal
from datetime import datetime
from typing import Optional
from trex_core.adapters.broker.seq_store import SeqStore
@@ -80,7 +81,15 @@ class PaperBroker:
self._orders[oid] = _Order(oid, req)
return OrderAck(accepted=True, broker_order_id=oid, venue=req.venue)
def poll_fill(self, broker_order_id: str, *, side: str, symbol: str) -> FillStatus:
def poll_fill(
self,
broker_order_id: str,
*,
side: str,
symbol: str,
order_date: Optional[str] = None,
submitted_at: Optional[datetime] = None,
) -> FillStatus:
with self._lock:
o = self._orders.get(broker_order_id)
if o is None:
@@ -91,7 +100,12 @@ class PaperBroker:
if symbol in self._reject:
return FillStatus(state="rejected", raw={"reason": "paper_reject"})
if o.polls <= self._fill_after:
return FillStatus(state="accepted", filled_qty=o.filled, avg_price=o.price)
return FillStatus(
state="accepted",
filled_qty=o.filled,
avg_price=o.price,
raw={"order_open_confirmed": True},
)
step = self._qty_per_poll if self._qty_per_poll is not None else (o.qty - o.filled)
o.filled = min(o.qty, o.filled + step)
if o.filled >= o.qty:
@@ -105,7 +119,12 @@ class PaperBroker:
state="filled", filled_qty=o.filled, avg_price=o.price,
execution_id=execution_id, fee=fee, tax=tax,
)
return FillStatus(state="partially_filled", filled_qty=o.filled, avg_price=o.price)
return FillStatus(
state="partially_filled",
filled_qty=o.filled,
avg_price=o.price,
raw={"order_open_confirmed": True},
)
def cancel_order(
self,