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
+46
View File
@@ -0,0 +1,46 @@
"""Fail-closed holding mirror auto-reconciliation contracts (issue #94)."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from typing import Protocol
from uuid import UUID
@dataclass(frozen=True)
class HoldingsCostBasisEvidence:
"""Canonical portfolio-hub evidence captured by one successful HTTP read."""
portfolio_id: str
symbol: str
current_quantity: float
cost_basis_per_unit: float
total_cost_basis: float
observed_at: datetime
data_completeness_warning: str | None = None
@dataclass(frozen=True)
class AutoReconcileCommand:
"""CAS-bound command for one atomic mirror repair."""
slot_id: UUID
instance_key: str
portfolio_id: str
symbol: str
expected_qty: float
expected_avg_price: float | None
target_qty: float
target_avg_price: float
hub_quantity: float
broker_quantity: int
evidence_observed_at: datetime
class AutoReconcileStore(Protocol):
"""Atomic qty/avg/audit/B-warning persistence boundary."""
def apply_holding_mirror(self, command: AutoReconcileCommand) -> bool:
"""Apply exactly once when every CAS/eligibility condition still holds."""
...
+9
View File
@@ -8,6 +8,8 @@ from __future__ import annotations
from typing import Optional, Protocol
from uuid import UUID
from trex_core.application.auto_reconcile import HoldingsCostBasisEvidence
class IntentStore(Protocol):
"""Durable idempotency + audit store for accepted intents (dcy-002 OQ-1).
@@ -64,3 +66,10 @@ class PortfolioHubCostBasisPort(Protocol):
or any error occurs. The caller must handle None as a graceful-degrade signal.
"""
...
def get_cost_basis_evidence(self, portfolio_id: str) -> HoldingsCostBasisEvidence:
"""Return strict canonical evidence for automatic holding reconciliation.
Implementations must not fall back to legacy cost-basis fields.
"""
...
+151 -1
View File
@@ -29,12 +29,18 @@ import hashlib
import logging
import math
import re
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
from typing import cast
from trex_core.application.alarm_emitter import AlarmEmitter
from trex_core.application.auto_reconcile import (
AutoReconcileCommand,
AutoReconcileStore,
HoldingsCostBasisEvidence,
)
from trex_core.application.broker_port import BrokerPort
from trex_core.application.portfolio import (
PortfolioItem,
ReconcileWarningStore,
SnapshotStore,
UnmanagedPositionStore,
@@ -377,6 +383,113 @@ def _broker_balances_are_authoritative_for_unmanaged_resolution(broker: BrokerPo
return False
_AUTO_RECONCILE_EVIDENCE_MAX_AGE = timedelta(seconds=60)
_AUTO_RECONCILE_FUTURE_TOLERANCE = timedelta(seconds=5)
def _try_auto_reconcile_holding(
*,
slot,
broker_quantity: int,
portfolio_items: list[PortfolioItem] | None,
cost_basis_port,
auto_reconcile_store: AutoReconcileStore | None,
observed_at: datetime,
) -> bool:
"""Return True only after the atomic mirror repair commits."""
if portfolio_items is None or cost_basis_port is None or auto_reconcile_store is None:
return False
if (
slot.status is not SlotState.HOLDING
or slot.retired_at is not None
or not slot.portfolio_id
or not slot.symbol
or slot.qty is None
or broker_quantity <= 0
or slot.limit_price is not None
or slot.broker_order_id is not None
or slot.cancel_state is not None
or slot.cancel_requested_at is not None
or slot.order_submitted_at is not None
or slot.order_date is not None
or slot.cancel_attempts != 0
):
return False
matches = [item for item in portfolio_items if item.portfolio_id == slot.portfolio_id]
symbol_matches = [item for item in portfolio_items if item.symbol == slot.symbol]
if len(matches) != 1 or len(symbol_matches) != 1:
return False
item = matches[0]
hub_quantity = item.asset_quantity
if (
item.symbol != slot.symbol
or not math.isfinite(hub_quantity)
or hub_quantity <= 0
or not float(hub_quantity).is_integer()
or int(hub_quantity) != broker_quantity
):
return False
getter = getattr(cost_basis_port, "get_cost_basis_evidence", None)
if not callable(getter):
return False
try:
evidence = cast(HoldingsCostBasisEvidence, getter(slot.portfolio_id))
except Exception: # noqa: BLE001 - fail closed without provider details
_log.warning(
"auto_reconcile skipped: slot_id=%s reason=cost_basis_evidence_unavailable",
slot.slot_id,
)
return False
age = observed_at - evidence.observed_at
expected_total = evidence.current_quantity * evidence.cost_basis_per_unit
if (
evidence.observed_at.tzinfo is None
or age > _AUTO_RECONCILE_EVIDENCE_MAX_AGE
or age < -_AUTO_RECONCILE_FUTURE_TOLERANCE
or evidence.data_completeness_warning is not None
or evidence.portfolio_id != slot.portfolio_id
or evidence.symbol != slot.symbol
or not math.isfinite(evidence.current_quantity)
or not math.isfinite(evidence.cost_basis_per_unit)
or not math.isfinite(evidence.total_cost_basis)
or evidence.current_quantity != hub_quantity
or evidence.cost_basis_per_unit <= 0
or evidence.total_cost_basis <= 0
or not math.isclose(
evidence.total_cost_basis,
expected_total,
rel_tol=1e-9,
abs_tol=1e-6,
)
):
return False
command = AutoReconcileCommand(
slot_id=slot.slot_id,
instance_key=slot.instance_key,
portfolio_id=slot.portfolio_id,
symbol=slot.symbol,
expected_qty=float(slot.qty),
expected_avg_price=(float(slot.avg_price) if slot.avg_price is not None else None),
target_qty=float(broker_quantity),
target_avg_price=float(evidence.cost_basis_per_unit),
hub_quantity=float(hub_quantity),
broker_quantity=broker_quantity,
evidence_observed_at=evidence.observed_at,
)
try:
return bool(auto_reconcile_store.apply_holding_mirror(command))
except Exception: # noqa: BLE001 - persistence failure keeps warning active
_log.warning(
"auto_reconcile failed: slot_id=%s reason=atomic_persistence_failure",
slot.slot_id,
)
return False
def reconcile_core_holdings(
*,
slot_repo,
@@ -387,6 +500,9 @@ def reconcile_core_holdings(
warn_store: ReconcileWarningStore | None = None,
notification_store=None,
unmanaged_position_store: UnmanagedPositionStore | None = None,
portfolio_items: list[PortfolioItem] | None = None,
cost_basis_port=None,
auto_reconcile_store: AutoReconcileStore | None = None,
) -> dict[str, str]:
"""dcy-020 / REQ-062 (BAT-029) + REQ-103 (BAT-050): surface core-slot holding
vs broker divergence and set durable reconcile-warning for B case.
@@ -451,6 +567,7 @@ def reconcile_core_holdings(
slot_id_str, slot.portfolio_id, sym, bq, slot.qty,
)
# REQ-103 / BAT-050: durable reconcile-warning (case_code='B').
warning_persisted = False
if warn_store is not None:
try:
warn_store.set_warned(
@@ -460,6 +577,7 @@ def reconcile_core_holdings(
actor="reconcile",
reason=f"slot_qty={slot.qty} broker_qty={bq} symbol={sym}",
)
warning_persisted = True
except Exception: # noqa: BLE001 - never let reconcile crash the loop
_log.exception(
"failed to set reconcile_warning for slot %s", slot.slot_id
@@ -482,6 +600,38 @@ def reconcile_core_holdings(
_log.exception(
"reconcile_core_holdings: notify_mismatch failed for slot=%s", slot_id_str,
)
if warning_persisted and _try_auto_reconcile_holding(
slot=slot,
broker_quantity=bq,
portfolio_items=portfolio_items,
cost_basis_port=cost_basis_port,
auto_reconcile_store=auto_reconcile_store,
observed_at=snapshot_observed_at,
):
decisions[slot_id_str] = "auto_synced"
active_b_identity_keys.discard(slot_id_str)
_log.info(
"auto_reconcile committed: slot_id=%s portfolio_id=%s symbol=%s",
slot_id_str,
slot.portfolio_id,
sym,
)
if alarm_emitter is not None and notification_store is not None:
try:
_notify_resolved(
alarm_emitter,
notification_store,
identity_key=slot_id_str,
case_code="B",
instance_key=instance_key,
symbol=sym,
slot_id=slot_id_str,
)
except Exception: # noqa: BLE001 - business commit is authoritative
_log.exception(
"reconcile_core_holdings: auto resolved notify failed for slot=%s",
slot_id_str,
)
else:
decisions[str(slot.slot_id)] = "ok"
for sym, bq in broker_qty.items(): # unmatched broker position
+14 -2
View File
@@ -16,6 +16,7 @@ from collections.abc import Callable
from datetime import UTC, date, datetime, time, timedelta, timezone
from trex_core.application.alarm_emitter import AlarmEmitter
from trex_core.application.auto_reconcile import AutoReconcileStore
from trex_core.application.balance_health import BalanceHealthTracker
from trex_core.application.broker_port import BrokerBalanceError, BrokerPort
from trex_core.application.portfolio import (
@@ -55,6 +56,8 @@ class SnapshotScheduler:
warn_store: ReconcileWarningStore | None = None,
notification_store: ReconcileNotificationStore | None = None,
unmanaged_position_store: UnmanagedPositionStore | None = None,
cost_basis_port=None,
auto_reconcile_store: AutoReconcileStore | None = None,
credential_fetch_enabled: bool = False,
kis_reinit_callback: Callable[[BrokerCredentialPayload], None] | None = None,
credential_refresh: Callable[[], object] | None = None,
@@ -81,6 +84,8 @@ class SnapshotScheduler:
# req-0007 / ac-0024: durable notification deduplication store (Optional).
self._notification_store = notification_store
self._unmanaged_position_store = unmanaged_position_store
self._cost_basis_port = cost_basis_port
self._auto_reconcile_store = auto_reconcile_store
# dcy-036 / REQ-113/114/115: credential fetch opt-in + break-glass detection.
# credential_fetch_enabled=False (default) → env-only, no portfolio-hub fetch.
self._credential_fetch_enabled: bool = credential_fetch_enabled
@@ -332,7 +337,7 @@ class SnapshotScheduler:
if self._slot_service is not None:
try:
# BAT-050 / REQ-103: pass warn_store for B-case durable warning.
self._run_core_holding_reconciliation_worker()
self._run_core_holding_reconciliation_worker(items)
except Exception: # noqa: BLE001
_log.exception("core-holding divergence reconcile failed")
self._last_fired_date = self._clock()
@@ -353,7 +358,7 @@ class SnapshotScheduler:
),
)
def _run_core_holding_reconciliation_worker(self) -> None:
def _run_core_holding_reconciliation_worker(self, items=None) -> None:
from trex_core.application.reconciliation import reconcile_core_holdings
self._run_balance_worker(
@@ -368,6 +373,13 @@ class SnapshotScheduler:
warn_store=self._warn_store, # BAT-050 / REQ-103
notification_store=self._notification_store, # req-0007 / ac-0024
unmanaged_position_store=self._unmanaged_position_store,
portfolio_items=(
list(items)
if items is not None
else [asset.item for asset in self._store.list_assets()]
),
cost_basis_port=self._cost_basis_port,
auto_reconcile_store=self._auto_reconcile_store,
),
)