fix: harden unmanaged position lifecycle writes
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
This commit is contained in:
co-authored by
genichin
parent
5a13c0494b
commit
be2e73a2d6
@@ -741,6 +741,53 @@ def test_unmanaged_record_text_output(monkeypatch, capsys):
|
|||||||
assert "evidence_ref=kis-balance:2026-09-11" in out
|
assert "evidence_ref=kis-balance:2026-09-11" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_unmanaged_record_main_dispatch(monkeypatch, capsys):
|
||||||
|
class _Store:
|
||||||
|
def __init__(self, *_args, **_kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def record_operator_action(self, **kwargs):
|
||||||
|
return {
|
||||||
|
"instance_key": "default",
|
||||||
|
"symbol": kwargs["symbol"],
|
||||||
|
"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": kwargs["action"],
|
||||||
|
"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,
|
||||||
|
)
|
||||||
|
|
||||||
|
rc = cli.main(
|
||||||
|
[
|
||||||
|
"unmanaged",
|
||||||
|
"record",
|
||||||
|
"008110",
|
||||||
|
"investigating",
|
||||||
|
"--evidence-ref",
|
||||||
|
"kis-balance:2026-09-11",
|
||||||
|
"--json",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert rc == 0
|
||||||
|
out = json.loads(capsys.readouterr().out)
|
||||||
|
assert out["result"] == "recorded"
|
||||||
|
assert out["state"]["symbol"] == "008110"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# REL-010 regression: _cmd_verify broker import must not raise ImportError
|
# REL-010 regression: _cmd_verify broker import must not raise ImportError
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -31,82 +31,89 @@ class PostgreSQLUnmanagedPositionStore:
|
|||||||
actor: str = "reconcile",
|
actor: str = "reconcile",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
now = observed_at or datetime.now(tz=UTC)
|
now = observed_at or datetime.now(tz=UTC)
|
||||||
with self._psycopg.connect(self._dsn, autocommit=False) as conn, conn.cursor() as cur:
|
for _attempt in range(2):
|
||||||
cur.execute(
|
try:
|
||||||
f"SELECT {_STATE_COLUMNS} FROM unmanaged_position_state "
|
with self._psycopg.connect(self._dsn, autocommit=False) as conn, conn.cursor() as cur:
|
||||||
"WHERE instance_key = %s AND symbol = %s "
|
cur.execute(
|
||||||
"FOR UPDATE",
|
f"SELECT {_STATE_COLUMNS} FROM unmanaged_position_state "
|
||||||
(instance_key, symbol),
|
"WHERE instance_key = %s AND symbol = %s "
|
||||||
)
|
"FOR UPDATE",
|
||||||
row = cur.fetchone()
|
(instance_key, symbol),
|
||||||
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
|
row = cur.fetchone()
|
||||||
if previous is None:
|
previous = self._row_to_state(row) if row is not None else None
|
||||||
transition = "detected"
|
previous_quantity = previous.get("quantity") if previous is not None else None
|
||||||
cur.execute(
|
if previous is None:
|
||||||
"INSERT INTO unmanaged_position_state "
|
transition = "detected"
|
||||||
f"({_STATE_COLUMNS}) "
|
cur.execute(
|
||||||
"VALUES (%s, %s, %s, 'active', %s, %s, %s, NULL, "
|
"INSERT INTO unmanaged_position_state "
|
||||||
" NULL, NULL, '', NULL, NULL, %s, %s) "
|
f"({_STATE_COLUMNS}) "
|
||||||
f"RETURNING {_STATE_COLUMNS}",
|
"VALUES (%s, %s, %s, 'active', %s, %s, %s, NULL, "
|
||||||
(instance_key, symbol, quantity, now, now, now, now, now),
|
" NULL, NULL, '', NULL, NULL, %s, %s) "
|
||||||
)
|
f"RETURNING {_STATE_COLUMNS}",
|
||||||
elif previous["lifecycle_state"] == "resolved":
|
(instance_key, symbol, quantity, now, now, now, now, now),
|
||||||
transition = "detected"
|
)
|
||||||
cur.execute(
|
elif previous["lifecycle_state"] == "resolved":
|
||||||
"UPDATE unmanaged_position_state "
|
transition = "detected"
|
||||||
"SET quantity = %s, lifecycle_state = 'active', "
|
cur.execute(
|
||||||
" first_seen_at = %s, last_seen_at = %s, quantity_changed_at = %s, "
|
"UPDATE unmanaged_position_state "
|
||||||
" resolved_at = NULL, updated_at = %s "
|
"SET quantity = %s, lifecycle_state = 'active', "
|
||||||
"WHERE instance_key = %s AND symbol = %s "
|
" first_seen_at = %s, last_seen_at = %s, quantity_changed_at = %s, "
|
||||||
f"RETURNING {_STATE_COLUMNS}",
|
" resolved_at = NULL, updated_at = %s "
|
||||||
(quantity, now, now, now, now, instance_key, symbol),
|
"WHERE instance_key = %s AND symbol = %s "
|
||||||
)
|
f"RETURNING {_STATE_COLUMNS}",
|
||||||
elif int(previous["quantity"]) != int(quantity):
|
(quantity, now, now, now, now, instance_key, symbol),
|
||||||
transition = "quantity_changed"
|
)
|
||||||
cur.execute(
|
elif int(previous["quantity"]) != int(quantity):
|
||||||
"UPDATE unmanaged_position_state "
|
transition = "quantity_changed"
|
||||||
"SET quantity = %s, last_seen_at = %s, quantity_changed_at = %s, "
|
cur.execute(
|
||||||
" resolved_at = NULL, lifecycle_state = 'active', updated_at = %s "
|
"UPDATE unmanaged_position_state "
|
||||||
"WHERE instance_key = %s AND symbol = %s "
|
"SET quantity = %s, last_seen_at = %s, quantity_changed_at = %s, "
|
||||||
f"RETURNING {_STATE_COLUMNS}",
|
" resolved_at = NULL, lifecycle_state = 'active', updated_at = %s "
|
||||||
(quantity, now, now, now, instance_key, symbol),
|
"WHERE instance_key = %s AND symbol = %s "
|
||||||
)
|
f"RETURNING {_STATE_COLUMNS}",
|
||||||
else:
|
(quantity, now, now, now, instance_key, symbol),
|
||||||
transition = "unchanged"
|
)
|
||||||
cur.execute(
|
else:
|
||||||
"UPDATE unmanaged_position_state "
|
transition = "unchanged"
|
||||||
"SET last_seen_at = %s, updated_at = %s "
|
cur.execute(
|
||||||
"WHERE instance_key = %s AND symbol = %s "
|
"UPDATE unmanaged_position_state "
|
||||||
f"RETURNING {_STATE_COLUMNS}",
|
"SET last_seen_at = %s, updated_at = %s "
|
||||||
(now, now, instance_key, symbol),
|
"WHERE instance_key = %s AND symbol = %s "
|
||||||
)
|
f"RETURNING {_STATE_COLUMNS}",
|
||||||
state = self._row_to_state(cur.fetchone())
|
(now, now, instance_key, symbol),
|
||||||
if transition != "unchanged":
|
)
|
||||||
note = (
|
state = self._row_to_state(cur.fetchone())
|
||||||
f"previous_quantity={previous_quantity}"
|
if transition != "unchanged":
|
||||||
if transition == "quantity_changed" and previous_quantity is not None
|
note = (
|
||||||
else ""
|
f"previous_quantity={previous_quantity}"
|
||||||
)
|
if transition == "quantity_changed" and previous_quantity is not None
|
||||||
self._insert_audit(
|
else ""
|
||||||
cur,
|
)
|
||||||
instance_key=instance_key,
|
self._insert_audit(
|
||||||
symbol=symbol,
|
cur,
|
||||||
event_type=transition,
|
instance_key=instance_key,
|
||||||
action=None,
|
symbol=symbol,
|
||||||
quantity=quantity,
|
event_type=transition,
|
||||||
lifecycle_state=state["lifecycle_state"],
|
action=None,
|
||||||
actor=actor,
|
quantity=quantity,
|
||||||
evidence_ref=state.get("evidence_ref"),
|
lifecycle_state=state["lifecycle_state"],
|
||||||
note=note,
|
actor=actor,
|
||||||
created_at=now,
|
evidence_ref=state.get("evidence_ref"),
|
||||||
)
|
note=note,
|
||||||
conn.commit()
|
created_at=now,
|
||||||
return {
|
)
|
||||||
"transition": transition,
|
conn.commit()
|
||||||
"previous_quantity": previous_quantity,
|
return {
|
||||||
"state": state,
|
"transition": transition,
|
||||||
}
|
"previous_quantity": previous_quantity,
|
||||||
|
"state": state,
|
||||||
|
}
|
||||||
|
except self._psycopg.errors.UniqueViolation:
|
||||||
|
continue
|
||||||
|
raise RuntimeError(
|
||||||
|
"failed to persist unmanaged_position_state due to repeated concurrent insert race"
|
||||||
|
)
|
||||||
|
|
||||||
def resolve_absent(
|
def resolve_absent(
|
||||||
self,
|
self,
|
||||||
|
|||||||
Reference in New Issue
Block a user