Files
trex-core/tests/application/test_unmanaged_position_lifecycle.py
T

308 lines
10 KiB
Python

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"