From b6e9b96dc4aaa486b594accef912ffd60ffe71fe Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Tue, 21 Jul 2026 16:48:12 +0200 Subject: [PATCH] feat: cockpit --- backend/routers/saxo.py | 23 +++ backend/services/ai_analyzer.py | 26 +-- backend/services/data_fetcher.py | 199 ++++++++++---------- backend/services/database.py | 26 +++ backend/services/iv_engine.py | 6 +- backend/services/market_event_detector.py | 8 +- backend/services/saxo_iv_engine.py | 211 +++++++++++++++++++++ frontend/src/hooks/useApi.ts | 25 +++ frontend/src/pages/Dashboard.tsx | 48 ++--- frontend/src/pages/OptionsLab.tsx | 214 +++++++++++++++++++++- 10 files changed, 644 insertions(+), 142 deletions(-) create mode 100644 backend/services/saxo_iv_engine.py 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 }) {
{CURRENCY_FLAGS[ev.currency] ?? ev.currency} - {ev.event_name} + {ev.event_name} {showActual && ev.actual_value ? ( - + {ev.actual_value} - {ev.forecast_value && /{ev.forecast_value}} + {ev.forecast_value && /{ev.forecast_value}} ) : ev.forecast_value ? ( - F {ev.forecast_value} + F {ev.forecast_value} ) : null} - {ev.event_time && {ev.event_time}} + {ev.event_time && {ev.event_time}}
) } @@ -347,7 +347,7 @@ export default function Dashboard() {
Geopolitical Risk
- + Geo news @@ -376,14 +376,16 @@ export default function Dashboard() {
{riskScore.computed_at && ( -
- Score IA — figé au dernier cycle ({formatTimeShort(riskScore.computed_at)}) - {riskScore.rationale && · {riskScore.rationale}} +
+ AI Score — frozen at last cycle ({formatTimeShort(riskScore.computed_at)}) + {riskScore.rationale && ( +

{riskScore.rationale}

+ )}
)} {topNews.length > 0 && ( -
-
+
+
Top news
@@ -410,7 +412,7 @@ export default function Dashboard() {
📡 Watchlist Radar
- + Manage
@@ -419,7 +421,7 @@ export default function Dashboard() { - + @@ -427,9 +429,9 @@ export default function Dashboard() {
{((watchlistQuotesData as any)?.items ?? []).map((it: any) => (
- {it.ticker} + {it.ticker}
- {fmtPrice(it.price)} + {fmtPrice(it.price)} = 0 ? 'text-emerald-400' : 'text-red-400')}> {it.change_pct != null ? `${it.change_pct >= 0 ? '+' : ''}${it.change_pct.toFixed(2)}%` : '—'} @@ -499,7 +501,7 @@ export default function Dashboard() { style={row1Height ? { height: row1Height } : undefined}>
🌐 Macro Regime - +
{macroInfo ? `${macroInfo.emoji} ${macroInfo.label}` : '—'} @@ -512,7 +514,7 @@ export default function Dashboard() { const isDom = key === dom return (
- + {REGIME_LABELS[key] ?? key}
@@ -521,7 +523,7 @@ export default function Dashboard() { style={{ width: `${pct}%` }} />
- + {score}
@@ -542,11 +544,11 @@ export default function Dashboard() { const h = hint(val, chg) return (
- {label} - + {label} + {fmt(val, chg)} - {h} + {h}
) })} @@ -575,7 +577,7 @@ export default function Dashboard() { {todayEvents.length > 0 && (
- Aujourd'hui TODAY + Today TODAY
{todayEvents.map((ev: any, i: number) => )} @@ -593,7 +595,7 @@ export default function Dashboard() {
))}
- ) :
No events this week
} + ) :
No events this week
}
diff --git a/frontend/src/pages/OptionsLab.tsx b/frontend/src/pages/OptionsLab.tsx index 251b873..e8b3c0a 100644 --- a/frontend/src/pages/OptionsLab.tsx +++ b/frontend/src/pages/OptionsLab.tsx @@ -1,6 +1,9 @@ import { useState } from 'react' -import { useIvWatchlist, useIvSnapshot, useIvHistory, useWatchlistTickers, useAddWatchlistTicker, useRemoveWatchlistTicker } from '../hooks/useApi' -import { Activity, TrendingUp, TrendingDown, Minus, RefreshCw, ChevronDown, ChevronUp, Database, Plus, Trash2, List } from 'lucide-react' +import { + useIvWatchlist, useIvSnapshot, useIvHistory, useWatchlistTickers, useAddWatchlistTicker, useRemoveWatchlistTicker, + useSaxoIvWatchlist, useSaxoIvSnapshot, useSaxoIvHistory, +} from '../hooks/useApi' +import { Activity, TrendingUp, TrendingDown, Minus, RefreshCw, ChevronDown, ChevronUp, Database, Plus, Trash2, List, Link2 } from 'lucide-react' import { api } from '../hooks/useApi' import { useQueryClient } from '@tanstack/react-query' import clsx from 'clsx' @@ -176,7 +179,7 @@ function TickerDetail({ ticker }: { ticker: string }) { {' · '}{snap.history_days}d of history {snap.iv_change_1d_pct != null && ( <> - {' · '}vs veille{' '} + {' · '}vs prior day{' '} 0 ? 'text-orange-400' : snap.iv_change_1d_pct < 0 ? 'text-blue-400' : 'text-slate-400'}> {snap.iv_change_1d_pct >= 0 ? '+' : ''}{snap.iv_change_1d_pct}pt @@ -261,6 +264,170 @@ function WatchlistRow({ item }: { item: any }) { ) } +// ── Saxo-only expanded detail — separate from TickerDetail above by design: this reads +// exclusively from /api/saxo/iv-*, computed from our own accumulated saxo_option_snapshots, +// never blended with the yfinance-based IV watchlist. No Options Flow section — Saxo +// snapshots carry no open interest/volume, so there's nothing honest to show there. ── +function SaxoTickerDetail({ ticker }: { ticker: string }) { + const { data: snap, isLoading } = useSaxoIvSnapshot(ticker) + const { data: histData } = useSaxoIvHistory(ticker, 90) + + if (isLoading) return
+ if (!snap) return null + + const ts = (snap.term_structure || {}) as any + const skew = (snap.skew || {}) as any + const history: any[] = histData?.history || [] + + return ( +
+ {/* Term Structure */} +
+
Term Structure (Saxo)
+
+ {([['30d', ts.iv_30d], ['60d', ts.iv_60d], ['90d', ts.iv_90d], ['180d', ts.iv_180d]] as [string, number | undefined][]).map(([label, iv]) => + iv ? ( +
+ {label} +
+
+
+ {(iv * 100).toFixed(1)}% +
+ ) : null + )} + {ts.structure &&
} + {!ts.iv_30d && !ts.iv_60d && !ts.iv_90d && !ts.iv_180d && ( +
Not enough expiries captured yet
+ )} +
+
+ + {/* Skew */} +
+
Skew Put/Call (Saxo)
+ {skew.put_skew != null ? ( +
+
+ Put 25Δ + {skew.iv_put_25d}% +
+
+ Call 25Δ + {skew.iv_call_25d}% +
+
3 ? 'text-red-400' : skew.skew_pct < -2 ? 'text-blue-400' : 'text-slate-400')}> + {skew.skew_pct > 0 ? '+' : ''}{skew.skew_pct} pts +
+ {skew.interpretation && ( +
{skew.interpretation}
+ )} +
+ ) : ( +
Insufficient data
+ )} +
+ + {/* History sparkline */} + {history.length > 4 && ( +
+ IV 90d + +
+ min {snap.iv_min_52w_pct}% + {' · '}max {snap.iv_max_52w_pct}% + {' · '}{snap.history_days}d of Saxo history + {snap.iv_change_1d_pct != null && ( + <> + {' · '}vs prior day{' '} + 0 ? 'text-orange-400' : snap.iv_change_1d_pct < 0 ? 'text-blue-400' : 'text-slate-400'}> + {snap.iv_change_1d_pct >= 0 ? '+' : ''}{snap.iv_change_1d_pct}pt + + + )} +
+
+ )} + {history.length <= 4 && ( +
+ Not enough accumulated days yet for a Rank/Percentile — keep the Saxo watchlist snapshotting and it fills in on its own. +
+ )} +
+ ) +} + +// ── Saxo Watchlist Row — same layout as WatchlistRow, fed by /api/saxo/iv-* only ── +function SaxoWatchlistRow({ item }: { item: any }) { + const [expanded, setExpanded] = useState(false) + const signal = ivSignalLabel(item.iv_rank) + + return ( +
+
setExpanded(!expanded)} + > +
+
{item.ticker}
+
Saxo
+
+ +
+ + {item.iv_current_pct != null ? `${item.iv_current_pct}%` : '—'} + + {item.iv_change_1d_pct != null && Math.abs(item.iv_change_1d_pct) >= 0.1 && ( + 0 ? 'text-orange-400' : 'text-blue-400')}> + {item.iv_change_1d_pct >= 0 ? '+' : ''}{item.iv_change_1d_pct.toFixed(1)} + + )} +
Current IV
+
+ +
+
+ IV Rank + + {item.iv_rank != null ? `${item.iv_rank}%` : 'N/A'} + +
+ +
+ +
+
Pctile
+
+ {item.iv_percentile != null ? `${item.iv_percentile}%` : '—'} +
+
+ +
+ {signal && ( +
+ {signal.icon} {signal.text} +
+ )} + {item.history_days > 0 && ( +
{item.history_days}d Saxo history
+ )} +
+ +
+ {expanded ? : } +
+
+ + {expanded && ( +
+ +
+ )} +
+ ) +} + // ── Watchlist Manager ───────────────────────────────────────────────────────── function WatchlistManager() { const qc = useQueryClient() @@ -364,9 +531,11 @@ function WatchlistManager() { // ── Main page ───────────────────────────────────────────────────────────────── export default function OptionsLab() { const { data, isLoading, refetch, isFetching } = useIvWatchlist() + const { data: saxoData, isLoading: saxoLoading, refetch: refetchSaxo, isFetching: saxoFetching } = useSaxoIvWatchlist() const [bootstrapping, setBootstrapping] = useState(false) const [bootstrapMsg, setBootstrapMsg] = useState(null) const items: any[] = data?.items || [] + const saxoItems: any[] = saxoData?.items || [] const sellVol = items.filter(i => (i.iv_rank ?? 50) >= 80) const buyVol = items.filter(i => i.iv_rank != null && i.iv_rank < 20) @@ -505,6 +674,45 @@ export default function OptionsLab() {
)} + {/* ── Saxo section — deliberately separate from the yfinance watchlist above. + Symbols come from the Saxo watchlist (Config → Saxo), not from IV Watchlist + Manager below — everything here is computed purely from our own accumulated + saxo_option_snapshots, never blended with yfinance IV. ── */} +
+
+
+
+ Saxo Broker Data +
+

+ IV Rank · Term Structure · Skew, computed only from your Saxo watchlist's accumulated option snapshots — manage the symbol list in Config → Saxo. +

+
+ +
+ + {saxoLoading ? ( +
+ {[...Array(2)].map((_, i) =>
)} +
+ ) : saxoItems.length === 0 ? ( +
+ No Saxo IV data yet — add symbols to the Saxo watchlist (Config → Saxo) and let a few snapshot cycles accumulate. +
+ ) : ( +
+ {saxoItems.map(item => )} +
+ )} +
+
)