feat: auto-reconcile stale holding mirrors

Closes #94
This commit is contained in:
Hermes Agent
2026-09-14 12:55:04 +09:00
parent adf131f19c
commit c70ac0fb24
19 changed files with 1181 additions and 16 deletions
@@ -0,0 +1,175 @@
"""Atomic persistence adapters for holding mirror auto-reconciliation."""
from __future__ import annotations
import copy
import json
from dataclasses import replace
from datetime import UTC, datetime
from typing import Any
from trex_core.adapters.persistence.postgresql import _require_psycopg
from trex_core.application.auto_reconcile import AutoReconcileCommand
from trex_core.application.portfolio import InMemoryReconcileWarningStore
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
from trex_core.domain.slot.models import Slot, SlotTransitionRecord
from trex_core.domain.slot.state import SlotState, slot_advisory_lock_id
_REASON = "hub_broker_matched_core_mirror_stale"
_ACTOR = "auto_reconcile"
def _eligible(current: Slot | None, command: AutoReconcileCommand) -> bool:
return bool(
current is not None
and current.slot_id == command.slot_id
and current.instance_key == command.instance_key
and current.portfolio_id == command.portfolio_id
and current.symbol == command.symbol
and current.status is SlotState.HOLDING
and current.retired_at is None
and current.qty == command.expected_qty
and current.avg_price == command.expected_avg_price
and current.limit_price is None
and current.broker_order_id is None
and current.cancel_state is None
and current.cancel_requested_at is None
and current.order_submitted_at is None
and current.order_date is None
and current.cancel_attempts == 0
)
def _audit_extra(command: AutoReconcileCommand) -> dict[str, object]:
return {
"portfolio_id": command.portfolio_id,
"symbol": command.symbol,
"before_qty": command.expected_qty,
"after_qty": command.target_qty,
"before_avg_price": command.expected_avg_price,
"after_avg_price": command.target_avg_price,
"hub_quantity": command.hub_quantity,
"broker_quantity": command.broker_quantity,
}
class InMemoryAutoReconcileStore:
"""In-process atomic-equivalent adapter used by tests and local runtimes."""
def __init__(
self,
slot_repo: InMemorySlotRepository,
warning_store: InMemoryReconcileWarningStore,
) -> None:
self._slot_repo = slot_repo
self._warning_store = warning_store
def apply_holding_mirror(self, command: AutoReconcileCommand) -> bool:
lock_id = slot_advisory_lock_id(command.instance_key)
with self._slot_repo.transactional_advisory_lock(lock_id):
with self._warning_store._lock: # same critical section as warning CRUD
current = self._slot_repo.fetch_slot(command.slot_id)
warnings = self._warning_store._warned.get(str(command.slot_id), {})
if current is None or not _eligible(current, command) or "B" not in warnings:
return False
previous_slot = current
previous_transitions = list(self._slot_repo._transitions)
previous_warnings = copy.deepcopy(self._warning_store._warned)
now = datetime.now(tz=UTC)
try:
self._slot_repo.update_slot(
replace(
current,
qty=command.target_qty,
avg_price=command.target_avg_price,
updated_at=now,
)
)
self._slot_repo.append_transition_log(
SlotTransitionRecord(
slot_id=command.slot_id,
from_state=SlotState.HOLDING,
to_state=SlotState.HOLDING,
ts=now,
reason=_REASON,
actor=_ACTOR,
extra=_audit_extra(command),
)
)
warnings.pop("B", None)
if not warnings:
self._warning_store._warned.pop(str(command.slot_id), None)
return True
except Exception:
self._slot_repo._slots[command.slot_id] = previous_slot
self._slot_repo._transitions[:] = previous_transitions
self._warning_store._warned = previous_warnings
raise
class PostgreSQLAutoReconcileStore:
"""One-transaction CAS update, B-only clear, and append-only audit."""
def __init__(self, dsn: str, *, psycopg_module: Any = None) -> None:
self._psycopg = _require_psycopg(psycopg_module)
self._dsn = dsn
def apply_holding_mirror(self, command: AutoReconcileCommand) -> bool:
now = datetime.now(tz=UTC)
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
try:
with conn.cursor() as cur:
cur.execute(
"SELECT pg_advisory_xact_lock(%s)",
(slot_advisory_lock_id(command.instance_key),),
)
cur.execute(
"UPDATE daily_slot SET qty=%s, avg_price=%s, updated_at=%s "
"WHERE slot_id=%s AND instance_key=%s AND portfolio_id=%s "
"AND symbol=%s AND status='holding' AND retired_at IS NULL "
"AND qty IS NOT DISTINCT FROM %s "
"AND avg_price IS NOT DISTINCT FROM %s "
"AND limit_price IS NULL AND broker_order_id IS NULL "
"AND cancel_state IS NULL AND cancel_requested_at IS NULL "
"AND order_submitted_at IS NULL AND order_date IS NULL "
"AND cancel_attempts=0",
(
command.target_qty,
command.target_avg_price,
now,
command.slot_id,
command.instance_key,
command.portfolio_id,
command.symbol,
command.expected_qty,
command.expected_avg_price,
),
)
if cur.rowcount != 1:
conn.rollback()
return False
cur.execute(
"DELETE FROM slot_reconcile_warning WHERE slot_id=%s AND case_code=%s",
(str(command.slot_id), "B"),
)
if cur.rowcount != 1:
conn.rollback()
return False
cur.execute(
"INSERT INTO slot_transition_log "
"(slot_id, from_state, to_state, ts, reason, actor, extra) "
"VALUES (%s, 'holding', 'holding', %s, %s, %s, %s)",
(
command.slot_id,
now,
_REASON,
_ACTOR,
json.dumps(_audit_extra(command)),
),
)
conn.commit()
return True
except Exception:
conn.rollback()
raise