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
|
||||
|
||||
Reference in New Issue
Block a user