Fence unmanaged lifecycle by snapshot time

Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-09-13 05:54:51 +00:00
committed by GitHub
co-authored by genichin
parent 84d81658a1
commit 8b1de8e26a
5 changed files with 239 additions and 34 deletions
@@ -161,13 +161,83 @@ class TestCRUDRoundTrip:
audit = store.list_audit(instance_key="inst-A", symbol="008110") audit = store.list_audit(instance_key="inst-A", symbol="008110")
assert [row["event_type"] for row in audit] == ["detected"] assert [row["event_type"] for row in audit] == ["detected"]
def test_concurrent_observe_and_resolve_leave_consistent_state_and_audit(self, pg_clean): def test_older_absent_snapshot_applied_after_newer_observation_is_ignored(self, pg_unmanaged_store):
t1 = datetime(2026, 9, 7, 0, 0, tzinfo=UTC)
t2 = t1 + timedelta(minutes=1)
t3 = t2 + timedelta(minutes=1)
pg_unmanaged_store.observe(
instance_key="inst-A",
symbol="008110",
quantity=3,
observed_at=t1,
)
observed = pg_unmanaged_store.observe(
instance_key="inst-A",
symbol="008110",
quantity=5,
observed_at=t3,
)
resolved = pg_unmanaged_store.resolve_absent(
instance_key="inst-A",
observed_symbols=set(),
observed_at=t2,
)
assert observed["transition"] == "quantity_changed"
assert resolved == []
state = pg_unmanaged_store.get_state(instance_key="inst-A", symbol="008110")
assert state is not None
assert state["lifecycle_state"] == "active"
assert state["quantity"] == 5
assert [row["event_type"] for row in pg_unmanaged_store.list_audit(
instance_key="inst-A",
symbol="008110",
)] == ["detected", "quantity_changed"]
def test_older_observation_applied_after_newer_resolution_is_ignored(self, pg_unmanaged_store):
t1 = datetime(2026, 9, 7, 0, 0, tzinfo=UTC)
t2 = t1 + timedelta(minutes=1)
t3 = t2 + timedelta(minutes=1)
pg_unmanaged_store.observe(
instance_key="inst-A",
symbol="008110",
quantity=3,
observed_at=t1,
)
pg_unmanaged_store.resolve_absent(
instance_key="inst-A",
observed_symbols=set(),
observed_at=t3,
)
stale = pg_unmanaged_store.observe(
instance_key="inst-A",
symbol="008110",
quantity=5,
observed_at=t2,
)
assert stale["transition"] == "stale"
state = pg_unmanaged_store.get_state(instance_key="inst-A", symbol="008110")
assert state is not None
assert state["lifecycle_state"] == "resolved"
assert state["quantity"] == 3
assert [row["event_type"] for row in pg_unmanaged_store.list_audit(
instance_key="inst-A",
symbol="008110",
)] == ["detected", "resolved"]
def test_concurrent_observe_and_resolve_respects_newest_snapshot_timestamp(self, pg_clean):
from trex_core.adapters.portfolio.unmanaged_position_store import ( from trex_core.adapters.portfolio.unmanaged_position_store import (
PostgreSQLUnmanagedPositionStore, PostgreSQLUnmanagedPositionStore,
) )
store = PostgreSQLUnmanagedPositionStore(pg_clean) store = PostgreSQLUnmanagedPositionStore(pg_clean)
store.observe(instance_key="inst-A", symbol="008110", quantity=3) t1 = datetime(2026, 9, 7, 0, 0, tzinfo=UTC)
t2 = t1 + timedelta(minutes=1)
t3 = t2 + timedelta(minutes=1)
store.observe(instance_key="inst-A", symbol="008110", quantity=3, observed_at=t1)
with ThreadPoolExecutor(max_workers=2) as pool: with ThreadPoolExecutor(max_workers=2) as pool:
observe_future = pool.submit( observe_future = pool.submit(
@@ -175,32 +245,24 @@ class TestCRUDRoundTrip:
instance_key="inst-A", instance_key="inst-A",
symbol="008110", symbol="008110",
quantity=5, quantity=5,
observed_at=t3,
) )
resolve_future = pool.submit( resolve_future = pool.submit(
PostgreSQLUnmanagedPositionStore(pg_clean).resolve_absent, PostgreSQLUnmanagedPositionStore(pg_clean).resolve_absent,
instance_key="inst-A", instance_key="inst-A",
observed_symbols=set(), observed_symbols=set(),
observed_at=t2,
) )
observe_result = observe_future.result() observe_result = observe_future.result()
resolve_result = resolve_future.result() resolve_result = resolve_future.result()
state = store.get_state(instance_key="inst-A", symbol="008110") state = store.get_state(instance_key="inst-A", symbol="008110")
assert state is not None assert state is not None
audit = store.list_audit(instance_key="inst-A", symbol="008110") assert observe_result["transition"] == "quantity_changed"
event_types = [row["event_type"] for row in audit] assert resolve_result == []
assert event_types[0] == "detected" assert state["lifecycle_state"] == "active"
assert event_types in ( assert state["quantity"] == 5
["detected", "quantity_changed", "resolved"], assert [row["event_type"] for row in store.list_audit(
["detected", "resolved", "detected"], instance_key="inst-A",
) symbol="008110",
if state["lifecycle_state"] == "resolved": )] == ["detected", "quantity_changed"]
assert observe_result["transition"] == "quantity_changed"
assert len(resolve_result) == 1
assert state["quantity"] == 5
assert event_types[-1] == "resolved"
else:
assert state["lifecycle_state"] == "active"
assert observe_result["transition"] == "detected"
assert len(resolve_result) == 1
assert state["quantity"] == 5
assert event_types[-1] == "detected"
@@ -140,6 +140,49 @@ def test_in_memory_store_preserves_first_seen_when_resolved_symbol_reappears():
assert reopened["state"]["evidence_ref"] 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(): def test_reconcile_core_holdings_suppresses_repeated_unmanaged_alarm_spam():
repo, svc = _wire() repo, svc = _wire()
emitter, transport = _make_emitter() emitter, transport = _make_emitter()
@@ -21,6 +21,18 @@ class PostgreSQLUnmanagedPositionStore:
self._psycopg = _require_psycopg(psycopg_module) self._psycopg = _require_psycopg(psycopg_module)
self._dsn = dsn self._dsn = dsn
@staticmethod
def _latest_snapshot_at(state: dict) -> datetime:
resolved_at = state.get("resolved_at")
last_seen_at = state.get("last_seen_at")
if isinstance(resolved_at, datetime) and (
not isinstance(last_seen_at, datetime) or resolved_at >= last_seen_at
):
return resolved_at
if isinstance(last_seen_at, datetime):
return last_seen_at
return state.get("updated_at") or state.get("created_at") or datetime.min.replace(tzinfo=UTC)
def observe( def observe(
self, self,
*, *,
@@ -43,6 +55,13 @@ class PostgreSQLUnmanagedPositionStore:
row = cur.fetchone() row = cur.fetchone()
previous = self._row_to_state(row) if row is not None else None previous = self._row_to_state(row) if row is not None else None
previous_quantity = previous.get("quantity") if previous is not None else None previous_quantity = previous.get("quantity") if previous is not None else None
if previous is not None and now < self._latest_snapshot_at(previous):
conn.rollback()
return {
"transition": "stale",
"previous_quantity": previous_quantity,
"state": previous,
}
if previous is None: if previous is None:
transition = "detected" transition = "detected"
cur.execute( cur.execute(
@@ -54,6 +73,13 @@ class PostgreSQLUnmanagedPositionStore:
(instance_key, symbol, quantity, now, now, now, now, now), (instance_key, symbol, quantity, now, now, now, now, now),
) )
elif previous["lifecycle_state"] == "resolved": elif previous["lifecycle_state"] == "resolved":
if now <= self._latest_snapshot_at(previous):
conn.rollback()
return {
"transition": "stale",
"previous_quantity": previous_quantity,
"state": previous,
}
transition = "detected" transition = "detected"
cur.execute( cur.execute(
"UPDATE unmanaged_position_state " "UPDATE unmanaged_position_state "
@@ -63,29 +89,58 @@ class PostgreSQLUnmanagedPositionStore:
" operator_actor = NULL, operator_updated_at = NULL, " " operator_actor = NULL, operator_updated_at = NULL, "
" updated_at = %s " " updated_at = %s "
"WHERE instance_key = %s AND symbol = %s " "WHERE instance_key = %s AND symbol = %s "
" AND lifecycle_state = 'resolved' AND resolved_at < %s "
f"RETURNING {_STATE_COLUMNS}", f"RETURNING {_STATE_COLUMNS}",
(quantity, now, now, now, instance_key, symbol), (quantity, now, now, now, instance_key, symbol, now),
) )
elif int(previous["quantity"]) != int(quantity): elif int(previous["quantity"]) != int(quantity):
if now <= previous["last_seen_at"]:
conn.rollback()
return {
"transition": "stale",
"previous_quantity": previous_quantity,
"state": previous,
}
transition = "quantity_changed" transition = "quantity_changed"
cur.execute( cur.execute(
"UPDATE unmanaged_position_state " "UPDATE unmanaged_position_state "
"SET quantity = %s, last_seen_at = %s, quantity_changed_at = %s, " "SET quantity = %s, last_seen_at = %s, quantity_changed_at = %s, "
" resolved_at = NULL, lifecycle_state = 'active', updated_at = %s " " resolved_at = NULL, lifecycle_state = 'active', updated_at = %s "
"WHERE instance_key = %s AND symbol = %s " "WHERE instance_key = %s AND symbol = %s "
" AND lifecycle_state = 'active' AND last_seen_at < %s "
f"RETURNING {_STATE_COLUMNS}", f"RETURNING {_STATE_COLUMNS}",
(quantity, now, now, now, instance_key, symbol), (quantity, now, now, now, instance_key, symbol, now),
) )
else: else:
transition = "unchanged" transition = "unchanged"
cur.execute( if now > previous["last_seen_at"]:
"UPDATE unmanaged_position_state " cur.execute(
"SET last_seen_at = %s, updated_at = %s " "UPDATE unmanaged_position_state "
"WHERE instance_key = %s AND symbol = %s " "SET last_seen_at = %s, updated_at = %s "
f"RETURNING {_STATE_COLUMNS}", "WHERE instance_key = %s AND symbol = %s "
(now, now, instance_key, symbol), " AND lifecycle_state = 'active' AND quantity = %s AND last_seen_at < %s "
) f"RETURNING {_STATE_COLUMNS}",
state = self._row_to_state(cur.fetchone()) (now, now, instance_key, symbol, quantity, now),
)
fetched = cur.fetchone()
state = previous if fetched is None else self._row_to_state(fetched)
else:
conn.rollback()
return {
"transition": "unchanged",
"previous_quantity": previous_quantity,
"state": previous,
}
if transition != "unchanged":
fetched = cur.fetchone()
if fetched is None:
conn.rollback()
return {
"transition": "stale",
"previous_quantity": previous_quantity,
"state": previous,
}
state = self._row_to_state(fetched)
if transition != "unchanged": if transition != "unchanged":
note = ( note = (
f"previous_quantity={previous_quantity}" f"previous_quantity={previous_quantity}"
@@ -139,14 +194,20 @@ class PostgreSQLUnmanagedPositionStore:
for state in active: for state in active:
if state["symbol"] in observed_symbols: if state["symbol"] in observed_symbols:
continue continue
if now <= state["last_seen_at"]:
continue
cur.execute( cur.execute(
"UPDATE unmanaged_position_state " "UPDATE unmanaged_position_state "
"SET lifecycle_state = 'resolved', resolved_at = %s, updated_at = %s " "SET lifecycle_state = 'resolved', resolved_at = %s, updated_at = %s "
"WHERE instance_key = %s AND symbol = %s " "WHERE instance_key = %s AND symbol = %s "
" AND lifecycle_state = 'active' AND last_seen_at < %s "
f"RETURNING {_STATE_COLUMNS}", f"RETURNING {_STATE_COLUMNS}",
(now, now, instance_key, state["symbol"]), (now, now, instance_key, state["symbol"], now),
) )
resolved_state = self._row_to_state(cur.fetchone()) row = cur.fetchone()
if row is None:
continue
resolved_state = self._row_to_state(row)
resolved.append(resolved_state) resolved.append(resolved_state)
self._insert_audit( self._insert_audit(
cur, cur,
+37 -2
View File
@@ -625,6 +625,18 @@ class InMemoryUnmanagedPositionStore:
self._states: dict[tuple[str, str], dict] = {} self._states: dict[tuple[str, str], dict] = {}
self._audit: list[dict] = [] self._audit: list[dict] = []
@staticmethod
def _latest_snapshot_at(state: dict) -> datetime:
resolved_at = state.get("resolved_at")
last_seen_at = state.get("last_seen_at")
if isinstance(resolved_at, datetime) and (
not isinstance(last_seen_at, datetime) or resolved_at >= last_seen_at
):
return resolved_at
if isinstance(last_seen_at, datetime):
return last_seen_at
return state.get("updated_at") or state.get("created_at") or datetime.min.replace(tzinfo=UTC)
def observe( def observe(
self, self,
*, *,
@@ -642,7 +654,21 @@ class InMemoryUnmanagedPositionStore:
preserved = {} if existing is None else { preserved = {} if existing is None else {
"created_at": existing.get("created_at", now), "created_at": existing.get("created_at", now),
} }
if existing is not None:
latest_snapshot_at = self._latest_snapshot_at(existing)
if now < latest_snapshot_at:
return {
"transition": "stale",
"previous_quantity": previous_quantity,
"state": dict(existing),
}
if existing is None or existing.get("lifecycle_state") == "resolved": if existing is None or existing.get("lifecycle_state") == "resolved":
if existing is not None and now == self._latest_snapshot_at(existing):
return {
"transition": "stale",
"previous_quantity": previous_quantity,
"state": dict(existing),
}
transition = "detected" transition = "detected"
state = { state = {
"instance_key": instance_key, "instance_key": instance_key,
@@ -666,6 +692,12 @@ class InMemoryUnmanagedPositionStore:
} }
self._states[key] = state self._states[key] = state
elif int(existing["quantity"]) != int(quantity): elif int(existing["quantity"]) != int(quantity):
if now <= existing["last_seen_at"]:
return {
"transition": "stale",
"previous_quantity": previous_quantity,
"state": dict(existing),
}
transition = "quantity_changed" transition = "quantity_changed"
existing["quantity"] = quantity existing["quantity"] = quantity
existing["last_seen_at"] = now existing["last_seen_at"] = now
@@ -676,8 +708,9 @@ class InMemoryUnmanagedPositionStore:
state = existing state = existing
else: else:
transition = "unchanged" transition = "unchanged"
existing["last_seen_at"] = now if now > existing["last_seen_at"]:
existing["updated_at"] = now existing["last_seen_at"] = now
existing["updated_at"] = now
state = existing state = existing
if transition != "unchanged": if transition != "unchanged":
self._audit.append( self._audit.append(
@@ -722,6 +755,8 @@ class InMemoryUnmanagedPositionStore:
continue continue
if state["symbol"] in observed_symbols: if state["symbol"] in observed_symbols:
continue continue
if now <= state["last_seen_at"]:
continue
state["lifecycle_state"] = "resolved" state["lifecycle_state"] = "resolved"
state["resolved_at"] = now state["resolved_at"] = now
state["updated_at"] = now state["updated_at"] = now
+4
View File
@@ -29,6 +29,7 @@ import hashlib
import logging import logging
import math import math
import re import re
from datetime import UTC, datetime
from typing import cast from typing import cast
from trex_core.application.alarm_emitter import AlarmEmitter from trex_core.application.alarm_emitter import AlarmEmitter
@@ -407,6 +408,7 @@ def reconcile_core_holdings(
Returns {key: 'error'|'qty_warning'|'ok'|'unmanaged_position'| Returns {key: 'error'|'qty_warning'|'ok'|'unmanaged_position'|
'managed_non_holding_divergence'} for telemetry. 'managed_non_holding_divergence'} for telemetry.
""" """
snapshot_observed_at = datetime.now(tz=UTC)
broker_qty: dict[str, int] = { broker_qty: dict[str, int] = {
item.symbol: int(item.hldg_qty) for item in broker.get_balance() item.symbol: int(item.hldg_qty) for item in broker.get_balance()
} }
@@ -507,6 +509,7 @@ def reconcile_core_holdings(
instance_key=instance_key, instance_key=instance_key,
symbol=sym, symbol=sym,
quantity=bq, quantity=bq,
observed_at=snapshot_observed_at,
) )
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
_log.exception( _log.exception(
@@ -555,6 +558,7 @@ def reconcile_core_holdings(
for state in unmanaged_position_store.resolve_absent( for state in unmanaged_position_store.resolve_absent(
instance_key=instance_key, instance_key=instance_key,
observed_symbols=active_unmanaged_symbols, observed_symbols=active_unmanaged_symbols,
observed_at=snapshot_observed_at,
): ):
_log.info( _log.info(
"unmanaged position resolved: symbol=%s qty=%d instance_key=%s", "unmanaged position resolved: symbol=%s qty=%d instance_key=%s",