fix: converge broker failures without retry
This commit is contained in:
@@ -91,38 +91,28 @@ def test_order_request_venue_nxt():
|
||||
assert req.venue == "NXT"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("at", "expected"),
|
||||
[
|
||||
(datetime(2026, 7, 29, 8, 0, tzinfo=timezone(timedelta(hours=9))), "NXT"),
|
||||
(datetime(2026, 7, 29, 8, 49, 59, tzinfo=timezone(timedelta(hours=9))), "NXT"),
|
||||
(datetime(2026, 7, 29, 9, 0, 30, tzinfo=timezone(timedelta(hours=9))), "KRX"),
|
||||
(datetime(2026, 7, 29, 15, 29, 59, tzinfo=timezone(timedelta(hours=9))), "KRX"),
|
||||
(datetime(2026, 7, 29, 15, 30, tzinfo=timezone(timedelta(hours=9))), "NXT"),
|
||||
(datetime(2026, 7, 29, 20, 0, tzinfo=timezone(timedelta(hours=9))), "NXT"),
|
||||
],
|
||||
)
|
||||
def test_auto_venue_resolves_by_kst_session(at, expected):
|
||||
assert resolve_order_venue("AUTO", at) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("venue", ["KRX", "NXT", "SOR"])
|
||||
@pytest.mark.parametrize(
|
||||
"at",
|
||||
[
|
||||
datetime(2026, 7, 29, 7, 59, 59, tzinfo=timezone(timedelta(hours=9))),
|
||||
datetime(2026, 7, 29, 8, 50, tzinfo=timezone(timedelta(hours=9))),
|
||||
datetime(2026, 7, 29, 9, 0, 29, tzinfo=timezone(timedelta(hours=9))),
|
||||
datetime(2026, 7, 29, 8, 0, tzinfo=timezone(timedelta(hours=9))),
|
||||
datetime(2026, 7, 29, 8, 55, tzinfo=timezone(timedelta(hours=9))),
|
||||
datetime(2026, 7, 29, 20, 0, 1, tzinfo=timezone(timedelta(hours=9))),
|
||||
],
|
||||
)
|
||||
def test_auto_venue_fails_closed_outside_supported_sessions(at):
|
||||
with pytest.raises(ValueError, match="auto_venue_session_closed"):
|
||||
def test_explicit_venue_is_preserved_independent_of_time(venue, at):
|
||||
assert resolve_order_venue(venue, at) == venue
|
||||
|
||||
|
||||
def test_auto_venue_is_rejected():
|
||||
at = datetime(2026, 7, 29, 8, 1, tzinfo=timezone(timedelta(hours=9)))
|
||||
with pytest.raises(ValueError, match="unsupported_order_venue"):
|
||||
resolve_order_venue("AUTO", at)
|
||||
|
||||
|
||||
def test_submit_auto_uses_nxt_and_reports_actual_venue(monkeypatch):
|
||||
def test_submit_sor_preserves_sor(monkeypatch):
|
||||
captured = {}
|
||||
at = datetime(2026, 7, 29, 8, 1, tzinfo=timezone(timedelta(hours=9)))
|
||||
at = datetime(2026, 7, 29, 8, 55, tzinfo=timezone(timedelta(hours=9)))
|
||||
|
||||
def fake_post(url, headers=None, json=None, timeout=None):
|
||||
captured["body"] = json
|
||||
@@ -134,39 +124,17 @@ def test_submit_auto_uses_nxt_and_reports_actual_venue(monkeypatch):
|
||||
monkeypatch.setattr(kis_mod.httpx, "post", fake_post)
|
||||
ack = broker.submit_order(
|
||||
OrderRequest(
|
||||
slot_id="s-auto",
|
||||
slot_id="s-sor",
|
||||
side="buy",
|
||||
symbol="005930",
|
||||
qty=1,
|
||||
venue="AUTO",
|
||||
venue="SOR",
|
||||
)
|
||||
)
|
||||
|
||||
assert ack.accepted is True
|
||||
assert ack.venue == "NXT"
|
||||
assert captured["body"]["EXCG_ID_DVSN_CD"] == "NXT"
|
||||
|
||||
|
||||
def test_submit_auto_outside_session_rejects_without_http(monkeypatch):
|
||||
at = datetime(2026, 7, 29, 8, 55, tzinfo=timezone(timedelta(hours=9)))
|
||||
broker = _mock_broker(clock=lambda: at)
|
||||
|
||||
def unexpected_post(*args, **kwargs):
|
||||
raise AssertionError("AUTO session rejection must happen before HTTP")
|
||||
|
||||
monkeypatch.setattr(kis_mod.httpx, "post", unexpected_post)
|
||||
ack = broker.submit_order(
|
||||
OrderRequest(
|
||||
slot_id="s-auto-closed",
|
||||
side="buy",
|
||||
symbol="005930",
|
||||
qty=1,
|
||||
venue="AUTO",
|
||||
)
|
||||
)
|
||||
|
||||
assert ack.accepted is False
|
||||
assert ack.reason == "auto_venue_session_closed"
|
||||
assert ack.venue == "SOR"
|
||||
assert captured["body"]["EXCG_ID_DVSN_CD"] == "SOR"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -7,10 +7,11 @@ iteration: EXPECTED_SLOT_FIELDS (code contract) vs daily_slot actual columns
|
||||
from __future__ import annotations
|
||||
|
||||
import psycopg
|
||||
import pytest
|
||||
|
||||
from trex_core.adapters.persistence.migrate import apply_migrations, migration_files
|
||||
from trex_core.adapters.persistence.postgresql import PostgreSQLSlotRepository
|
||||
from trex_core.domain.slot import EXPECTED_SLOT_FIELDS, SlotService, SlotState
|
||||
from trex_core.domain.slot import EXPECTED_SLOT_FIELDS
|
||||
|
||||
_EXPECTED_TABLES = {
|
||||
"daily_slot",
|
||||
@@ -64,20 +65,29 @@ def test_apply_migrations_is_idempotent(pg_dsn):
|
||||
apply_migrations(pg_dsn)
|
||||
|
||||
|
||||
def test_migration_replay_accepts_existing_auto_venue_row(pg_clean):
|
||||
def test_migration_replay_converts_existing_auto_venue_row_to_sor(pg_clean):
|
||||
repo = PostgreSQLSlotRepository(pg_clean)
|
||||
service = SlotService(repo, instance_key="migration-auto")
|
||||
repo.reconcile_universe("migration-auto", {"pf-auto": "005930"})
|
||||
slot = repo.find_slot_by_portfolio("migration-auto", "pf-auto")
|
||||
service.transit_slot(
|
||||
slot.slot_id,
|
||||
SlotState.INACTIVE,
|
||||
SlotState.PENDING_BUY,
|
||||
reason="migration_replay",
|
||||
actor="test",
|
||||
venue="AUTO",
|
||||
)
|
||||
assert slot is not None
|
||||
|
||||
# Simulate a row written before migration 023 tightened the constraint.
|
||||
with psycopg.connect(pg_clean) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("ALTER TABLE daily_slot DROP CONSTRAINT daily_slot_venue_check")
|
||||
cur.execute(
|
||||
"UPDATE daily_slot SET venue = 'AUTO' WHERE slot_id = %s",
|
||||
(slot.slot_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
apply_migrations(pg_clean)
|
||||
|
||||
assert repo.fetch_slot(slot.slot_id).venue == "AUTO"
|
||||
assert repo.fetch_slot(slot.slot_id).venue == "SOR"
|
||||
with psycopg.connect(pg_clean) as conn:
|
||||
with conn.cursor() as cur:
|
||||
with pytest.raises(psycopg.errors.CheckViolation):
|
||||
cur.execute(
|
||||
"UPDATE daily_slot SET venue = 'AUTO' WHERE slot_id = %s",
|
||||
(slot.slot_id,),
|
||||
)
|
||||
|
||||
@@ -59,7 +59,7 @@ def test_buy_fill_enqueues_one_ledger_item_and_writer_records_it():
|
||||
assert (t.asset_id, t.side, t.qty, t.price) == ("PF-1", "buy", 10, 1000.0)
|
||||
assert t.execution_id.startswith("paper:PAPER-")
|
||||
assert t.fee > 0.0 and t.tax == 0.0
|
||||
assert t.venue == "AUTO"
|
||||
assert t.venue == "SOR"
|
||||
|
||||
|
||||
def test_sell_fill_enqueues_sell_ledger_item():
|
||||
|
||||
@@ -253,6 +253,33 @@ class TestOrderResultLogging:
|
||||
assert all(r.levelno == logging.ERROR for r in rejects)
|
||||
assert "호가단위" in rejects[0].getMessage()
|
||||
|
||||
def test_sell_reject_returns_to_holding_without_new_attempt(self):
|
||||
broker = _RecordingBroker()
|
||||
repo, _, intents, execution = _wire(broker)
|
||||
slot_id = _buy_to_pending(intents)
|
||||
assert slot_id is not None
|
||||
execution.run_once()
|
||||
holding = repo.fetch_slot(slot_id)
|
||||
assert holding is not None and holding.status is SlotState.HOLDING
|
||||
|
||||
intents.submit_sell(SellIntent(intent_id="s1", slot_id=slot_id))
|
||||
broker.submit_order = lambda req: (
|
||||
broker.orders.append(req)
|
||||
or OrderAck(accepted=False, reason="broker_rejected", venue=req.venue)
|
||||
)
|
||||
|
||||
result = execution.run_once()
|
||||
recovered = repo.fetch_slot(slot_id)
|
||||
assert recovered is not None
|
||||
assert recovered.status is SlotState.HOLDING
|
||||
assert recovered.qty == 10
|
||||
assert recovered.broker_order_id is None
|
||||
assert result.rolled_back == 1
|
||||
assert len([order for order in broker.orders if order.side == "sell"]) == 1
|
||||
|
||||
execution.run_once()
|
||||
assert len([order for order in broker.orders if order.side == "sell"]) == 1
|
||||
|
||||
def test_normal_fill_emits_no_error(self, caplog):
|
||||
broker = _RecordingBroker()
|
||||
repo, svc, intents, execution = _wire(broker)
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
"""rejected SellIntent `retryable` flag (issue #27 / REQ-007 CHG-20260615-01).
|
||||
|
||||
Additive: only on intent_type="sell" + status="rejected". Maps reason→retryable
|
||||
via the core-owned table (unrecognized → True, safety default; bad_request → False).
|
||||
admitted / processing / buy responses must NOT carry the field.
|
||||
"""
|
||||
"""Strategy RPC responses never expose broker retryability (GitHub #60)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
|
||||
from trex_core.application.intents import IntentService
|
||||
from trex_core.application.messaging import RpcRequest
|
||||
from trex_core.application.messaging_handlers import (
|
||||
_sell_reject_retryable,
|
||||
make_intent_rpc_handler,
|
||||
)
|
||||
from trex_core.application.messaging_handlers import make_intent_rpc_handler
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
@@ -22,9 +16,14 @@ INST = "default"
|
||||
def _wire():
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo, instance_key=INST)
|
||||
intents = IntentService(svc, repo, InMemoryIntentStore(),
|
||||
resolve_symbol=lambda pid: "005930",
|
||||
instance_key=INST, max_active_slots=5)
|
||||
intents = IntentService(
|
||||
svc,
|
||||
repo,
|
||||
InMemoryIntentStore(),
|
||||
resolve_symbol=lambda pid: "005930",
|
||||
instance_key=INST,
|
||||
max_active_slots=5,
|
||||
)
|
||||
repo.reconcile_universe(INST, {"PF-1": "SYM"})
|
||||
handler = make_intent_rpc_handler(intents)
|
||||
slot = repo.list_slots(INST)[0]
|
||||
@@ -32,72 +31,65 @@ def _wire():
|
||||
|
||||
|
||||
def _sell(handler, slot_id, intent_id="s1"):
|
||||
return handler(RpcRequest(payload={
|
||||
"intent_type": "sell", "intent_id": intent_id, "slot_id": str(slot_id),
|
||||
}, correlation_id=intent_id))
|
||||
return handler(
|
||||
RpcRequest(
|
||||
payload={
|
||||
"intent_type": "sell",
|
||||
"intent_id": intent_id,
|
||||
"slot_id": str(slot_id),
|
||||
},
|
||||
correlation_id=intent_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class TestRetryableMapping:
|
||||
def test_each_reason_maps(self):
|
||||
assert _sell_reject_retryable("slot_state_changed") is True
|
||||
assert _sell_reject_retryable("slot_not_holding:pending_buy") is True
|
||||
assert _sell_reject_retryable("duplicate_in_flight") is True
|
||||
assert _sell_reject_retryable("slot_not_found") is False
|
||||
assert _sell_reject_retryable("slot_not_holding:inactive") is False
|
||||
assert _sell_reject_retryable("slot_not_holding:pending_sell") is False
|
||||
assert _sell_reject_retryable("slot_not_holding:error") is False
|
||||
|
||||
def test_unrecognized_defaults_true(self):
|
||||
assert _sell_reject_retryable("some_future_reason") is True
|
||||
assert _sell_reject_retryable(None) is True
|
||||
|
||||
def test_bad_request_is_false(self):
|
||||
assert _sell_reject_retryable("bad_request:KeyError('slot_id')") is False
|
||||
def test_rejected_sell_does_not_expose_retryability():
|
||||
_, _, _, handler, slot = _wire()
|
||||
response = _sell(handler, slot.slot_id)
|
||||
assert response.payload["status"] == "rejected"
|
||||
assert response.payload["reason"] == "slot_not_holding:inactive"
|
||||
assert "retryable" not in response.payload
|
||||
|
||||
|
||||
class TestResponseField:
|
||||
def test_rejected_sell_inactive_slot_has_retryable_false(self):
|
||||
# slot is inactive (never bought) → slot_not_holding:inactive → terminal
|
||||
repo, svc, intents, handler, slot = _wire()
|
||||
resp = _sell(handler, slot.slot_id)
|
||||
assert resp.payload["status"] == "rejected"
|
||||
assert resp.payload["reason"] == "slot_not_holding:inactive"
|
||||
assert resp.payload["retryable"] is False
|
||||
def test_unknown_slot_sell_does_not_expose_retryability():
|
||||
_, _, _, handler, _ = _wire()
|
||||
response = _sell(handler, uuid4())
|
||||
assert response.payload["status"] == "rejected"
|
||||
assert response.payload["reason"] == "slot_not_found"
|
||||
assert "retryable" not in response.payload
|
||||
|
||||
def test_rejected_sell_unknown_slot_false(self):
|
||||
from uuid import uuid4
|
||||
_, _, _, handler, _ = _wire()
|
||||
resp = _sell(handler, uuid4())
|
||||
assert resp.payload["status"] == "rejected"
|
||||
assert resp.payload["reason"] == "slot_not_found"
|
||||
assert resp.payload["retryable"] is False
|
||||
|
||||
def test_bad_request_sell_carries_retryable_false(self):
|
||||
_, _, _, handler, _ = _wire()
|
||||
resp = handler(RpcRequest(payload={
|
||||
"intent_type": "sell", "intent_id": "x", # missing slot_id
|
||||
}, correlation_id="x"))
|
||||
assert resp.payload["status"] == "rejected"
|
||||
assert resp.payload["reason"].startswith("bad_request")
|
||||
assert resp.payload["retryable"] is False
|
||||
def test_malformed_sell_does_not_expose_retryability():
|
||||
_, _, _, handler, _ = _wire()
|
||||
response = handler(
|
||||
RpcRequest(
|
||||
payload={"intent_type": "sell", "intent_id": "x"},
|
||||
correlation_id="x",
|
||||
)
|
||||
)
|
||||
assert response.payload["status"] == "rejected"
|
||||
assert response.payload["reason"].startswith("bad_request")
|
||||
assert "retryable" not in response.payload
|
||||
|
||||
def test_admitted_sell_has_no_retryable(self):
|
||||
repo, svc, intents, handler, slot = _wire()
|
||||
# bring slot to holding so the sell is admitted
|
||||
svc.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="t")
|
||||
svc.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="filled", actor="t", qty=10, avg_price=100)
|
||||
resp = _sell(handler, slot.slot_id)
|
||||
assert resp.payload["status"] == "admitted"
|
||||
assert "retryable" not in resp.payload
|
||||
|
||||
def test_buy_rejection_has_no_retryable(self):
|
||||
# buy on a non-existent slot → rejected, but buy responses omit retryable
|
||||
from uuid import uuid4
|
||||
_, _, _, handler, _ = _wire()
|
||||
resp = handler(RpcRequest(payload={
|
||||
"intent_type": "buy", "intent_id": "b1", "slot_id": str(uuid4()),
|
||||
}, correlation_id="b1"))
|
||||
assert resp.payload["status"] == "rejected"
|
||||
assert "retryable" not in resp.payload
|
||||
def test_admitted_sell_does_not_expose_retryability():
|
||||
_, svc, _, handler, slot = _wire()
|
||||
svc.transit_slot(
|
||||
slot.slot_id,
|
||||
SlotState.INACTIVE,
|
||||
SlotState.PENDING_BUY,
|
||||
reason="buy",
|
||||
actor="test",
|
||||
)
|
||||
svc.transit_slot(
|
||||
slot.slot_id,
|
||||
SlotState.PENDING_BUY,
|
||||
SlotState.HOLDING,
|
||||
reason="filled",
|
||||
actor="test",
|
||||
qty=10,
|
||||
avg_price=100,
|
||||
)
|
||||
response = _sell(handler, slot.slot_id)
|
||||
assert response.payload["status"] == "admitted"
|
||||
assert "retryable" not in response.payload
|
||||
|
||||
@@ -3,6 +3,8 @@ from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
|
||||
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
|
||||
from trex_core.application.broker_port import (
|
||||
BalanceItem,
|
||||
@@ -96,7 +98,8 @@ def test_nxt_buy_wire_value_reaches_broker_order() -> None:
|
||||
assert broker.orders[0].venue == "NXT"
|
||||
|
||||
|
||||
def test_invalid_venue_is_rejected_before_slot_transition() -> None:
|
||||
@pytest.mark.parametrize("invalid_venue", ["AUTO", "UNKNOWN"])
|
||||
def test_invalid_venue_is_rejected_before_slot_transition(invalid_venue: str) -> None:
|
||||
repo, intents, _, _ = _wire()
|
||||
handler = make_intent_rpc_handler(intents)
|
||||
|
||||
@@ -107,7 +110,7 @@ def test_invalid_venue_is_rejected_before_slot_transition() -> None:
|
||||
"intent_id": "bad-venue",
|
||||
"portfolio_id": "PF-1",
|
||||
"qty": 1,
|
||||
"venue": "UNKNOWN",
|
||||
"venue": invalid_venue,
|
||||
},
|
||||
correlation_id="bad-venue",
|
||||
)
|
||||
@@ -118,7 +121,7 @@ def test_invalid_venue_is_rejected_before_slot_transition() -> None:
|
||||
assert repo.list_slots("default")[0].status is SlotState.INACTIVE
|
||||
|
||||
|
||||
def test_omitted_wire_venue_is_auto_then_actual_venue_is_persisted() -> None:
|
||||
def test_omitted_wire_venue_is_sor_then_actual_venue_is_persisted() -> None:
|
||||
repo = InMemorySlotRepository()
|
||||
service = SlotService(repo)
|
||||
intents = IntentService(
|
||||
@@ -146,10 +149,10 @@ def test_omitted_wire_venue_is_auto_then_actual_venue_is_persisted() -> None:
|
||||
|
||||
slot_id = UUID(response.payload["slot_id"])
|
||||
pending = repo.fetch_slot(slot_id)
|
||||
assert pending is not None and pending.venue == "AUTO"
|
||||
assert pending is not None and pending.venue == "SOR"
|
||||
execution.run_once()
|
||||
dispatched = repo.fetch_slot(slot_id)
|
||||
assert broker.orders[0].venue == "AUTO"
|
||||
assert broker.orders[0].venue == "SOR"
|
||||
assert dispatched is not None and dispatched.venue == "NXT"
|
||||
|
||||
|
||||
@@ -180,7 +183,7 @@ def test_inactive_transition_resets_venue_to_krx() -> None:
|
||||
assert reset.venue == "KRX"
|
||||
|
||||
|
||||
def test_holding_transition_resets_next_order_venue_to_auto() -> None:
|
||||
def test_holding_transition_resets_next_order_venue_to_sor() -> None:
|
||||
repo, intents, _, _ = _wire()
|
||||
decision = intents.submit_buy(
|
||||
BuyIntent(
|
||||
@@ -201,7 +204,7 @@ def test_holding_transition_resets_next_order_venue_to_auto() -> None:
|
||||
qty=1,
|
||||
avg_price=1000,
|
||||
)
|
||||
assert holding.venue == "AUTO"
|
||||
assert holding.venue == "SOR"
|
||||
|
||||
|
||||
def test_operator_cancel_uses_original_nxt_venue() -> None:
|
||||
|
||||
Reference in New Issue
Block a user