fix: preserve sell event routing owner

This commit is contained in:
Hermes Agent
2026-09-20 20:34:00 +09:00
parent 3f6a967aa1
commit c66acf6e47
20 changed files with 766 additions and 32 deletions
+60 -2
View File
@@ -65,7 +65,7 @@ class ProgrammableBroker:
return None
def _wire(broker, *, stale_sec=None):
def _wire(broker, *, stale_sec=None, event_emitter=None):
repo = InMemorySlotRepository()
svc = SlotService(repo)
intents = IntentService(svc, repo, InMemoryIntentStore(),
@@ -74,7 +74,7 @@ def _wire(broker, *, stale_sec=None):
ledger = InMemoryLedger()
execution = ExecutionService(
svc, repo, broker, ledger_outbox=outbox,
stale_pending_threshold_sec=stale_sec,
stale_pending_threshold_sec=stale_sec, event_emitter=event_emitter,
)
writer = LedgerWriter(outbox, ledger)
repo.reconcile_universe("default", {"PF-1": "SYM"})
@@ -104,6 +104,64 @@ def test_full_external_cancel_returns_slot_to_holding():
assert r.rolled_back == 1
def test_zero_fill_cancel_preserves_owner_for_ownerless_resell():
broker = ProgrammableBroker()
repo, _svc, intents, execution, _ledger, _writer = _wire(broker)
sid = _buy_to_holding(intents, execution)
assert sid is not None
intents.submit_sell(SellIntent(
intent_id="s-owner-1", slot_id=sid, sell_strategy_id="SELL-B",
))
broker.responder = lambda side: FillStatus(state="cancelled")
execution.run_once()
holding = repo.fetch_slot(sid)
assert holding is not None
assert holding.status is SlotState.HOLDING
assert holding.sell_strategy_id == "SELL-B"
intents.submit_sell(SellIntent(intent_id="s-owner-2", slot_id=sid))
pending = repo.fetch_slot(sid)
assert pending is not None
assert pending.status is SlotState.PENDING_SELL
assert pending.sell_strategy_id == "SELL-B"
def test_partial_sell_cancel_routes_with_sell_owner_and_preserves_it():
emitted: list[tuple[str, str | None]] = []
def emitter(event_type, _slot, owner=None):
emitted.append((event_type, owner))
broker = ProgrammableBroker()
repo, _svc, intents, execution, _ledger, _writer = _wire(
broker, event_emitter=emitter,
)
sid = _buy_to_holding(intents, execution)
assert sid is not None
intents.submit_sell(SellIntent(
intent_id="s-partial-owner", slot_id=sid, sell_strategy_id="SELL-B",
))
broker.balance["005930"] = 6
broker.responder = lambda side: FillStatus(
state="partially_filled",
filled_qty=4,
avg_price=1100.0,
execution_id="exec-partial-owner",
cancelled=True,
)
execution.run_once()
holding = repo.fetch_slot(sid)
assert holding is not None
assert holding.status is SlotState.HOLDING
assert holding.sell_strategy_id == "SELL-B"
assert ("slot_filled_sell", "SELL-B") in emitted
def test_unresolved_poll_observability_is_sanitized_and_deduplicated(caplog):
broker = ProgrammableBroker()
repo, _svc, intents, execution, _ledger, _writer = _wire(broker)
@@ -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