feat(reconcile): safely seed canonical cost basis
This commit is contained in:
@@ -15,7 +15,8 @@ signal (REQ-028 AC-2) without callers seeing raw httpx exceptions.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
import math
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -250,28 +251,88 @@ class PortfolioHubReadAdapter:
|
||||
total_realized_pnl=total,
|
||||
)
|
||||
|
||||
def _parse_cost_basis_value(self, raw: Any) -> tuple[Optional[float], str]:
|
||||
"""Parse and validate one cost-basis field.
|
||||
|
||||
Returns (value, reason). `reason` is "ok" on success.
|
||||
"""
|
||||
if raw is None:
|
||||
return None, "missing"
|
||||
if isinstance(raw, bool):
|
||||
return None, "invalid"
|
||||
try:
|
||||
value = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None, "invalid"
|
||||
if not math.isfinite(value) or value <= 0:
|
||||
return None, "invalid"
|
||||
return value, "ok"
|
||||
|
||||
def get_cost_basis_with_diagnostics(
|
||||
self,
|
||||
portfolio_id: str,
|
||||
) -> tuple[Optional[float], str, Optional[str]]:
|
||||
"""Read cost-basis from portfolio-hub with normalized diagnostics.
|
||||
|
||||
Source priority:
|
||||
1) cost_basis_per_unit (canonical)
|
||||
2) avg_cost
|
||||
3) wac
|
||||
4) cost_basis
|
||||
|
||||
``source`` is always one of ``canonical``, ``legacy``, or ``none``.
|
||||
If the canonical field is present but invalid, legacy fields are not used.
|
||||
"""
|
||||
url = f"{self._base_url}/v1/portfolios/{portfolio_id}/holdings/cost-basis"
|
||||
try:
|
||||
resp = self._get(url)
|
||||
except Exception:
|
||||
return None, "none", "transport"
|
||||
|
||||
if not (200 <= resp.status_code < 300):
|
||||
return None, "none", f"http_status_{resp.status_code}"
|
||||
|
||||
try:
|
||||
body = resp.json()
|
||||
except ValueError:
|
||||
return None, "none", "invalid_json"
|
||||
|
||||
if not isinstance(body, dict):
|
||||
return None, "none", "invalid_payload"
|
||||
|
||||
has_any = any(
|
||||
key in body for key in ("cost_basis_per_unit", "avg_cost", "wac", "cost_basis")
|
||||
)
|
||||
if not has_any:
|
||||
return None, "none", "missing"
|
||||
|
||||
if "cost_basis_per_unit" in body:
|
||||
value, reason = self._parse_cost_basis_value(
|
||||
body.get("cost_basis_per_unit")
|
||||
)
|
||||
if reason == "ok":
|
||||
return value, "canonical", None
|
||||
return None, "none", reason
|
||||
|
||||
# Legacy fallback chain only when canonical is absent.
|
||||
legacy_reasons: list[str] = []
|
||||
for source in ("avg_cost", "wac", "cost_basis"):
|
||||
if source not in body:
|
||||
continue
|
||||
value, reason = self._parse_cost_basis_value(body.get(source))
|
||||
if reason == "ok":
|
||||
return value, "legacy", None
|
||||
legacy_reasons.append(reason)
|
||||
|
||||
if legacy_reasons:
|
||||
return None, "none", legacy_reasons[-1]
|
||||
return None, "none", "missing"
|
||||
|
||||
def get_cost_basis(self, portfolio_id: str) -> "Optional[float]":
|
||||
"""REQ-105 FR-7: GET /v1/portfolios/{id}/holdings/cost-basis -> avg_cost (WAC).
|
||||
|
||||
Returns None on any error (graceful degrade — caller logs WARN and skips
|
||||
avg_price seed). portfolio-hub REQ-043 must be live for this to succeed.
|
||||
"""
|
||||
url = f"{self._base_url}/v1/portfolios/{portfolio_id}/holdings/cost-basis"
|
||||
try:
|
||||
resp = self._get(url)
|
||||
except Exception: # noqa: BLE001 — network/timeout — degrade
|
||||
return None
|
||||
if not (200 <= resp.status_code < 300):
|
||||
return None
|
||||
try:
|
||||
body = resp.json()
|
||||
except ValueError:
|
||||
return None
|
||||
if isinstance(body, dict):
|
||||
val = body.get("avg_cost") or body.get("wac") or body.get("cost_basis")
|
||||
if val is not None:
|
||||
try:
|
||||
return float(val)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return None
|
||||
value, _source, _reason = self.get_cost_basis_with_diagnostics(portfolio_id)
|
||||
return value
|
||||
|
||||
@@ -23,7 +23,9 @@ BAT-051 / REQ-106: verify_slot_reconcile — operator CLI verify path (C-only, r
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
import math
|
||||
import re
|
||||
from typing import Optional, cast
|
||||
|
||||
from trex_core.application.alarm_emitter import AlarmEmitter
|
||||
from trex_core.application.broker_port import BrokerPort
|
||||
@@ -32,6 +34,68 @@ from trex_core.domain.slot.state import SlotState
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
_COST_BASIS_REASON_CODES = frozenset({
|
||||
"missing",
|
||||
"invalid",
|
||||
"transport",
|
||||
"invalid_json",
|
||||
"invalid_payload",
|
||||
"not_configured",
|
||||
"diagnostic_exception",
|
||||
"diagnostic_source_invalid",
|
||||
})
|
||||
_COST_BASIS_LEGACY_SOURCE_ALIASES = frozenset({"avg_cost", "wac", "cost_basis"})
|
||||
|
||||
|
||||
def _sanitize_cost_basis_reason(reason: object) -> Optional[str]:
|
||||
"""Keep public diagnostics in the bounded REQ-123 reason taxonomy."""
|
||||
if not isinstance(reason, str):
|
||||
return None
|
||||
if reason in _COST_BASIS_REASON_CODES:
|
||||
return reason
|
||||
if re.fullmatch(r"http_status_\d{3}", reason):
|
||||
return reason
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_cost_basis_diagnostics(
|
||||
value: object,
|
||||
source: object,
|
||||
reason: object,
|
||||
) -> tuple[Optional[float], str, Optional[str]]:
|
||||
"""Normalize diagnostic-port output before it reaches reconciliation results."""
|
||||
safe_reason = _sanitize_cost_basis_reason(reason)
|
||||
if source == "canonical":
|
||||
normalized_source = "canonical"
|
||||
elif source == "legacy" or source in _COST_BASIS_LEGACY_SOURCE_ALIASES:
|
||||
normalized_source = "legacy"
|
||||
elif source == "none":
|
||||
normalized_source = "none"
|
||||
else:
|
||||
return None, "none", safe_reason or "diagnostic_source_invalid"
|
||||
|
||||
if value is None:
|
||||
return None, normalized_source, safe_reason
|
||||
if isinstance(value, bool):
|
||||
return None, "none", safe_reason or "invalid_payload"
|
||||
try:
|
||||
normalized_value = float(cast(str | int | float, value))
|
||||
except (TypeError, ValueError):
|
||||
return None, "none", safe_reason or "invalid_payload"
|
||||
if not math.isfinite(normalized_value) or normalized_value <= 0:
|
||||
return None, "none", safe_reason or "invalid_payload"
|
||||
if normalized_source == "none":
|
||||
return None, "none", safe_reason or "invalid_payload"
|
||||
return normalized_value, normalized_source, safe_reason
|
||||
|
||||
|
||||
def _normalize_legacy_cost_basis(value: object) -> tuple[Optional[float], str, Optional[str]]:
|
||||
"""Make the diagnostic-free compatibility seam obey safe WAC semantics."""
|
||||
normalized_value, _, _ = _normalize_cost_basis_diagnostics(value, "legacy", "invalid")
|
||||
if normalized_value is None:
|
||||
return None, "none", "invalid"
|
||||
return normalized_value, "legacy", None
|
||||
|
||||
|
||||
def reconcile_core_holdings(
|
||||
*,
|
||||
@@ -308,25 +372,114 @@ def sync_slot_to_broker(
|
||||
# Try to seed avg_price from portfolio-hub cost-basis port (FR-7, graceful degrade).
|
||||
avg_price = slot.avg_price
|
||||
seeded_avg_price = False
|
||||
cost_basis_source = "none"
|
||||
cost_basis_reason: Optional[str] = None
|
||||
|
||||
def _set_cost_basis_unavailable_message() -> tuple[str, Optional[str]]:
|
||||
if pid:
|
||||
return "cost_basis_unavailable", f"inactive slot {slot_id} portfolio_id={pid}"
|
||||
return "cost_basis_unavailable", "inactive slot without portfolio_id"
|
||||
|
||||
if cost_basis_port is not None and pid:
|
||||
try:
|
||||
wac = cost_basis_port.get_cost_basis(pid)
|
||||
if wac is not None:
|
||||
avg_price = wac
|
||||
seeded_avg_price = True
|
||||
except Exception: # noqa: BLE001 — graceful degrade
|
||||
_log.warning(
|
||||
"sync: cost_basis_port.get_cost_basis failed for portfolio_id=%s slot=%s "
|
||||
"— skipping avg_price seed (graceful degrade)",
|
||||
pid, slot_id,
|
||||
)
|
||||
elif cost_basis_port is None and pid:
|
||||
diagnostics = getattr(cost_basis_port, "get_cost_basis_with_diagnostics", None)
|
||||
if callable(diagnostics):
|
||||
try:
|
||||
result = diagnostics(pid)
|
||||
|
||||
if isinstance(result, dict):
|
||||
raw_value = result.get("value")
|
||||
raw_source = result.get("source")
|
||||
raw_reason = result.get("reason")
|
||||
elif isinstance(result, tuple) and len(result) >= 1:
|
||||
raw_value = result[0]
|
||||
raw_source = result[1] if len(result) >= 2 else None
|
||||
raw_reason = result[2] if len(result) >= 3 else None
|
||||
else:
|
||||
raise TypeError("invalid_cost_basis_diagnostic_format")
|
||||
|
||||
cost_basis_value, cost_basis_source, cost_basis_reason = (
|
||||
_normalize_cost_basis_diagnostics(raw_value, raw_source, raw_reason)
|
||||
)
|
||||
if cost_basis_value is not None:
|
||||
avg_price = cost_basis_value
|
||||
seeded_avg_price = True
|
||||
cost_basis_reason = None
|
||||
except Exception: # noqa: BLE001 — graceful degrade
|
||||
cost_basis_source = "none"
|
||||
cost_basis_reason = "diagnostic_exception"
|
||||
_log.warning(
|
||||
"sync: cost_basis_port.get_cost_basis_with_diagnostics failed for "
|
||||
"portfolio_id=%s slot=%s — skipping avg_price seed (graceful degrade)",
|
||||
pid, slot_id,
|
||||
)
|
||||
else:
|
||||
# Backward compatibility path if diagnostic method is not available.
|
||||
try:
|
||||
legacy_value, legacy_source, legacy_reason = _normalize_legacy_cost_basis(
|
||||
cost_basis_port.get_cost_basis(pid)
|
||||
)
|
||||
cost_basis_source = legacy_source
|
||||
cost_basis_reason = legacy_reason
|
||||
if legacy_value is not None:
|
||||
avg_price = legacy_value
|
||||
seeded_avg_price = True
|
||||
except Exception: # noqa: BLE001 — graceful degrade
|
||||
cost_basis_source = "none"
|
||||
cost_basis_reason = "diagnostic_exception"
|
||||
_log.warning(
|
||||
"sync: cost_basis_port.get_cost_basis failed for portfolio_id=%s slot=%s "
|
||||
"— skipping avg_price seed (graceful degrade)",
|
||||
pid, slot_id,
|
||||
)
|
||||
elif pid:
|
||||
cost_basis_reason = "not_configured"
|
||||
_log.warning(
|
||||
"sync: no cost_basis_port configured for slot=%s portfolio_id=%s "
|
||||
"— skipping avg_price seed (graceful degrade)",
|
||||
slot_id, pid,
|
||||
)
|
||||
|
||||
inactive_no_cost_basis = slot.status is SlotState.INACTIVE and avg_price is None and broker_qty > 0
|
||||
fail_closed_cost_basis = inactive_no_cost_basis and not dry_run
|
||||
if inactive_no_cost_basis and dry_run:
|
||||
return {
|
||||
"slot_id": slot_id,
|
||||
"symbol": sym,
|
||||
"portfolio_id": pid,
|
||||
"before": before,
|
||||
"after": before,
|
||||
"warning_cleared": False,
|
||||
"dry_run": True,
|
||||
"skipped": True,
|
||||
"skip_reason": _set_cost_basis_unavailable_message()[0],
|
||||
"seeded_avg_price": False,
|
||||
"cost_basis_source": cost_basis_source,
|
||||
"cost_basis_reason": cost_basis_reason,
|
||||
}
|
||||
|
||||
if fail_closed_cost_basis:
|
||||
_log.error(
|
||||
"sync fail-closed: INACTIVE onboarding requires avg_price seed (portfolio_id=%s slot=%s) "
|
||||
"— refuse live write",
|
||||
pid,
|
||||
slot_id,
|
||||
)
|
||||
return {
|
||||
"slot_id": slot_id,
|
||||
"symbol": sym,
|
||||
"portfolio_id": pid,
|
||||
"before": before,
|
||||
"after": before,
|
||||
"warning_cleared": False,
|
||||
"dry_run": False,
|
||||
"skipped": True,
|
||||
"skip_reason": _set_cost_basis_unavailable_message()[0],
|
||||
"seeded_avg_price": False,
|
||||
"cost_basis_source": cost_basis_source,
|
||||
"cost_basis_reason": cost_basis_reason,
|
||||
"error": "cost_basis_unavailable",
|
||||
}
|
||||
|
||||
if dry_run:
|
||||
# Compute what *would* happen without writing anything.
|
||||
new_status = SlotState.HOLDING if slot.status is SlotState.INACTIVE else slot.status
|
||||
@@ -343,6 +496,8 @@ def sync_slot_to_broker(
|
||||
"dry_run": True,
|
||||
"skipped": False,
|
||||
"seeded_avg_price": seeded_avg_price,
|
||||
"cost_basis_source": cost_basis_source,
|
||||
"cost_basis_reason": cost_basis_reason,
|
||||
}
|
||||
|
||||
# --- Live write ---
|
||||
@@ -374,11 +529,15 @@ def sync_slot_to_broker(
|
||||
_log.exception("sync_slot_to_broker: write failed for slot=%s", slot_id)
|
||||
return {
|
||||
"slot_id": slot_id, "symbol": sym, "portfolio_id": pid,
|
||||
"before": before, "after": before,
|
||||
"before": before,
|
||||
"after": before,
|
||||
"warning_cleared": False,
|
||||
"dry_run": False,
|
||||
"skipped": False,
|
||||
"error": "write_failed",
|
||||
"seeded_avg_price": seeded_avg_price,
|
||||
"cost_basis_source": cost_basis_source,
|
||||
"cost_basis_reason": cost_basis_reason,
|
||||
}
|
||||
|
||||
# Refresh to confirm
|
||||
@@ -409,9 +568,10 @@ def sync_slot_to_broker(
|
||||
"dry_run": False,
|
||||
"skipped": False,
|
||||
"seeded_avg_price": seeded_avg_price,
|
||||
"cost_basis_source": cost_basis_source,
|
||||
"cost_basis_reason": cost_basis_reason,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BAT-051 / REQ-106: operator verify command — C-case re-check (read-centric)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -568,7 +568,9 @@ def _cmd_sync(args, cfg, confirm) -> int:
|
||||
dry_run=dry_run,
|
||||
)
|
||||
results.append(res)
|
||||
if res.get("error") and not res.get("skipped"):
|
||||
# A skipped result can still be a fail-closed live error (REQ-123).
|
||||
# Do not let the no-write marker hide a failed sync from automation.
|
||||
if res.get("error"):
|
||||
exit_code = 1
|
||||
|
||||
if args.json:
|
||||
@@ -584,6 +586,9 @@ def _cmd_sync(args, cfg, confirm) -> int:
|
||||
skipped = res.get("skipped", False)
|
||||
skip_reason = res.get("skip_reason", "")
|
||||
cleared = res.get("warning_cleared", False)
|
||||
seeded_avg_price = res.get("seeded_avg_price", False)
|
||||
cost_basis_source = res.get("cost_basis_source")
|
||||
cost_basis_reason = res.get("cost_basis_reason")
|
||||
prefix = "[dry-run] " if dry_run else ""
|
||||
if skipped:
|
||||
print(f" {prefix}SKIP slot={sid} symbol={sym} pid={pid} reason={skip_reason}")
|
||||
@@ -595,6 +600,13 @@ def _cmd_sync(args, cfg, confirm) -> int:
|
||||
f"qty={after.get('qty')} avg_price={after.get('avg_price')}")
|
||||
if cleared:
|
||||
print(" reconcile-warning CLEARED")
|
||||
if seeded_avg_price is not None:
|
||||
print(f" cost_basis.seeded={seeded_avg_price}")
|
||||
if cost_basis_source or cost_basis_reason:
|
||||
print(f" cost_basis.source={cost_basis_source} reason={cost_basis_reason}")
|
||||
|
||||
if res.get("error"):
|
||||
print(f" error={res.get('error')}")
|
||||
|
||||
return exit_code
|
||||
|
||||
|
||||
Reference in New Issue
Block a user