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
+175 -15
View File
@@ -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)
# ---------------------------------------------------------------------------
+13 -1
View File
@@ -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