feat: cockpit
This commit is contained in:
@@ -225,3 +225,26 @@ def catalog(
|
|||||||
@router.get("/catalog/summary")
|
@router.get("/catalog/summary")
|
||||||
def catalog_summary():
|
def catalog_summary():
|
||||||
return get_saxo_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)
|
||||||
|
|||||||
@@ -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:
|
if not get_client() or not news:
|
||||||
return {
|
return {
|
||||||
"score": algo_score.get("score", 0), "level": algo_score.get("level", "low"),
|
"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": [],
|
"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
|
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')}).
|
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')}).
|
||||||
Repartition par categorie : {json.dumps(algo_score.get('breakdown', {}), ensure_ascii=False)}
|
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)}
|
{json.dumps(compact, ensure_ascii=False)}
|
||||||
|
|
||||||
Consignes :
|
Instructions:
|
||||||
- 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.
|
- 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.
|
||||||
- Un evenement de desescalade/resolution doit FAIRE BAISSER le score meme si son impact brut est eleve.
|
- A de-escalation/resolution event should LOWER the score even if its raw impact is high.
|
||||||
- Ne t'ancre pas mecaniquement sur le score algorithmique si le contexte reel (titres) justifie un score different.
|
- Don't mechanically anchor on the algorithmic score if the real context (headlines) justifies a different one.
|
||||||
- rationale : 2-3 phrases en francais expliquant precisement pourquoi ce score, en citant les evenements les plus determinants.
|
- rationale: 2-3 sentences in English precisely explaining why this score, citing the most determining events.
|
||||||
- top_risks : 3 a 5 items les plus determinants pour ce score (titres courts).
|
- 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": ["...", ...]}}"""
|
JSON: {{"score": <0-100 float>, "level": "low"|"medium"|"high"|"extreme", "rationale": "...", "top_risks": ["...", ...]}}"""
|
||||||
|
|
||||||
result = _chat(
|
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,
|
user, model="gpt-4o", json_mode=True, max_tokens=700, log_meta=log_meta,
|
||||||
)
|
)
|
||||||
if not result:
|
if not result:
|
||||||
return {
|
return {
|
||||||
"score": algo_score.get("score", 0), "level": algo_score.get("level", "low"),
|
"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": [],
|
"top_risks": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -109,7 +109,14 @@ def get_quote(symbol: str) -> Optional[Dict[str, Any]]:
|
|||||||
if hist.empty:
|
if hist.empty:
|
||||||
continue
|
continue
|
||||||
price = float(hist["Close"].iloc[-1])
|
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 = price - prev
|
||||||
change_pct = (change / prev * 100) if prev else 0
|
change_pct = (change / prev * 100) if prev else 0
|
||||||
return {
|
return {
|
||||||
@@ -447,13 +454,13 @@ MACRO_GAUGE_CONFIG = [
|
|||||||
|
|
||||||
SCENARIO_META = {
|
SCENARIO_META = {
|
||||||
"goldilocks": {"label": "Goldilocks", "color": "#10b981", "emoji": "🟢"},
|
"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": "🔷"},
|
"soft_landing": {"label": "Soft Landing", "color": "#06b6d4", "emoji": "🔷"},
|
||||||
"reflation": {"label": "Reflation", "color": "#f97316", "emoji": "🟠"},
|
"reflation": {"label": "Reflation", "color": "#f97316", "emoji": "🟠"},
|
||||||
"stagflation": {"label": "Stagflation", "color": "#f59e0b", "emoji": "🟡"},
|
"stagflation": {"label": "Stagflation", "color": "#f59e0b", "emoji": "🟡"},
|
||||||
"inflation_shock": {"label": "Choc Inflationniste", "color": "#dc2626", "emoji": "🔥"},
|
"inflation_shock": {"label": "Inflation Shock", "color": "#dc2626", "emoji": "🔥"},
|
||||||
"recession": {"label": "Récession", "color": "#ef4444", "emoji": "🔴"},
|
"recession": {"label": "Recession", "color": "#ef4444", "emoji": "🔴"},
|
||||||
"crise_liquidite": {"label": "Crise de liquidité", "color": "#7c3aed", "emoji": "🟣"},
|
"crise_liquidite": {"label": "Liquidity Crisis", "color": "#7c3aed", "emoji": "🟣"},
|
||||||
}
|
}
|
||||||
|
|
||||||
SCENARIO_ASSET_BIAS = {
|
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 < 18: s += 20; r.append("VIX<18")
|
||||||
elif vix < 22: s += 10
|
elif vix < 22: s += 10
|
||||||
if slope is not None:
|
if slope is not None:
|
||||||
if slope > 1.0: s += 20; r.append("Courbe +1%pt")
|
if slope > 1.0: s += 20; r.append("Curve +1%pt")
|
||||||
elif slope > 0.3: s += 10; r.append("Courbe légèrement positive")
|
elif slope > 0.3: s += 10; r.append("Curve slightly positive")
|
||||||
if gcr is not None:
|
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
|
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
|
elif hyg_c > 0: s += 5
|
||||||
if vs200 is not None:
|
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
|
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 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 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 tech_vs_staples > 0.5: s += 7; r.append("Tech > Defensives (sector risk-on)")
|
||||||
if eem_c > 0.3: s += 6; r.append("EM↑ (croissance globale)")
|
if eem_c > 0.3: s += 6; r.append("EM↑ (global growth)")
|
||||||
if usdjpy_c > 0.2: s += 4; r.append("JPY↓ (carry actif = risk-on)")
|
if usdjpy_c > 0.2: s += 4; r.append("JPY↓ (active carry = risk-on)")
|
||||||
scores["goldilocks"] = min(100, s); reasons["goldilocks"] = r
|
scores["goldilocks"] = min(100, s); reasons["goldilocks"] = r
|
||||||
|
|
||||||
# DÉSINFLATION / BAISSE DE TAUX
|
# DISINFLATION / RATE CUTS
|
||||||
s = 0; r = []
|
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
|
elif brent_c < 0: s += 10
|
||||||
if ng_c < -1.0: s += 10; r.append("Gaz↓")
|
if ng_c < -1.0: s += 10; r.append("Gas↓")
|
||||||
if ief_c > 0.2: s += 20; r.append("IEF↑ (taux longs baissent)")
|
if ief_c > 0.2: s += 20; r.append("IEF↑ (long rates falling)")
|
||||||
elif ief_c > 0: s += 10
|
elif ief_c > 0: s += 10
|
||||||
if vix < 20: s += 15; r.append("VIX<20")
|
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 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 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↑↑ (désinflation confirmée)")
|
if tlt_c > 0.5: s += 12; r.append("TLT↑↑ (disinflation confirmed)")
|
||||||
elif tlt_c > 0.2: s += 6; r.append("TLT↑ (bonds longs soutiennent)")
|
elif tlt_c > 0.2: s += 6; r.append("TLT↑ (long bonds supportive)")
|
||||||
if xlf_c > 0: s += 5; r.append("XLF↑ (anticipent baisse taux)")
|
if xlf_c > 0: s += 5; r.append("XLF↑ (pricing in rate cuts)")
|
||||||
scores["desinflation"] = min(100, s); reasons["desinflation"] = r
|
scores["desinflation"] = min(100, s); reasons["desinflation"] = r
|
||||||
|
|
||||||
# STAGFLATION
|
# STAGFLATION
|
||||||
s = 0; r = []
|
s = 0; r = []
|
||||||
if brent_c > 2.0: s += 30; r.append("Brent↑↑")
|
if brent_c > 2.0: s += 30; r.append("Brent↑↑")
|
||||||
elif brent_c > 0.5: s += 15; 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
|
elif ng_c > 0.5: s += 7
|
||||||
if slope is not None:
|
if slope is not None:
|
||||||
if slope < 0: s += 20; r.append("Courbe inversée")
|
if slope < 0: s += 20; r.append("Curve inverted")
|
||||||
elif slope < 0.3: s += 10; r.append("Courbe plate")
|
elif slope < 0.3: s += 10; r.append("Curve flat")
|
||||||
if gold_c > 0.5: s += 15; r.append("Or↑ (protection inflation)")
|
if gold_c > 0.5: s += 15; r.append("Gold↑ (inflation hedge)")
|
||||||
if copper_c < 0: s += 15; r.append("Cuivre↓ (demande faible)")
|
if copper_c < 0: s += 15; r.append("Copper↓ (weak demand)")
|
||||||
if vix > 18: s += 10; r.append("VIX élevé")
|
if vix > 18: s += 10; r.append("VIX elevated")
|
||||||
if xlp_c > xlk_c + 0.5: s += 8; r.append("Défensifs > Tech (rotation stagflationniste)")
|
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↑ (revenus stables)")
|
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} (tail risk croissant)")
|
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↓ (inflation persistante)")
|
if tlt_c < -0.3: s += 5; r.append("TLT↓ (persistent inflation)")
|
||||||
scores["stagflation"] = min(100, s); reasons["stagflation"] = r
|
scores["stagflation"] = min(100, s); reasons["stagflation"] = r
|
||||||
|
|
||||||
# RÉCESSION
|
# RECESSION
|
||||||
s = 0; r = []
|
s = 0; r = []
|
||||||
if slope is not None:
|
if slope is not None:
|
||||||
if slope < -0.5: s += 30; r.append("Courbe fortement inversée")
|
if slope < -0.5: s += 30; r.append("Curve deeply inverted")
|
||||||
elif slope < 0: s += 15; r.append("Courbe inversée")
|
elif slope < 0: s += 15; r.append("Curve inverted")
|
||||||
if gcr is not None:
|
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
|
elif gcr > 650: s += 10
|
||||||
if vix > 28: s += 25; r.append("VIX>28")
|
if vix > 28: s += 25; r.append("VIX>28")
|
||||||
elif vix > 22: s += 12
|
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
|
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
|
elif hyg_c < 0: s += 5
|
||||||
if gold_c > 0.3: s += 10; r.append("Or↑ (refuge)")
|
if gold_c > 0.3: s += 10; r.append("Gold↑ (safe haven)")
|
||||||
if tlt_c > 0.5: s += 15; r.append("TLT↑↑ (signal recessionnaire fort)")
|
if tlt_c > 0.5: s += 15; r.append("TLT↑↑ (strong recession signal)")
|
||||||
elif tlt_c > 0.2: s += 7; r.append("TLT↑ (obligations soutenues)")
|
elif tlt_c > 0.2: s += 7; r.append("TLT↑ (bonds supported)")
|
||||||
if xlf_c < -1.0: s += 12; r.append("Financières↓↓ (leading indicator récession)")
|
if xlf_c < -1.0: s += 12; r.append("Financials↓↓ (recession leading indicator)")
|
||||||
elif xlf_c < -0.3: s += 5
|
elif xlf_c < -0.3: s += 5
|
||||||
if eem_c < -1.0: s += 8; r.append("EM↓ (global slowdown)")
|
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
|
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
|
scores["recession"] = min(100, s); reasons["recession"] = r
|
||||||
|
|
||||||
# CRISE DE LIQUIDITÉ
|
# LIQUIDITY CRISIS
|
||||||
s = 0; r = []
|
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 > 28: s += 20; r.append("VIX>28")
|
||||||
elif vix > 22: s += 8
|
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
|
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 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
|
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 dxy_c > 1.0: s += 15; r.append("Dollar↑↑")
|
||||||
if ief_c > 0.5: s += 10; r.append("Obligations souveraines↑↑")
|
if ief_c > 0.5: s += 10; r.append("Sovereign bonds↑↑")
|
||||||
if skew_v > 145: s += 15; r.append(f"SKEW {skew_v:.0f} — tail risk extrême")
|
if skew_v > 145: s += 15; r.append(f"SKEW {skew_v:.0f} — extreme tail risk")
|
||||||
elif skew_v > 135: s += 8
|
elif skew_v > 135: s += 8
|
||||||
if vvix_v > 115: s += 15; r.append(f"VVIX {vvix_v:.0f} — vol-of-vol panique")
|
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} — vol élevée")
|
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 = panique globale)")
|
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)")
|
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
|
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
|
scores["crise_liquidite"] = min(100, s); reasons["crise_liquidite"] = r
|
||||||
|
|
||||||
# REFLATION
|
# REFLATION
|
||||||
s = 0; r = []
|
s = 0; r = []
|
||||||
if copper_c > 1.5: s += 25; r.append("Cuivre↑↑ (Dr Copper = croissance)")
|
if copper_c > 1.5: s += 25; r.append("Copper↑↑ (Dr Copper = growth)")
|
||||||
elif copper_c > 0.5: s += 12; r.append("Cuivre↑")
|
elif copper_c > 0.5: s += 12; r.append("Copper↑")
|
||||||
if xli_c > 0.8: s += 20; r.append("Industriels↑↑ (activité mfg forte)")
|
if xli_c > 0.8: s += 20; r.append("Industrials↑↑ (strong mfg activity)")
|
||||||
elif xli_c > 0.2: s += 10; r.append("Industriels↑")
|
elif xli_c > 0.2: s += 10; r.append("Industrials↑")
|
||||||
if brent_c > 1.5: s += 15; r.append("Brent↑ (reflation énergie)")
|
if brent_c > 1.5: s += 15; r.append("Brent↑ (energy reflation)")
|
||||||
elif brent_c > 0.3: s += 6
|
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
|
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
|
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
|
elif rel_perf > 0: s += 4
|
||||||
if vix < 18: s += 5
|
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)")
|
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 xlk_c > 1.0: s += 8; r.append("Tech↑↑ (growth+momentum)")
|
||||||
if silver_c > 1.5: s += 8; r.append("Argent↑↑ (reflation industrielle)")
|
if silver_c > 1.5: s += 8; r.append("Silver↑↑ (industrial reflation)")
|
||||||
if usdjpy_c > 0.5: s += 6; r.append("JPY↓ (carry trades actifs)")
|
if usdjpy_c > 0.5: s += 6; r.append("JPY↓ (active carry trades)")
|
||||||
scores["reflation"] = min(100, s); reasons["reflation"] = r
|
scores["reflation"] = min(100, s); reasons["reflation"] = r
|
||||||
|
|
||||||
# SOFT LANDING
|
# SOFT LANDING
|
||||||
s = 0; r = []
|
s = 0; r = []
|
||||||
if vs200 is not None and vs200 > 0: s += 20; r.append("S&P > MA200 (croissance intacte)")
|
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 légèrement ↓ (désinflation graduelle)")
|
if brent_c < -0.5 and brent_c > -3: s += 20; r.append("Brent slightly ↓ (gradual disinflation)")
|
||||||
elif brent_c < 0: s += 8
|
elif brent_c < 0: s += 8
|
||||||
if vix < 20: s += 15; r.append("VIX<20 (pas de stress)")
|
if vix < 20: s += 15; r.append("VIX<20 (no stress)")
|
||||||
if hyg_c > 0: s += 12; r.append("HYG↑ (crédit solide)")
|
if hyg_c > 0: s += 12; r.append("HYG↑ (solid credit)")
|
||||||
if lqd_c > 0: s += 8; r.append("IG↑ (spreads IG calmes)")
|
if lqd_c > 0: s += 8; r.append("IG↑ (calm IG spreads)")
|
||||||
if slope is not None and slope > 0: s += 10; r.append("Courbe non-inversée")
|
if slope is not None and slope > 0: s += 10; r.append("Curve not inverted")
|
||||||
if xli_c > 0: s += 8; r.append("Industriels positifs")
|
if xli_c > 0: s += 8; r.append("Industrials positive")
|
||||||
if copper_c > 0: s += 5; r.append("Cuivre stable")
|
if copper_c > 0: s += 5; r.append("Copper stable")
|
||||||
if ief_c > 0 and brent_c < 0: s += 7; r.append("Taux baissent + énergie recule")
|
if ief_c > 0 and brent_c < 0: s += 7; r.append("Rates falling + energy retreating")
|
||||||
if xlf_c > 0: s += 8; r.append("Financières↑ (économie saine)")
|
if xlf_c > 0: s += 8; r.append("Financials↑ (healthy economy)")
|
||||||
if eem_c > 0: s += 5; r.append("EM stable (croissance globale intacte)")
|
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} (tail risk non-extrême)")
|
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
|
scores["soft_landing"] = min(100, s); reasons["soft_landing"] = r
|
||||||
|
|
||||||
# CHOC INFLATIONNISTE
|
# INFLATION SHOCK
|
||||||
s = 0; r = []
|
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 > 2.0: s += 25; r.append("Brent↑↑")
|
||||||
elif brent_c > 0.8: s += 10
|
elif brent_c > 0.8: s += 10
|
||||||
if ng_c > 4.0: s += 20; r.append("Gaz↑↑↑ (choc supply gaz)")
|
if ng_c > 4.0: s += 20; r.append("Gas↑↑↑ (gas supply shock)")
|
||||||
elif ng_c > 2.0: s += 12; r.append("Gaz↑↑")
|
elif ng_c > 2.0: s += 12; r.append("Gas↑↑")
|
||||||
if gold_c > 1.0: s += 20; r.append("Or↑↑ (refuge inflation/géo)")
|
if gold_c > 1.0: s += 20; r.append("Gold↑↑ (inflation/geo hedge)")
|
||||||
elif gold_c > 0.3: s += 8; r.append("Or↑")
|
elif gold_c > 0.3: s += 8; r.append("Gold↑")
|
||||||
if vix > 22: s += 15; r.append("VIX↑ (stress montant)")
|
if vix > 22: s += 15; r.append("VIX↑ (rising stress)")
|
||||||
elif vix > 18: s += 5
|
elif vix > 18: s += 5
|
||||||
if copper_c < -0.5: s += 8; r.append("Cuivre↓ (demand destruction)")
|
if copper_c < -0.5: s += 8; r.append("Copper↓ (demand destruction)")
|
||||||
if ief_c < -0.2: s += 8; r.append("Trésor↓ (taux longs remontent)")
|
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} — vol pétrole extrême")
|
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} — vol pétrole élevée")
|
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} — vol or élevée")
|
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("Défensifs↑ (rotation anti-inflation)")
|
if xlp_c > 0.5: s += 6; r.append("Defensives↑ (anti-inflation rotation)")
|
||||||
if tlt_c < -0.5: s += 8; r.append("TLT↓↓ (anticipation inflation)")
|
if tlt_c < -0.5: s += 8; r.append("TLT↓↓ (inflation expectations)")
|
||||||
scores["inflation_shock"] = min(100, s); reasons["inflation_shock"] = r
|
scores["inflation_shock"] = min(100, s); reasons["inflation_shock"] = r
|
||||||
|
|
||||||
return scores, reasons
|
return scores, reasons
|
||||||
|
|||||||
@@ -6239,6 +6239,32 @@ def get_latest_saxo_snapshot_rows(symbol: str) -> List[Dict[str, Any]]:
|
|||||||
return [dict(r) for r in rows]
|
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]]:
|
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
|
"""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
|
before `as_of` (an ISO datetime string), or simply the freshest capture overall when
|
||||||
|
|||||||
@@ -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)
|
result["iv_call_25d"] = round(iv_call * 100, 1)
|
||||||
|
|
||||||
if put_skew > 0.03:
|
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:
|
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:
|
else:
|
||||||
result["interpretation"] = "Skew équilibré — pas de biais directionnel fort"
|
result["interpretation"] = "Balanced skew — no strong directional bias"
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"[Skew] {proxy}: {e}")
|
logger.debug(f"[Skew] {proxy}: {e}")
|
||||||
|
|||||||
@@ -1316,11 +1316,11 @@ _REGIME_SEVERITY = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_REGIME_LABELS = {
|
_REGIME_LABELS = {
|
||||||
"goldilocks": "Goldilocks", "desinflation": "Désinflation",
|
"goldilocks": "Goldilocks", "desinflation": "Disinflation",
|
||||||
"soft_landing": "Soft Landing", "reflation": "Reflation",
|
"soft_landing": "Soft Landing", "reflation": "Reflation",
|
||||||
"stagflation": "Stagflation", "inflation_shock": "Choc Inflationniste",
|
"stagflation": "Stagflation", "inflation_shock": "Inflation Shock",
|
||||||
"recession": "Récession", "crise_liquidite": "Crise de liquidité",
|
"recession": "Recession", "crise_liquidite": "Liquidity Crisis",
|
||||||
"incertain": "Incertain",
|
"incertain": "Uncertain",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
211
backend/services/saxo_iv_engine.py
Normal file
211
backend/services/saxo_iv_engine.py
Normal file
@@ -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)}
|
||||||
@@ -919,6 +919,31 @@ export const useIvForTrade = (underlying: string) =>
|
|||||||
staleTime: 60 * 60_000,
|
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 ───────────────────
|
// ── Analytics Phase 4 — Bayesian + Clustering + Embeddings ───────────────────
|
||||||
|
|
||||||
export const useBayesianPosteriors = () =>
|
export const useBayesianPosteriors = () =>
|
||||||
|
|||||||
@@ -122,16 +122,16 @@ function EcoEventRow({ ev, showActual }: { ev: any; showActual: boolean }) {
|
|||||||
<div className="flex items-center gap-1.5 text-[10px]">
|
<div className="flex items-center gap-1.5 text-[10px]">
|
||||||
<span className="shrink-0">{CURRENCY_FLAGS[ev.currency] ?? ev.currency}</span>
|
<span className="shrink-0">{CURRENCY_FLAGS[ev.currency] ?? ev.currency}</span>
|
||||||
<span className={clsx('w-1.5 h-1.5 rounded-full shrink-0', IMPACT_DOT[ev.impact] ?? 'bg-slate-400')} />
|
<span className={clsx('w-1.5 h-1.5 rounded-full shrink-0', IMPACT_DOT[ev.impact] ?? 'bg-slate-400')} />
|
||||||
<span className="text-white truncate flex-1 line-clamp-1">{ev.event_name}</span>
|
<span className="text-slate-200 truncate flex-1 line-clamp-1 max-w-[108px]" title={ev.event_name}>{ev.event_name}</span>
|
||||||
{showActual && ev.actual_value ? (
|
{showActual && ev.actual_value ? (
|
||||||
<span className="text-[9px] font-mono shrink-0">
|
<span className="text-[10px] font-mono shrink-0">
|
||||||
<span className="text-white font-semibold">{ev.actual_value}</span>
|
<span className="text-white font-semibold">{ev.actual_value}</span>
|
||||||
{ev.forecast_value && <span className="text-slate-600"> /{ev.forecast_value}</span>}
|
{ev.forecast_value && <span className="text-slate-400"> /{ev.forecast_value}</span>}
|
||||||
</span>
|
</span>
|
||||||
) : ev.forecast_value ? (
|
) : ev.forecast_value ? (
|
||||||
<span className="text-[9px] font-mono text-slate-600 shrink-0">F {ev.forecast_value}</span>
|
<span className="text-[10px] font-mono text-slate-400 shrink-0">F {ev.forecast_value}</span>
|
||||||
) : null}
|
) : null}
|
||||||
{ev.event_time && <span className="text-slate-700 text-[9px] shrink-0 font-mono">{ev.event_time}</span>}
|
{ev.event_time && <span className="text-slate-500 text-[9px] shrink-0 font-mono">{ev.event_time}</span>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -347,7 +347,7 @@ export default function Dashboard() {
|
|||||||
<div className="section-title flex items-center gap-1 mb-0">
|
<div className="section-title flex items-center gap-1 mb-0">
|
||||||
<Globe className="w-3 h-3" /> Geopolitical Risk
|
<Globe className="w-3 h-3" /> Geopolitical Risk
|
||||||
</div>
|
</div>
|
||||||
<Link to="/geo" className="flex items-center gap-0.5 text-[10px] text-slate-600 hover:text-slate-300 transition-colors">
|
<Link to="/geo" className="flex items-center gap-0.5 text-[10px] text-slate-400 hover:text-slate-300 transition-colors">
|
||||||
Geo news <ArrowUpRight className="w-2.5 h-2.5" />
|
Geo news <ArrowUpRight className="w-2.5 h-2.5" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -376,14 +376,16 @@ export default function Dashboard() {
|
|||||||
<div className={clsx('h-2 rounded-full', gauge.bg)} style={{ width: `${riskScore.score}%` }} />
|
<div className={clsx('h-2 rounded-full', gauge.bg)} style={{ width: `${riskScore.score}%` }} />
|
||||||
</div>
|
</div>
|
||||||
{riskScore.computed_at && (
|
{riskScore.computed_at && (
|
||||||
<div className="mt-1.5 text-[9px] text-slate-600 shrink-0">
|
<div className="mt-1.5 text-[9px] text-slate-400 shrink-0">
|
||||||
Score IA — figé au dernier cycle ({formatTimeShort(riskScore.computed_at)})
|
<span>AI Score — frozen at last cycle ({formatTimeShort(riskScore.computed_at)})</span>
|
||||||
{riskScore.rationale && <span className="text-slate-500"> · {riskScore.rationale}</span>}
|
{riskScore.rationale && (
|
||||||
|
<p className="text-slate-500 line-clamp-2 mt-0.5" title={riskScore.rationale}>{riskScore.rationale}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{topNews.length > 0 && (
|
{topNews.length > 0 && (
|
||||||
<div className="mt-2.5 pt-2 border-t border-slate-700/30 flex flex-col flex-1 min-h-0">
|
<div className="mt-2 pt-2 border-t border-slate-700/30 flex flex-col flex-1 min-h-0">
|
||||||
<div className="flex items-center gap-1 text-[9px] text-slate-600 mb-1 shrink-0">
|
<div className="flex items-center gap-1 text-[9px] text-slate-400 mb-1 shrink-0">
|
||||||
<Newspaper className="w-2.5 h-2.5" /> Top news
|
<Newspaper className="w-2.5 h-2.5" /> Top news
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5 overflow-y-auto flex-1 min-h-0">
|
<div className="space-y-1.5 overflow-y-auto flex-1 min-h-0">
|
||||||
@@ -410,7 +412,7 @@ export default function Dashboard() {
|
|||||||
<div ref={watchlistCardRef} className="card col-span-1">
|
<div ref={watchlistCardRef} className="card col-span-1">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="section-title mb-0">📡 Watchlist Radar</div>
|
<div className="section-title mb-0">📡 Watchlist Radar</div>
|
||||||
<Link to="/config" className="flex items-center gap-0.5 text-[10px] text-slate-600 hover:text-slate-300 transition-colors">
|
<Link to="/config" className="flex items-center gap-0.5 text-[10px] text-slate-400 hover:text-slate-300 transition-colors">
|
||||||
Manage <ArrowUpRight className="w-2.5 h-2.5" />
|
Manage <ArrowUpRight className="w-2.5 h-2.5" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -419,7 +421,7 @@ export default function Dashboard() {
|
|||||||
<ResponsiveContainer width="100%" height={130}>
|
<ResponsiveContainer width="100%" height={130}>
|
||||||
<RadarChart data={watchlistRadarData}>
|
<RadarChart data={watchlistRadarData}>
|
||||||
<PolarGrid stroke="#1e2d4d" />
|
<PolarGrid stroke="#1e2d4d" />
|
||||||
<PolarAngleAxis dataKey="subject" tick={{ fill: '#64748b', fontSize: 9 }} />
|
<PolarAngleAxis dataKey="subject" tick={{ fill: '#94a3b8', fontSize: 9 }} />
|
||||||
<Radar dataKey="ref" stroke="#334155" strokeDasharray="3 3" fill="transparent" isAnimationActive={false} />
|
<Radar dataKey="ref" stroke="#334155" strokeDasharray="3 3" fill="transparent" isAnimationActive={false} />
|
||||||
<Radar dataKey="value" stroke="#3b82f6" fill="#3b82f6" fillOpacity={0.25} />
|
<Radar dataKey="value" stroke="#3b82f6" fill="#3b82f6" fillOpacity={0.25} />
|
||||||
</RadarChart>
|
</RadarChart>
|
||||||
@@ -427,9 +429,9 @@ export default function Dashboard() {
|
|||||||
<div className="mt-1.5 pt-1.5 border-t border-slate-700/30 space-y-1">
|
<div className="mt-1.5 pt-1.5 border-t border-slate-700/30 space-y-1">
|
||||||
{((watchlistQuotesData as any)?.items ?? []).map((it: any) => (
|
{((watchlistQuotesData as any)?.items ?? []).map((it: any) => (
|
||||||
<div key={it.ticker} className="flex items-center justify-between text-[10px]">
|
<div key={it.ticker} className="flex items-center justify-between text-[10px]">
|
||||||
<span className="text-slate-400 font-mono">{it.ticker}</span>
|
<span className="text-slate-300 font-mono">{it.ticker}</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-slate-500 font-mono">{fmtPrice(it.price)}</span>
|
<span className="text-slate-400 font-mono">{fmtPrice(it.price)}</span>
|
||||||
<span className={clsx('font-mono font-bold w-12 text-right', (it.change_pct ?? 0) >= 0 ? 'text-emerald-400' : 'text-red-400')}>
|
<span className={clsx('font-mono font-bold w-12 text-right', (it.change_pct ?? 0) >= 0 ? 'text-emerald-400' : 'text-red-400')}>
|
||||||
{it.change_pct != null ? `${it.change_pct >= 0 ? '+' : ''}${it.change_pct.toFixed(2)}%` : '—'}
|
{it.change_pct != null ? `${it.change_pct >= 0 ? '+' : ''}${it.change_pct.toFixed(2)}%` : '—'}
|
||||||
</span>
|
</span>
|
||||||
@@ -499,7 +501,7 @@ export default function Dashboard() {
|
|||||||
style={row1Height ? { height: row1Height } : undefined}>
|
style={row1Height ? { height: row1Height } : undefined}>
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<span className="section-title mb-0 text-[10px]">🌐 Macro Regime</span>
|
<span className="section-title mb-0 text-[10px]">🌐 Macro Regime</span>
|
||||||
<ArrowUpRight className="w-3 h-3 text-slate-600" />
|
<ArrowUpRight className="w-3 h-3 text-slate-500" />
|
||||||
</div>
|
</div>
|
||||||
<div className={clsx('text-base font-bold mt-1', colorClass)}>
|
<div className={clsx('text-base font-bold mt-1', colorClass)}>
|
||||||
{macroInfo ? `${macroInfo.emoji} ${macroInfo.label}` : '—'}
|
{macroInfo ? `${macroInfo.emoji} ${macroInfo.label}` : '—'}
|
||||||
@@ -512,7 +514,7 @@ export default function Dashboard() {
|
|||||||
const isDom = key === dom
|
const isDom = key === dom
|
||||||
return (
|
return (
|
||||||
<div key={key} className="flex items-center gap-1.5">
|
<div key={key} className="flex items-center gap-1.5">
|
||||||
<span className={clsx('text-[9px] w-16 truncate', isDom ? 'text-slate-200 font-semibold' : 'text-slate-500')}>
|
<span className={clsx('text-[10px] w-16 truncate', isDom ? 'text-slate-200 font-semibold' : 'text-slate-400')}>
|
||||||
{REGIME_LABELS[key] ?? key}
|
{REGIME_LABELS[key] ?? key}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1 h-1.5 bg-dark-700 rounded-full overflow-hidden">
|
<div className="flex-1 h-1.5 bg-dark-700 rounded-full overflow-hidden">
|
||||||
@@ -521,7 +523,7 @@ export default function Dashboard() {
|
|||||||
style={{ width: `${pct}%` }}
|
style={{ width: `${pct}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className={clsx('text-[9px] font-mono w-5 text-right', isDom ? 'text-blue-400' : 'text-slate-600')}>
|
<span className={clsx('text-[10px] font-mono w-5 text-right', isDom ? 'text-blue-400' : 'text-slate-400')}>
|
||||||
{score}
|
{score}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -542,11 +544,11 @@ export default function Dashboard() {
|
|||||||
const h = hint(val, chg)
|
const h = hint(val, chg)
|
||||||
return (
|
return (
|
||||||
<div key={key} className="flex items-center gap-1.5">
|
<div key={key} className="flex items-center gap-1.5">
|
||||||
<span className="text-[9px] text-slate-600 w-16 shrink-0">{label}</span>
|
<span className="text-[10px] text-slate-400 w-16 shrink-0">{label}</span>
|
||||||
<span className={clsx('text-[9px] font-mono font-semibold shrink-0', c)}>
|
<span className={clsx('text-[10px] font-mono font-semibold shrink-0', c)}>
|
||||||
{fmt(val, chg)}
|
{fmt(val, chg)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[8px] text-slate-700 truncate">{h}</span>
|
<span className="text-[9px] text-slate-500 truncate">{h}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -575,7 +577,7 @@ export default function Dashboard() {
|
|||||||
{todayEvents.length > 0 && (
|
{todayEvents.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-1.5 text-[9px] font-semibold text-slate-300 bg-dark-700/50 rounded px-1.5 py-0.5 mb-1">
|
<div className="flex items-center gap-1.5 text-[9px] font-semibold text-slate-300 bg-dark-700/50 rounded px-1.5 py-0.5 mb-1">
|
||||||
Aujourd'hui <span className="badge-blue text-[8px] px-1 py-0">TODAY</span>
|
Today <span className="badge-blue text-[8px] px-1 py-0">TODAY</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 pl-0.5">
|
<div className="space-y-1 pl-0.5">
|
||||||
{todayEvents.map((ev: any, i: number) => <EcoEventRow key={i} ev={ev} showActual />)}
|
{todayEvents.map((ev: any, i: number) => <EcoEventRow key={i} ev={ev} showActual />)}
|
||||||
@@ -593,7 +595,7 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : <div className="text-slate-600 text-xs mt-2">No events this week</div>}
|
) : <div className="text-slate-500 text-xs mt-2">No events this week</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useIvWatchlist, useIvSnapshot, useIvHistory, useWatchlistTickers, useAddWatchlistTicker, useRemoveWatchlistTicker } from '../hooks/useApi'
|
import {
|
||||||
import { Activity, TrendingUp, TrendingDown, Minus, RefreshCw, ChevronDown, ChevronUp, Database, Plus, Trash2, List } from 'lucide-react'
|
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 { api } from '../hooks/useApi'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
@@ -176,7 +179,7 @@ function TickerDetail({ ticker }: { ticker: string }) {
|
|||||||
{' · '}{snap.history_days}d of history
|
{' · '}{snap.history_days}d of history
|
||||||
{snap.iv_change_1d_pct != null && (
|
{snap.iv_change_1d_pct != null && (
|
||||||
<>
|
<>
|
||||||
{' · '}vs veille{' '}
|
{' · '}vs prior day{' '}
|
||||||
<span className={snap.iv_change_1d_pct > 0 ? 'text-orange-400' : snap.iv_change_1d_pct < 0 ? 'text-blue-400' : 'text-slate-400'}>
|
<span className={snap.iv_change_1d_pct > 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
|
{snap.iv_change_1d_pct >= 0 ? '+' : ''}{snap.iv_change_1d_pct}pt
|
||||||
</span>
|
</span>
|
||||||
@@ -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 <div className="h-20 mt-3 animate-pulse bg-dark-700 rounded" />
|
||||||
|
if (!snap) return null
|
||||||
|
|
||||||
|
const ts = (snap.term_structure || {}) as any
|
||||||
|
const skew = (snap.skew || {}) as any
|
||||||
|
const history: any[] = histData?.history || []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 border-t border-slate-700/30 pt-3 grid grid-cols-2 gap-4">
|
||||||
|
{/* Term Structure */}
|
||||||
|
<div>
|
||||||
|
<div className="text-[9px] text-slate-500 uppercase tracking-wide font-semibold mb-2">Term Structure (Saxo)</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{([['30d', ts.iv_30d], ['60d', ts.iv_60d], ['90d', ts.iv_90d], ['180d', ts.iv_180d]] as [string, number | undefined][]).map(([label, iv]) =>
|
||||||
|
iv ? (
|
||||||
|
<div key={label} className="flex items-center gap-2">
|
||||||
|
<span className="text-[9px] text-slate-500 w-7 shrink-0">{label}</span>
|
||||||
|
<div className="flex-1 h-1 bg-slate-800 rounded-full overflow-hidden">
|
||||||
|
<div className="h-full bg-blue-500/50 rounded-full" style={{ width: `${Math.min(iv * 300, 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-[9px] text-slate-300 font-mono w-9 text-right">{(iv * 100).toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
)}
|
||||||
|
{ts.structure && <div className="mt-1"><StructureBadge structure={ts.structure} /></div>}
|
||||||
|
{!ts.iv_30d && !ts.iv_60d && !ts.iv_90d && !ts.iv_180d && (
|
||||||
|
<div className="text-[9px] text-slate-500 italic">Not enough expiries captured yet</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Skew */}
|
||||||
|
<div>
|
||||||
|
<div className="text-[9px] text-slate-500 uppercase tracking-wide font-semibold mb-2">Skew Put/Call (Saxo)</div>
|
||||||
|
{skew.put_skew != null ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between text-[9px]">
|
||||||
|
<span className="text-slate-500">Put 25Δ</span>
|
||||||
|
<span className="text-slate-300 font-mono">{skew.iv_put_25d}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-[9px]">
|
||||||
|
<span className="text-slate-500">Call 25Δ</span>
|
||||||
|
<span className="text-slate-300 font-mono">{skew.iv_call_25d}%</span>
|
||||||
|
</div>
|
||||||
|
<div className={clsx('text-[10px] font-bold mt-1',
|
||||||
|
skew.skew_pct > 3 ? 'text-red-400' : skew.skew_pct < -2 ? 'text-blue-400' : 'text-slate-400')}>
|
||||||
|
{skew.skew_pct > 0 ? '+' : ''}{skew.skew_pct} pts
|
||||||
|
</div>
|
||||||
|
{skew.interpretation && (
|
||||||
|
<div className="text-[9px] text-slate-500 italic leading-tight">{skew.interpretation}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-[9px] text-slate-500 italic">Insufficient data</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* History sparkline */}
|
||||||
|
{history.length > 4 && (
|
||||||
|
<div className="col-span-2 flex items-center gap-3 pt-1 border-t border-slate-700/20">
|
||||||
|
<span className="text-[9px] text-slate-500 shrink-0">IV 90d</span>
|
||||||
|
<IvSparkline history={history} />
|
||||||
|
<div className="text-[9px] text-slate-500">
|
||||||
|
min <span className="text-slate-400">{snap.iv_min_52w_pct}%</span>
|
||||||
|
{' · '}max <span className="text-slate-400">{snap.iv_max_52w_pct}%</span>
|
||||||
|
{' · '}{snap.history_days}d of Saxo history
|
||||||
|
{snap.iv_change_1d_pct != null && (
|
||||||
|
<>
|
||||||
|
{' · '}vs prior day{' '}
|
||||||
|
<span className={snap.iv_change_1d_pct > 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
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{history.length <= 4 && (
|
||||||
|
<div className="col-span-2 text-[9px] text-slate-500 italic pt-1 border-t border-slate-700/20">
|
||||||
|
Not enough accumulated days yet for a Rank/Percentile — keep the Saxo watchlist snapshotting and it fills in on its own.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 (
|
||||||
|
<div className={clsx('rounded-lg border transition-all', ivRankBg(item.iv_rank))}>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-3 px-3 py-2.5 cursor-pointer"
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
>
|
||||||
|
<div className="w-14 shrink-0">
|
||||||
|
<div className="text-sm font-bold text-slate-200">{item.ticker}</div>
|
||||||
|
<div className="text-[8px] text-slate-500 flex items-center gap-0.5"><Link2 className="w-2 h-2" /> Saxo</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-16 shrink-0">
|
||||||
|
<span className={clsx('text-sm font-bold font-mono', ivRankColor(item.iv_rank))}>
|
||||||
|
{item.iv_current_pct != null ? `${item.iv_current_pct}%` : '—'}
|
||||||
|
</span>
|
||||||
|
{item.iv_change_1d_pct != null && Math.abs(item.iv_change_1d_pct) >= 0.1 && (
|
||||||
|
<span className={clsx('ml-1 text-[9px] font-mono', item.iv_change_1d_pct > 0 ? 'text-orange-400' : 'text-blue-400')}>
|
||||||
|
{item.iv_change_1d_pct >= 0 ? '+' : ''}{item.iv_change_1d_pct.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="text-[8px] text-slate-500">Current IV</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0 space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[9px] text-slate-500">IV Rank</span>
|
||||||
|
<span className={clsx('text-[10px] font-bold font-mono', ivRankColor(item.iv_rank))}>
|
||||||
|
{item.iv_rank != null ? `${item.iv_rank}%` : 'N/A'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<IvBar rank={item.iv_rank} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-16 text-right shrink-0">
|
||||||
|
<div className="text-[8px] text-slate-500">Pctile</div>
|
||||||
|
<div className={clsx('text-[10px] font-mono font-bold', ivRankColor(item.iv_rank))}>
|
||||||
|
{item.iv_percentile != null ? `${item.iv_percentile}%` : '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-32 text-right shrink-0">
|
||||||
|
{signal && (
|
||||||
|
<div className={clsx('flex items-center justify-end gap-1 text-[10px] font-semibold', signal.cls)}>
|
||||||
|
{signal.icon} {signal.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.history_days > 0 && (
|
||||||
|
<div className="text-[8px] text-slate-500">{item.history_days}d Saxo history</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-slate-500">
|
||||||
|
{expanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className="px-3 pb-3">
|
||||||
|
<SaxoTickerDetail ticker={item.ticker} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Watchlist Manager ─────────────────────────────────────────────────────────
|
// ── Watchlist Manager ─────────────────────────────────────────────────────────
|
||||||
function WatchlistManager() {
|
function WatchlistManager() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
@@ -364,9 +531,11 @@ function WatchlistManager() {
|
|||||||
// ── Main page ─────────────────────────────────────────────────────────────────
|
// ── Main page ─────────────────────────────────────────────────────────────────
|
||||||
export default function OptionsLab() {
|
export default function OptionsLab() {
|
||||||
const { data, isLoading, refetch, isFetching } = useIvWatchlist()
|
const { data, isLoading, refetch, isFetching } = useIvWatchlist()
|
||||||
|
const { data: saxoData, isLoading: saxoLoading, refetch: refetchSaxo, isFetching: saxoFetching } = useSaxoIvWatchlist()
|
||||||
const [bootstrapping, setBootstrapping] = useState(false)
|
const [bootstrapping, setBootstrapping] = useState(false)
|
||||||
const [bootstrapMsg, setBootstrapMsg] = useState<string | null>(null)
|
const [bootstrapMsg, setBootstrapMsg] = useState<string | null>(null)
|
||||||
const items: any[] = data?.items || []
|
const items: any[] = data?.items || []
|
||||||
|
const saxoItems: any[] = saxoData?.items || []
|
||||||
|
|
||||||
const sellVol = items.filter(i => (i.iv_rank ?? 50) >= 80)
|
const sellVol = items.filter(i => (i.iv_rank ?? 50) >= 80)
|
||||||
const buyVol = items.filter(i => i.iv_rank != null && i.iv_rank < 20)
|
const buyVol = items.filter(i => i.iv_rank != null && i.iv_rank < 20)
|
||||||
@@ -505,6 +674,45 @@ export default function OptionsLab() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── 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. ── */}
|
||||||
|
<div className="pt-2 border-t border-slate-700/40">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-bold text-white flex items-center gap-2">
|
||||||
|
<Link2 className="w-4 h-4 text-emerald-400" /> Saxo Broker Data
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-slate-500 mt-0.5">
|
||||||
|
IV Rank · Term Structure · Skew, computed only from your Saxo watchlist's accumulated option snapshots — manage the symbol list in Config → Saxo.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => refetchSaxo()}
|
||||||
|
disabled={saxoFetching}
|
||||||
|
className="flex items-center gap-1.5 text-xs border border-slate-600 text-slate-400 hover:text-slate-200 hover:border-slate-500 px-3 py-1.5 rounded transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className={clsx('w-3.5 h-3.5', saxoFetching && 'animate-spin')} />
|
||||||
|
{saxoFetching ? 'Loading...' : 'Refresh'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{saxoLoading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[...Array(2)].map((_, i) => <div key={i} className="h-12 animate-pulse bg-dark-700 rounded-lg" />)}
|
||||||
|
</div>
|
||||||
|
) : saxoItems.length === 0 ? (
|
||||||
|
<div className="card text-center py-8 text-slate-500 text-xs">
|
||||||
|
No Saxo IV data yet — add symbols to the Saxo watchlist (Config → Saxo) and let a few snapshot cycles accumulate.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{saxoItems.map(item => <SaxoWatchlistRow key={item.ticker} item={item} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<WatchlistManager />
|
<WatchlistManager />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user