feat(reconcile): safely seed canonical cost basis

This commit is contained in:
Hermes Agent
2026-07-17 09:57:08 +09:00
parent 18bd8d9f0b
commit 81a853a314
25 changed files with 1689 additions and 66 deletions
+81 -20
View File
@@ -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