from __future__ import annotations from datetime import UTC, datetime, timedelta import pytest from trex_core.adapters.messaging.in_memory import InMemoryTransport from trex_core.application.alarm_emitter import AlarmEmitter from trex_core.application.broker_port import BalanceItem, BrokerBalanceError from trex_core.application.portfolio import InMemoryUnmanagedPositionStore from trex_core.application.reconciliation import reconcile_core_holdings from trex_core.domain.slot import SlotService from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository INSTANCE = "inst-1" class FakeBroker: def __init__(self, balances): self._balances = balances def name(self): return "fake" def submit_order(self, req): ... def poll_fill(self, *args, **kwargs): ... def cancel_order(self, *args, **kwargs): ... def get_balance(self): return [BalanceItem(symbol=symbol, hldg_qty=qty) for symbol, qty in self._balances.items()] class RaisingBroker(FakeBroker): def get_balance(self): raise BrokerBalanceError("transport_failed") class AuthoritativeBroker(FakeBroker): def exposes_full_balance_snapshot(self) -> bool: return True class FilteredBroker(FakeBroker): def exposes_full_balance_snapshot(self) -> bool: return False def _wire(): repo = InMemorySlotRepository() return repo, SlotService(repo, instance_key=INSTANCE) def _make_emitter(): transport = InMemoryTransport() return AlarmEmitter(transport), transport def test_in_memory_store_tracks_lifecycle_and_operator_audit(): store = InMemoryUnmanagedPositionStore() t1 = datetime(2026, 9, 7, 0, 0, tzinfo=UTC) t2 = t1 + timedelta(minutes=1) t3 = t2 + timedelta(minutes=1) first = store.observe(instance_key=INSTANCE, symbol="008110", quantity=3, observed_at=t1) repeat = store.observe(instance_key=INSTANCE, symbol="008110", quantity=3, observed_at=t2) changed = store.observe(instance_key=INSTANCE, symbol="008110", quantity=4, observed_at=t3) recorded = store.record_operator_action( instance_key=INSTANCE, symbol="008110", action="investigating", evidence_ref="kis-balance:2026-09-07", note="confirmed at broker", ) resolved = store.resolve_absent( instance_key=INSTANCE, observed_symbols=set(), observed_at=t3 + timedelta(minutes=1), ) assert first["transition"] == "detected" assert repeat["transition"] == "unchanged" assert changed["transition"] == "quantity_changed" assert recorded is not None assert recorded["evidence_ref"] == "kis-balance:2026-09-07" assert len(resolved) == 1 state = store.get_state(instance_key=INSTANCE, symbol="008110") assert state is not None assert state["quantity"] == 4 assert state["first_seen_at"] == t1 assert state["last_seen_at"] == t3 assert state["quantity_changed_at"] == t3 assert state["lifecycle_state"] == "resolved" assert state["resolved_at"] == t3 + timedelta(minutes=1) audit = store.list_audit(instance_key=INSTANCE, symbol="008110") assert [row["event_type"] for row in audit] == [ "detected", "quantity_changed", "operator_action", "resolved", ] assert all("recorded_at" in row for row in audit) def test_in_memory_store_isolates_same_symbol_by_instance(): store = InMemoryUnmanagedPositionStore() store.observe(instance_key="inst-A", symbol="008110", quantity=3) store.observe(instance_key="inst-B", symbol="008110", quantity=7) store.resolve_absent(instance_key="inst-A", observed_symbols=set()) state_a = store.get_state(instance_key="inst-A", symbol="008110") state_b = store.get_state(instance_key="inst-B", symbol="008110") assert state_a is not None and state_a["lifecycle_state"] == "resolved" assert state_b is not None and state_b["lifecycle_state"] == "active" assert state_b["quantity"] == 7 def test_in_memory_store_preserves_first_seen_when_resolved_symbol_reappears(): store = InMemoryUnmanagedPositionStore() t1 = datetime(2026, 9, 7, 0, 0, tzinfo=UTC) t2 = t1 + timedelta(minutes=1) t3 = t2 + timedelta(minutes=1) store.observe(instance_key=INSTANCE, symbol="008110", quantity=3, observed_at=t1) store.record_operator_action( instance_key=INSTANCE, symbol="008110", action="investigating", evidence_ref="kis-balance:2026-09-07", note="before resolve", ) store.resolve_absent(instance_key=INSTANCE, observed_symbols=set(), observed_at=t2) reopened = store.observe(instance_key=INSTANCE, symbol="008110", quantity=5, observed_at=t3) assert reopened["transition"] == "detected" assert reopened["state"]["first_seen_at"] == t1 assert reopened["state"]["last_seen_at"] == t3 assert reopened["state"]["quantity_changed_at"] == t3 assert reopened["state"]["operator_action"] is None assert reopened["state"]["evidence_ref"] is None def test_in_memory_store_ignores_stale_resolve_after_newer_observation(): store = InMemoryUnmanagedPositionStore() t1 = datetime(2026, 9, 7, 0, 0, tzinfo=UTC) t2 = t1 + timedelta(minutes=1) t3 = t2 + timedelta(minutes=1) store.observe(instance_key=INSTANCE, symbol="008110", quantity=3, observed_at=t1) store.observe(instance_key=INSTANCE, symbol="008110", quantity=5, observed_at=t3) resolved = store.resolve_absent( instance_key=INSTANCE, observed_symbols=set(), observed_at=t2, ) assert resolved == [] state = store.get_state(instance_key=INSTANCE, symbol="008110") assert state is not None assert state["lifecycle_state"] == "active" assert state["quantity"] == 5 assert store.list_audit(instance_key=INSTANCE, symbol="008110")[-1]["event_type"] == "quantity_changed" def test_in_memory_store_ignores_stale_observation_after_newer_resolution(): store = InMemoryUnmanagedPositionStore() t1 = datetime(2026, 9, 7, 0, 0, tzinfo=UTC) t2 = t1 + timedelta(minutes=1) t3 = t2 + timedelta(minutes=1) store.observe(instance_key=INSTANCE, symbol="008110", quantity=3, observed_at=t1) store.resolve_absent(instance_key=INSTANCE, observed_symbols=set(), observed_at=t3) stale = store.observe(instance_key=INSTANCE, symbol="008110", quantity=5, observed_at=t2) assert stale["transition"] == "stale" state = store.get_state(instance_key=INSTANCE, symbol="008110") assert state is not None assert state["lifecycle_state"] == "resolved" assert state["quantity"] == 3 assert [row["event_type"] for row in store.list_audit(instance_key=INSTANCE, symbol="008110")] == [ "detected", "resolved", ] def test_reconcile_core_holdings_suppresses_repeated_unmanaged_alarm_spam(): repo, svc = _wire() emitter, transport = _make_emitter() store = InMemoryUnmanagedPositionStore() for _ in range(3): decisions = reconcile_core_holdings( slot_repo=repo, slot_service=svc, broker=AuthoritativeBroker({"008110": 3}), instance_key=INSTANCE, alarm_emitter=emitter, unmanaged_position_store=store, ) assert decisions == {"broker:008110": "unmanaged_position"} state = store.get_state(instance_key=INSTANCE, symbol="008110") assert state is not None assert state["quantity"] == 3 codes = [payload["code"] for _, payload in transport.alarm_events()] assert codes == ["unmanaged_position_detected"] audit = store.list_audit(instance_key=INSTANCE, symbol="008110") assert [row["event_type"] for row in audit] == ["detected"] def test_reconcile_core_holdings_tracks_change_and_resolution(): repo, svc = _wire() emitter, transport = _make_emitter() store = InMemoryUnmanagedPositionStore() reconcile_core_holdings( slot_repo=repo, slot_service=svc, broker=FakeBroker({"008110": 3}), instance_key=INSTANCE, alarm_emitter=emitter, unmanaged_position_store=store, ) reconcile_core_holdings( slot_repo=repo, slot_service=svc, broker=AuthoritativeBroker({"008110": 5}), instance_key=INSTANCE, alarm_emitter=emitter, unmanaged_position_store=store, ) reconcile_core_holdings( slot_repo=repo, slot_service=svc, broker=AuthoritativeBroker({}), instance_key=INSTANCE, alarm_emitter=emitter, unmanaged_position_store=store, ) state = store.get_state(instance_key=INSTANCE, symbol="008110") assert state is not None assert state["quantity"] == 5 assert state["lifecycle_state"] == "resolved" codes = [payload["code"] for _, payload in transport.alarm_events()] assert codes == [ "unmanaged_position_detected", "unmanaged_position_changed", "unmanaged_position_resolved", ] def test_broker_failure_does_not_resolve_unmanaged_state(): repo, svc = _wire() store = InMemoryUnmanagedPositionStore() store.observe(instance_key=INSTANCE, symbol="008110", quantity=3) with pytest.raises(BrokerBalanceError): reconcile_core_holdings( slot_repo=repo, slot_service=svc, broker=RaisingBroker({}), instance_key=INSTANCE, unmanaged_position_store=store, ) state = store.get_state(instance_key=INSTANCE, symbol="008110") assert state is not None assert state["lifecycle_state"] == "active" def test_missing_authoritative_marker_does_not_resolve_unmanaged_state(): repo, svc = _wire() store = InMemoryUnmanagedPositionStore() store.observe(instance_key=INSTANCE, symbol="008110", quantity=3) decisions = reconcile_core_holdings( slot_repo=repo, slot_service=svc, broker=FakeBroker({}), instance_key=INSTANCE, unmanaged_position_store=store, ) assert decisions == {} state = store.get_state(instance_key=INSTANCE, symbol="008110") assert state is not None assert state["lifecycle_state"] == "active" def test_non_authoritative_broker_view_does_not_resolve_unmanaged_state(): repo, svc = _wire() store = InMemoryUnmanagedPositionStore() store.observe(instance_key=INSTANCE, symbol="008110", quantity=3) decisions = reconcile_core_holdings( slot_repo=repo, slot_service=svc, broker=FilteredBroker({}), instance_key=INSTANCE, unmanaged_position_store=store, ) assert decisions == {} state = store.get_state(instance_key=INSTANCE, symbol="008110") assert state is not None assert state["lifecycle_state"] == "active"