feat(execution): reconcile timed-out orders safely
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -14,7 +14,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Iterator, Optional
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
@@ -38,9 +38,11 @@ _SLOT_COLUMNS = (
|
||||
"sell_strategy_id, forced_close_at, stop_loss_price, take_profit_price, "
|
||||
"created_at, updated_at, instance_key, retired_at, "
|
||||
"timeout_sec, slippage_pct, occupancy_seq, venue, "
|
||||
"cancel_state, cancel_requested_at"
|
||||
"cancel_state, cancel_requested_at, order_submitted_at, order_date, cancel_attempts"
|
||||
)
|
||||
|
||||
_KST = timezone(timedelta(hours=9))
|
||||
|
||||
|
||||
def _require_psycopg(module: Any = None) -> Any:
|
||||
if module is not None:
|
||||
@@ -218,7 +220,8 @@ class PostgreSQLSlotRepository:
|
||||
"forced_close_at=%s, stop_loss_price=%s, take_profit_price=%s, "
|
||||
"instance_key=%s, retired_at=%s, timeout_sec=%s, slippage_pct=%s, "
|
||||
"occupancy_seq=%s, venue=%s, cancel_state=%s, "
|
||||
"cancel_requested_at=%s, updated_at=%s WHERE slot_id=%s",
|
||||
"cancel_requested_at=%s, order_submitted_at=%s, order_date=%s, "
|
||||
"cancel_attempts=%s, updated_at=%s WHERE slot_id=%s",
|
||||
(
|
||||
slot.status.value,
|
||||
slot.symbol,
|
||||
@@ -241,6 +244,9 @@ class PostgreSQLSlotRepository:
|
||||
slot.venue,
|
||||
slot.cancel_state,
|
||||
slot.cancel_requested_at,
|
||||
slot.order_submitted_at,
|
||||
slot.order_date,
|
||||
slot.cancel_attempts,
|
||||
slot.updated_at or datetime.now(tz=timezone.utc),
|
||||
slot.slot_id,
|
||||
),
|
||||
@@ -295,16 +301,22 @@ class PostgreSQLSlotRepository:
|
||||
broker_order_id: str,
|
||||
*,
|
||||
venue: Optional[str] = None,
|
||||
submitted_at: Optional[datetime] = None,
|
||||
) -> bool:
|
||||
submitted_at = submitted_at or datetime.now(tz=timezone.utc)
|
||||
order_date = submitted_at.astimezone(_KST).strftime("%Y%m%d")
|
||||
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE daily_slot SET broker_order_id=%s, "
|
||||
"venue=COALESCE(%s, venue), updated_at=%s "
|
||||
"venue=COALESCE(%s, venue), order_submitted_at=%s, order_date=%s, "
|
||||
"cancel_attempts=0, updated_at=%s "
|
||||
"WHERE slot_id=%s AND broker_order_id IS NULL",
|
||||
(
|
||||
broker_order_id,
|
||||
venue,
|
||||
submitted_at,
|
||||
order_date,
|
||||
datetime.now(tz=timezone.utc),
|
||||
slot_id,
|
||||
),
|
||||
@@ -366,6 +378,31 @@ class PostgreSQLSlotRepository:
|
||||
conn.commit()
|
||||
return ok
|
||||
|
||||
def reserve_cancel_attempt(
|
||||
self,
|
||||
slot_id: UUID,
|
||||
broker_order_id: str,
|
||||
*,
|
||||
retry_before: Optional[datetime] = None,
|
||||
) -> bool:
|
||||
"""Atomically reserve first cancel or an authoritative backoff-gated retry."""
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE daily_slot SET cancel_state='unknown', cancel_requested_at=%s, "
|
||||
"cancel_attempts=cancel_attempts+1, updated_at=%s "
|
||||
"WHERE slot_id=%s AND broker_order_id=%s "
|
||||
"AND status IN ('pending_buy','pending_sell') "
|
||||
"AND (cancel_state IS NULL OR "
|
||||
"(%s IS NOT NULL AND cancel_state IN ('rejected','unknown') "
|
||||
"AND cancel_attempts < 4 AND cancel_requested_at <= %s))",
|
||||
(now, now, slot_id, broker_order_id, retry_before, retry_before),
|
||||
)
|
||||
ok = cur.rowcount == 1
|
||||
conn.commit()
|
||||
return ok
|
||||
|
||||
def list_dispatched_pending(self, instance_key: str = "default") -> list[Slot]:
|
||||
with self._psycopg.connect(self._dsn) as conn:
|
||||
with conn.cursor() as cur:
|
||||
@@ -382,7 +419,8 @@ class PostgreSQLSlotRepository:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE daily_slot SET broker_order_id=NULL, cancel_state=NULL, "
|
||||
"cancel_requested_at=NULL, updated_at=%s WHERE slot_id=%s",
|
||||
"cancel_requested_at=NULL, order_submitted_at=NULL, order_date=NULL, "
|
||||
"cancel_attempts=0, updated_at=%s WHERE slot_id=%s",
|
||||
(datetime.now(tz=timezone.utc), slot_id),
|
||||
)
|
||||
conn.commit()
|
||||
@@ -410,7 +448,8 @@ class PostgreSQLSlotRepository:
|
||||
# created_at(14) updated_at(15) instance_key(16) retired_at(17)
|
||||
# timeout_sec(18) slippage_pct(19) [dcy-022/REQ-068]
|
||||
# occupancy_seq(20) [dcy-028/REQ-081], venue(21),
|
||||
# cancel_state(22), cancel_requested_at(23)
|
||||
# cancel_state(22), cancel_requested_at(23), order_submitted_at(24),
|
||||
# order_date(25), cancel_attempts(26)
|
||||
return Slot(
|
||||
slot_id=row[0] if isinstance(row[0], UUID) else UUID(str(row[0])),
|
||||
status=SlotState(row[1]),
|
||||
@@ -436,6 +475,9 @@ class PostgreSQLSlotRepository:
|
||||
venue=str(row[21]) if len(row) > 21 and row[21] is not None else "KRX",
|
||||
cancel_state=str(row[22]) if len(row) > 22 and row[22] is not None else None,
|
||||
cancel_requested_at=row[23] if len(row) > 23 else None,
|
||||
order_submitted_at=row[24] if len(row) > 24 else None,
|
||||
order_date=str(row[25]) if len(row) > 25 and row[25] is not None else None,
|
||||
cancel_attempts=int(row[26]) if len(row) > 26 and row[26] is not None else 0,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
-- GitHub #58: persist the effective timeout's immutable dispatch anchor and
|
||||
-- original KST order date separately from mutable row bookkeeping. Existing
|
||||
-- dispatched rows deliberately remain NULL: updated_at/created_at are mutable
|
||||
-- bookkeeping and cannot prove the original submission timestamp or order day.
|
||||
-- Runtime keeps those legacy rows fail-closed and raises a sanitized alarm.
|
||||
ALTER TABLE daily_slot
|
||||
ADD COLUMN IF NOT EXISTS order_submitted_at TIMESTAMPTZ;
|
||||
|
||||
ALTER TABLE daily_slot
|
||||
ADD COLUMN IF NOT EXISTS order_date TEXT;
|
||||
|
||||
ALTER TABLE daily_slot
|
||||
ADD COLUMN IF NOT EXISTS cancel_attempts INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
ALTER TABLE daily_slot
|
||||
DROP CONSTRAINT IF EXISTS daily_slot_order_date_check;
|
||||
|
||||
ALTER TABLE daily_slot
|
||||
ADD CONSTRAINT daily_slot_order_date_check
|
||||
CHECK (order_date IS NULL OR order_date ~ '^[0-9]{8}$');
|
||||
|
||||
ALTER TABLE daily_slot
|
||||
DROP CONSTRAINT IF EXISTS daily_slot_cancel_attempts_check;
|
||||
|
||||
ALTER TABLE daily_slot
|
||||
ADD CONSTRAINT daily_slot_cancel_attempts_check
|
||||
CHECK (cancel_attempts >= 0);
|
||||
Reference in New Issue
Block a user