@@ -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."""
|
||||
...
|
||||
@@ -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.
|
||||
"""
|
||||
...
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user