@@ -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
|
||||
@@ -16,10 +16,12 @@ signal (REQ-028 AC-2) without callers seeing raw httpx exceptions.
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from trex_core.application.auto_reconcile import HoldingsCostBasisEvidence
|
||||
from trex_core.application.portfolio import (
|
||||
BrokerCredentialPayload,
|
||||
PortfolioHubUnavailableError,
|
||||
@@ -268,6 +270,56 @@ class PortfolioHubReadAdapter:
|
||||
return None, "invalid"
|
||||
return value, "ok"
|
||||
|
||||
def get_cost_basis_evidence(self, portfolio_id: str) -> HoldingsCostBasisEvidence:
|
||||
"""Return only canonical, complete evidence used by auto-reconcile."""
|
||||
url = f"{self._base_url}/v1/portfolios/{portfolio_id}/holdings/cost-basis"
|
||||
try:
|
||||
resp = self._get(url)
|
||||
except httpx.HTTPError as exc:
|
||||
raise PortfolioHubUnavailableError(
|
||||
f"cost_basis network error: {exc.__class__.__name__}"
|
||||
) from exc
|
||||
if not (200 <= resp.status_code < 300):
|
||||
raise PortfolioHubUnavailableError(
|
||||
f"cost_basis http_status_{resp.status_code}"
|
||||
)
|
||||
try:
|
||||
body = resp.json()
|
||||
except ValueError as exc:
|
||||
raise PortfolioHubUnavailableError("cost_basis invalid_json") from exc
|
||||
if not isinstance(body, dict):
|
||||
raise PortfolioHubUnavailableError("cost_basis invalid_payload")
|
||||
|
||||
response_pid = body.get("portfolio_id")
|
||||
symbol = body.get("symbol")
|
||||
if str(response_pid or "") != portfolio_id or not isinstance(symbol, str) or not symbol:
|
||||
raise PortfolioHubUnavailableError("cost_basis identity_mismatch")
|
||||
|
||||
def _finite_number(name: str, *, positive: bool) -> float:
|
||||
raw = body.get(name)
|
||||
if isinstance(raw, bool):
|
||||
raise PortfolioHubUnavailableError(f"cost_basis invalid_{name}")
|
||||
try:
|
||||
value = float(str(raw))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise PortfolioHubUnavailableError(f"cost_basis invalid_{name}") from exc
|
||||
if not math.isfinite(value) or (positive and value <= 0) or (not positive and value < 0):
|
||||
raise PortfolioHubUnavailableError(f"cost_basis invalid_{name}")
|
||||
return value
|
||||
|
||||
warning = body.get("data_completeness_warning")
|
||||
if warning is not None and not isinstance(warning, str):
|
||||
raise PortfolioHubUnavailableError("cost_basis invalid_completeness_warning")
|
||||
return HoldingsCostBasisEvidence(
|
||||
portfolio_id=portfolio_id,
|
||||
symbol=symbol,
|
||||
current_quantity=_finite_number("current_quantity", positive=True),
|
||||
cost_basis_per_unit=_finite_number("cost_basis_per_unit", positive=True),
|
||||
total_cost_basis=_finite_number("total_cost_basis", positive=True),
|
||||
observed_at=datetime.now(tz=UTC),
|
||||
data_completeness_warning=warning or None,
|
||||
)
|
||||
|
||||
def get_cost_basis_with_diagnostics(
|
||||
self,
|
||||
portfolio_id: str,
|
||||
|
||||
Reference in New Issue
Block a user