fix: preserve sell event routing owner
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"""Slot transition event emission tests (REQ-021 + REQ-023 + REQ-018 hook)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from trex_core.adapters.broker.paper import PaperBroker
|
||||
@@ -218,3 +219,213 @@ def test_sell_fill_dlq_sentinel_absent_when_strategy_id_set():
|
||||
assert bad_keys == [], (
|
||||
f"DLQ-sentinel routing keys found (strategy_id=None leak): {bad_keys}"
|
||||
)
|
||||
|
||||
|
||||
def test_sell_fill_prefers_admission_sell_strategy_owner():
|
||||
emitted: list[tuple[str, str | None]] = []
|
||||
|
||||
def emitter(event_type: str, _slot, owner: str | None = None) -> None:
|
||||
emitted.append((event_type, owner))
|
||||
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo)
|
||||
intents = IntentService(
|
||||
svc,
|
||||
repo,
|
||||
InMemoryIntentStore(),
|
||||
resolve_symbol=lambda _pid: "005930",
|
||||
)
|
||||
outbox = InMemoryLedgerOutbox(instance_key="test-instance")
|
||||
execution = ExecutionService(
|
||||
svc,
|
||||
repo,
|
||||
PaperBroker(fill_after_polls=0),
|
||||
ledger_outbox=outbox,
|
||||
event_emitter=emitter,
|
||||
)
|
||||
repo.reconcile_universe("default", {"PF-1": "SYM"})
|
||||
|
||||
decision = intents.submit_buy(BuyIntent(
|
||||
intent_id="b-owner", qty=10, strategy_id="BUY-A", portfolio_id="PF-1",
|
||||
))
|
||||
assert decision.slot_id is not None
|
||||
execution.run_once()
|
||||
intents.submit_sell(SellIntent(
|
||||
intent_id="s-owner", slot_id=decision.slot_id, sell_strategy_id="SELL-B",
|
||||
))
|
||||
pending = repo.fetch_slot(decision.slot_id)
|
||||
assert pending is not None
|
||||
assert pending.sell_strategy_id == "SELL-B"
|
||||
|
||||
execution.run_once()
|
||||
|
||||
assert ("slot_filled_sell", "SELL-B") in emitted
|
||||
assert ("slot_filled_sell", "BUY-A") not in emitted
|
||||
execution.run_once()
|
||||
assert emitted.count(("slot_filled_sell", "SELL-B")) == 1
|
||||
|
||||
|
||||
def test_ownerless_sell_admission_persists_legacy_buy_owner():
|
||||
repo, _svc, intents, execution, _writer = _wire(InMemoryTransport())
|
||||
decision = intents.submit_buy(BuyIntent(
|
||||
intent_id="b-legacy-owner", qty=10, strategy_id="BUY-A", portfolio_id="PF-1",
|
||||
))
|
||||
assert decision.slot_id is not None
|
||||
execution.run_once()
|
||||
|
||||
intents.submit_sell(SellIntent(
|
||||
intent_id="s-legacy-owner",
|
||||
slot_id=decision.slot_id,
|
||||
sell_strategy_id=" ",
|
||||
))
|
||||
|
||||
pending = repo.fetch_slot(decision.slot_id)
|
||||
assert pending is not None
|
||||
assert pending.sell_strategy_id == "BUY-A"
|
||||
|
||||
|
||||
def test_sell_owner_survives_execution_service_restart():
|
||||
emitted: list[tuple[str, str | None]] = []
|
||||
|
||||
def emitter(event_type: str, _slot, owner: str | None = None) -> None:
|
||||
emitted.append((event_type, owner))
|
||||
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo)
|
||||
intents = IntentService(
|
||||
svc, repo, InMemoryIntentStore(), resolve_symbol=lambda _pid: "005930",
|
||||
)
|
||||
outbox = InMemoryLedgerOutbox(instance_key="default")
|
||||
repo.reconcile_universe("default", {"PF-1": "SYM"})
|
||||
broker = PaperBroker(fill_after_polls=0)
|
||||
first_execution = ExecutionService(
|
||||
svc, repo, broker, ledger_outbox=outbox,
|
||||
)
|
||||
decision = intents.submit_buy(BuyIntent(
|
||||
intent_id="b-restart-owner", qty=10, strategy_id="BUY-A", portfolio_id="PF-1",
|
||||
))
|
||||
assert decision.slot_id is not None
|
||||
first_execution.run_once()
|
||||
intents.submit_sell(SellIntent(
|
||||
intent_id="s-restart-owner",
|
||||
slot_id=decision.slot_id,
|
||||
sell_strategy_id="SELL-B",
|
||||
))
|
||||
|
||||
restarted_execution = ExecutionService(
|
||||
svc,
|
||||
repo,
|
||||
broker,
|
||||
ledger_outbox=outbox,
|
||||
event_emitter=emitter,
|
||||
)
|
||||
restarted_execution.run_once()
|
||||
|
||||
assert ("slot_filled_sell", "SELL-B") in emitted
|
||||
|
||||
|
||||
class _AlarmRecorder:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, dict]] = []
|
||||
|
||||
def publish_alarm(self, code, _title, *, fields=None, severity="critical"):
|
||||
self.calls.append((code, {**(fields or {}), "severity": severity}))
|
||||
return True
|
||||
|
||||
|
||||
def test_ownerless_sell_fill_is_skipped_without_financial_rollback(caplog):
|
||||
emitted: list[tuple[str, str | None]] = []
|
||||
alarm = _AlarmRecorder()
|
||||
|
||||
def emitter(event_type: str, _slot, owner: str | None = None) -> None:
|
||||
emitted.append((event_type, owner))
|
||||
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo)
|
||||
intents = IntentService(
|
||||
svc,
|
||||
repo,
|
||||
InMemoryIntentStore(),
|
||||
resolve_symbol=lambda _pid: "005930",
|
||||
)
|
||||
outbox = InMemoryLedgerOutbox(instance_key="test-instance")
|
||||
execution = ExecutionService(
|
||||
svc,
|
||||
repo,
|
||||
PaperBroker(fill_after_polls=0),
|
||||
ledger_outbox=outbox,
|
||||
event_emitter=emitter,
|
||||
alarm_emitter=alarm, # type: ignore[arg-type]
|
||||
instance_key="default",
|
||||
)
|
||||
repo.reconcile_universe("default", {"PF-1": "SYM"})
|
||||
|
||||
decision = intents.submit_buy(BuyIntent(
|
||||
intent_id="b-ownerless", qty=10, portfolio_id="PF-1",
|
||||
))
|
||||
assert decision.slot_id is not None
|
||||
execution.run_once()
|
||||
intents.submit_sell(SellIntent(intent_id="s-ownerless", slot_id=decision.slot_id))
|
||||
|
||||
with caplog.at_level(logging.ERROR, logger="trex_core.application.execution"):
|
||||
result = execution.run_once()
|
||||
|
||||
sell_calls = [call for call in emitted if call[0] == "slot_filled_sell"]
|
||||
assert sell_calls == []
|
||||
assert result.sell_event_owner_missing == 1
|
||||
assert result.filled == 1
|
||||
assert result.ledger_enqueued == 1
|
||||
final_slot = repo.fetch_slot(decision.slot_id)
|
||||
assert final_slot is not None
|
||||
assert final_slot.status is SlotState.INACTIVE
|
||||
assert any("reason=sell_event_owner_missing" in rec.message for rec in caplog.records)
|
||||
assert alarm.calls == [(
|
||||
"sell_event_owner_missing",
|
||||
{
|
||||
"instance_key": "default",
|
||||
"slot_id": str(decision.slot_id),
|
||||
"symbol": "005930",
|
||||
"event_type": "slot_filled_sell",
|
||||
"reason": "sell_event_owner_missing",
|
||||
"severity": "critical",
|
||||
},
|
||||
)]
|
||||
|
||||
|
||||
def test_ownerless_sell_alarm_failure_does_not_rollback_fill():
|
||||
class RaisingAlarm:
|
||||
def publish_alarm(self, *_args, **_kwargs):
|
||||
raise RuntimeError("alarm unavailable")
|
||||
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo)
|
||||
intents = IntentService(
|
||||
svc, repo, InMemoryIntentStore(), resolve_symbol=lambda _pid: "005930",
|
||||
)
|
||||
outbox = InMemoryLedgerOutbox(instance_key="default")
|
||||
execution = ExecutionService(
|
||||
svc,
|
||||
repo,
|
||||
PaperBroker(fill_after_polls=0),
|
||||
ledger_outbox=outbox,
|
||||
alarm_emitter=RaisingAlarm(), # type: ignore[arg-type]
|
||||
instance_key="default",
|
||||
)
|
||||
repo.reconcile_universe("default", {"PF-1": "SYM"})
|
||||
decision = intents.submit_buy(BuyIntent(
|
||||
intent_id="b-ownerless-alarm", qty=10, portfolio_id="PF-1",
|
||||
))
|
||||
assert decision.slot_id is not None
|
||||
execution.run_once()
|
||||
intents.submit_sell(SellIntent(
|
||||
intent_id="s-ownerless-alarm", slot_id=decision.slot_id,
|
||||
))
|
||||
|
||||
result = execution.run_once()
|
||||
|
||||
final_slot = repo.fetch_slot(decision.slot_id)
|
||||
assert final_slot is not None
|
||||
assert final_slot.status is SlotState.INACTIVE
|
||||
assert result.filled == 1
|
||||
assert result.ledger_enqueued == 1
|
||||
assert result.sell_event_owner_missing == 1
|
||||
|
||||
Reference in New Issue
Block a user