feat: curve regime
This commit is contained in:
@@ -100,6 +100,29 @@ def wavelet_cache(instrument_id: str) -> Dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{instrument_id}/curve-regime")
|
||||||
|
async def curve_regime(
|
||||||
|
instrument_id: str,
|
||||||
|
period: str = Query(default="1y"),
|
||||||
|
interval: str = Query(default="1d"),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Instrument-level regime classification (Direction/Dynamique/Volatilité/Structure IV,
|
||||||
|
matched against the 15-regime reference table) — distinct from the global macro regime
|
||||||
|
already shown elsewhere. See services.curve_regime module docstring for the full
|
||||||
|
methodology and its data-availability caveats (no cross-asset correlation source,
|
||||||
|
structure_iv only available when this instrument's options chain is Saxo-linked)."""
|
||||||
|
config = get_instrument(instrument_id)
|
||||||
|
if not config:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Instrument '{instrument_id}' not found")
|
||||||
|
|
||||||
|
snapshot = await get_snapshot(instrument_id, period=period, interval=interval)
|
||||||
|
if "error" in snapshot:
|
||||||
|
raise HTTPException(status_code=500, detail=snapshot["error"])
|
||||||
|
|
||||||
|
from services.curve_regime import classify_instrument_regime
|
||||||
|
return classify_instrument_regime(instrument_id, snapshot)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{instrument_id}/narrative")
|
@router.post("/{instrument_id}/narrative")
|
||||||
async def generate_narrative(
|
async def generate_narrative(
|
||||||
instrument_id: str,
|
instrument_id: str,
|
||||||
|
|||||||
397
backend/services/curve_regime.py
Normal file
397
backend/services/curve_regime.py
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
"""
|
||||||
|
Instrument-level "Curve Regime" — distinct from the global macro regime
|
||||||
|
(services.data_fetcher.score_macro_scenarios / snapshot.macro_regime, one dominant
|
||||||
|
scenario for the whole market). This classifies a SINGLE instrument's current price/vol
|
||||||
|
state into one of 15 canonical regimes (spec below, provided by the user as an analyst
|
||||||
|
reference table), combining:
|
||||||
|
|
||||||
|
- direction / dynamique: from the instrument's wavelet decomposition — the SAME cache
|
||||||
|
services.wavelet_signals writes and the Wavelet tab reads (services.database.
|
||||||
|
get_wavelet_decomposition_cache), so this tab can never disagree with the Wavelet tab
|
||||||
|
it sits next to — plus the trend/regime signals instrument_service already computes
|
||||||
|
(ATR ratio, momentum, MA slopes) for confirmation.
|
||||||
|
- volatilite: realized-vol level (ATR vs its own 3-month average) + the direction that
|
||||||
|
level has been moving in over the last ~10 sessions (rising/falling fast).
|
||||||
|
- structure_iv / liquidite: Saxo options IV/skew/term-structure (services.saxo_iv_engine),
|
||||||
|
only when this instrument has a saxo_option_symbol linked (Config -> Instruments
|
||||||
|
Watchlist -> "Option") — otherwise both axes are "n/a" and excluded from scoring
|
||||||
|
rather than guessed at. Only a single 25-delta skew point + term-structure shape exist
|
||||||
|
today (see saxo_iv_engine.py), not a full smile/butterfly curve — so structure_iv only
|
||||||
|
distinguishes flat / skew_put / skew_put_fort / degraded, not the finer smile/BF/RR
|
||||||
|
categories some regime rows reference; those regimes fall back to their other axes.
|
||||||
|
- facteur_dominant: the global macro regime's asset-class bias (already computed per
|
||||||
|
snapshot) — shown alongside the match for context, NOT used as a hard scoring
|
||||||
|
criterion, since instrument category -> macro asset-class is an approximate mapping.
|
||||||
|
|
||||||
|
Cross-asset correlation (the spec table's "Corrélation cross-actifs" column) has no
|
||||||
|
existing data source anywhere in this codebase (checked: only a portfolio-scoped pairwise
|
||||||
|
correlation exists, for the Risk radar) — always "n/a" here, not scored. A real version
|
||||||
|
would need a new rolling-correlation-vs-benchmark fetch; flagged as a known gap rather than
|
||||||
|
approximated.
|
||||||
|
|
||||||
|
This is a transparent rule-based scorer, not a black box: classify_instrument_regime()
|
||||||
|
returns every candidate regime's per-axis score breakdown, not just the winner, so a wrong
|
||||||
|
call is inspectable and the thresholds/weights can be tuned without re-deriving the whole
|
||||||
|
design.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ── Regime reference table (digitized from the user-provided spec) ────────────────────
|
||||||
|
# Each entry's axis values are what THAT regime expects to see. `None` means "this axis
|
||||||
|
# doesn't discriminate this regime" (not scored against it). volatilite accepts either a
|
||||||
|
# level bucket (faible/moyenne/elevee/tres_elevee/explosive) or a trend word
|
||||||
|
# (hausse/baisse_rapide) — matched against the corresponding computed signal, see
|
||||||
|
# _score_regime() below.
|
||||||
|
REGIME_SPECS: List[Dict[str, Any]] = [
|
||||||
|
{"key": "bull_trend", "label": "Bull Trend",
|
||||||
|
"direction": "up", "dynamique": "progressive", "volatilite": "faible", "structure_iv": "flat",
|
||||||
|
"facteur_dominant": "Croissance / BCE",
|
||||||
|
"declencheurs": "BCE plus hawkish que Fed, croissance Europe",
|
||||||
|
"strategies": "Bull Call Spread, Bull Put Spread", "exemple": "+120 pips, IV stable → +60 à 90%"},
|
||||||
|
{"key": "bear_trend", "label": "Bear Trend",
|
||||||
|
"direction": "down", "dynamique": "progressive", "volatilite": "faible", "structure_iv": "skew_put",
|
||||||
|
"facteur_dominant": "USD / Diff. de taux",
|
||||||
|
"declencheurs": "Fed hawkish, différentiel de taux",
|
||||||
|
"strategies": "Bear Put Spread", "exemple": "-120 pips → +60 à 90%"},
|
||||||
|
{"key": "bull_breakout", "label": "Bull Breakout",
|
||||||
|
"direction": "up", "dynamique": "explosive", "volatilite": "elevee", "structure_iv": "smile",
|
||||||
|
"facteur_dominant": "Événement",
|
||||||
|
"declencheurs": "BCE surprise, cassure technique",
|
||||||
|
"strategies": "Long Call, Call Backspread", "exemple": "+150 à 250%"},
|
||||||
|
{"key": "bear_breakout", "label": "Bear Breakout",
|
||||||
|
"direction": "down", "dynamique": "explosive", "volatilite": "elevee", "structure_iv": "skew_put_fort",
|
||||||
|
"facteur_dominant": "Événement",
|
||||||
|
"declencheurs": "Fed surprise, cassure, crise localisée",
|
||||||
|
"strategies": "Long Put, Put Backspread", "exemple": "+150 à 250%"},
|
||||||
|
{"key": "compression", "label": "Compression",
|
||||||
|
"direction": "flat", "dynamique": "progressive", "volatilite": "faible", "structure_iv": "flat",
|
||||||
|
"facteur_dominant": "Attente",
|
||||||
|
"declencheurs": "Vacances, avant FOMC/BCE",
|
||||||
|
"strategies": "Calendar, Gamma positif", "exemple": "Préparer une cassure"},
|
||||||
|
{"key": "iv_crush", "label": "IV Crush",
|
||||||
|
"direction": "flat", "dynamique": "progressive", "volatilite": "tres_elevee", "structure_iv": "flat",
|
||||||
|
"facteur_dominant": "Décroissance du risque",
|
||||||
|
"declencheurs": "Après BCE/Fed/NFP sans surprise",
|
||||||
|
"strategies": "Iron Condor, Butterfly", "exemple": "+30 à 70%"},
|
||||||
|
{"key": "whipsaw", "label": "Whipsaw",
|
||||||
|
"direction": "flat", "dynamique": "chaotique", "volatilite": "elevee", "structure_iv": "smile",
|
||||||
|
"facteur_dominant": "Flux contradictoires",
|
||||||
|
"declencheurs": "Faux breakouts, marché sans conviction",
|
||||||
|
"strategies": "Petite taille, Gamma contrôlé", "exemple": "Peu de stratégies robustes"},
|
||||||
|
{"key": "stress_prolonge", "label": "Stress prolongé",
|
||||||
|
"direction": "mixed", "dynamique": "progressive", "volatilite": "elevee", "structure_iv": "skew_put",
|
||||||
|
"facteur_dominant": "Inflation / Taux / EM",
|
||||||
|
"declencheurs": "Brexit, crise EM, inflation durable",
|
||||||
|
"strategies": "Calendar directionnel, Diagonal", "exemple": "+40 à 80%"},
|
||||||
|
{"key": "explosion_incertitude", "label": "Explosion d'incertitude",
|
||||||
|
"direction": "mixed", "dynamique": "explosive", "volatilite": "explosive", "structure_iv": "skew_put_fort",
|
||||||
|
"facteur_dominant": "Géopolitique / Banque centrale",
|
||||||
|
"declencheurs": "Guerre, surprise extrême, banques",
|
||||||
|
"strategies": "Long Straddle, Reverse Iron Condor", "exemple": "IV 5→8 : +50 à 120%"},
|
||||||
|
{"key": "flash_crash", "label": "Flash Crash / Crise de liquidité",
|
||||||
|
"direction": "mixed", "dynamique": "explosive", "volatilite": "explosive", "structure_iv": "degradee",
|
||||||
|
"facteur_dominant": "Déleveraging",
|
||||||
|
"declencheurs": "Vente forcée, défaut, crise systémique",
|
||||||
|
"strategies": "Hedges existants uniquement", "exemple": "Priorité = survie et exécution"},
|
||||||
|
{"key": "retour_normale", "label": "Retour à la normale",
|
||||||
|
"direction": "flat", "dynamique": "progressive", "volatilite": "baisse_rapide", "structure_iv": "flat",
|
||||||
|
"facteur_dominant": "Dissipation du risque",
|
||||||
|
"declencheurs": "Cessez-le-feu, marché rassuré",
|
||||||
|
"strategies": "Vente Vega", "exemple": "+40 à 80%"},
|
||||||
|
{"key": "transition_regime", "label": "Transition de régime",
|
||||||
|
"direction": "mixed", "dynamique": "accelere", "volatilite": "hausse", "structure_iv": "skew_put",
|
||||||
|
"facteur_dominant": "Changement macro",
|
||||||
|
"declencheurs": "Brent, COT, Fed/BCE changent progressivement",
|
||||||
|
"strategies": "Calendar, Diagonal, structures hybrides", "exemple": "+40 à 90%"},
|
||||||
|
{"key": "risk_on", "label": "Risk-On",
|
||||||
|
"direction": "up", "dynamique": "progressive", "volatilite": "faible", "structure_iv": "flat",
|
||||||
|
"facteur_dominant": "Sentiment",
|
||||||
|
"declencheurs": "QE, détente géopolitique",
|
||||||
|
"strategies": "Bull Spread", "exemple": "+50 à 80%"},
|
||||||
|
{"key": "risk_off", "label": "Risk-Off",
|
||||||
|
"direction": "down", "dynamique": "accelere", "volatilite": "elevee", "structure_iv": "skew_put",
|
||||||
|
"facteur_dominant": "Sentiment",
|
||||||
|
"declencheurs": "Flight to Quality",
|
||||||
|
"strategies": "Bear Put, Long Put", "exemple": "+70 à 150%"},
|
||||||
|
{"key": "dispersion", "label": "Dispersion / Décorrélation",
|
||||||
|
"direction": "mixed", "dynamique": "variable", "volatilite": "moyenne", "structure_iv": None,
|
||||||
|
"facteur_dominant": "Rotation sectorielle",
|
||||||
|
"declencheurs": "Corrélations qui cassent",
|
||||||
|
"strategies": "Paniers, dispersion, relative value",
|
||||||
|
"exemple": "Plus adapté au multi-actifs qu'au single instrument"},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Per-axis scoring weight — direction/dynamique/volatilite are the axes we can compute with
|
||||||
|
# real confidence (wavelets + price data, always available); structure_iv only weighs in
|
||||||
|
# when options data exists, so it's naturally excluded (not zero-penalized) otherwise.
|
||||||
|
_AXIS_WEIGHTS = {"direction": 3.0, "dynamique": 2.5, "volatilite": 2.0, "structure_iv": 1.5}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Axis 1+2: direction & dynamique, from the wavelet cache + trend/regime signals ─────
|
||||||
|
|
||||||
|
def _wavelet_band_slopes(decomposition: Optional[Dict]) -> List[float]:
|
||||||
|
"""Last-point slope of each band's series (today vs yesterday) — same math as
|
||||||
|
services.wavelet_signals._compute_slope, applied directly to whatever's already in the
|
||||||
|
cached decomposition rather than recomputing anything."""
|
||||||
|
if not decomposition:
|
||||||
|
return []
|
||||||
|
bands = decomposition.get("bands") or []
|
||||||
|
slopes = []
|
||||||
|
for band in bands:
|
||||||
|
series = band.get("series") or []
|
||||||
|
if len(series) >= 2 and series[-1] is not None and series[-2] is not None:
|
||||||
|
slopes.append(float(series[-1]) - float(series[-2]))
|
||||||
|
return slopes
|
||||||
|
|
||||||
|
|
||||||
|
def _axis_direction(trend: Dict, regime_signals: Dict, wavelet_slopes: List[float]) -> str:
|
||||||
|
"""'up'/'down' on a clear, consistent trend; 'flat' when momentum is negligible;
|
||||||
|
'mixed' when the fast (wavelet) and slow (momentum/MA) signals disagree — that
|
||||||
|
disagreement IS the "?"/"↑ ou ↓" state several regimes in the spec are built around
|
||||||
|
(stress prolongé, explosion d'incertitude, transition, flash crash)."""
|
||||||
|
mom = trend.get("momentum_1m_pct")
|
||||||
|
ma_above = regime_signals.get("ma50_above_ma200")
|
||||||
|
if mom is None:
|
||||||
|
return "flat"
|
||||||
|
slow_dir = "up" if mom > 1.0 else "down" if mom < -1.0 else "flat"
|
||||||
|
if wavelet_slopes:
|
||||||
|
up_count = sum(1 for s in wavelet_slopes if s > 0)
|
||||||
|
down_count = sum(1 for s in wavelet_slopes if s < 0)
|
||||||
|
fast_dir = "up" if up_count > down_count else "down" if down_count > up_count else "flat"
|
||||||
|
if slow_dir != "flat" and fast_dir != "flat" and slow_dir != fast_dir:
|
||||||
|
return "mixed"
|
||||||
|
if slow_dir == "flat":
|
||||||
|
return "flat"
|
||||||
|
if ma_above is not None:
|
||||||
|
# MA50/MA200 disagreeing with recent momentum direction = the trend is stalling/
|
||||||
|
# reversing under the surface, not a clean trend — treat as mixed too.
|
||||||
|
ma_dir = "up" if ma_above else "down"
|
||||||
|
if ma_dir != slow_dir:
|
||||||
|
return "mixed"
|
||||||
|
return slow_dir
|
||||||
|
|
||||||
|
|
||||||
|
def _axis_dynamique(trend: Dict, regime_signals: Dict, wavelet_slopes: List[float]) -> str:
|
||||||
|
"""progressive (steady) / explosive (sharp acceleration) / chaotique (bands
|
||||||
|
disagreeing on direction) / accelere (momentum building) / variable (bands agree on
|
||||||
|
neither direction nor magnitude — dispersion-like)."""
|
||||||
|
vol_ratio = regime_signals.get("vol_ratio_pct")
|
||||||
|
mom_1m = trend.get("momentum_1m_pct")
|
||||||
|
mom_3m = trend.get("momentum_3m_pct")
|
||||||
|
|
||||||
|
if wavelet_slopes and len(wavelet_slopes) >= 2:
|
||||||
|
signs = [1 if s > 0 else -1 if s < 0 else 0 for s in wavelet_slopes]
|
||||||
|
disagreement = len(set(signs)) > 1 and 0 not in signs # genuine split, not just noise near zero
|
||||||
|
else:
|
||||||
|
disagreement = False
|
||||||
|
|
||||||
|
if vol_ratio is not None and vol_ratio > 180:
|
||||||
|
return "explosive"
|
||||||
|
if disagreement and vol_ratio is not None and vol_ratio > 110:
|
||||||
|
return "chaotique"
|
||||||
|
if disagreement:
|
||||||
|
return "variable"
|
||||||
|
if mom_1m is not None and mom_3m is not None and mom_3m != 0:
|
||||||
|
# This month's pace vs the trailing 3-month pace — accelerating if 1m alone is
|
||||||
|
# already running hotter than the average of the last 3.
|
||||||
|
monthly_avg_3m = mom_3m / 3
|
||||||
|
if abs(mom_1m) > abs(monthly_avg_3m) * 1.8 and abs(mom_1m) > 3:
|
||||||
|
return "accelere"
|
||||||
|
return "progressive"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Axis 3: volatilite level + trend, from ATR ratio + the realized-vol series ─────────
|
||||||
|
|
||||||
|
def _axis_volatilite(trend: Dict, regime_signals: Dict, vol_series: List[Dict]) -> Dict[str, Optional[str]]:
|
||||||
|
"""Returns {"level": ..., "trend": ...} — level is the primary bucket regimes are
|
||||||
|
matched against; trend (hausse/baisse_rapide/stable) only matters for the couple of
|
||||||
|
regimes defined by a MOVE in vol rather than its absolute level (Retour à la normale,
|
||||||
|
Transition de régime)."""
|
||||||
|
vol_ratio = regime_signals.get("vol_ratio_pct") # ATR14 / its own 3-month average, %
|
||||||
|
level = None
|
||||||
|
if vol_ratio is not None:
|
||||||
|
if vol_ratio > 200:
|
||||||
|
level = "explosive"
|
||||||
|
elif vol_ratio > 160:
|
||||||
|
level = "tres_elevee"
|
||||||
|
elif vol_ratio > 115:
|
||||||
|
level = "elevee"
|
||||||
|
elif vol_ratio >= 85:
|
||||||
|
level = "moyenne"
|
||||||
|
else:
|
||||||
|
level = "faible"
|
||||||
|
|
||||||
|
trend_word = None
|
||||||
|
values = [p.get("value") for p in (vol_series or []) if p.get("value") is not None]
|
||||||
|
if len(values) >= 10:
|
||||||
|
recent = values[-1]
|
||||||
|
baseline = sum(values[-10:-3]) / len(values[-10:-3]) if len(values[-10:-3]) else None
|
||||||
|
if baseline and baseline > 0:
|
||||||
|
change_pct = (recent - baseline) / baseline * 100
|
||||||
|
if change_pct <= -25:
|
||||||
|
trend_word = "baisse_rapide"
|
||||||
|
elif change_pct >= 30:
|
||||||
|
trend_word = "hausse"
|
||||||
|
else:
|
||||||
|
trend_word = "stable"
|
||||||
|
return {"level": level, "trend": trend_word}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Axis 4+5: structure_iv / liquidite, from Saxo options data when linked ─────────────
|
||||||
|
|
||||||
|
def _axis_options(watchlist_row: Optional[Dict]) -> Dict[str, Any]:
|
||||||
|
"""None when this instrument has no saxo_option_symbol link — the caller excludes
|
||||||
|
structure_iv from scoring entirely in that case rather than defaulting to 'flat'."""
|
||||||
|
if not watchlist_row or not watchlist_row.get("saxo_option_symbol"):
|
||||||
|
return {"structure_iv": None, "liquidite": None, "iv_snapshot": None}
|
||||||
|
try:
|
||||||
|
from services.saxo_iv_engine import get_saxo_iv_snapshot
|
||||||
|
snap = get_saxo_iv_snapshot(watchlist_row["saxo_option_symbol"])
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[curve_regime] IV snapshot failed for '{watchlist_row.get('saxo_option_symbol')}': {e}")
|
||||||
|
return {"structure_iv": None, "liquidite": None, "iv_snapshot": None}
|
||||||
|
|
||||||
|
if snap.get("iv_current_pct") is None:
|
||||||
|
# No usable Saxo option data at all (empty/stale chain) — this itself is the
|
||||||
|
# "surface cassée" signal, not just an absent one.
|
||||||
|
return {"structure_iv": "degradee", "liquidite": "degradee", "iv_snapshot": snap}
|
||||||
|
|
||||||
|
skew_pct = (snap.get("skew") or {}).get("skew_pct")
|
||||||
|
structure_iv = "flat"
|
||||||
|
if skew_pct is not None:
|
||||||
|
if skew_pct > 4:
|
||||||
|
structure_iv = "skew_put_fort"
|
||||||
|
elif skew_pct > 1.2:
|
||||||
|
structure_iv = "skew_put"
|
||||||
|
elif skew_pct < -1.2:
|
||||||
|
structure_iv = "smile" # calls bid up relative to puts — treated as the closest bucket we can score
|
||||||
|
|
||||||
|
liquidite = "faible" if (snap.get("history_days") or 0) < 5 else "normal"
|
||||||
|
|
||||||
|
return {"structure_iv": structure_iv, "liquidite": liquidite, "iv_snapshot": snap}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Scoring ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _score_regime(spec: Dict, profile: Dict) -> Dict[str, Any]:
|
||||||
|
axis_hits: Dict[str, bool] = {}
|
||||||
|
total_weight = 0.0
|
||||||
|
earned = 0.0
|
||||||
|
|
||||||
|
for axis in ("direction", "dynamique"):
|
||||||
|
expected = spec.get(axis)
|
||||||
|
observed = profile.get(axis)
|
||||||
|
if expected is None or observed is None:
|
||||||
|
continue
|
||||||
|
w = _AXIS_WEIGHTS[axis]
|
||||||
|
total_weight += w
|
||||||
|
hit = observed == expected
|
||||||
|
if hit:
|
||||||
|
earned += w
|
||||||
|
axis_hits[axis] = hit
|
||||||
|
|
||||||
|
# volatilite: match level OR trend, whichever the spec entry is actually about
|
||||||
|
expected_vol = spec.get("volatilite")
|
||||||
|
vol = profile.get("volatilite") or {}
|
||||||
|
if expected_vol is not None:
|
||||||
|
w = _AXIS_WEIGHTS["volatilite"]
|
||||||
|
total_weight += w
|
||||||
|
observed_vol = vol.get("trend") if expected_vol in ("hausse", "baisse_rapide") else vol.get("level")
|
||||||
|
hit = observed_vol == expected_vol
|
||||||
|
if hit:
|
||||||
|
earned += w
|
||||||
|
axis_hits["volatilite"] = hit
|
||||||
|
|
||||||
|
expected_iv = spec.get("structure_iv")
|
||||||
|
observed_iv = profile.get("structure_iv")
|
||||||
|
if expected_iv is not None and observed_iv is not None:
|
||||||
|
w = _AXIS_WEIGHTS["structure_iv"]
|
||||||
|
total_weight += w
|
||||||
|
hit = observed_iv == expected_iv
|
||||||
|
if hit:
|
||||||
|
earned += w
|
||||||
|
axis_hits["structure_iv"] = hit
|
||||||
|
|
||||||
|
score = (earned / total_weight) if total_weight > 0 else 0.0
|
||||||
|
return {"key": spec["key"], "label": spec["label"], "score": round(score, 3), "axis_hits": axis_hits}
|
||||||
|
|
||||||
|
|
||||||
|
def classify_instrument_regime(instrument_id: str, snapshot: Dict) -> Dict[str, Any]:
|
||||||
|
"""Main entry point — called by routers/instruments.py with the already-built snapshot
|
||||||
|
(indicators/regime/trend/macro_regime) so this doesn't refetch price history a second
|
||||||
|
time. Returns the top match, full ranked scores, the computed axis profile (for the UI
|
||||||
|
to show its work), and the matched regime's reference info (déclencheurs/stratégies)."""
|
||||||
|
from services.instrument_service import resolve_watchlist_ticker
|
||||||
|
from services.database import get_wavelet_decomposition_cache, get_instruments_watchlist
|
||||||
|
|
||||||
|
watchlist_ticker = resolve_watchlist_ticker(instrument_id)
|
||||||
|
cached = get_wavelet_decomposition_cache(watchlist_ticker)
|
||||||
|
decomposition = cached.get("decomposition") if cached else None
|
||||||
|
wavelet_slopes = _wavelet_band_slopes(decomposition)
|
||||||
|
|
||||||
|
watchlist_row = next(
|
||||||
|
(r for r in get_instruments_watchlist() if r["ticker"].upper() == watchlist_ticker.upper()), None
|
||||||
|
)
|
||||||
|
|
||||||
|
regime_signals = (snapshot.get("regime") or {}).get("signals") or {}
|
||||||
|
trend = snapshot.get("trend") or {}
|
||||||
|
vol_series = (snapshot.get("indicators") or {}).get("volatility") or []
|
||||||
|
|
||||||
|
direction = _axis_direction(trend, regime_signals, wavelet_slopes)
|
||||||
|
dynamique = _axis_dynamique(trend, regime_signals, wavelet_slopes)
|
||||||
|
volatilite = _axis_volatilite(trend, regime_signals, vol_series)
|
||||||
|
options_axes = _axis_options(watchlist_row)
|
||||||
|
|
||||||
|
profile = {
|
||||||
|
"direction": direction,
|
||||||
|
"dynamique": dynamique,
|
||||||
|
"volatilite": volatilite,
|
||||||
|
"structure_iv": options_axes["structure_iv"],
|
||||||
|
"liquidite": options_axes["liquidite"],
|
||||||
|
"correlation": None, # no data source — always n/a, see module docstring
|
||||||
|
}
|
||||||
|
|
||||||
|
scored = sorted(
|
||||||
|
(_score_regime(spec, profile) for spec in REGIME_SPECS),
|
||||||
|
key=lambda s: -s["score"],
|
||||||
|
)
|
||||||
|
top = scored[0] if scored else None
|
||||||
|
top_spec = next((s for s in REGIME_SPECS if s["key"] == (top or {}).get("key")), None)
|
||||||
|
|
||||||
|
macro_regime = snapshot.get("macro_regime") or {}
|
||||||
|
config_category = (snapshot.get("instrument") or {}).get("category")
|
||||||
|
facteur_dominant_bias = _macro_bias_for_category(macro_regime.get("asset_bias") or {}, config_category)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"instrument_id": instrument_id,
|
||||||
|
"watchlist_ticker": watchlist_ticker,
|
||||||
|
"profile": profile,
|
||||||
|
"top_match": {
|
||||||
|
"key": top_spec["key"], "label": top_spec["label"], "score": top["score"],
|
||||||
|
"axis_hits": top["axis_hits"],
|
||||||
|
"facteur_dominant_ref": top_spec["facteur_dominant"],
|
||||||
|
"declencheurs": top_spec["declencheurs"],
|
||||||
|
"strategies": top_spec["strategies"],
|
||||||
|
"exemple": top_spec["exemple"],
|
||||||
|
} if top_spec else None,
|
||||||
|
"ranked": scored,
|
||||||
|
"macro_regime_label": macro_regime.get("label"),
|
||||||
|
"facteur_dominant_bias": facteur_dominant_bias,
|
||||||
|
"iv_snapshot": options_axes.get("iv_snapshot"),
|
||||||
|
"has_options_data": options_axes["structure_iv"] is not None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# instruments.json's own category vocabulary -> macro asset_bias's asset-class keys
|
||||||
|
_CATEGORY_TO_ASSET_BIAS_KEY = {
|
||||||
|
"fx": "forex", "energy": "energy", "metal": "metals",
|
||||||
|
"equity_index": "indices", "equity_intl": "indices", "stock": "equities",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _macro_bias_for_category(asset_bias: Dict[str, str], category: Optional[str]) -> Optional[str]:
|
||||||
|
key = _CATEGORY_TO_ASSET_BIAS_KEY.get(category or "")
|
||||||
|
return asset_bias.get(key) if key else None
|
||||||
@@ -1449,7 +1449,10 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
|
|||||||
const [searchParams] = useSearchParams()
|
const [searchParams] = useSearchParams()
|
||||||
// ?tab=wavelets lets other pages (e.g. Dashboard's Wavelets Signal card) deep-link
|
// ?tab=wavelets lets other pages (e.g. Dashboard's Wavelets Signal card) deep-link
|
||||||
// straight into the Wavelets panel instead of always landing on Counters.
|
// straight into the Wavelets panel instead of always landing on Counters.
|
||||||
const [tabUnder, setTabUnder] = useState<'counters' | 'analyse' | 'wavelets'>(() => (searchParams.get('tab') === 'wavelets' ? 'wavelets' : 'counters'))
|
const [tabUnder, setTabUnder] = useState<'counters' | 'analyse' | 'wavelets' | 'regime'>(() => {
|
||||||
|
const t = searchParams.get('tab')
|
||||||
|
return t === 'counters' || t === 'analyse' || t === 'regime' ? t : 'wavelets'
|
||||||
|
})
|
||||||
const [waveletLevels, setWaveletLevels] = useState(4)
|
const [waveletLevels, setWaveletLevels] = useState(4)
|
||||||
const [waveletFamily, setWaveletFamily] = useState<'gmw' | 'morlet' | 'bump'>('gmw')
|
const [waveletFamily, setWaveletFamily] = useState<'gmw' | 'morlet' | 'bump'>('gmw')
|
||||||
const [waveletMethod, setWaveletMethod] = useState<'cwt' | 'ssq'>('cwt')
|
const [waveletMethod, setWaveletMethod] = useState<'cwt' | 'ssq'>('cwt')
|
||||||
@@ -1466,6 +1469,8 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
|
|||||||
const [waveletReliability, setWaveletReliability] = useState<any | null>(null)
|
const [waveletReliability, setWaveletReliability] = useState<any | null>(null)
|
||||||
const [loadingReliability, setLoadingReliability] = useState(false)
|
const [loadingReliability, setLoadingReliability] = useState(false)
|
||||||
const [hiddenBands, setHiddenBands] = useState<Set<string>>(new Set())
|
const [hiddenBands, setHiddenBands] = useState<Set<string>>(new Set())
|
||||||
|
const [curveRegime, setCurveRegime] = useState<any | null>(null)
|
||||||
|
const [loadingCurveRegime, setLoadingCurveRegime] = useState(false)
|
||||||
const [hoveredWaveletIdx, setHoveredWaveletIdx] = useState<number | null>(null)
|
const [hoveredWaveletIdx, setHoveredWaveletIdx] = useState<number | null>(null)
|
||||||
const [templates, setTemplates] = useState<CausalTemplate[]>([])
|
const [templates, setTemplates] = useState<CausalTemplate[]>([])
|
||||||
const [causalScores, setCausalScores] = useState<Record<number, number | null>>({}) // eventId → activation_score*100
|
const [causalScores, setCausalScores] = useState<Record<number, number | null>>({}) // eventId → activation_score*100
|
||||||
@@ -1716,6 +1721,22 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [tabUnder, instrumentId, selected])
|
}, [tabUnder, instrumentId, selected])
|
||||||
|
|
||||||
|
// Curve Regime tab — auto-loads once per instrument, same pattern as the Wavelet tab
|
||||||
|
// above (reuses the period selector's current snapshot, no separate live trigger).
|
||||||
|
const curveRegimeAutoFetchKey = useRef<string | null>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
if (tabUnder !== 'regime' || !selected) return
|
||||||
|
if (curveRegimeAutoFetchKey.current === `${instrumentId}|${period}`) return
|
||||||
|
curveRegimeAutoFetchKey.current = `${instrumentId}|${period}`
|
||||||
|
setCurveRegime(null)
|
||||||
|
setLoadingCurveRegime(true)
|
||||||
|
api.get(`/instruments/${encodeURIComponent(instrumentId)}/curve-regime`, { params: { period } })
|
||||||
|
.then(({ data }) => setCurveRegime(data))
|
||||||
|
.catch((e) => console.error(`[CurveRegime] fetch failed for ${instrumentId}:`, e))
|
||||||
|
.finally(() => setLoadingCurveRegime(false))
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [tabUnder, instrumentId, selected, period])
|
||||||
|
|
||||||
const runWaveletReliability = async () => {
|
const runWaveletReliability = async () => {
|
||||||
if (!selected) return
|
if (!selected) return
|
||||||
setLoadingReliability(true)
|
setLoadingReliability(true)
|
||||||
@@ -2120,9 +2141,10 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
|
|||||||
{/* ── Tabs sous la courbe ── */}
|
{/* ── Tabs sous la courbe ── */}
|
||||||
<div className="border-b border-slate-700/40 flex gap-1">
|
<div className="border-b border-slate-700/40 flex gap-1">
|
||||||
{([
|
{([
|
||||||
|
{ key: 'wavelets', label: 'Wavelet' },
|
||||||
|
{ key: 'regime', label: 'Curve Regime' },
|
||||||
{ key: 'counters', label: 'Compteurs' },
|
{ key: 'counters', label: 'Compteurs' },
|
||||||
{ key: 'analyse', label: 'Analyse de la courbe' },
|
{ key: 'analyse', label: 'Analyse de la courbe' },
|
||||||
{ key: 'wavelets', label: 'Wavelet' },
|
|
||||||
] as const).map(t => (
|
] as const).map(t => (
|
||||||
<button
|
<button
|
||||||
key={t.key}
|
key={t.key}
|
||||||
@@ -2577,6 +2599,110 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{tabUnder === 'regime' && (
|
||||||
|
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Curve Regime — état de marché de l'instrument</span>
|
||||||
|
<span className="text-[10px] text-slate-600" title="Distinct du régime macro global (Inflation Shock, Goldilocks...) affiché ailleurs dans le Cockpit — celui-ci ne concerne que cet instrument.">
|
||||||
|
≠ régime macro global
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadingCurveRegime ? (
|
||||||
|
<div className="text-xs text-slate-500 text-center py-10">Calcul en cours…</div>
|
||||||
|
) : !curveRegime ? (
|
||||||
|
<div className="text-xs text-slate-500 text-center py-10">Aucune donnée — réessayez ou vérifiez que l'instrument a un historique de prix.</div>
|
||||||
|
) : (() => {
|
||||||
|
const top = curveRegime.top_match
|
||||||
|
const profile = curveRegime.profile
|
||||||
|
const vol = profile?.volatilite ?? {}
|
||||||
|
const scoreColorFor = (s: number) => s >= 0.75 ? 'text-emerald-400' : s >= 0.5 ? 'text-amber-400' : 'text-slate-400'
|
||||||
|
const AXIS_LABELS: Record<string, string> = { direction: 'Direction', dynamique: 'Dynamique', volatilite: 'Volatilité', structure_iv: 'Structure IV' }
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Top match */}
|
||||||
|
<div className="flex items-center gap-4 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<div className={clsx('text-2xl font-bold', top ? scoreColorFor(top.score) : 'text-slate-500')}>
|
||||||
|
{top ? top.label : 'Indéterminé'}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-slate-600">
|
||||||
|
Confiance {top ? `${(top.score * 100).toFixed(0)}%` : '—'} · basé sur {top ? Object.keys(top.axis_hits).length : 0} axes calculables
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{curveRegime.facteur_dominant_bias && (
|
||||||
|
<div className="text-[10px] px-2 py-1 rounded bg-dark-900/60 border border-slate-700/30 text-slate-400">
|
||||||
|
Biais macro ({curveRegime.macro_regime_label}) sur cette classe d'actif : <span className="font-semibold text-slate-300">{curveRegime.facteur_dominant_bias}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!curveRegime.has_options_data && (
|
||||||
|
<div className="text-[10px] text-amber-400/80 bg-amber-900/10 border border-amber-700/20 rounded px-2 py-1.5">
|
||||||
|
Structure IV / Liquidité indisponibles — lie l'option chain de cet instrument (Config → Instruments Watchlist → "Option") pour les inclure dans le classement.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Axis breakdown */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||||
|
{[
|
||||||
|
{ key: 'direction', value: profile?.direction },
|
||||||
|
{ key: 'dynamique', value: profile?.dynamique },
|
||||||
|
{ key: 'volatilite', value: vol.level ? `${vol.level}${vol.trend && vol.trend !== 'stable' ? ` (${vol.trend})` : ''}` : null },
|
||||||
|
{ key: 'structure_iv', value: profile?.structure_iv },
|
||||||
|
].map(({ key, value }) => {
|
||||||
|
const hit = top?.axis_hits?.[key]
|
||||||
|
return (
|
||||||
|
<div key={key} className="bg-dark-900/50 rounded px-2 py-1.5">
|
||||||
|
<div className="text-slate-500 text-[10px]">{AXIS_LABELS[key]}</div>
|
||||||
|
<div className={clsx('font-mono font-semibold text-xs',
|
||||||
|
value == null ? 'text-slate-600' : hit === true ? 'text-emerald-400' : hit === false ? 'text-slate-300' : 'text-slate-300'
|
||||||
|
)}>
|
||||||
|
{value ?? 'n/a'}{hit === true && ' ✓'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<div className="bg-dark-900/50 rounded px-2 py-1.5">
|
||||||
|
<div className="text-slate-500 text-[10px]">Liquidité</div>
|
||||||
|
<div className="font-mono font-semibold text-xs text-slate-300">{profile?.liquidite ?? 'n/a'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-dark-900/50 rounded px-2 py-1.5" title="Aucune source de données de corrélation cross-actifs par instrument n'existe encore dans le Cockpit — axe non noté.">
|
||||||
|
<div className="text-slate-500 text-[10px]">Corrélation cross-actifs</div>
|
||||||
|
<div className="font-mono font-semibold text-xs text-slate-600">n/a</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reference info for the matched regime */}
|
||||||
|
{top && (
|
||||||
|
<div className="text-xs space-y-1.5 border-t border-slate-700/30 pt-3">
|
||||||
|
<div><span className="text-slate-500">Facteur dominant type : </span><span className="text-slate-300">{top.facteur_dominant_ref}</span></div>
|
||||||
|
<div><span className="text-slate-500">Déclencheurs typiques : </span><span className="text-slate-300">{top.declencheurs}</span></div>
|
||||||
|
<div><span className="text-slate-500">Stratégies privilégiées : </span><span className="text-slate-300">{top.strategies}</span></div>
|
||||||
|
<div><span className="text-slate-500">Exemple : </span><span className="text-slate-300">{top.exemple}</span></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Runner-up regimes — visible when the top match isn't a clean, unambiguous winner */}
|
||||||
|
{curveRegime.ranked?.length > 1 && (
|
||||||
|
<div className="border-t border-slate-700/30 pt-3">
|
||||||
|
<div className="text-[10px] text-slate-500 uppercase tracking-wide mb-1.5">Autres régimes candidats</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{curveRegime.ranked.slice(1, 4).map((r: any) => (
|
||||||
|
<div key={r.key} className="flex items-center justify-between text-[10px]">
|
||||||
|
<span className="text-slate-400">{r.label}</span>
|
||||||
|
<span className={clsx('font-mono', scoreColorFor(r.score))}>{(r.score * 100).toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<NarrativeCard
|
<NarrativeCard
|
||||||
narrative={narrative}
|
narrative={narrative}
|
||||||
loading={loadingNarr}
|
loading={loadingNarr}
|
||||||
|
|||||||
Reference in New Issue
Block a user