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)}
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}")
def get_context_snapshot(run_id: str):
"""Return the full context snapshot for a given cycle run_id."""
snap = get_cycle_context_snapshot(run_id)
if not snap:
raise HTTPException(404, "Snapshot non trouvé pour ce cycle")
return snap
return _sanitize_floats(snap)
@router.get("/ai-calls/{run_id}")

View File

@@ -7,12 +7,26 @@ Provides:
- build_portfolio_context_block(): formatted prompt block for AI injection
"""
import logging
import math
from typing import List, Dict, Optional
from datetime import date, datetime
_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]:
"""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
@@ -29,11 +43,13 @@ def get_open_trades_with_moves() -> List[Dict]:
try:
hist = yf.Ticker(sym).history(period="5d", auto_adjust=True)
if not hist.empty:
current_price = float(hist["Close"].iloc[-1])
if len(hist) >= 2:
move_1d = (hist["Close"].iloc[-1] / hist["Close"].iloc[-2] - 1) * 100
if len(hist) >= 5:
move_5d = (hist["Close"].iloc[-1] / hist["Close"].iloc[0] - 1) * 100
closes = hist["Close"].squeeze().dropna()
if len(closes) >= 1:
current_price = _safe_float(closes.iloc[-1], 4)
if len(closes) >= 2 and closes.iloc[-2] != 0:
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:
_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 "?",
"asset_class": cls,
"pattern_name": (t.get("pattern_name") or "")[:50],
"entry_price": entry_price,
"current_price": round(current_price, 4) if current_price else None,
"move_1d_pct": round(move_1d, 2) if move_1d is not None else None,
"move_5d_pct": round(move_5d, 2) if move_5d is not None else None,
"entry_price": _safe_float(entry_price, 4),
"current_price": current_price,
"move_1d_pct": move_1d,
"move_5d_pct": move_5d,
"score_at_entry": t.get("score_at_entry"),
"days_held": days_held,
"days_remaining": days_remaining,