From d4bc4e6624be33f284cb1031249d0f9e3809b633 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Sun, 21 Jun 2026 19:48:19 +0200 Subject: [PATCH] fix: JSON serialization crash on NaN floats in cycle context snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/routers/cycle.py | 14 ++++++++++- backend/services/portfolio_context.py | 34 ++++++++++++++++++++------- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/backend/routers/cycle.py b/backend/routers/cycle.py index cadab42..cdcb025 100644 --- a/backend/routers/cycle.py +++ b/backend/routers/cycle.py @@ -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}") diff --git a/backend/services/portfolio_context.py b/backend/services/portfolio_context.py index 3c40367..b173c31 100644 --- a/backend/services/portfolio_context.py +++ b/backend/services/portfolio_context.py @@ -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,