feat: persist unmanaged position lifecycle state
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
This commit is contained in:
co-authored by
genichin
parent
ae2e598209
commit
fe51d211d9
@@ -0,0 +1,190 @@
|
||||
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("broker_unavailable")
|
||||
|
||||
|
||||
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",
|
||||
]
|
||||
|
||||
|
||||
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_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=FakeBroker({"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=FakeBroker({"008110": 5}),
|
||||
instance_key=INSTANCE,
|
||||
alarm_emitter=emitter,
|
||||
unmanaged_position_store=store,
|
||||
)
|
||||
reconcile_core_holdings(
|
||||
slot_repo=repo,
|
||||
slot_service=svc,
|
||||
broker=FakeBroker({}),
|
||||
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"
|
||||
Reference in New Issue
Block a user