feat: persist unmanaged position lifecycle state

Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-09-12 13:24:51 +00:00
committed by GitHub
co-authored by genichin
parent ae2e598209
commit fe51d211d9
12 changed files with 1274 additions and 5 deletions
@@ -0,0 +1,101 @@
"""PostgreSQLUnmanagedPositionStore integration tests."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
import pytest
@pytest.fixture
def pg_unmanaged_store(pg_clean):
from trex_core.adapters.portfolio.unmanaged_position_store import (
PostgreSQLUnmanagedPositionStore,
)
return PostgreSQLUnmanagedPositionStore(pg_clean)
class TestMigration028:
def test_tables_exist_after_migration(self, pg_clean):
import psycopg
with psycopg.connect(pg_clean) as conn, conn.cursor() as cur:
cur.execute(
"SELECT table_name FROM information_schema.tables "
"WHERE table_schema = 'public' "
"AND table_name IN ('unmanaged_position_state', 'unmanaged_position_audit')"
)
assert {row[0] for row in cur.fetchall()} == {
"unmanaged_position_state",
"unmanaged_position_audit",
}
class TestCRUDRoundTrip:
def test_observe_change_resolve_and_audit(self, pg_unmanaged_store):
t1 = datetime(2026, 9, 7, 0, 0, tzinfo=UTC)
t2 = t1 + timedelta(minutes=1)
t3 = t2 + timedelta(minutes=1)
first = pg_unmanaged_store.observe(
instance_key="inst-A",
symbol="008110",
quantity=3,
observed_at=t1,
)
repeat = pg_unmanaged_store.observe(
instance_key="inst-A",
symbol="008110",
quantity=3,
observed_at=t2,
)
changed = pg_unmanaged_store.observe(
instance_key="inst-A",
symbol="008110",
quantity=5,
observed_at=t3,
)
recorded = pg_unmanaged_store.record_operator_action(
instance_key="inst-A",
symbol="008110",
action="approved_exception",
evidence_ref="kis:2026-09-11",
actor="cli",
note="broker-confirmed",
)
resolved = pg_unmanaged_store.resolve_absent(
instance_key="inst-A",
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 len(resolved) == 1
state = pg_unmanaged_store.get_state(instance_key="inst-A", symbol="008110")
assert state is not None
assert state["quantity"] == 5
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["evidence_ref"] == "kis:2026-09-11"
audit = pg_unmanaged_store.list_audit(instance_key="inst-A", symbol="008110")
assert [row["event_type"] for row in audit] == [
"detected",
"quantity_changed",
"operator_action",
"resolved",
]
def test_same_symbol_isolated_by_instance(self, pg_unmanaged_store):
pg_unmanaged_store.observe(instance_key="inst-A", symbol="008110", quantity=3)
pg_unmanaged_store.observe(instance_key="inst-B", symbol="008110", quantity=7)
pg_unmanaged_store.resolve_absent(instance_key="inst-A", observed_symbols=set())
rows_a = pg_unmanaged_store.list_states(instance_key="inst-A", lifecycle_state="all")
rows_b = pg_unmanaged_store.list_states(instance_key="inst-B", lifecycle_state="all")
assert len(rows_a) == 1 and rows_a[0]["lifecycle_state"] == "resolved"
assert len(rows_b) == 1 and rows_b[0]["quantity"] == 7
@@ -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"
+2
View File
@@ -28,6 +28,8 @@ _PG_TABLES = (
"daily_slot",
"reconcile_notification_state",
"slot_strategy_state",
"unmanaged_position_audit",
"unmanaged_position_state",
)
+169
View File
@@ -516,6 +516,175 @@ def test_verify_no_db_no_live_flag_needed(capsys, monkeypatch):
assert rc == 0
# ---------------------------------------------------------------------------
# unmanaged parser/tests
# ---------------------------------------------------------------------------
def test_unmanaged_parser_accepts_list_and_record():
parser = cli._build_parser()
args = parser.parse_args(["unmanaged", "list", "--status", "all", "--symbol", "008110"])
assert args.cmd == "unmanaged"
assert args.unmanaged_cmd == "list"
assert args.status == "all"
args = parser.parse_args(
[
"unmanaged",
"record",
"008110",
"investigating",
"--evidence-ref",
"kis-balance:2026-09-11",
]
)
assert args.unmanaged_cmd == "record"
assert args.action == "investigating"
def test_unmanaged_list_outputs_durable_state(monkeypatch, capsys):
class _Store:
def __init__(self, *_args, **_kwargs):
pass
def list_states(self, **kwargs):
assert kwargs["instance_key"] == "inst-1"
assert kwargs["lifecycle_state"] == "all"
return [
{
"instance_key": "inst-1",
"symbol": "008110",
"quantity": 3,
"lifecycle_state": "active",
"first_seen_at": "2026-09-07T00:00:00+00:00",
"last_seen_at": "2026-09-11T00:00:00+00:00",
"quantity_changed_at": "2026-09-07T00:00:00+00:00",
"resolved_at": None,
"operator_action": "investigating",
"evidence_ref": "kis-balance:2026-09-11",
"operator_note": "confirmed",
"operator_actor": "operator",
"operator_updated_at": "2026-09-11T00:00:00+00:00",
"created_at": "2026-09-07T00:00:00+00:00",
"updated_at": "2026-09-11T00:00:00+00:00",
}
]
monkeypatch.setattr(
"trex_core.adapters.portfolio.unmanaged_position_store.PostgreSQLUnmanagedPositionStore",
_Store,
)
args = SimpleNamespace(
cmd="unmanaged",
unmanaged_cmd="list",
status="all",
symbol=None,
instance="inst-1",
config=None,
config_path=None,
env_file=None,
json=True,
timeout=10.0,
)
rc = cli._cmd_unmanaged_list(args, SimpleNamespace(dsn="postgresql://x/y"))
assert rc == 0
out = json.loads(capsys.readouterr().out)
assert out["count"] == 1
assert out["states"][0]["symbol"] == "008110"
assert out["states"][0]["evidence_ref"] == "kis-balance:2026-09-11"
def test_unmanaged_record_requires_existing_state(monkeypatch, capsys):
class _Store:
def __init__(self, *_args, **_kwargs):
pass
def record_operator_action(self, **_kwargs):
return None
monkeypatch.setattr(
"trex_core.adapters.portfolio.unmanaged_position_store.PostgreSQLUnmanagedPositionStore",
_Store,
)
args = SimpleNamespace(
cmd="unmanaged",
unmanaged_cmd="record",
symbol="008110",
action="approved_exception",
evidence_ref="kis-balance:2026-09-11",
note="confirmed",
actor="operator",
instance="inst-1",
config=None,
config_path=None,
env_file=None,
json=True,
timeout=10.0,
)
rc = cli._cmd_unmanaged_record(args, SimpleNamespace(dsn="postgresql://x/y"))
assert rc == 1
err = json.loads(capsys.readouterr().err)
assert "not found" in err["error"]
def test_unmanaged_record_persists_operator_action(monkeypatch, capsys):
class _Store:
def __init__(self, *_args, **_kwargs):
pass
def record_operator_action(self, **kwargs):
assert kwargs["instance_key"] == "inst-1"
assert kwargs["symbol"] == "008110"
assert kwargs["action"] == "managed_inclusion"
return {
"instance_key": "inst-1",
"symbol": "008110",
"quantity": 3,
"lifecycle_state": "active",
"first_seen_at": "2026-09-07T00:00:00+00:00",
"last_seen_at": "2026-09-11T00:00:00+00:00",
"quantity_changed_at": "2026-09-07T00:00:00+00:00",
"resolved_at": None,
"operator_action": "managed_inclusion",
"evidence_ref": kwargs["evidence_ref"],
"operator_note": kwargs["note"],
"operator_actor": kwargs["actor"],
"operator_updated_at": "2026-09-11T00:00:00+00:00",
"created_at": "2026-09-07T00:00:00+00:00",
"updated_at": "2026-09-11T00:00:00+00:00",
}
monkeypatch.setattr(
"trex_core.adapters.portfolio.unmanaged_position_store.PostgreSQLUnmanagedPositionStore",
_Store,
)
args = SimpleNamespace(
cmd="unmanaged",
unmanaged_cmd="record",
symbol="008110",
action="managed_inclusion",
evidence_ref="kis-balance:2026-09-11",
note="manual inclusion planned",
actor="operator",
instance="inst-1",
config=None,
config_path=None,
env_file=None,
json=True,
timeout=10.0,
)
rc = cli._cmd_unmanaged_record(args, SimpleNamespace(dsn="postgresql://x/y"))
assert rc == 0
out = json.loads(capsys.readouterr().out)
assert out["result"] == "recorded"
assert out["state"]["operator_action"] == "managed_inclusion"
assert out["state"]["evidence_ref"] == "kis-balance:2026-09-11"
# ---------------------------------------------------------------------------
# REL-010 regression: _cmd_verify broker import must not raise ImportError
# ---------------------------------------------------------------------------