fix: JSON serialization crash on NaN floats in cycle context snapshot

- portfolio_context.py: add _safe_float() helper (converts NaN/Inf → None);
  use .squeeze().dropna() on yfinance closes before computing moves;
  guard division by checking closes.iloc[-2] != 0
- cycle.py: add _sanitize_floats() recursive sanitizer applied to the full
  snapshot before FastAPI serializes it — catches any remaining NaN from
  iv_rank, technical indicators, or other sources

Fixes 500 on GET /api/cycle/contexts/{run_id} when yfinance returns NaN
weekend data for portfolio positions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-21 19:48:19 +02:00
parent 96327bec8f
commit d4bc4e6624
2 changed files with 38 additions and 10 deletions

View File

@@ -91,13 +91,25 @@ def list_context_snapshots(limit: int = 30):
return {"snapshots": list_cycle_context_snapshots(limit=limit)} return {"snapshots": list_cycle_context_snapshots(limit=limit)}
def _sanitize_floats(obj):
"""Recursively replace NaN/Inf floats with None for JSON-safe serialization."""
import math
if isinstance(obj, float):
return None if (math.isnan(obj) or math.isinf(obj)) else obj
if isinstance(obj, dict):
return {k: _sanitize_floats(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_sanitize_floats(v) for v in obj]
return obj
@router.get("/contexts/{run_id}") @router.get("/contexts/{run_id}")
def get_context_snapshot(run_id: str): def get_context_snapshot(run_id: str):
"""Return the full context snapshot for a given cycle run_id.""" """Return the full context snapshot for a given cycle run_id."""
snap = get_cycle_context_snapshot(run_id) snap = get_cycle_context_snapshot(run_id)
if not snap: if not snap:
raise HTTPException(404, "Snapshot non trouvé pour ce cycle") raise HTTPException(404, "Snapshot non trouvé pour ce cycle")
return snap return _sanitize_floats(snap)
@router.get("/ai-calls/{run_id}") @router.get("/ai-calls/{run_id}")

View File

@@ -7,12 +7,26 @@ Provides:
- build_portfolio_context_block(): formatted prompt block for AI injection - build_portfolio_context_block(): formatted prompt block for AI injection
""" """
import logging import logging
import math
from typing import List, Dict, Optional from typing import List, Dict, Optional
from datetime import date, datetime from datetime import date, datetime
_log = logging.getLogger("portfolio_context") _log = logging.getLogger("portfolio_context")
def _safe_float(v, ndigits: int = 2) -> Optional[float]:
"""Round float, returning None for NaN/Inf/None — JSON-safe."""
if v is None:
return None
try:
f = float(v)
if math.isnan(f) or math.isinf(f):
return None
return round(f, ndigits)
except (TypeError, ValueError):
return None
def get_open_trades_with_moves() -> List[Dict]: def get_open_trades_with_moves() -> List[Dict]:
"""Fetch all open trades and compute recent underlying price moves.""" """Fetch all open trades and compute recent underlying price moves."""
from services.database import get_trade_entry_prices, _normalize_asset_class, _asset_class_from_ticker from services.database import get_trade_entry_prices, _normalize_asset_class, _asset_class_from_ticker
@@ -29,11 +43,13 @@ def get_open_trades_with_moves() -> List[Dict]:
try: try:
hist = yf.Ticker(sym).history(period="5d", auto_adjust=True) hist = yf.Ticker(sym).history(period="5d", auto_adjust=True)
if not hist.empty: if not hist.empty:
current_price = float(hist["Close"].iloc[-1]) closes = hist["Close"].squeeze().dropna()
if len(hist) >= 2: if len(closes) >= 1:
move_1d = (hist["Close"].iloc[-1] / hist["Close"].iloc[-2] - 1) * 100 current_price = _safe_float(closes.iloc[-1], 4)
if len(hist) >= 5: if len(closes) >= 2 and closes.iloc[-2] != 0:
move_5d = (hist["Close"].iloc[-1] / hist["Close"].iloc[0] - 1) * 100 move_1d = _safe_float((closes.iloc[-1] / closes.iloc[-2] - 1) * 100)
if len(closes) >= 5 and closes.iloc[0] != 0:
move_5d = _safe_float((closes.iloc[-1] / closes.iloc[0] - 1) * 100)
except Exception as e: except Exception as e:
_log.debug(f"[PortfolioCtx] yfinance failed for {sym}: {e}") _log.debug(f"[PortfolioCtx] yfinance failed for {sym}: {e}")
@@ -58,10 +74,10 @@ def get_open_trades_with_moves() -> List[Dict]:
"strategy": t.get("strategy") or "?", "strategy": t.get("strategy") or "?",
"asset_class": cls, "asset_class": cls,
"pattern_name": (t.get("pattern_name") or "")[:50], "pattern_name": (t.get("pattern_name") or "")[:50],
"entry_price": entry_price, "entry_price": _safe_float(entry_price, 4),
"current_price": round(current_price, 4) if current_price else None, "current_price": current_price,
"move_1d_pct": round(move_1d, 2) if move_1d is not None else None, "move_1d_pct": move_1d,
"move_5d_pct": round(move_5d, 2) if move_5d is not None else None, "move_5d_pct": move_5d,
"score_at_entry": t.get("score_at_entry"), "score_at_entry": t.get("score_at_entry"),
"days_held": days_held, "days_held": days_held,
"days_remaining": days_remaining, "days_remaining": days_remaining,