- 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>
145 lines
5.6 KiB
Python
145 lines
5.6 KiB
Python
"""
|
|
Portfolio context builder — injects open positions into AI prompts.
|
|
|
|
Provides:
|
|
- get_open_trades_with_moves(): open trades + 1d/5d price moves via yfinance
|
|
- get_portfolio_concentration(): count per asset_class
|
|
- 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
|
|
import yfinance as yf
|
|
|
|
trades = get_trade_entry_prices(days=365) # all open trades regardless of age
|
|
|
|
result = []
|
|
for t in trades:
|
|
sym = t.get("underlying", "")
|
|
entry_price = t.get("entry_price")
|
|
move_1d = move_5d = current_price = None
|
|
|
|
try:
|
|
hist = yf.Ticker(sym).history(period="5d", auto_adjust=True)
|
|
if not hist.empty:
|
|
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}")
|
|
|
|
# Days held / remaining
|
|
entry_str = t.get("entry_date", "")
|
|
horizon = t.get("horizon_days") or 30
|
|
days_held = 0
|
|
days_remaining = horizon
|
|
try:
|
|
entry_dt = datetime.strptime(entry_str, "%Y-%m-%d").date()
|
|
days_held = (date.today() - entry_dt).days
|
|
days_remaining = max(0, horizon - days_held)
|
|
except Exception:
|
|
pass
|
|
|
|
# Canonical asset class
|
|
cls = _normalize_asset_class(t.get("asset_class") or "", sym)
|
|
|
|
result.append({
|
|
"id": t.get("id"),
|
|
"underlying": sym,
|
|
"strategy": t.get("strategy") or "?",
|
|
"asset_class": cls,
|
|
"pattern_name": (t.get("pattern_name") or "")[:50],
|
|
"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,
|
|
"horizon_days": horizon,
|
|
})
|
|
|
|
return result
|
|
|
|
|
|
def get_portfolio_concentration(open_trades: List[Dict]) -> Dict[str, int]:
|
|
"""Count open trades by canonical asset_class."""
|
|
conc: Dict[str, int] = {}
|
|
for t in open_trades:
|
|
cls = t.get("asset_class") or "unknown"
|
|
conc[cls] = conc.get(cls, 0) + 1
|
|
return conc
|
|
|
|
|
|
def build_portfolio_context_block(open_trades: List[Dict], concentration: Dict[str, int]) -> str:
|
|
"""Build a prompt section describing current portfolio for injection into AI prompts."""
|
|
if not open_trades:
|
|
return "\n## PORTEFEUILLE ACTUEL\nAucun trade en cours — portefeuille vide.\n"
|
|
|
|
total = len(open_trades)
|
|
conc_sorted = sorted(concentration.items(), key=lambda x: -x[1])
|
|
conc_str = " | ".join(f"{cls.upper()}: {n}" for cls, n in conc_sorted)
|
|
|
|
lines: List[str] = [f"Total: {total} trade(s) ouvert(s) | Concentration: {conc_str}", ""]
|
|
|
|
for t in open_trades:
|
|
sym = t["underlying"]
|
|
strat = t["strategy"]
|
|
cls = t.get("asset_class") or "?"
|
|
held = t.get("days_held", "?")
|
|
rem = t.get("days_remaining", "?")
|
|
m1d_str = f"{t['move_1d_pct']:+.1f}%" if t.get("move_1d_pct") is not None else "N/A"
|
|
m5d_str = f"{t['move_5d_pct']:+.1f}%" if t.get("move_5d_pct") is not None else "N/A"
|
|
pat = t.get("pattern_name", "")
|
|
entry = t.get("entry_price")
|
|
cur = t.get("current_price")
|
|
ep_str = f"entrée {entry:.2f} → actuel {cur:.2f}" if entry and cur else ""
|
|
|
|
line = f" • {sym} | {strat} [{cls}] | {held}j tenu / {rem}j restants | J-1: {m1d_str} | J-5: {m5d_str}"
|
|
if ep_str:
|
|
line += f" | {ep_str}"
|
|
if pat:
|
|
line += f" | thèse: «{pat}»"
|
|
lines.append(line)
|
|
|
|
# Identify overweight classes
|
|
overweight = [cls for cls, n in conc_sorted if n >= 3]
|
|
ow_str = ", ".join(overweight) if overweight else "aucune"
|
|
|
|
block = (
|
|
"\n## PORTEFEUILLE ACTUEL — POSITIONS OUVERTES\n"
|
|
+ "\n".join(lines)
|
|
+ f"\n\nClasses surpondérées (≥3 trades): {ow_str}\n"
|
|
+ "\n⚠️ CONSIGNES IMPÉRATIVES (non négociables):\n"
|
|
+ "1. NE PAS suggérer un nouveau trade sur un sous-jacent déjà en portefeuille — doublement interdit.\n"
|
|
+ "2. Signaler explicitement dans 'rationale' si une suggestion CONTREDIT une position ouverte (signal de clôture potentiel).\n"
|
|
+ "3. Éviter d'alourdir une classe surpondérée (≥3 trades) sauf catalyseur exceptionnel justifié.\n"
|
|
+ "4. Un signal opposé à une position ouverte = opportunité de SORTIE à documenter, pas d'entrée inversée.\n"
|
|
)
|
|
return block
|