fix: persist uncertain cancel outcomes
This commit is contained in:
@@ -12,7 +12,11 @@ import httpx
|
||||
import pytest
|
||||
|
||||
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"
|
||||
|
||||
@@ -179,15 +183,13 @@ def test_submit_transport_failure_blocks_automatic_duplicate(monkeypatch):
|
||||
assert ack.accepted is True
|
||||
assert ack.broker_order_id.startswith("submission-unknown:")
|
||||
assert ack.reason == "submit_outcome_unknown:ReadTimeout"
|
||||
assert (
|
||||
b.cancel_order(
|
||||
ack.broker_order_id,
|
||||
side="buy",
|
||||
symbol="005930",
|
||||
qty=4,
|
||||
)
|
||||
is False
|
||||
cancel = b.cancel_order(
|
||||
ack.broker_order_id,
|
||||
side="buy",
|
||||
symbol="005930",
|
||||
qty=4,
|
||||
)
|
||||
assert cancel.state is CancelState.UNKNOWN
|
||||
|
||||
|
||||
def test_submit_gateway_5xx_blocks_automatic_duplicate(monkeypatch):
|
||||
|
||||
@@ -9,6 +9,8 @@ from trex_core.adapters.broker.ownership import (
|
||||
from trex_core.application.broker_port import (
|
||||
BalanceItem,
|
||||
BrokerBalanceError,
|
||||
CancelOutcome,
|
||||
CancelState,
|
||||
FillStatus,
|
||||
OrderAck,
|
||||
)
|
||||
@@ -29,7 +31,7 @@ class FakeBroker:
|
||||
return self.fill
|
||||
|
||||
def cancel_order(self, broker_order_id, *, side, symbol, qty, venue="KRX"):
|
||||
return True
|
||||
return CancelOutcome(CancelState.REQUESTED)
|
||||
|
||||
def get_balance(self):
|
||||
return self.balance
|
||||
|
||||
@@ -26,12 +26,13 @@ from __future__ import annotations
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
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.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"
|
||||
_LIVE_URL = "https://openapi.koreainvestment.com:9443"
|
||||
@@ -218,7 +219,7 @@ def test_paper_cancel_venue_ignored():
|
||||
ack = b.submit_order(req)
|
||||
# NXT venue로 cancel — paper는 venue 무관하게 취소 성공
|
||||
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():
|
||||
@@ -227,7 +228,7 @@ def test_paper_cancel_default_venue():
|
||||
req = OrderRequest(slot_id="s1", side="buy", symbol="005930", qty=3)
|
||||
ack = b.submit_order(req)
|
||||
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"
|
||||
|
||||
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -422,4 +457,4 @@ def test_paper_cancel_backward_compat_no_venue():
|
||||
ack = b.submit_order(req)
|
||||
# 기존 스타일 호출 (venue 없음)
|
||||
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, "
|
||||
"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"
|
||||
)
|
||||
_MANAGED_SLOT_SQL = (
|
||||
f"SELECT {_SLOT_COLUMNS} FROM daily_slot "
|
||||
@@ -146,6 +147,8 @@ def _row(slot: Slot) -> tuple:
|
||||
Decimal(str(slot.slippage_pct)),
|
||||
slot.occupancy_seq,
|
||||
slot.venue,
|
||||
slot.cancel_state,
|
||||
slot.cancel_requested_at,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,12 @@ from __future__ import annotations
|
||||
|
||||
from trex_core.adapters.broker.paper import PaperBroker
|
||||
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.intents import BuyIntent, IntentService, SellIntent
|
||||
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"):
|
||||
self.cancel_count += 1
|
||||
return False
|
||||
return CancelOutcome(
|
||||
CancelState.UNKNOWN,
|
||||
"submission_outcome_unknown",
|
||||
)
|
||||
|
||||
broker = UnknownOutcomeBroker()
|
||||
repo, svc, intents, execution = _wire(broker)
|
||||
|
||||
@@ -13,7 +13,13 @@ from datetime import datetime, timedelta, timezone
|
||||
|
||||
from trex_core.adapters.ledger.in_memory import InMemoryLedger
|
||||
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.intents import BuyIntent, IntentService, SellIntent
|
||||
from trex_core.application.ledger_outbox import InMemoryLedgerOutbox, LedgerWriter
|
||||
@@ -46,8 +52,8 @@ class ProgrammableBroker:
|
||||
self.polls += 1
|
||||
return self.responder(side)
|
||||
|
||||
def cancel_order(self, broker_order_id, *, side, symbol, qty):
|
||||
return True
|
||||
def cancel_order(self, broker_order_id, *, side, symbol, qty, venue="KRX"):
|
||||
return CancelOutcome(CancelState.REQUESTED)
|
||||
|
||||
def get_balance(self):
|
||||
# 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.application.broker_port import (
|
||||
BalanceItem,
|
||||
CancelOutcome,
|
||||
CancelState,
|
||||
FillStatus,
|
||||
OrderAck,
|
||||
OrderRequest,
|
||||
@@ -83,8 +85,16 @@ class StubBroker:
|
||||
return FillStatus(state="unknown")
|
||||
return o.fill_status
|
||||
|
||||
def cancel_order(self, broker_order_id: str, *, side: str, symbol: str, qty: int) -> bool:
|
||||
return False
|
||||
def cancel_order(
|
||||
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]:
|
||||
return [
|
||||
|
||||
@@ -11,7 +11,14 @@ from datetime import datetime, timedelta, timezone
|
||||
|
||||
|
||||
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.intents import BuyIntent, IntentService, SellIntent
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
@@ -26,6 +33,7 @@ class _RecordingBroker:
|
||||
self.orders: list[OrderRequest] = []
|
||||
self.cancel_calls: list = []
|
||||
self.cancel_raises = False
|
||||
self.cancel_outcome = CancelOutcome(CancelState.REQUESTED)
|
||||
self.responder = lambda side: FillStatus(
|
||||
state="filled", filled_qty=10, avg_price=1000.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))
|
||||
if self.cancel_raises:
|
||||
raise RuntimeError("broker error")
|
||||
return True
|
||||
return self.cancel_outcome
|
||||
|
||||
def get_balance(self):
|
||||
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()
|
||||
assert r.timeout_cancelled == 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):
|
||||
broker = _RecordingBroker()
|
||||
@@ -330,7 +341,12 @@ class TestTimeoutCancel:
|
||||
r = execution.run_once()
|
||||
# timeout_cancelled NOT incremented when the cancel call raises
|
||||
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):
|
||||
"""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
|
||||
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):
|
||||
"""Cancel sent, but broker fills the order — is_filled path wins on next poll."""
|
||||
broker = _RecordingBroker()
|
||||
@@ -368,7 +406,10 @@ class TestTimeoutCancel:
|
||||
)
|
||||
r2 = execution.run_once()
|
||||
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)
|
||||
assert result == "not_dispatched"
|
||||
|
||||
def test_cancel_fail_returns_cancel_failed(self):
|
||||
def test_cancel_exception_returns_unknown(self):
|
||||
broker = _RecordingBroker()
|
||||
broker.cancel_raises = True
|
||||
repo, svc, intents, execution = _wire(broker)
|
||||
@@ -427,7 +468,22 @@ class TestCancelPendingSlot:
|
||||
execution.run_once() # dispatch
|
||||
|
||||
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):
|
||||
"""Operator cancel sends cancel_order; slot stays PENDING_BUY (execution loop transitions)."""
|
||||
|
||||
@@ -4,7 +4,14 @@ from __future__ import annotations
|
||||
from uuid import UUID
|
||||
|
||||
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.intents import BuyIntent, IntentService
|
||||
from trex_core.application.messaging import RpcRequest
|
||||
@@ -38,9 +45,9 @@ class RecordingBroker:
|
||||
symbol: str,
|
||||
qty: int,
|
||||
venue: str = "KRX",
|
||||
) -> bool:
|
||||
) -> CancelOutcome:
|
||||
self.cancel_venues.append(venue)
|
||||
return True
|
||||
return CancelOutcome(CancelState.REQUESTED)
|
||||
|
||||
def get_balance(self) -> list[BalanceItem]:
|
||||
return []
|
||||
|
||||
Reference in New Issue
Block a user