diff --git a/backend/routers/saxo.py b/backend/routers/saxo.py index 8881410..8dbe11e 100644 --- a/backend/routers/saxo.py +++ b/backend/routers/saxo.py @@ -225,3 +225,26 @@ def catalog( @router.get("/catalog/summary") def catalog_summary(): return get_saxo_catalog_summary() + + +# ── Saxo-only options analytics (Options Lab "Saxo" section) ───────────────── +# Computed exclusively from our own accumulated saxo_option_snapshots history — never +# blended with the yfinance-based /api/options-vol/* endpoints. Symbols come from the +# Saxo watchlist above (the same ones already being snapshotted every ~5 min). + +@router.get("/iv-watchlist") +def saxo_iv_watchlist(): + from services.saxo_iv_engine import get_saxo_iv_watchlist + return get_saxo_iv_watchlist() + + +@router.get("/iv-snapshot/{symbol}") +def saxo_iv_snapshot(symbol: str): + from services.saxo_iv_engine import get_saxo_iv_snapshot + return get_saxo_iv_snapshot(symbol) + + +@router.get("/iv-history/{symbol}") +def saxo_iv_history(symbol: str, days: int = Query(90, ge=1, le=730)): + from services.saxo_iv_engine import get_saxo_iv_history + return get_saxo_iv_history(symbol, days) diff --git a/backend/services/ai_analyzer.py b/backend/services/ai_analyzer.py index 66e579c..15ccdc9 100644 --- a/backend/services/ai_analyzer.py +++ b/backend/services/ai_analyzer.py @@ -1747,7 +1747,7 @@ def ai_score_geo_risk(news: List[Dict], algo_score: Dict, log_meta: Optional[Dic if not get_client() or not news: return { "score": algo_score.get("score", 0), "level": algo_score.get("level", "low"), - "rationale": "IA indisponible — score algorithmique utilisé tel quel.", + "rationale": "AI unavailable — algorithmic score used as-is.", "top_risks": [], } @@ -1757,31 +1757,31 @@ def ai_score_geo_risk(news: List[Dict], algo_score: Dict, log_meta: Optional[Dic for n in top_news ] - user = f"""Evalue le niveau de risque geopolitique global actuel pour les marches financiers, sur une echelle de 0 a 100. + user = f"""Assess the current overall geopolitical risk level for financial markets, on a scale of 0 to 100. -Score algorithmique de reference (formule ponderee par categorie, a titre indicatif seulement — exerce ton propre jugement, ne le recopie pas mecaniquement) : {algo_score.get('score')}/100 ({algo_score.get('level')}). -Repartition par categorie : {json.dumps(algo_score.get('breakdown', {}), ensure_ascii=False)} +Reference algorithmic score (category-weighted formula, indicative only — use your own judgment, don't just copy it): {algo_score.get('score')}/100 ({algo_score.get('level')}). +Breakdown by category: {json.dumps(algo_score.get('breakdown', {}), ensure_ascii=False)} -Top actualites (triees par impact) : +Top news (sorted by impact): {json.dumps(compact, ensure_ascii=False)} -Consignes : -- Un score eleve doit refleter un risque REEL et actuel pour les marches (escalade militaire active, rupture commerciale majeure, crise politique...), pas juste un volume de news. -- Un evenement de desescalade/resolution doit FAIRE BAISSER le score meme si son impact brut est eleve. -- Ne t'ancre pas mecaniquement sur le score algorithmique si le contexte reel (titres) justifie un score different. -- rationale : 2-3 phrases en francais expliquant precisement pourquoi ce score, en citant les evenements les plus determinants. -- top_risks : 3 a 5 items les plus determinants pour ce score (titres courts). +Instructions: +- A high score must reflect a REAL, current risk to markets (active military escalation, major trade rupture, political crisis...), not just a volume of news. +- A de-escalation/resolution event should LOWER the score even if its raw impact is high. +- Don't mechanically anchor on the algorithmic score if the real context (headlines) justifies a different one. +- rationale: 2-3 sentences in English precisely explaining why this score, citing the most determining events. +- top_risks: the 3 to 5 most determining items for this score (short titles). JSON: {{"score": <0-100 float>, "level": "low"|"medium"|"high"|"extreme", "rationale": "...", "top_risks": ["...", ...]}}""" result = _chat( - "Tu es un analyste geopolitique senior qui evalue le risque marche global, pas evenement par evenement.", + "You are a senior geopolitical analyst assessing overall market risk, not event by event.", user, model="gpt-4o", json_mode=True, max_tokens=700, log_meta=log_meta, ) if not result: return { "score": algo_score.get("score", 0), "level": algo_score.get("level", "low"), - "rationale": "Erreur IA — score algorithmique utilisé tel quel.", + "rationale": "AI error — algorithmic score used as-is.", "top_risks": [], } diff --git a/backend/services/data_fetcher.py b/backend/services/data_fetcher.py index f64b782..07d6b6b 100644 --- a/backend/services/data_fetcher.py +++ b/backend/services/data_fetcher.py @@ -109,7 +109,14 @@ def get_quote(symbol: str) -> Optional[Dict[str, Any]]: if hist.empty: continue price = float(hist["Close"].iloc[-1]) - prev = float(hist["Close"].iloc[-2]) if len(hist) > 1 else price + # Explicit D-1 close: the latest row whose calendar date differs from the + # most recent row's date, not just "the row before last" — near-24h + # instruments (FX, futures) can otherwise return two rows for the same + # session, silently comparing "today vs today" and making change_pct swing + # around against a moving reference instead of a fixed prior close. + last_date = hist.index[-1].date() + prior_rows = hist[hist.index.date < last_date] + prev = float(prior_rows["Close"].iloc[-1]) if not prior_rows.empty else price change = price - prev change_pct = (change / prev * 100) if prev else 0 return { @@ -447,13 +454,13 @@ MACRO_GAUGE_CONFIG = [ SCENARIO_META = { "goldilocks": {"label": "Goldilocks", "color": "#10b981", "emoji": "🟢"}, - "desinflation": {"label": "Désinflation / Baisse taux","color": "#3b82f6", "emoji": "🔵"}, + "desinflation": {"label": "Disinflation / Rate Cuts", "color": "#3b82f6", "emoji": "🔵"}, "soft_landing": {"label": "Soft Landing", "color": "#06b6d4", "emoji": "🔷"}, "reflation": {"label": "Reflation", "color": "#f97316", "emoji": "🟠"}, "stagflation": {"label": "Stagflation", "color": "#f59e0b", "emoji": "🟡"}, - "inflation_shock": {"label": "Choc Inflationniste", "color": "#dc2626", "emoji": "🔥"}, - "recession": {"label": "Récession", "color": "#ef4444", "emoji": "🔴"}, - "crise_liquidite": {"label": "Crise de liquidité", "color": "#7c3aed", "emoji": "🟣"}, + "inflation_shock": {"label": "Inflation Shock", "color": "#dc2626", "emoji": "🔥"}, + "recession": {"label": "Recession", "color": "#ef4444", "emoji": "🔴"}, + "crise_liquidite": {"label": "Liquidity Crisis", "color": "#7c3aed", "emoji": "🟣"}, } SCENARIO_ASSET_BIAS = { @@ -807,165 +814,165 @@ def _score_raw(gauges: Dict[str, Any]) -> tuple: elif vix < 18: s += 20; r.append("VIX<18") elif vix < 22: s += 10 if slope is not None: - if slope > 1.0: s += 20; r.append("Courbe +1%pt") - elif slope > 0.3: s += 10; r.append("Courbe légèrement positive") + if slope > 1.0: s += 20; r.append("Curve +1%pt") + elif slope > 0.3: s += 10; r.append("Curve slightly positive") if gcr is not None: - if gcr < 500: s += 20; r.append(f"Or/Cu {gcr} (croissance)") + if gcr < 500: s += 20; r.append(f"Gold/Cu {gcr} (growth)") elif gcr < 600: s += 10 - if hyg_c > 0.2: s += 15; r.append("HYG↑ (crédit OK)") + if hyg_c > 0.2: s += 15; r.append("HYG↑ (credit OK)") elif hyg_c > 0: s += 5 if vs200 is not None: - if vs200 > 5: s += 15; r.append(f"S&P+{vs200}% vs 200j") + if vs200 > 5: s += 15; r.append(f"S&P+{vs200}% vs 200d") elif vs200 > 0: s += 7 - if copper_c > 0.5: s += 10; r.append("Cuivre↑") + if copper_c > 0.5: s += 10; r.append("Copper↑") if skew_v < 115: s += 6; r.append(f"SKEW {skew_v:.0f} (no tail hedge)") if vvix_v < 85: s += 5; r.append(f"VVIX {vvix_v:.0f} (vol stable)") - if tech_vs_staples > 0.5: s += 7; r.append("Tech > Défensifs (risk-on sectoriel)") - if eem_c > 0.3: s += 6; r.append("EM↑ (croissance globale)") - if usdjpy_c > 0.2: s += 4; r.append("JPY↓ (carry actif = risk-on)") + if tech_vs_staples > 0.5: s += 7; r.append("Tech > Defensives (sector risk-on)") + if eem_c > 0.3: s += 6; r.append("EM↑ (global growth)") + if usdjpy_c > 0.2: s += 4; r.append("JPY↓ (active carry = risk-on)") scores["goldilocks"] = min(100, s); reasons["goldilocks"] = r - # DÉSINFLATION / BAISSE DE TAUX + # DISINFLATION / RATE CUTS s = 0; r = [] - if brent_c < -1.0: s += 25; r.append("Brent↓↓ (désinflationniste)") + if brent_c < -1.0: s += 25; r.append("Brent↓↓ (disinflationary)") elif brent_c < 0: s += 10 - if ng_c < -1.0: s += 10; r.append("Gaz↓") - if ief_c > 0.2: s += 20; r.append("IEF↑ (taux longs baissent)") + if ng_c < -1.0: s += 10; r.append("Gas↓") + if ief_c > 0.2: s += 20; r.append("IEF↑ (long rates falling)") elif ief_c > 0: s += 10 if vix < 20: s += 15; r.append("VIX<20") - if vs200 is not None and vs200 > 0: s += 20; r.append("S&P au-dessus 200j") + if vs200 is not None and vs200 > 0: s += 20; r.append("S&P above 200d") if hyg_c > 0: s += 10; r.append("HYG↑") - if gold_c > 0 and brent_c < 0: s += 10; r.append("Or↑+Brent↓ (taux réels ↓)") - if tlt_c > 0.5: s += 12; r.append("TLT↑↑ (désinflation confirmée)") - elif tlt_c > 0.2: s += 6; r.append("TLT↑ (bonds longs soutiennent)") - if xlf_c > 0: s += 5; r.append("XLF↑ (anticipent baisse taux)") + if gold_c > 0 and brent_c < 0: s += 10; r.append("Gold↑+Brent↓ (real rates ↓)") + if tlt_c > 0.5: s += 12; r.append("TLT↑↑ (disinflation confirmed)") + elif tlt_c > 0.2: s += 6; r.append("TLT↑ (long bonds supportive)") + if xlf_c > 0: s += 5; r.append("XLF↑ (pricing in rate cuts)") scores["desinflation"] = min(100, s); reasons["desinflation"] = r # STAGFLATION s = 0; r = [] if brent_c > 2.0: s += 30; r.append("Brent↑↑") elif brent_c > 0.5: s += 15; r.append("Brent↑") - if ng_c > 2.0: s += 15; r.append("Gaz↑↑") + if ng_c > 2.0: s += 15; r.append("Gas↑↑") elif ng_c > 0.5: s += 7 if slope is not None: - if slope < 0: s += 20; r.append("Courbe inversée") - elif slope < 0.3: s += 10; r.append("Courbe plate") - if gold_c > 0.5: s += 15; r.append("Or↑ (protection inflation)") - if copper_c < 0: s += 15; r.append("Cuivre↓ (demande faible)") - if vix > 18: s += 10; r.append("VIX élevé") - if xlp_c > xlk_c + 0.5: s += 8; r.append("Défensifs > Tech (rotation stagflationniste)") - if xlu_c > 0.4: s += 6; r.append("Utilities↑ (revenus stables)") - if skew_v > 130: s += 5; r.append(f"SKEW {skew_v:.0f} (tail risk croissant)") - if tlt_c < -0.3: s += 5; r.append("TLT↓ (inflation persistante)") + if slope < 0: s += 20; r.append("Curve inverted") + elif slope < 0.3: s += 10; r.append("Curve flat") + if gold_c > 0.5: s += 15; r.append("Gold↑ (inflation hedge)") + if copper_c < 0: s += 15; r.append("Copper↓ (weak demand)") + if vix > 18: s += 10; r.append("VIX elevated") + if xlp_c > xlk_c + 0.5: s += 8; r.append("Defensives > Tech (stagflationary rotation)") + if xlu_c > 0.4: s += 6; r.append("Utilities↑ (stable income)") + if skew_v > 130: s += 5; r.append(f"SKEW {skew_v:.0f} (rising tail risk)") + if tlt_c < -0.3: s += 5; r.append("TLT↓ (persistent inflation)") scores["stagflation"] = min(100, s); reasons["stagflation"] = r - # RÉCESSION + # RECESSION s = 0; r = [] if slope is not None: - if slope < -0.5: s += 30; r.append("Courbe fortement inversée") - elif slope < 0: s += 15; r.append("Courbe inversée") + if slope < -0.5: s += 30; r.append("Curve deeply inverted") + elif slope < 0: s += 15; r.append("Curve inverted") if gcr is not None: - if gcr > 750: s += 25; r.append(f"Or/Cu {gcr} (peur)") + if gcr > 750: s += 25; r.append(f"Gold/Cu {gcr} (fear)") elif gcr > 650: s += 10 if vix > 28: s += 25; r.append("VIX>28") elif vix > 22: s += 12 - if copper_c < -1.5: s += 20; r.append("Cuivre↓↓") + if copper_c < -1.5: s += 20; r.append("Copper↓↓") elif copper_c < -0.5: s += 8 - if hyg_c < -0.5: s += 15; r.append("HYG↓ (spreads s'écartent)") + if hyg_c < -0.5: s += 15; r.append("HYG↓ (spreads widening)") elif hyg_c < 0: s += 5 - if gold_c > 0.3: s += 10; r.append("Or↑ (refuge)") - if tlt_c > 0.5: s += 15; r.append("TLT↑↑ (signal recessionnaire fort)") - elif tlt_c > 0.2: s += 7; r.append("TLT↑ (obligations soutenues)") - if xlf_c < -1.0: s += 12; r.append("Financières↓↓ (leading indicator récession)") + if gold_c > 0.3: s += 10; r.append("Gold↑ (safe haven)") + if tlt_c > 0.5: s += 15; r.append("TLT↑↑ (strong recession signal)") + elif tlt_c > 0.2: s += 7; r.append("TLT↑ (bonds supported)") + if xlf_c < -1.0: s += 12; r.append("Financials↓↓ (recession leading indicator)") elif xlf_c < -0.3: s += 5 if eem_c < -1.0: s += 8; r.append("EM↓ (global slowdown)") - if usdjpy_c < -1.0: s += 10; r.append("JPY↑↑ (carry unwind = risk-off global)") + if usdjpy_c < -1.0: s += 10; r.append("JPY↑↑ (carry unwind = global risk-off)") elif usdjpy_c < -0.5: s += 5 - if skew_v > 135: s += 8; r.append(f"SKEW {skew_v:.0f} (tail risk extrême)") + if skew_v > 135: s += 8; r.append(f"SKEW {skew_v:.0f} (extreme tail risk)") scores["recession"] = min(100, s); reasons["recession"] = r - # CRISE DE LIQUIDITÉ + # LIQUIDITY CRISIS s = 0; r = [] - if vix > 35: s += 35; r.append("VIX>35 (panique)") + if vix > 35: s += 35; r.append("VIX>35 (panic)") elif vix > 28: s += 20; r.append("VIX>28") elif vix > 22: s += 8 - if hyg_c < -1.5: s += 35; r.append("HYG↓↓ (crise crédit)") + if hyg_c < -1.5: s += 35; r.append("HYG↓↓ (credit crisis)") elif hyg_c < -0.5: s += 15 - if lqd_c < -0.5: s += 10; r.append("IG↓ (spreads s'écartent)") + if lqd_c < -0.5: s += 10; r.append("IG↓ (spreads widening)") if vs200 is not None: - if vs200 < -10: s += 25; r.append("S&P<200j -10%") + if vs200 < -10: s += 25; r.append("S&P<200d -10%") elif vs200 < -3: s += 10 - if gold_c > 1.0 and copper_c < -1.0: s += 20; r.append("Or↑+Cuivre↓ (fuite sécurité)") + if gold_c > 1.0 and copper_c < -1.0: s += 20; r.append("Gold↑+Copper↓ (flight to safety)") if dxy_c > 1.0: s += 15; r.append("Dollar↑↑") - if ief_c > 0.5: s += 10; r.append("Obligations souveraines↑↑") - if skew_v > 145: s += 15; r.append(f"SKEW {skew_v:.0f} — tail risk extrême") + if ief_c > 0.5: s += 10; r.append("Sovereign bonds↑↑") + if skew_v > 145: s += 15; r.append(f"SKEW {skew_v:.0f} — extreme tail risk") elif skew_v > 135: s += 8 - if vvix_v > 115: s += 15; r.append(f"VVIX {vvix_v:.0f} — vol-of-vol panique") - elif vvix_v > 100: s += 8; r.append(f"VVIX {vvix_v:.0f} — vol élevée") - if usdjpy_c < -1.5: s += 15; r.append("JPY↑↑↑ (carry unwind = panique globale)") + if vvix_v > 115: s += 15; r.append(f"VVIX {vvix_v:.0f} — vol-of-vol panic") + elif vvix_v > 100: s += 8; r.append(f"VVIX {vvix_v:.0f} — elevated vol") + if usdjpy_c < -1.5: s += 15; r.append("JPY↑↑↑ (carry unwind = global panic)") elif usdjpy_c < -0.8: s += 7; r.append("JPY↑ (risk-off carry)") - if xlf_c < -2.0: s += 15; r.append("Banques↓↓ (stress bancaire systémique)") + if xlf_c < -2.0: s += 15; r.append("Banks↓↓ (systemic banking stress)") elif xlf_c < -1.0: s += 7 - if emb_c < -1.0: s += 10; r.append("EM Bonds↓ (fuite liquidité EM)") + if emb_c < -1.0: s += 10; r.append("EM Bonds↓ (EM liquidity flight)") scores["crise_liquidite"] = min(100, s); reasons["crise_liquidite"] = r # REFLATION s = 0; r = [] - if copper_c > 1.5: s += 25; r.append("Cuivre↑↑ (Dr Copper = croissance)") - elif copper_c > 0.5: s += 12; r.append("Cuivre↑") - if xli_c > 0.8: s += 20; r.append("Industriels↑↑ (activité mfg forte)") - elif xli_c > 0.2: s += 10; r.append("Industriels↑") - if brent_c > 1.5: s += 15; r.append("Brent↑ (reflation énergie)") + if copper_c > 1.5: s += 25; r.append("Copper↑↑ (Dr Copper = growth)") + elif copper_c > 0.5: s += 12; r.append("Copper↑") + if xli_c > 0.8: s += 20; r.append("Industrials↑↑ (strong mfg activity)") + elif xli_c > 0.2: s += 10; r.append("Industrials↑") + if brent_c > 1.5: s += 15; r.append("Brent↑ (energy reflation)") elif brent_c > 0.3: s += 6 - if vs200 is not None and vs200 > 8: s += 20; r.append(f"S&P+{vs200}% vs 200j (bull fort)") + if vs200 is not None and vs200 > 8: s += 20; r.append(f"S&P+{vs200}% vs 200d (strong bull)") elif vs200 is not None and vs200 > 3: s += 10 - if slope is not None and slope > 1.0: s += 15; r.append("Courbe pentue (croissance)") + if slope is not None and slope > 1.0: s += 15; r.append("Curve steep (growth)") elif slope is not None and slope > 0.3: s += 6 - if rel_perf > 0.3: s += 10; r.append("Small caps > large (risk-on large)") + if rel_perf > 0.3: s += 10; r.append("Small caps > large (broad risk-on)") elif rel_perf > 0: s += 4 if vix < 18: s += 5 - if eem_c > 1.0: s += 10; r.append("EM↑↑ (reflation globale)") + if eem_c > 1.0: s += 10; r.append("EM↑↑ (global reflation)") elif eem_c > 0.3: s += 5; r.append("EM↑ (global risk-on)") - if xlk_c > 1.0: s += 8; r.append("Tech↑↑ (croissance+momentum)") - if silver_c > 1.5: s += 8; r.append("Argent↑↑ (reflation industrielle)") - if usdjpy_c > 0.5: s += 6; r.append("JPY↓ (carry trades actifs)") + if xlk_c > 1.0: s += 8; r.append("Tech↑↑ (growth+momentum)") + if silver_c > 1.5: s += 8; r.append("Silver↑↑ (industrial reflation)") + if usdjpy_c > 0.5: s += 6; r.append("JPY↓ (active carry trades)") scores["reflation"] = min(100, s); reasons["reflation"] = r # SOFT LANDING s = 0; r = [] - if vs200 is not None and vs200 > 0: s += 20; r.append("S&P > MA200 (croissance intacte)") - if brent_c < -0.5 and brent_c > -3: s += 20; r.append("Brent légèrement ↓ (désinflation graduelle)") + if vs200 is not None and vs200 > 0: s += 20; r.append("S&P > MA200 (growth intact)") + if brent_c < -0.5 and brent_c > -3: s += 20; r.append("Brent slightly ↓ (gradual disinflation)") elif brent_c < 0: s += 8 - if vix < 20: s += 15; r.append("VIX<20 (pas de stress)") - if hyg_c > 0: s += 12; r.append("HYG↑ (crédit solide)") - if lqd_c > 0: s += 8; r.append("IG↑ (spreads IG calmes)") - if slope is not None and slope > 0: s += 10; r.append("Courbe non-inversée") - if xli_c > 0: s += 8; r.append("Industriels positifs") - if copper_c > 0: s += 5; r.append("Cuivre stable") - if ief_c > 0 and brent_c < 0: s += 7; r.append("Taux baissent + énergie recule") - if xlf_c > 0: s += 8; r.append("Financières↑ (économie saine)") - if eem_c > 0: s += 5; r.append("EM stable (croissance globale intacte)") - if skew_v < 130: s += 4; r.append(f"SKEW {skew_v:.0f} (tail risk non-extrême)") + if vix < 20: s += 15; r.append("VIX<20 (no stress)") + if hyg_c > 0: s += 12; r.append("HYG↑ (solid credit)") + if lqd_c > 0: s += 8; r.append("IG↑ (calm IG spreads)") + if slope is not None and slope > 0: s += 10; r.append("Curve not inverted") + if xli_c > 0: s += 8; r.append("Industrials positive") + if copper_c > 0: s += 5; r.append("Copper stable") + if ief_c > 0 and brent_c < 0: s += 7; r.append("Rates falling + energy retreating") + if xlf_c > 0: s += 8; r.append("Financials↑ (healthy economy)") + if eem_c > 0: s += 5; r.append("EM stable (global growth intact)") + if skew_v < 130: s += 4; r.append(f"SKEW {skew_v:.0f} (non-extreme tail risk)") scores["soft_landing"] = min(100, s); reasons["soft_landing"] = r - # CHOC INFLATIONNISTE + # INFLATION SHOCK s = 0; r = [] - if brent_c > 4.0: s += 40; r.append("Brent↑↑↑ (choc énergie majeur)") + if brent_c > 4.0: s += 40; r.append("Brent↑↑↑ (major energy shock)") elif brent_c > 2.0: s += 25; r.append("Brent↑↑") elif brent_c > 0.8: s += 10 - if ng_c > 4.0: s += 20; r.append("Gaz↑↑↑ (choc supply gaz)") - elif ng_c > 2.0: s += 12; r.append("Gaz↑↑") - if gold_c > 1.0: s += 20; r.append("Or↑↑ (refuge inflation/géo)") - elif gold_c > 0.3: s += 8; r.append("Or↑") - if vix > 22: s += 15; r.append("VIX↑ (stress montant)") + if ng_c > 4.0: s += 20; r.append("Gas↑↑↑ (gas supply shock)") + elif ng_c > 2.0: s += 12; r.append("Gas↑↑") + if gold_c > 1.0: s += 20; r.append("Gold↑↑ (inflation/geo hedge)") + elif gold_c > 0.3: s += 8; r.append("Gold↑") + if vix > 22: s += 15; r.append("VIX↑ (rising stress)") elif vix > 18: s += 5 - if copper_c < -0.5: s += 8; r.append("Cuivre↓ (demand destruction)") - if ief_c < -0.2: s += 8; r.append("Trésor↓ (taux longs remontent)") - if ovx_v > 45: s += 15; r.append(f"OVX {ovx_v:.0f} — vol pétrole extrême") - elif ovx_v > 35: s += 8; r.append(f"OVX {ovx_v:.0f} — vol pétrole élevée") - if gvz_v > 22: s += 8; r.append(f"GVZ {gvz_v:.0f} — vol or élevée") - if xlp_c > 0.5: s += 6; r.append("Défensifs↑ (rotation anti-inflation)") - if tlt_c < -0.5: s += 8; r.append("TLT↓↓ (anticipation inflation)") + if copper_c < -0.5: s += 8; r.append("Copper↓ (demand destruction)") + if ief_c < -0.2: s += 8; r.append("Treasuries↓ (long rates rising)") + if ovx_v > 45: s += 15; r.append(f"OVX {ovx_v:.0f} — extreme oil vol") + elif ovx_v > 35: s += 8; r.append(f"OVX {ovx_v:.0f} — elevated oil vol") + if gvz_v > 22: s += 8; r.append(f"GVZ {gvz_v:.0f} — elevated gold vol") + if xlp_c > 0.5: s += 6; r.append("Defensives↑ (anti-inflation rotation)") + if tlt_c < -0.5: s += 8; r.append("TLT↓↓ (inflation expectations)") scores["inflation_shock"] = min(100, s); reasons["inflation_shock"] = r return scores, reasons diff --git a/backend/services/database.py b/backend/services/database.py index 992f5dd..685bbc6 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -6239,6 +6239,32 @@ def get_latest_saxo_snapshot_rows(symbol: str) -> List[Dict[str, Any]]: return [dict(r) for r in rows] +def get_saxo_daily_snapshot_rows(symbol: str, days: int = 365) -> List[Dict[str, Any]]: + """One row per (snapshot_date, expiry_date, strike, option_type) — the day's last + capture per contract, for every day in the last `days` that has any snapshot. Unlike + get_latest_saxo_snapshot_rows (overall-latest only), this gives an end-of-day + cross-section per day, used to build a Saxo-only ATM IV history (services.saxo_iv_engine) + for IV Rank/Percentile computed purely from our own accumulated Saxo captures — never + blended with the yfinance-based iv_history table.""" + conn = get_conn() + rows = conn.execute(""" + SELECT s.* FROM saxo_option_snapshots s + JOIN ( + SELECT snapshot_date, expiry_date, strike, option_type, MAX(created_at) AS max_created + FROM saxo_option_snapshots + WHERE symbol = ? AND snapshot_date >= date('now', ?) + GROUP BY snapshot_date, expiry_date, strike, option_type + ) latest + ON s.snapshot_date = latest.snapshot_date AND s.expiry_date = latest.expiry_date + AND s.strike = latest.strike AND s.option_type = latest.option_type + AND s.created_at = latest.max_created + WHERE s.symbol = ? + ORDER BY s.snapshot_date, s.expiry_date, s.strike + """, (symbol, f"-{days} days", symbol)).fetchall() + conn.close() + return [dict(r) for r in rows] + + def get_snapshot_rows_asof(symbol: Optional[str] = None, as_of: Optional[str] = None) -> List[Dict[str, Any]]: """One row per (symbol, expiry_date, strike, option_type) — the freshest capture at or before `as_of` (an ISO datetime string), or simply the freshest capture overall when diff --git a/backend/services/iv_engine.py b/backend/services/iv_engine.py index 5705a11..b534a1b 100644 --- a/backend/services/iv_engine.py +++ b/backend/services/iv_engine.py @@ -278,11 +278,11 @@ def get_skew(ticker: str, target_days: int = 30) -> Dict[str, Any]: result["iv_call_25d"] = round(iv_call * 100, 1) if put_skew > 0.03: - result["interpretation"] = "Marché achète des puts — protection baissière élevée" + result["interpretation"] = "Market buying puts — high downside protection" elif put_skew < -0.02: - result["interpretation"] = "Marché achète des calls — biais haussier spéculatif" + result["interpretation"] = "Market buying calls — speculative bullish bias" else: - result["interpretation"] = "Skew équilibré — pas de biais directionnel fort" + result["interpretation"] = "Balanced skew — no strong directional bias" except Exception as e: logger.debug(f"[Skew] {proxy}: {e}") diff --git a/backend/services/market_event_detector.py b/backend/services/market_event_detector.py index 4088c74..0b7226d 100644 --- a/backend/services/market_event_detector.py +++ b/backend/services/market_event_detector.py @@ -1316,11 +1316,11 @@ _REGIME_SEVERITY = { } _REGIME_LABELS = { - "goldilocks": "Goldilocks", "desinflation": "Désinflation", + "goldilocks": "Goldilocks", "desinflation": "Disinflation", "soft_landing": "Soft Landing", "reflation": "Reflation", - "stagflation": "Stagflation", "inflation_shock": "Choc Inflationniste", - "recession": "Récession", "crise_liquidite": "Crise de liquidité", - "incertain": "Incertain", + "stagflation": "Stagflation", "inflation_shock": "Inflation Shock", + "recession": "Recession", "crise_liquidite": "Liquidity Crisis", + "incertain": "Uncertain", } diff --git a/backend/services/saxo_iv_engine.py b/backend/services/saxo_iv_engine.py new file mode 100644 index 0000000..bf12b5f --- /dev/null +++ b/backend/services/saxo_iv_engine.py @@ -0,0 +1,211 @@ +""" +Options analytics computed exclusively from our own accumulated Saxo snapshot history +(services.database.saxo_option_snapshots) — deliberately never blended with the +yfinance-based services.iv_engine, so IV/skew/term-structure for a Saxo watchlist symbol +always reflects what the broker itself quoted. Powers Options Lab's Saxo section. + +Mirrors iv_engine.py's output shape (iv_current_pct, iv_rank, iv_percentile, +history_days, iv_change_1d_pct, skew, term_structure, signal) so the frontend can reuse +the same rendering patterns — just fed by a different, non-mixed data source. Options +flow (open interest based) isn't available: Saxo snapshots don't carry OI/volume. +""" +from datetime import date, datetime, timezone +from typing import Any, Dict, List, Optional + +_DAYS_TARGETS = {"iv_30d": 30, "iv_60d": 60, "iv_90d": 90, "iv_180d": 180} + + +def _days_to(expiry_date: str, today: date) -> int: + return (datetime.strptime(expiry_date[:10], "%Y-%m-%d").date() - today).days + + +def _atm_iv(rows: List[Dict[str, Any]], spot: Optional[float], target_days: int, today: date) -> Optional[Dict[str, Any]]: + """Pick the expiry closest to target_days among `rows` (already one row per contract), + then the strike closest to spot within it. Averages call+put IV at that strike.""" + if not rows or not spot: + return None + by_expiry: Dict[str, List[Dict[str, Any]]] = {} + for r in rows: + by_expiry.setdefault(r["expiry_date"], []).append(r) + + candidates = [(e, _days_to(e, today)) for e in by_expiry if _days_to(e, today) >= 0] + if not candidates: + return None + best_exp, best_days = min(candidates, key=lambda x: abs(x[1] - target_days)) + + exp_rows = by_expiry[best_exp] + strikes = sorted({r["strike"] for r in exp_rows}) + if not strikes: + return None + atm_strike = min(strikes, key=lambda s: abs(s - spot)) + + ivs = [r["volatility_pct"] for r in exp_rows if r["strike"] == atm_strike and r.get("volatility_pct")] + if not ivs: + return None + return { + "iv_pct": round(sum(ivs) / len(ivs), 1), + "expiry_date": best_exp, + "days_to_expiry": best_days, + } + + +def _skew(rows: List[Dict[str, Any]], target_days: int, today: date) -> Dict[str, Any]: + """25-delta put IV minus 25-delta call IV, using Saxo's own delta field (no strike + approximation needed, unlike the yfinance path which has to estimate ~10%/90% OTM).""" + result: Dict[str, Any] = {"put_skew": None, "skew_pct": None, "iv_put_25d": None, "iv_call_25d": None, "interpretation": None} + if not rows: + return result + by_expiry: Dict[str, List[Dict[str, Any]]] = {} + for r in rows: + by_expiry.setdefault(r["expiry_date"], []).append(r) + candidates = [(e, _days_to(e, today)) for e in by_expiry if _days_to(e, today) >= 0] + if not candidates: + return result + best_exp, _ = min(candidates, key=lambda x: abs(x[1] - target_days)) + exp_rows = by_expiry[best_exp] + + puts = [r for r in exp_rows if r["option_type"] == "put" and r.get("delta") is not None and r.get("volatility_pct")] + calls = [r for r in exp_rows if r["option_type"] == "call" and r.get("delta") is not None and r.get("volatility_pct")] + if not puts or not calls: + return result + + put_row = min(puts, key=lambda r: abs(r["delta"] - (-0.25))) + call_row = min(calls, key=lambda r: abs(r["delta"] - 0.25)) + + iv_put_pct, iv_call_pct = put_row["volatility_pct"], call_row["volatility_pct"] + put_skew = (iv_put_pct - iv_call_pct) / 100 # decimal, matches iv_engine.py's convention + + result["put_skew"] = round(put_skew, 4) + result["skew_pct"] = round(iv_put_pct - iv_call_pct, 1) + result["iv_put_25d"] = round(iv_put_pct, 1) + result["iv_call_25d"] = round(iv_call_pct, 1) + if put_skew > 0.03: + result["interpretation"] = "Market buying puts — high downside protection" + elif put_skew < -0.02: + result["interpretation"] = "Market buying calls — speculative bullish bias" + else: + result["interpretation"] = "Balanced skew — no strong directional bias" + return result + + +def _term_structure(rows: List[Dict[str, Any]], spot: Optional[float], today: date) -> Dict[str, Any]: + result: Dict[str, Any] = {"iv_30d": None, "iv_60d": None, "iv_90d": None, "iv_180d": None, "structure": None} + for field, target in _DAYS_TARGETS.items(): + atm = _atm_iv(rows, spot, target, today) + if atm and abs(atm["days_to_expiry"] - target) <= 20: + result[field] = round(atm["iv_pct"] / 100, 4) # decimal, matches iv_engine.py's convention + iv30, iv90 = result.get("iv_30d"), result.get("iv_90d") + if iv30 and iv90: + diff = iv90 - iv30 + result["structure"] = "contango" if diff > 0.015 else "backwardation" if diff < -0.015 else "flat" + return result + + +def _daily_atm_series(symbol: str, target_days: int = 30, days: int = 365) -> List[Dict[str, Any]]: + """One ATM-IV point per day that has Saxo snapshots, oldest first.""" + from services.database import get_saxo_daily_snapshot_rows + + daily_rows = get_saxo_daily_snapshot_rows(symbol, days=days) + by_date: Dict[str, List[Dict[str, Any]]] = {} + for r in daily_rows: + by_date.setdefault(r["snapshot_date"][:10], []).append(r) + + series = [] + for d, rows in sorted(by_date.items()): + spot = next((r["spot"] for r in rows if r.get("spot") is not None), None) + try: + ref_date = datetime.strptime(d, "%Y-%m-%d").date() + except ValueError: + continue + atm = _atm_iv(rows, spot, target_days, ref_date) + if atm: + series.append({"date": d, "iv_pct": atm["iv_pct"]}) + return series + + +def get_saxo_iv_snapshot(symbol: str, target_days: int = 30) -> Dict[str, Any]: + """Full Saxo-only IV snapshot for one watchlist symbol: current ATM IV, rank/percentile + (from Saxo's own history), term structure, skew, day-over-day change.""" + from services.database import get_latest_saxo_snapshot_rows + + symbol = symbol.upper() + today = date.today() + rows = get_latest_saxo_snapshot_rows(symbol) + spot = next((r["spot"] for r in rows if r.get("spot") is not None), None) if rows else None + + atm = _atm_iv(rows, spot, target_days, today) if rows else None + iv_current_pct = atm["iv_pct"] if atm else None + + series = _daily_atm_series(symbol, target_days) + history_days = len(series) + iv_rank = iv_percentile = iv_min_52w = iv_max_52w = None + iv_change_1d_pct = None + if series: + hist_vals = [p["iv_pct"] for p in series] + iv_min_52w, iv_max_52w = min(hist_vals), max(hist_vals) + if iv_current_pct is not None: + if iv_max_52w > iv_min_52w: + iv_rank = round((iv_current_pct - iv_min_52w) / (iv_max_52w - iv_min_52w) * 100, 1) + else: + iv_rank = 50.0 + iv_percentile = round(sum(1 for v in hist_vals if v < iv_current_pct) / len(hist_vals) * 100, 1) + # Day-over-day: last series point strictly before today vs current + todays_iso = today.isoformat() + prior = [p for p in series if p["date"] < todays_iso] + if prior and iv_current_pct is not None: + iv_change_1d_pct = round(iv_current_pct - prior[-1]["iv_pct"], 1) + + return { + "ticker": symbol, + "proxy": symbol, + "iv_current_pct": iv_current_pct, + "iv_change_1d_pct": iv_change_1d_pct, + "iv_rank": iv_rank, + "iv_percentile": iv_percentile, + "history_days": history_days, + "iv_min_52w_pct": iv_min_52w, + "iv_max_52w_pct": iv_max_52w, + "term_structure": _term_structure(rows, spot, today) if rows else _term_structure([], None, today), + "skew": _skew(rows, target_days, today) if rows else _skew([], target_days, today), + "options_flow": {}, # not available — Saxo snapshots carry no open interest/volume + "fetched_at": datetime.now(timezone.utc).isoformat(), + "iv_source": "saxo" if atm else "none", + "spot": spot, + } + + +def get_saxo_iv_history(symbol: str, days: int = 90) -> Dict[str, Any]: + series = _daily_atm_series(symbol.upper(), days=days) + # Match iv_engine's get_iv_history row shape (recorded_date, iv_current as a decimal) + history = [{"recorded_date": p["date"], "iv_current": p["iv_pct"] / 100} for p in reversed(series)] + return {"ticker": symbol, "proxy": symbol, "history": history, "count": len(history)} + + +def get_saxo_iv_watchlist() -> Dict[str, Any]: + """Summary row per symbol in the Saxo watchlist (services.saxo_scheduler) — the same + symbols already being snapshotted every ~5 min, no separate mapping to maintain.""" + from services.saxo_scheduler import get_watchlist + + results = [] + for symbol in get_watchlist(): + snap = get_saxo_iv_snapshot(symbol) + if snap["iv_current_pct"] is None: + continue + rank = snap["iv_rank"] + results.append({ + "ticker": symbol, + "iv_current_pct": snap["iv_current_pct"], + "iv_change_1d_pct": snap["iv_change_1d_pct"], + "iv_rank": rank, + "iv_percentile": snap["iv_percentile"], + "history_days": snap["history_days"], + "iv_source": "saxo", + "signal": ( + "sell_vol" if (rank or 0) > 80 + else "buy_vol" if (rank or 100) < 20 + else "neutral" + ), + }) + + results.sort(key=lambda x: -(x.get("iv_rank") or 0)) + return {"items": results, "count": len(results)} diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 944ce68..3f350a4 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -919,6 +919,31 @@ export const useIvForTrade = (underlying: string) => staleTime: 60 * 60_000, }) +// ── Saxo-only IV analytics (Options Lab "Saxo" section) — computed from our own +// accumulated saxo_option_snapshots, never blended with the yfinance-based hooks above ── +export const useSaxoIvWatchlist = () => + useQuery({ + queryKey: ['saxo-iv-watchlist'], + queryFn: () => api.get('/saxo/iv-watchlist').then(r => r.data), + staleTime: 5 * 60_000, + }) + +export const useSaxoIvSnapshot = (symbol: string) => + useQuery({ + queryKey: ['saxo-iv-snapshot', symbol], + queryFn: () => api.get(`/saxo/iv-snapshot/${encodeURIComponent(symbol)}`).then(r => r.data), + enabled: !!symbol, + staleTime: 5 * 60_000, + }) + +export const useSaxoIvHistory = (symbol: string, days = 90) => + useQuery({ + queryKey: ['saxo-iv-history', symbol, days], + queryFn: () => api.get(`/saxo/iv-history/${encodeURIComponent(symbol)}`, { params: { days } }).then(r => r.data), + enabled: !!symbol, + staleTime: 5 * 60_000, + }) + // ── Analytics Phase 4 — Bayesian + Clustering + Embeddings ─────────────────── export const useBayesianPosteriors = () => diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 14fb30a..17b0b75 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -122,16 +122,16 @@ function EcoEventRow({ ev, showActual }: { ev: any; showActual: boolean }) {
{riskScore.rationale}
+ )}+ IV Rank · Term Structure · Skew, computed only from your Saxo watchlist's accumulated option snapshots — manage the symbol list in Config → Saxo. +
+