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
+2
View File
@@ -50,6 +50,8 @@
- allocated_capital :: numeric? :: 배정 자본 - allocated_capital :: numeric? :: 배정 자본
- order_id / broker_order_id :: str? :: 주문 식별자(자유 주석 / 브로커 핸들) - order_id / broker_order_id :: str? :: 주문 식별자(자유 주석 / 브로커 핸들)
- venue :: TEXT NOT NULL DEFAULT 'KRX' :: 현재 주문 거래소 구분(AUTO/KRX/NXT/SOR). intent 단계의 AUTO는 KIS 제출 시 실제 KRX/NXT로 교체되며 주문번호와 원자 저장한다. pending 주문·취소까지 보존하고 HOLDING 진입 시 다음 자율주문의 AUTO, INACTIVE 진입 시 KRX로 초기화한다. - venue :: TEXT NOT NULL DEFAULT 'KRX' :: 현재 주문 거래소 구분(AUTO/KRX/NXT/SOR). intent 단계의 AUTO는 KIS 제출 시 실제 KRX/NXT로 교체되며 주문번호와 원자 저장한다. pending 주문·취소까지 보존하고 HOLDING 진입 시 다음 자율주문의 AUTO, INACTIVE 진입 시 KRX로 초기화한다.
- cancel_state :: TEXT? :: 취소 요청의 즉시 결과(requested/rejected/unknown). 최종 취소 상태가 아니며 같은 주문의 자동 재취소를 막는다.
- cancel_requested_at :: TIMESTAMPTZ? :: 취소 요청 결과를 저장한 시각. 주문 종료·dispatch clear 때 함께 제거한다.
- strategy_id / sell_strategy_id :: str? :: 진입/청산 전략 식별 - strategy_id / sell_strategy_id :: str? :: 진입/청산 전략 식별
- portfolio_id :: str? :: 포트폴리오 식별 - portfolio_id :: str? :: 포트폴리오 식별
- forced_close_at :: datetime? :: 강제 청산 마감선 - forced_close_at :: datetime? :: 강제 청산 마감선
+3 -1
View File
@@ -431,10 +431,12 @@ resume_kill_switch 요청·응답 (REQ-046):
### cancel_order ### cancel_order
``` ```
cancel_order(broker_order_id: str, *, side: str, symbol: str, qty: int, venue: str = "KRX") -> bool cancel_order(broker_order_id: str, *, side: str, symbol: str, qty: int, venue: str = "KRX") -> CancelOutcome
``` ```
- `venue`: 원주문 venue. 취소 시 원주문과 동일한 `EXCG_ID_DVSN_CD`를 사용해야 한다. 기본값 `"KRX"`로 기존 호출자 하위 호환. - `venue`: 원주문 venue. 취소 시 원주문과 동일한 `EXCG_ID_DVSN_CD`를 사용해야 한다. 기본값 `"KRX"`로 기존 호출자 하위 호환.
- `CancelOutcome.state`: `requested`(요청 접수), `rejected`(명시적 거부), `unknown`(응답유실·전송 실패). `requested`도 최종 취소가 아니며 `poll_fill()`의 `cancelled|filled`만 주문의 최종 상태를 확정한다.
- Core는 외부 취소 호출 전에 `unknown`을 먼저 영속화하고 응답 뒤 실제 즉시 결과로 교체한다. 따라서 요청과 저장 사이에 프로세스가 종료돼도 자동 timeout 취소는 같은 주문을 재전송하지 않고, 재시작 뒤 체결조회로만 수렴한다.
### get_current_price ### get_current_price
+11 -9
View File
@@ -12,7 +12,11 @@ import httpx
import pytest import pytest
from trex_core.adapters.broker.kis import KISBroker from trex_core.adapters.broker.kis import KISBroker
from trex_core.application.broker_port import BrokerBalanceError, OrderRequest from trex_core.application.broker_port import (
BrokerBalanceError,
CancelState,
OrderRequest,
)
_MOCK_URL = "https://openapivts.koreainvestment.com:29443" _MOCK_URL = "https://openapivts.koreainvestment.com:29443"
@@ -179,15 +183,13 @@ def test_submit_transport_failure_blocks_automatic_duplicate(monkeypatch):
assert ack.accepted is True assert ack.accepted is True
assert ack.broker_order_id.startswith("submission-unknown:") assert ack.broker_order_id.startswith("submission-unknown:")
assert ack.reason == "submit_outcome_unknown:ReadTimeout" assert ack.reason == "submit_outcome_unknown:ReadTimeout"
assert ( cancel = b.cancel_order(
b.cancel_order( ack.broker_order_id,
ack.broker_order_id, side="buy",
side="buy", symbol="005930",
symbol="005930", qty=4,
qty=4,
)
is False
) )
assert cancel.state is CancelState.UNKNOWN
def test_submit_gateway_5xx_blocks_automatic_duplicate(monkeypatch): def test_submit_gateway_5xx_blocks_automatic_duplicate(monkeypatch):
+3 -1
View File
@@ -9,6 +9,8 @@ from trex_core.adapters.broker.ownership import (
from trex_core.application.broker_port import ( from trex_core.application.broker_port import (
BalanceItem, BalanceItem,
BrokerBalanceError, BrokerBalanceError,
CancelOutcome,
CancelState,
FillStatus, FillStatus,
OrderAck, OrderAck,
) )
@@ -29,7 +31,7 @@ class FakeBroker:
return self.fill return self.fill
def cancel_order(self, broker_order_id, *, side, symbol, qty, venue="KRX"): def cancel_order(self, broker_order_id, *, side, symbol, qty, venue="KRX"):
return True return CancelOutcome(CancelState.REQUESTED)
def get_balance(self): def get_balance(self):
return self.balance return self.balance
+39 -4
View File
@@ -26,12 +26,13 @@ from __future__ import annotations
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from decimal import Decimal from decimal import Decimal
import httpx
import pytest import pytest
from trex_core.adapters.broker import kis as kis_mod from trex_core.adapters.broker import kis as kis_mod
from trex_core.adapters.broker.kis import KISBroker, resolve_order_venue from trex_core.adapters.broker.kis import KISBroker, resolve_order_venue
from trex_core.adapters.broker.paper import PaperBroker from trex_core.adapters.broker.paper import PaperBroker
from trex_core.application.broker_port import OrderRequest from trex_core.application.broker_port import CancelState, OrderRequest
_MOCK_URL = "https://openapivts.koreainvestment.com:29443" _MOCK_URL = "https://openapivts.koreainvestment.com:29443"
_LIVE_URL = "https://openapi.koreainvestment.com:9443" _LIVE_URL = "https://openapi.koreainvestment.com:9443"
@@ -218,7 +219,7 @@ def test_paper_cancel_venue_ignored():
ack = b.submit_order(req) ack = b.submit_order(req)
# NXT venue로 cancel — paper는 venue 무관하게 취소 성공 # NXT venue로 cancel — paper는 venue 무관하게 취소 성공
result = b.cancel_order(ack.broker_order_id, side="buy", symbol="005930", qty=5, venue="NXT") result = b.cancel_order(ack.broker_order_id, side="buy", symbol="005930", qty=5, venue="NXT")
assert result is True assert result.state is CancelState.REQUESTED
def test_paper_cancel_default_venue(): def test_paper_cancel_default_venue():
@@ -227,7 +228,7 @@ def test_paper_cancel_default_venue():
req = OrderRequest(slot_id="s1", side="buy", symbol="005930", qty=3) req = OrderRequest(slot_id="s1", side="buy", symbol="005930", qty=3)
ack = b.submit_order(req) ack = b.submit_order(req)
result = b.cancel_order(ack.broker_order_id, side="buy", symbol="005930", qty=3) result = b.cancel_order(ack.broker_order_id, side="buy", symbol="005930", qty=3)
assert result is True assert result.state is CancelState.REQUESTED
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -301,6 +302,40 @@ def test_cancel_order_krx_includes_excg(monkeypatch):
assert captured["body"]["EXCG_ID_DVSN_CD"] == "KRX" assert captured["body"]["EXCG_ID_DVSN_CD"] == "KRX"
def test_cancel_explicit_broker_rejection_is_distinct(monkeypatch):
def fake_post(url, headers=None, json=None, timeout=None):
return _FakeResp({"rt_cd": "1", "msg_cd": "ORDER_NOT_CANCELLABLE"})
b = _mock_broker()
monkeypatch.setattr(kis_mod.httpx, "post", fake_post)
outcome = b.cancel_order(
"06010:0000123456",
side="buy",
symbol="005930",
qty=1,
)
assert outcome.state is CancelState.REJECTED
assert outcome.reason == "ORDER_NOT_CANCELLABLE"
def test_cancel_transport_loss_is_unknown(monkeypatch):
def fake_post(url, headers=None, json=None, timeout=None):
raise httpx.ReadTimeout("timed out")
b = _mock_broker()
monkeypatch.setattr(kis_mod.httpx, "post", fake_post)
outcome = b.cancel_order(
"06010:0000123456",
side="buy",
symbol="005930",
qty=1,
)
assert outcome.state is CancelState.UNKNOWN
assert outcome.reason == "transport_failed"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# REQ-118 AC-5: get_current_price 기본 market=J # REQ-118 AC-5: get_current_price 기본 market=J
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -422,4 +457,4 @@ def test_paper_cancel_backward_compat_no_venue():
ack = b.submit_order(req) ack = b.submit_order(req)
# 기존 스타일 호출 (venue 없음) # 기존 스타일 호출 (venue 없음)
result = b.cancel_order(ack.broker_order_id, side="sell", symbol="005930", qty=2) result = b.cancel_order(ack.broker_order_id, side="sell", symbol="005930", qty=2)
assert result is True assert result.state is CancelState.REQUESTED
@@ -35,7 +35,8 @@ _SLOT_COLUMNS = (
"order_id, strategy_id, broker_order_id, portfolio_id, " "order_id, strategy_id, broker_order_id, portfolio_id, "
"sell_strategy_id, forced_close_at, stop_loss_price, take_profit_price, " "sell_strategy_id, forced_close_at, stop_loss_price, take_profit_price, "
"created_at, updated_at, instance_key, retired_at, " "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"
) )
_MANAGED_SLOT_SQL = ( _MANAGED_SLOT_SQL = (
f"SELECT {_SLOT_COLUMNS} FROM daily_slot " f"SELECT {_SLOT_COLUMNS} FROM daily_slot "
@@ -146,6 +147,8 @@ def _row(slot: Slot) -> tuple:
Decimal(str(slot.slippage_pct)), Decimal(str(slot.slippage_pct)),
slot.occupancy_seq, slot.occupancy_seq,
slot.venue, slot.venue,
slot.cancel_state,
slot.cancel_requested_at,
) )
+10 -2
View File
@@ -7,7 +7,12 @@ from __future__ import annotations
from trex_core.adapters.broker.paper import PaperBroker from trex_core.adapters.broker.paper import PaperBroker
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
from trex_core.application.broker_port import FillStatus, OrderAck from trex_core.application.broker_port import (
CancelOutcome,
CancelState,
FillStatus,
OrderAck,
)
from trex_core.application.execution import ExecutionService from trex_core.application.execution import ExecutionService
from trex_core.application.intents import BuyIntent, IntentService, SellIntent from trex_core.application.intents import BuyIntent, IntentService, SellIntent
from trex_core.domain.slot import SlotService, SlotState from trex_core.domain.slot import SlotService, SlotState
@@ -78,7 +83,10 @@ def test_unknown_submit_outcome_stays_dispatched_without_retry():
def cancel_order(self, broker_order_id, *, side, symbol, qty, venue="KRX"): def cancel_order(self, broker_order_id, *, side, symbol, qty, venue="KRX"):
self.cancel_count += 1 self.cancel_count += 1
return False return CancelOutcome(
CancelState.UNKNOWN,
"submission_outcome_unknown",
)
broker = UnknownOutcomeBroker() broker = UnknownOutcomeBroker()
repo, svc, intents, execution = _wire(broker) repo, svc, intents, execution = _wire(broker)
+9 -3
View File
@@ -13,7 +13,13 @@ from datetime import datetime, timedelta, timezone
from trex_core.adapters.ledger.in_memory import InMemoryLedger from trex_core.adapters.ledger.in_memory import InMemoryLedger
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
from trex_core.application.broker_port import BalanceItem, FillStatus, OrderAck from trex_core.application.broker_port import (
BalanceItem,
CancelOutcome,
CancelState,
FillStatus,
OrderAck,
)
from trex_core.application.execution import ExecutionService from trex_core.application.execution import ExecutionService
from trex_core.application.intents import BuyIntent, IntentService, SellIntent from trex_core.application.intents import BuyIntent, IntentService, SellIntent
from trex_core.application.ledger_outbox import InMemoryLedgerOutbox, LedgerWriter from trex_core.application.ledger_outbox import InMemoryLedgerOutbox, LedgerWriter
@@ -46,8 +52,8 @@ class ProgrammableBroker:
self.polls += 1 self.polls += 1
return self.responder(side) return self.responder(side)
def cancel_order(self, broker_order_id, *, side, symbol, qty): def cancel_order(self, broker_order_id, *, side, symbol, qty, venue="KRX"):
return True return CancelOutcome(CancelState.REQUESTED)
def get_balance(self): def get_balance(self):
# M2 (dcy-020/REQ-062): sell dispatch reads the broker balance. # M2 (dcy-020/REQ-062): sell dispatch reads the broker balance.
@@ -19,6 +19,8 @@ from trex_core.adapters.ledger.in_memory import InMemoryLedger
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
from trex_core.application.broker_port import ( from trex_core.application.broker_port import (
BalanceItem, BalanceItem,
CancelOutcome,
CancelState,
FillStatus, FillStatus,
OrderAck, OrderAck,
OrderRequest, OrderRequest,
@@ -83,8 +85,16 @@ class StubBroker:
return FillStatus(state="unknown") return FillStatus(state="unknown")
return o.fill_status return o.fill_status
def cancel_order(self, broker_order_id: str, *, side: str, symbol: str, qty: int) -> bool: def cancel_order(
return False self,
broker_order_id: str,
*,
side: str,
symbol: str,
qty: int,
venue: str = "KRX",
) -> CancelOutcome:
return CancelOutcome(CancelState.REJECTED, "order_not_cancellable")
def get_balance(self) -> list[BalanceItem]: def get_balance(self) -> list[BalanceItem]:
return [ return [
+62 -6
View File
@@ -11,7 +11,14 @@ from datetime import datetime, timedelta, timezone
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
from trex_core.application.broker_port import BalanceItem, FillStatus, OrderAck, OrderRequest from trex_core.application.broker_port import (
BalanceItem,
CancelOutcome,
CancelState,
FillStatus,
OrderAck,
OrderRequest,
)
from trex_core.application.execution import ExecutionService from trex_core.application.execution import ExecutionService
from trex_core.application.intents import BuyIntent, IntentService, SellIntent from trex_core.application.intents import BuyIntent, IntentService, SellIntent
from trex_core.domain.slot import SlotService, SlotState from trex_core.domain.slot import SlotService, SlotState
@@ -26,6 +33,7 @@ class _RecordingBroker:
self.orders: list[OrderRequest] = [] self.orders: list[OrderRequest] = []
self.cancel_calls: list = [] self.cancel_calls: list = []
self.cancel_raises = False self.cancel_raises = False
self.cancel_outcome = CancelOutcome(CancelState.REQUESTED)
self.responder = lambda side: FillStatus( self.responder = lambda side: FillStatus(
state="filled", filled_qty=10, avg_price=1000.0, state="filled", filled_qty=10, avg_price=1000.0,
execution_id="exec-1", fee=0.0, tax=0.0, execution_id="exec-1", fee=0.0, tax=0.0,
@@ -47,7 +55,7 @@ class _RecordingBroker:
self.cancel_calls.append((broker_order_id, side, symbol, qty, venue)) self.cancel_calls.append((broker_order_id, side, symbol, qty, venue))
if self.cancel_raises: if self.cancel_raises:
raise RuntimeError("broker error") raise RuntimeError("broker error")
return True return self.cancel_outcome
def get_balance(self): def get_balance(self):
return [BalanceItem(symbol=s, hldg_qty=q) for s, q in self.balance.items() if q > 0] return [BalanceItem(symbol=s, hldg_qty=q) for s, q in self.balance.items() if q > 0]
@@ -287,6 +295,9 @@ class TestTimeoutCancel:
r = execution.run_once() r = execution.run_once()
assert r.timeout_cancelled == 1 assert r.timeout_cancelled == 1
assert len(broker.cancel_calls) == 1 assert len(broker.cancel_calls) == 1
assert repo.fetch_slot(sid).cancel_state == "requested"
execution.run_once()
assert len(broker.cancel_calls) == 1
def test_timeout_not_exceeded_no_cancel(self): def test_timeout_not_exceeded_no_cancel(self):
broker = _RecordingBroker() broker = _RecordingBroker()
@@ -330,7 +341,12 @@ class TestTimeoutCancel:
r = execution.run_once() r = execution.run_once()
# timeout_cancelled NOT incremented when the cancel call raises # timeout_cancelled NOT incremented when the cancel call raises
assert r.timeout_cancelled == 0 assert r.timeout_cancelled == 0
assert repo.fetch_slot(sid).status is SlotState.PENDING_BUY # state unchanged slot = repo.fetch_slot(sid)
assert slot.status is SlotState.PENDING_BUY
assert slot.cancel_state == "unknown"
execution.run_once()
ExecutionService(svc, repo, broker).run_once()
assert len(broker.cancel_calls) == 1
def test_timeout_cancel_does_not_change_slot_state(self): def test_timeout_cancel_does_not_change_slot_state(self):
"""Timeout cancel is best-effort: slot stays PENDING_BUY after cancel_order.""" """Timeout cancel is best-effort: slot stays PENDING_BUY after cancel_order."""
@@ -347,6 +363,28 @@ class TestTimeoutCancel:
# slot still PENDING_BUY — cancel is best-effort; rollback on next poll # slot still PENDING_BUY — cancel is best-effort; rollback on next poll
assert repo.fetch_slot(sid).status is SlotState.PENDING_BUY assert repo.fetch_slot(sid).status is SlotState.PENDING_BUY
def test_timeout_cancel_rejection_is_persisted_without_retry(self):
broker = _RecordingBroker()
broker.cancel_outcome = CancelOutcome(
CancelState.REJECTED,
"broker_rejected",
)
repo, svc, intents, execution = _wire(broker)
sid = _buy_to_pending(intents, timeout_sec=1)
broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once()
slot = repo.fetch_slot(sid)
repo._slots[sid] = replace(
slot,
updated_at=datetime.now(tz=timezone.utc) - timedelta(seconds=60),
)
result = execution.run_once()
assert result.timeout_cancelled == 0
assert repo.fetch_slot(sid).cancel_state == "rejected"
ExecutionService(svc, repo, broker).run_once()
assert len(broker.cancel_calls) == 1
def test_timeout_cancel_then_fill_race_handled(self): def test_timeout_cancel_then_fill_race_handled(self):
"""Cancel sent, but broker fills the order — is_filled path wins on next poll.""" """Cancel sent, but broker fills the order — is_filled path wins on next poll."""
broker = _RecordingBroker() broker = _RecordingBroker()
@@ -368,7 +406,10 @@ class TestTimeoutCancel:
) )
r2 = execution.run_once() r2 = execution.run_once()
assert r2.filled == 1 assert r2.filled == 1
assert repo.fetch_slot(sid).status is SlotState.HOLDING slot = repo.fetch_slot(sid)
assert slot.status is SlotState.HOLDING
assert slot.cancel_state is None
assert slot.cancel_requested_at is None
# ============================================================================ # ============================================================================
@@ -418,7 +459,7 @@ class TestCancelPendingSlot:
result = execution.cancel_pending_slot(sid) result = execution.cancel_pending_slot(sid)
assert result == "not_dispatched" assert result == "not_dispatched"
def test_cancel_fail_returns_cancel_failed(self): def test_cancel_exception_returns_unknown(self):
broker = _RecordingBroker() broker = _RecordingBroker()
broker.cancel_raises = True broker.cancel_raises = True
repo, svc, intents, execution = _wire(broker) repo, svc, intents, execution = _wire(broker)
@@ -427,7 +468,22 @@ class TestCancelPendingSlot:
execution.run_once() # dispatch execution.run_once() # dispatch
result = execution.cancel_pending_slot(sid) result = execution.cancel_pending_slot(sid)
assert result == "cancel_failed" assert result == "cancel_unknown"
assert repo.fetch_slot(sid).cancel_state == "unknown"
def test_cancel_rejected_is_distinct_and_persisted(self):
broker = _RecordingBroker()
broker.cancel_outcome = CancelOutcome(
CancelState.REJECTED,
"broker_rejected",
)
repo, svc, intents, execution = _wire(broker)
sid = _buy_to_pending(intents)
broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once()
assert execution.cancel_pending_slot(sid) == "cancel_rejected"
assert repo.fetch_slot(sid).cancel_state == "rejected"
def test_cancel_does_not_change_slot_state(self): def test_cancel_does_not_change_slot_state(self):
"""Operator cancel sends cancel_order; slot stays PENDING_BUY (execution loop transitions).""" """Operator cancel sends cancel_order; slot stays PENDING_BUY (execution loop transitions)."""
+10 -3
View File
@@ -4,7 +4,14 @@ from __future__ import annotations
from uuid import UUID from uuid import UUID
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
from trex_core.application.broker_port import BalanceItem, FillStatus, OrderAck, OrderRequest from trex_core.application.broker_port import (
BalanceItem,
CancelOutcome,
CancelState,
FillStatus,
OrderAck,
OrderRequest,
)
from trex_core.application.execution import ExecutionService from trex_core.application.execution import ExecutionService
from trex_core.application.intents import BuyIntent, IntentService from trex_core.application.intents import BuyIntent, IntentService
from trex_core.application.messaging import RpcRequest from trex_core.application.messaging import RpcRequest
@@ -38,9 +45,9 @@ class RecordingBroker:
symbol: str, symbol: str,
qty: int, qty: int,
venue: str = "KRX", venue: str = "KRX",
) -> bool: ) -> CancelOutcome:
self.cancel_venues.append(venue) self.cancel_venues.append(venue)
return True return CancelOutcome(CancelState.REQUESTED)
def get_balance(self) -> list[BalanceItem]: def get_balance(self) -> list[BalanceItem]:
return [] return []
+26 -5
View File
@@ -24,6 +24,8 @@ import httpx
from trex_core.application.broker_port import ( from trex_core.application.broker_port import (
BalanceItem, BalanceItem,
BrokerBalanceError, BrokerBalanceError,
CancelOutcome,
CancelState,
FillStatus, FillStatus,
OrderAck, OrderAck,
OrderRequest, OrderRequest,
@@ -756,7 +758,7 @@ class KISBroker:
def cancel_order( def cancel_order(
self, broker_order_id: str, *, side: str, symbol: str, qty: int, venue: str = "KRX" self, broker_order_id: str, *, side: str, symbol: str, qty: int, venue: str = "KRX"
) -> bool: ) -> CancelOutcome:
"""Cancel a previously submitted order (REQ-118). """Cancel a previously submitted order (REQ-118).
venue: exchange venue of the original order. Must match the EXCG_ID_DVSN_CD 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. Real-account NXT cancel (EXCG=NXT) confirmed rt_cd=0 on 2026-07-06.
""" """
if broker_order_id.startswith(_SUBMISSION_UNKNOWN_PREFIX): if broker_order_id.startswith(_SUBMISSION_UNKNOWN_PREFIX):
return False return CancelOutcome(
CancelState.UNKNOWN,
"submission_outcome_unknown",
)
cano, product = self._account() cano, product = self._account()
parts = broker_order_id.split(":") parts = broker_order_id.split(":")
orgno, odno = (parts[0], parts[1]) if len(parts) == 2 else ("", parts[-1]) orgno, odno = (parts[0], parts[1]) if len(parts) == 2 else ("", parts[-1])
@@ -789,6 +794,22 @@ class KISBroker:
json=body, json=body,
timeout=self._timeout, timeout=self._timeout,
) )
return str(resp.json().get("rt_cd")) == "0" except httpx.HTTPError:
except (httpx.HTTPError, ValueError): return CancelOutcome(CancelState.UNKNOWN, "transport_failed")
return False 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 ( from trex_core.application.broker_port import (
BalanceItem, BalanceItem,
BrokerBalanceError, BrokerBalanceError,
CancelOutcome,
FillStatus, FillStatus,
OrderAck, OrderAck,
OrderRequest, OrderRequest,
@@ -146,7 +147,7 @@ class OwnershipAwareBroker:
symbol: str, symbol: str,
qty: int, qty: int,
venue: str = "KRX", venue: str = "KRX",
) -> bool: ) -> CancelOutcome:
return self._delegate.cancel_order( return self._delegate.cancel_order(
broker_order_id, broker_order_id,
side=side, 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.adapters.broker.seq_store import SeqStore
from trex_core.application.broker_port import ( from trex_core.application.broker_port import (
BalanceItem, BalanceItem,
CancelOutcome,
CancelState,
FillStatus, FillStatus,
OrderAck, OrderAck,
OrderRequest, OrderRequest,
@@ -105,16 +107,24 @@ class PaperBroker:
) )
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)
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 """Cancel a paper order. venue is accepted for BrokerPort protocol conformance
(REQ-118) but ignored — paper has no exchange concept. Existing behavior preserved. (REQ-118) but ignored — paper has no exchange concept. Existing behavior preserved.
""" """
with self._lock: with self._lock:
o = self._orders.get(broker_order_id) o = self._orders.get(broker_order_id)
if o is None or o.filled >= o.qty: if o is None or o.filled >= o.qty:
return False return CancelOutcome(CancelState.REJECTED, "order_not_cancellable")
o.cancelled = True o.cancelled = True
return True return CancelOutcome(CancelState.REQUESTED)
def get_balance(self) -> list[BalanceItem]: def get_balance(self) -> list[BalanceItem]:
"""Deterministic per-symbol fill aggregate (REQ-031 AC-3). """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, " "order_id, strategy_id, broker_order_id, portfolio_id, "
"sell_strategy_id, forced_close_at, stop_loss_price, take_profit_price, " "sell_strategy_id, forced_close_at, stop_loss_price, take_profit_price, "
"created_at, updated_at, instance_key, retired_at, " "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, " "broker_order_id=%s, portfolio_id=%s, sell_strategy_id=%s, "
"forced_close_at=%s, stop_loss_price=%s, take_profit_price=%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, " "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.status.value,
slot.symbol, slot.symbol,
@@ -237,6 +239,8 @@ class PostgreSQLSlotRepository:
slot.slippage_pct, slot.slippage_pct,
slot.occupancy_seq, slot.occupancy_seq,
slot.venue, slot.venue,
slot.cancel_state,
slot.cancel_requested_at,
slot.updated_at or datetime.now(tz=timezone.utc), slot.updated_at or datetime.now(tz=timezone.utc),
slot.slot_id, slot.slot_id,
), ),
@@ -332,6 +336,26 @@ class PostgreSQLSlotRepository:
conn.commit() conn.commit()
return ok 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]: def list_dispatched_pending(self, instance_key: str = "default") -> list[Slot]:
with self._psycopg.connect(self._dsn) as conn: with self._psycopg.connect(self._dsn) as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
@@ -347,7 +371,8 @@ class PostgreSQLSlotRepository:
with self._psycopg.connect(self._dsn, autocommit=False) as conn: with self._psycopg.connect(self._dsn, autocommit=False) as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute( 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), (datetime.now(tz=timezone.utc), slot_id),
) )
conn.commit() conn.commit()
@@ -374,7 +399,8 @@ class PostgreSQLSlotRepository:
# forced_close_at(11) stop_loss_price(12) take_profit_price(13) # forced_close_at(11) stop_loss_price(12) take_profit_price(13)
# created_at(14) updated_at(15) instance_key(16) retired_at(17) # created_at(14) updated_at(15) instance_key(16) retired_at(17)
# timeout_sec(18) slippage_pct(19) [dcy-022/REQ-068] # 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( return Slot(
slot_id=row[0] if isinstance(row[0], UUID) else UUID(str(row[0])), slot_id=row[0] if isinstance(row[0], UUID) else UUID(str(row[0])),
status=SlotState(row[1]), status=SlotState(row[1]),
@@ -398,6 +424,8 @@ class PostgreSQLSlotRepository:
slippage_pct=f(row[19]), slippage_pct=f(row[19]),
occupancy_seq=int(row[20]) if len(row) > 20 and row[20] is not None else 0, 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", 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')
);
+38 -2
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import os import os
from dataclasses import dataclass, field from dataclasses import dataclass, field
from decimal import Decimal from decimal import Decimal
from enum import Enum
from typing import Any, Optional, Protocol from typing import Any, Optional, Protocol
@@ -29,6 +30,30 @@ class BrokerBalanceError(RuntimeError):
super().__init__(f"broker balance unavailable ({code})") super().__init__(f"broker balance unavailable ({code})")
class CancelState(str, Enum):
"""Immediate result of a cancel request, not the final order state."""
REQUESTED = "requested"
REJECTED = "rejected"
UNKNOWN = "unknown"
@dataclass(frozen=True)
class CancelOutcome:
"""Sanitized cancel request outcome.
REQUESTED only means that the broker accepted the cancellation request.
Final cancelled/filled state is established exclusively by poll_fill().
"""
state: CancelState
reason: Optional[str] = None
@property
def is_requested(self) -> bool:
return self.state is CancelState.REQUESTED
@dataclass(frozen=True) @dataclass(frozen=True)
class OrderRequest: class OrderRequest:
slot_id: str # originating slot (audit/correlation) slot_id: str # originating slot (audit/correlation)
@@ -114,12 +139,23 @@ class BrokerPort(Protocol):
"""Observe current fill status for a previously accepted order.""" """Observe current fill status for a previously accepted order."""
... ...
def cancel_order(self, broker_order_id: str, *, side: str, symbol: str, qty: int, venue: str = "KRX") -> bool: def cancel_order(
"""Best-effort cancel. Returns True if the broker accepted the cancel. self,
broker_order_id: str,
*,
side: str,
symbol: str,
qty: int,
venue: str = "KRX",
) -> CancelOutcome:
"""Request cancellation and classify the immediate broker outcome.
venue: exchange venue of the original order (REQ-118). Cancel must use venue: exchange venue of the original order (REQ-118). Cancel must use
the same EXCG_ID_DVSN_CD as the original submit. Default "KRX" preserves the same EXCG_ID_DVSN_CD as the original submit. Default "KRX" preserves
backward compatibility for existing callers (orb/tsl). backward compatibility for existing callers (orb/tsl).
REQUESTED is not a final cancellation. poll_fill() remains the only
authority for cancelled/filled terminal state.
""" """
... ...
+83 -28
View File
@@ -29,7 +29,12 @@ from datetime import datetime, timezone
from typing import Callable, Optional from typing import Callable, Optional
from trex_core.application.alarm_emitter import AlarmEmitter from trex_core.application.alarm_emitter import AlarmEmitter
from trex_core.application.broker_port import BrokerPort, OrderRequest from trex_core.application.broker_port import (
BrokerPort,
CancelOutcome,
CancelState,
OrderRequest,
)
from trex_core.application.ledger_outbox import LedgerOutbox from trex_core.application.ledger_outbox import LedgerOutbox
from trex_core.application.ledger_port import LedgerTrade from trex_core.application.ledger_port import LedgerTrade
from trex_core.domain.pricing import ceil_to_tick, floor_to_tick from trex_core.domain.pricing import ceil_to_tick, floor_to_tick
@@ -546,12 +551,28 @@ class ExecutionService:
""" """
if str(slot.broker_order_id or "").startswith("submission-unknown:"): if str(slot.broker_order_id or "").startswith("submission-unknown:"):
return return
if slot.cancel_state is not None:
return
if slot.timeout_sec is None or slot.updated_at is None: if slot.timeout_sec is None or slot.updated_at is None:
return return
age = (now - slot.updated_at).total_seconds() age = (now - slot.updated_at).total_seconds()
if age < slot.timeout_sec: if age < slot.timeout_sec:
return return
qty = int(slot.qty or 0) qty = int(slot.qty or 0)
# Write UNKNOWN before external I/O. If the process dies after KIS
# receives the request but before the response is persisted, restart
# polls the order instead of issuing a blind second cancellation.
if not self._repo.mark_cancel_outcome(
slot.slot_id,
str(slot.broker_order_id),
CancelState.UNKNOWN.value,
):
_log.error(
"cancel attempt reservation conflict: slot=%s broker_order_id=%s",
slot.slot_id,
slot.broker_order_id,
)
return
try: try:
cancel_kwargs = { cancel_kwargs = {
"side": side, "side": side,
@@ -560,25 +581,40 @@ class ExecutionService:
} }
if slot.venue != "KRX": if slot.venue != "KRX":
cancel_kwargs["venue"] = slot.venue cancel_kwargs["venue"] = slot.venue
accepted = self._broker.cancel_order(slot.broker_order_id, **cancel_kwargs) outcome = self._broker.cancel_order(slot.broker_order_id, **cancel_kwargs)
except Exception: # noqa: BLE001 - adapter failure becomes durable unknown
outcome = CancelOutcome(CancelState.UNKNOWN, "adapter_exception")
_log.warning(
"timeout cancel outcome unknown: slot=%s broker_order_id=%s",
slot.slot_id,
slot.broker_order_id,
)
persisted = self._repo.mark_cancel_outcome(
slot.slot_id,
str(slot.broker_order_id),
outcome.state.value,
)
if not persisted:
_log.error(
"cancel outcome persistence conflict: slot=%s broker_order_id=%s "
"outcome=%s",
slot.slot_id,
slot.broker_order_id,
outcome.state.value,
)
return
if outcome.is_requested:
r.timeout_cancelled += 1 r.timeout_cancelled += 1
_log.warning( _log.warning(
"timeout cancel sent: slot=%s status=%s broker_order_id=%s " "timeout cancel result: slot=%s status=%s broker_order_id=%s "
"age_sec=%.0f timeout_sec=%d broker_accepted=%s", "age_sec=%.0f timeout_sec=%d outcome=%s",
slot.slot_id, slot.slot_id,
slot.status.value, slot.status.value,
slot.broker_order_id, slot.broker_order_id,
age, age,
slot.timeout_sec, slot.timeout_sec,
accepted, outcome.state.value,
) )
except Exception: # noqa: BLE001 - best-effort; do not block the loop
_log.warning(
"timeout cancel failed (exception): slot=%s broker_order_id=%s "
"(will retry next tick if still pending)",
slot.slot_id,
slot.broker_order_id,
)
def _emit_event(self, event_type: str, slot, routing_strategy_id: Optional[str] = None) -> None: def _emit_event(self, event_type: str, slot, routing_strategy_id: Optional[str] = None) -> None:
if self._event_emitter is None: if self._event_emitter is None:
@@ -655,9 +691,9 @@ class ExecutionService:
def cancel_pending_slot(self, slot_id) -> str: def cancel_pending_slot(self, slot_id) -> str:
"""Operator backstop: send cancel_order for a pending slot (dcy-022/REQ-071). """Operator backstop: send cancel_order for a pending slot (dcy-022/REQ-071).
Returns "ok" (cancel sent), "not_pending" (slot is HOLDING/INACTIVE), Returns "ok" (request accepted), "cancel_rejected", "cancel_unknown",
or "slot_not_found". Does NOT transition the slot — the execution loop's "not_pending", "not_dispatched", or "slot_not_found". Does NOT
next poll handles the rollback via the normal is_terminal_reject path. transition the slot — fill polling alone confirms cancelled/filled.
""" """
from uuid import UUID from uuid import UUID
@@ -680,21 +716,40 @@ class ExecutionService:
} }
if slot.venue != "KRX": if slot.venue != "KRX":
cancel_kwargs["venue"] = slot.venue cancel_kwargs["venue"] = slot.venue
self._broker.cancel_order(slot.broker_order_id, **cancel_kwargs) # Durable unknown reservation closes the crash window between the
except Exception: # noqa: BLE001 - best-effort # outbound request and saving its immediate result.
if not self._repo.mark_cancel_outcome(
slot.slot_id,
str(slot.broker_order_id),
CancelState.UNKNOWN.value,
):
return "cancel_state_conflict"
outcome = self._broker.cancel_order(slot.broker_order_id, **cancel_kwargs)
except Exception: # noqa: BLE001 - adapter failure becomes durable unknown
outcome = CancelOutcome(CancelState.UNKNOWN, "adapter_exception")
_log.warning( _log.warning(
"operator cancel failed: slot=%s broker_order_id=%s", "operator cancel outcome unknown: slot=%s broker_order_id=%s",
slot.slot_id, slot.slot_id,
slot.broker_order_id, slot.broker_order_id,
) )
return "cancel_failed" if not self._repo.mark_cancel_outcome(
slot.slot_id,
str(slot.broker_order_id),
outcome.state.value,
):
return "cancel_state_conflict"
_log.warning( _log.warning(
"operator cancel sent: slot=%s status=%s broker_order_id=%s", "operator cancel result: slot=%s status=%s broker_order_id=%s outcome=%s",
slot.slot_id, slot.slot_id,
slot.status.value, slot.status.value,
slot.broker_order_id, slot.broker_order_id,
outcome.state.value,
) )
return "ok" if outcome.state is CancelState.REQUESTED:
return "ok"
if outcome.state is CancelState.REJECTED:
return "cancel_rejected"
return "cancel_unknown"
def _rollback(self, slot, *, reason: str) -> None: def _rollback(self, slot, *, reason: str) -> None:
total = self._balance_map().get(slot.symbol or "", 0) total = self._balance_map().get(slot.symbol or "", 0)
+28 -1
View File
@@ -219,6 +219,29 @@ class InMemorySlotRepository:
) )
return True return True
def mark_cancel_outcome(
self,
slot_id: UUID,
broker_order_id: str,
state: str,
) -> bool:
"""Persist one cancel attempt iff the same order is still pending."""
s = self._slots.get(slot_id)
if (
s is None
or s.broker_order_id != broker_order_id
or s.status not in self._PENDING
):
return False
now = datetime.now(tz=timezone.utc)
self._slots[slot_id] = replace(
s,
cancel_state=state,
cancel_requested_at=now,
updated_at=now,
)
return True
def list_dispatched_pending(self, instance_key: str = "default") -> list[Slot]: def list_dispatched_pending(self, instance_key: str = "default") -> list[Slot]:
"""Pending slots already dispatched (broker_order_id NOT NULL) — poll targets.""" """Pending slots already dispatched (broker_order_id NOT NULL) — poll targets."""
return [ return [
@@ -236,7 +259,11 @@ class InMemorySlotRepository:
s = self._slots.get(slot_id) s = self._slots.get(slot_id)
if s is not None: if s is not None:
self._slots[slot_id] = replace( self._slots[slot_id] = replace(
s, broker_order_id=None, updated_at=datetime.now(tz=timezone.utc) s,
broker_order_id=None,
cancel_state=None,
cancel_requested_at=None,
updated_at=datetime.now(tz=timezone.utc),
) )
# --- bulk counts --- # --- bulk counts ---
+7
View File
@@ -59,6 +59,11 @@ class Slot:
# Order venue carried from the admitted intent through dispatch/cancel. # Order venue carried from the admitted intent through dispatch/cancel.
# It is reset to KRX when the permanent asset slot returns to INACTIVE. # It is reset to KRX when the permanent asset slot returns to INACTIVE.
venue: str = "KRX" venue: str = "KRX"
# Immediate cancel-request result. The slot remains pending until fill
# polling confirms cancelled/filled. Persisted to prevent blind retries
# across ticks and restarts.
cancel_state: Optional[str] = None
cancel_requested_at: Optional[datetime] = None
# Canonical column contract for `Slot` (dcy-001 OQ-2 / FR-5). # Canonical column contract for `Slot` (dcy-001 OQ-2 / FR-5).
@@ -88,6 +93,8 @@ EXPECTED_SLOT_FIELDS: frozenset[str] = frozenset({
"slippage_pct", # dcy-022 / REQ-068 (BAT-032) "slippage_pct", # dcy-022 / REQ-068 (BAT-032)
"occupancy_seq", # dcy-028 / REQ-081 (BAT-042) "occupancy_seq", # dcy-028 / REQ-081 (BAT-042)
"venue", "venue",
"cancel_state",
"cancel_requested_at",
}) })
+4
View File
@@ -205,6 +205,8 @@ class SlotService:
timeout_sec=None, timeout_sec=None,
slippage_pct=None, slippage_pct=None,
venue="KRX", venue="KRX",
cancel_state=None,
cancel_requested_at=None,
updated_at=datetime.now(tz=timezone.utc), updated_at=datetime.now(tz=timezone.utc),
) )
@@ -277,6 +279,8 @@ class SlotService:
slippage_pct=new_slippage_pct, slippage_pct=new_slippage_pct,
occupancy_seq=new_occupancy_seq, occupancy_seq=new_occupancy_seq,
venue=new_venue, venue=new_venue,
cancel_state=None,
cancel_requested_at=None,
updated_at=datetime.now(tz=timezone.utc), updated_at=datetime.now(tz=timezone.utc),
) )