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:
@@ -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}")
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user