fix: persist uncertain cancel outcomes

This commit is contained in:
jordan
2026-07-29 17:17:14 +09:00
parent a656350a35
commit 3dd8eefc92
21 changed files with 416 additions and 76 deletions
+26 -5
View File
@@ -24,6 +24,8 @@ import httpx
from trex_core.application.broker_port import (
BalanceItem,
BrokerBalanceError,
CancelOutcome,
CancelState,
FillStatus,
OrderAck,
OrderRequest,
@@ -756,7 +758,7 @@ class KISBroker:
def cancel_order(
self, broker_order_id: str, *, side: str, symbol: str, qty: int, venue: str = "KRX"
) -> bool:
) -> CancelOutcome:
"""Cancel a previously submitted order (REQ-118).
venue: exchange venue of the original order. Must match the EXCG_ID_DVSN_CD
@@ -764,7 +766,10 @@ class KISBroker:
Real-account NXT cancel (EXCG=NXT) confirmed rt_cd=0 on 2026-07-06.
"""
if broker_order_id.startswith(_SUBMISSION_UNKNOWN_PREFIX):
return False
return CancelOutcome(
CancelState.UNKNOWN,
"submission_outcome_unknown",
)
cano, product = self._account()
parts = broker_order_id.split(":")
orgno, odno = (parts[0], parts[1]) if len(parts) == 2 else ("", parts[-1])
@@ -789,6 +794,22 @@ class KISBroker:
json=body,
timeout=self._timeout,
)
return str(resp.json().get("rt_cd")) == "0"
except (httpx.HTTPError, ValueError):
return False
except httpx.HTTPError:
return CancelOutcome(CancelState.UNKNOWN, "transport_failed")
if getattr(resp, "status_code", 200) >= 500:
return CancelOutcome(CancelState.UNKNOWN, "broker_unavailable")
try:
payload = resp.json()
except ValueError:
return CancelOutcome(CancelState.UNKNOWN, "malformed_response")
rt_cd = payload.get("rt_cd")
if str(rt_cd) == "0":
return CancelOutcome(CancelState.REQUESTED)
if rt_cd is None:
if getattr(resp, "status_code", 200) >= 400:
return CancelOutcome(CancelState.REJECTED, "http_rejected")
return CancelOutcome(CancelState.UNKNOWN, "missing_result_code")
return CancelOutcome(
CancelState.REJECTED,
str(payload.get("msg_cd") or "broker_rejected"),
)
+2 -1
View File
@@ -16,6 +16,7 @@ from threading import Lock
from trex_core.application.broker_port import (
BalanceItem,
BrokerBalanceError,
CancelOutcome,
FillStatus,
OrderAck,
OrderRequest,
@@ -146,7 +147,7 @@ class OwnershipAwareBroker:
symbol: str,
qty: int,
venue: str = "KRX",
) -> bool:
) -> CancelOutcome:
return self._delegate.cancel_order(
broker_order_id,
side=side,
+13 -3
View File
@@ -14,6 +14,8 @@ from typing import Optional
from trex_core.adapters.broker.seq_store import SeqStore
from trex_core.application.broker_port import (
BalanceItem,
CancelOutcome,
CancelState,
FillStatus,
OrderAck,
OrderRequest,
@@ -105,16 +107,24 @@ class PaperBroker:
)
return FillStatus(state="partially_filled", filled_qty=o.filled, avg_price=o.price)
def cancel_order(self, broker_order_id: str, *, side: str, symbol: str, qty: int, venue: str = "KRX") -> bool:
def cancel_order(
self,
broker_order_id: str,
*,
side: str,
symbol: str,
qty: int,
venue: str = "KRX",
) -> CancelOutcome:
"""Cancel a paper order. venue is accepted for BrokerPort protocol conformance
(REQ-118) but ignored — paper has no exchange concept. Existing behavior preserved.
"""
with self._lock:
o = self._orders.get(broker_order_id)
if o is None or o.filled >= o.qty:
return False
return CancelOutcome(CancelState.REJECTED, "order_not_cancellable")
o.cancelled = True
return True
return CancelOutcome(CancelState.REQUESTED)
def get_balance(self) -> list[BalanceItem]:
"""Deterministic per-symbol fill aggregate (REQ-031 AC-3).
+32 -4
View File
@@ -37,7 +37,8 @@ _SLOT_COLUMNS = (
"order_id, strategy_id, broker_order_id, portfolio_id, "
"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"
"timeout_sec, slippage_pct, occupancy_seq, venue, "
"cancel_state, cancel_requested_at"
)
@@ -216,7 +217,8 @@ class PostgreSQLSlotRepository:
"broker_order_id=%s, portfolio_id=%s, sell_strategy_id=%s, "
"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, updated_at=%s WHERE slot_id=%s",
"occupancy_seq=%s, venue=%s, cancel_state=%s, "
"cancel_requested_at=%s, updated_at=%s WHERE slot_id=%s",
(
slot.status.value,
slot.symbol,
@@ -237,6 +239,8 @@ class PostgreSQLSlotRepository:
slot.slippage_pct,
slot.occupancy_seq,
slot.venue,
slot.cancel_state,
slot.cancel_requested_at,
slot.updated_at or datetime.now(tz=timezone.utc),
slot.slot_id,
),
@@ -332,6 +336,26 @@ class PostgreSQLSlotRepository:
conn.commit()
return ok
def mark_cancel_outcome(
self,
slot_id: UUID,
broker_order_id: str,
state: str,
) -> bool:
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=%s, "
"cancel_requested_at=%s, updated_at=%s "
"WHERE slot_id=%s AND broker_order_id=%s "
"AND status IN ('pending_buy','pending_sell')",
(state, now, now, slot_id, broker_order_id),
)
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:
@@ -347,7 +371,8 @@ class PostgreSQLSlotRepository:
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
with conn.cursor() as cur:
cur.execute(
"UPDATE daily_slot SET broker_order_id=NULL, updated_at=%s WHERE slot_id=%s",
"UPDATE daily_slot SET broker_order_id=NULL, cancel_state=NULL, "
"cancel_requested_at=NULL, updated_at=%s WHERE slot_id=%s",
(datetime.now(tz=timezone.utc), slot_id),
)
conn.commit()
@@ -374,7 +399,8 @@ class PostgreSQLSlotRepository:
# forced_close_at(11) stop_loss_price(12) take_profit_price(13)
# 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)
# occupancy_seq(20) [dcy-028/REQ-081], venue(21),
# cancel_state(22), cancel_requested_at(23)
return Slot(
slot_id=row[0] if isinstance(row[0], UUID) else UUID(str(row[0])),
status=SlotState(row[1]),
@@ -398,6 +424,8 @@ class PostgreSQLSlotRepository:
slippage_pct=f(row[19]),
occupancy_seq=int(row[20]) if len(row) > 20 and row[20] is not None else 0,
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,
)
@@ -0,0 +1,18 @@
-- dcy-041 / GitHub #44: cancellation request acceptance is distinct from
-- final order cancellation. Persist the immediate result to prevent blind
-- automatic retries across execution ticks and daemon restarts.
ALTER TABLE daily_slot
ADD COLUMN IF NOT EXISTS cancel_state TEXT;
ALTER TABLE daily_slot
ADD COLUMN IF NOT EXISTS cancel_requested_at TIMESTAMPTZ;
ALTER TABLE daily_slot
DROP CONSTRAINT IF EXISTS daily_slot_cancel_state_check;
ALTER TABLE daily_slot
ADD CONSTRAINT daily_slot_cancel_state_check
CHECK (
cancel_state IS NULL
OR cancel_state IN ('requested', 'rejected', 'unknown')
);