feat: driver-based timeline strip, regime signal metrics, drivers editor

- instruments.json: add keywords array to every driver across 20 instruments
  (Fed, BCE, BOJ, OPEC, CPI, AI, EIA, etc.) for event-to-driver matching
- instrument_service.py: add update_instrument_drivers() persisting changes to JSON
  and refreshing in-memory cache
- instruments.py: add PUT /api/instruments/{id}/drivers endpoint (DriverUpdate model)
- InstrumentDashboard:
  * RegimeCard: replace regime score bars with 6-metric signal grid
    (MA50/MA200 position, MA50 slope, MA200 slope, momentum 20j, dist MA200, ATR vol ratio)
    with colour-coded values and contextual sub-labels (Golden cross, Surextension, etc.)
  * EventTimelineStrip: rows now keyed by top-4 instrument drivers (by weight)
    instead of LT/MT/CT; events matched via case-insensitive keyword scan against
    title + description + category; fallback dashed line when no events match
  * DriversPanel: inline edit panel (toggle via Drivers button in header);
    edit label, weight, keywords (comma-separated) per driver; add/remove drivers;
    saves via PUT /api/instruments/{id}/drivers; optimistic local state update

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-24 22:48:10 +02:00
parent 418d03254d
commit aa81598278
4 changed files with 635 additions and 579 deletions

View File

@@ -8,25 +8,20 @@
"exchange": "CBOE", "exchange": "CBOE",
"category": "equity_index", "category": "equity_index",
"currency": "USD", "currency": "USD",
"description": "US large-cap equity benchmark", "description": "US large-cap equity benchmark — 500 plus grandes capitalisations américaines",
"chart": { "chart": { "ma_periods": [20, 50, 200], "bollinger_period": 20, "bollinger_std": 2, "show_volume": true },
"ma_periods": [20, 50, 200],
"bollinger_period": 20,
"bollinger_std": 2,
"show_volume": true
},
"drivers": [ "drivers": [
{"key": "fed_policy", "label": "Fed Policy", "weight": 0.9}, {"key": "fed_policy", "label": "Fed Policy", "weight": 0.9, "keywords": ["FOMC","Fed","Federal Reserve","Powell","taux directeur","pivot","dot plot","QT","QE","taux Fed"]},
{"key": "earnings_growth", "label": "Earnings Growth", "weight": 0.85}, {"key": "earnings_growth", "label": "Earnings Growth", "weight": 0.85, "keywords": ["Earnings","EPS","bénéfices","résultats","Earnings Season","reporting","profits"]},
{"key": "real_rates", "label": "Real Rates", "weight": 0.75}, {"key": "real_rates", "label": "Real Rates", "weight": 0.75, "keywords": ["taux réels","TIPS","real yields","breakeven","inflation","CPI","PCE"]},
{"key": "liquidity", "label": "Global Liquidity", "weight": 0.7}, {"key": "liquidity", "label": "Global Liquidity", "weight": 0.7, "keywords": ["liquidité","liquidity","QE","QT","bilan Fed","repo","M2","conditions financières"]},
{"key": "credit_spreads", "label": "Credit Spreads HY", "weight": 0.6} {"key": "vix", "label": "VIX / Fear", "weight": 0.6, "keywords": ["VIX","volatilité","vol spike","hedging","protection","skew","put","peur","fear"]}
], ],
"regime_labels": ["Risk-On Bull", "Risk-Off Bear", "Stagflation", "Rate Shock", "Recovery"], "regime_labels": ["Risk-On Bull", "Risk-Off Bear", "Stagflation", "Rate Shock", "Recovery"],
"event_keywords": ["FOMC", "CPI", "NFP", "GDP", "Earnings Season", "Fed Chair"], "event_keywords": ["FOMC","CPI","NFP","GDP","Earnings Season","Fed Chair","VIX"],
"related_assets": ["SPX", "SPY", "QQQ", "ES"], "related_assets": ["SPX","SPY","QQQ","ES"],
"correlation_instruments": ["QQQ", "TLT", "HYG", "VXX"], "correlation_instruments": ["QQQ","TLT","HYG","VXX"],
"ai_context": "SPY tracks the S&P 500 (US large-cap benchmark). Bullish on: dovish Fed, strong earnings, liquidity expansion. Bearish on: rate hikes, recession, credit events. Watch: VIX, HYG spreads, earnings." "ai_context": "SPY tracks the S&P 500. Bullish: dovish Fed, strong earnings, liquidity expansion. Bearish: rate hikes, recession, credit events. Watch: VIX, HYG spreads, FOMC."
}, },
{ {
"id": "QQQ", "id": "QQQ",
@@ -36,25 +31,20 @@
"exchange": "NASDAQ", "exchange": "NASDAQ",
"category": "equity_index", "category": "equity_index",
"currency": "USD", "currency": "USD",
"description": "US large-cap tech and growth benchmark", "description": "US large-cap tech and growth benchmark — 100 plus grandes valeurs Nasdaq",
"chart": { "chart": { "ma_periods": [20, 50, 200], "bollinger_period": 20, "bollinger_std": 2, "show_volume": true },
"ma_periods": [20, 50, 200],
"bollinger_period": 20,
"bollinger_std": 2,
"show_volume": true
},
"drivers": [ "drivers": [
{"key": "fed_policy", "label": "Fed Policy", "weight": 0.9}, {"key": "real_rates", "label": "Real Rates", "weight": 0.9, "keywords": ["taux réels","TIPS","real yields","FOMC","Fed","CPI","breakeven"]},
{"key": "earnings_tech", "label": "Tech Earnings", "weight": 0.85}, {"key": "ai_capex", "label": "AI Capex", "weight": 0.85, "keywords": ["IA","AI","Nvidia","Microsoft","capex tech","data center","GPU","intelligence artificielle","ChatGPT"]},
{"key": "real_rates", "label": "Real Rates", "weight": 0.8}, {"key": "megacaps", "label": "Mégacaps GAFAM", "weight": 0.8, "keywords": ["GAFAM","Apple","Meta","Alphabet","Amazon","Microsoft","earnings tech","Big Tech","résultats tech"]},
{"key": "ai_capex", "label": "AI Capex", "weight": 0.8}, {"key": "growth", "label": "Croissance US", "weight": 0.75, "keywords": ["croissance","GDP","récession","NFP","emploi","PMI","croissance économique"]},
{"key": "dollar", "label": "Dollar Index", "weight": 0.5} {"key": "dollar", "label": "Dollar Index", "weight": 0.5, "keywords": ["Dollar","DXY","dollar index","EUR/USD","devise"]}
], ],
"regime_labels": ["Tech Bull (AI-Driven)", "Tech Bear", "Rate Compression", "Earnings Crash", "Recovery"], "regime_labels": ["Tech Bull (AI-Driven)", "Tech Bear", "Rate Compression", "Earnings Crash", "Recovery"],
"event_keywords": ["FOMC", "Big Tech Earnings", "CPI", "AI", "Rate"], "event_keywords": ["FOMC","Big Tech Earnings","CPI","AI","Rate","NVDA","AAPL"],
"related_assets": ["QQQ", "NDX", "NQ"], "related_assets": ["QQQ","NDX","NQ"],
"correlation_instruments": ["SPY", "TLT", "NVDA", "AAPL"], "correlation_instruments": ["SPY","TLT","NVDA","AAPL"],
"ai_context": "QQQ tracks the Nasdaq 100 (tech/growth benchmark). Bullish on: dovish Fed, AI capex boom, strong Big Tech earnings. Bearish on: rate hikes, earnings misses, AI regulation. Watch: real yields, mega-cap earnings reports." "ai_context": "QQQ tracks Nasdaq 100. Bullish: dovish Fed, AI capex boom, Big Tech earnings. Bearish: rate hikes, earnings misses, AI regulation. Watch: real yields, mega-cap earnings."
}, },
{ {
"id": "IWM", "id": "IWM",
@@ -64,24 +54,19 @@
"exchange": "NYSE", "exchange": "NYSE",
"category": "equity_index", "category": "equity_index",
"currency": "USD", "currency": "USD",
"description": "US small-cap equity benchmark", "description": "US small-cap equity benchmark — 2000 petites capitalisations américaines",
"chart": { "chart": { "ma_periods": [20, 50, 200], "bollinger_period": 20, "bollinger_std": 2, "show_volume": true },
"ma_periods": [20, 50, 200],
"bollinger_period": 20,
"bollinger_std": 2,
"show_volume": true
},
"drivers": [ "drivers": [
{"key": "credit_conditions", "label": "Credit Conditions", "weight": 0.85}, {"key": "gdp_growth", "label": "Croissance US", "weight": 0.85, "keywords": ["GDP","PIB","croissance US","NFP","emploi","récession","PMI","domestic growth"]},
{"key": "fed_policy", "label": "Fed Policy", "weight": 0.8}, {"key": "credit", "label": "Crédit & Spreads", "weight": 0.8, "keywords": ["crédit","credit","HY","high yield","spreads","LBO","conditions de crédit","credit crunch"]},
{"key": "gdp_growth", "label": "GDP Growth", "weight": 0.75}, {"key": "regional_banks", "label": "Banques Régionales","weight": 0.75, "keywords": ["banques régionales","SVB","FDIC","banque","banking","bank failure","crédit bancaire"]},
{"key": "dollar", "label": "Dollar Index", "weight": 0.6} {"key": "dollar", "label": "Dollar Index", "weight": 0.6, "keywords": ["Dollar","DXY","dollar fort","USD strength"]}
], ],
"regime_labels": ["Small Cap Expansion", "Credit Crunch Squeeze", "Risk-Off", "Recovery", "Neutral"], "regime_labels": ["Small Cap Expansion", "Credit Crunch Squeeze", "Risk-Off", "Recovery", "Neutral"],
"event_keywords": ["FOMC", "Credit", "GDP", "NFP", "Rates"], "event_keywords": ["FOMC","Credit","GDP","NFP","Rates","Regional Banks"],
"related_assets": ["IWM", "RTY", "RUT"], "related_assets": ["IWM","RTY","RUT"],
"correlation_instruments": ["SPY", "HYG", "TLT"], "correlation_instruments": ["SPY","HYG","TLT"],
"ai_context": "IWM tracks the Russell 2000 (US small-cap). Heavily credit-sensitive — bullish when credit conditions loosen and domestic growth accelerates. Bearish on credit tightening, recession fears, dollar strength (imported cost pressure). Watch: HYG spreads, regional bank health." "ai_context": "IWM tracks Russell 2000. Heavily credit-sensitive. Bullish: credit loosening, domestic growth. Bearish: credit tightening, recession, dollar strength. Watch: HYG spreads, regional bank health."
}, },
{ {
"id": "EEM", "id": "EEM",
@@ -91,24 +76,19 @@
"exchange": "NYSE", "exchange": "NYSE",
"category": "equity_intl", "category": "equity_intl",
"currency": "USD", "currency": "USD",
"description": "MSCI Emerging Markets large-cap benchmark", "description": "MSCI Emerging Markets — grandes capitalisations des marchés émergents",
"chart": { "chart": { "ma_periods": [20, 50, 200], "bollinger_period": 20, "bollinger_std": 2, "show_volume": true },
"ma_periods": [20, 50, 200],
"bollinger_period": 20,
"bollinger_std": 2,
"show_volume": true
},
"drivers": [ "drivers": [
{"key": "dollar_index", "label": "Dollar Index", "weight": 0.9}, {"key": "dollar_index", "label": "Dollar Index", "weight": 0.9, "keywords": ["Dollar","DXY","USD","dollar index","dollar fort","faible dollar"]},
{"key": "china_growth", "label": "China Growth", "weight": 0.85}, {"key": "china_growth", "label": "Chine / PBoC", "weight": 0.85, "keywords": ["China","Chine","PBOC","PBoC","PIB chinois","Xi","immobilier chinois","stimulus Chine","relance Chine"]},
{"key": "em_flows", "label": "EM Flows", "weight": 0.75}, {"key": "em_flows", "label": "Flux EM", "weight": 0.75, "keywords": ["EM","émergents","emerging","flows","flux","capitaux émergents"]},
{"key": "commodities", "label": "Commodities", "weight": 0.7} {"key": "commodities", "label": "Matières Premières","weight": 0.7, "keywords": ["matières premières","commodities","minerai","pétrole","métaux","commodité"]}
], ],
"regime_labels": ["EM Growth Rally", "Dollar Squeeze", "China Risk-Off", "Commodity Lift", "Neutral"], "regime_labels": ["EM Growth Rally", "Dollar Squeeze", "China Risk-Off", "Commodity Lift", "Neutral"],
"event_keywords": ["China GDP", "Dollar", "EM", "PBOC", "Commodities"], "event_keywords": ["China GDP","Dollar","EM","PBOC","Commodities","stimulus Chine"],
"related_assets": ["EEM", "EM", "CNY", "China"], "related_assets": ["EEM","EM","CNY","China"],
"correlation_instruments": ["GLD", "USO", "EURUSD=X"], "correlation_instruments": ["GLD","USO","EURUSD=X"],
"ai_context": "EEM tracks MSCI Emerging Markets. Dollar weakness and China growth are the dominant drivers. Bullish on: PBOC stimulus, commodities rally, weak USD. Bearish on: Fed tightening, China slowdown, geopolitical trade tensions. Watch: USDCNY, China PMI, EM bond flows." "ai_context": "EEM tracks MSCI Emerging Markets. Bullish: PBOC stimulus, commodities rally, weak USD. Bearish: Fed tightening, China slowdown, trade tensions. Watch: USDCNY, China PMI, EM bond flows."
}, },
{ {
"id": "EFA", "id": "EFA",
@@ -118,24 +98,19 @@
"exchange": "NYSE", "exchange": "NYSE",
"category": "equity_intl", "category": "equity_intl",
"currency": "USD", "currency": "USD",
"description": "MSCI EAFE (Europe, Australasia, Far East) benchmark", "description": "MSCI EAFE Europe, Australasie, Extrême-Orient (hors US/Canada)",
"chart": { "chart": { "ma_periods": [20, 50, 200], "bollinger_period": 20, "bollinger_std": 2, "show_volume": true },
"ma_periods": [20, 50, 200],
"bollinger_period": 20,
"bollinger_std": 2,
"show_volume": true
},
"drivers": [ "drivers": [
{"key": "ecb_policy", "label": "ECB Policy", "weight": 0.85}, {"key": "ecb_policy", "label": "BCE / ECB", "weight": 0.85, "keywords": ["BCE","ECB","Lagarde","taux BCE","zone euro","politique BCE","ECB rate"]},
{"key": "dollar", "label": "Dollar Index", "weight": 0.8}, {"key": "energy", "label": "Énergie Europe", "weight": 0.8, "keywords": ["énergie","gaz européen","pétrole","LNG","crisis énergétique","energy","energy price"]},
{"key": "european_growth", "label": "European Growth", "weight": 0.75}, {"key": "eurusd", "label": "EUR/USD", "weight": 0.75, "keywords": ["EUR/USD","euro dollar","eurusd","parité euro","euro fort","euro faible"]},
{"key": "global_trade", "label": "Global Trade", "weight": 0.6} {"key": "european_growth", "label": "Croissance Européenne", "weight": 0.7, "keywords": ["Europe","zone euro","Allemagne","PIB européen","PMI Europe","récession Europe","croissance européenne"]}
], ],
"regime_labels": ["Global Risk-On", "USD Strength Drag", "Europe Recession", "Recovery", "Neutral"], "regime_labels": ["Global Risk-On", "USD Strength Drag", "Europe Recession", "Recovery", "Neutral"],
"event_keywords": ["ECB", "EU GDP", "Europe", "Germany", "BOJ", "Japan"], "event_keywords": ["ECB","EU GDP","Europe","Germany","BOJ","Japan","EUR"],
"related_assets": ["EFA", "EAFE", "EUR", "EZU"], "related_assets": ["EFA","EAFE","EUR","EZU","SX5E"],
"correlation_instruments": ["EURUSD=X", "EEM", "TLT"], "correlation_instruments": ["EURUSD=X","EEM","TLT"],
"ai_context": "EFA tracks developed international markets (Europe, Japan, Australia). Bullish on: EUR/JPY strength, ECB/BOJ dovishness, global reflation. Bearish on: USD strength (translation drag), European recession, geopolitical disruption. Watch: EUR/USD, German Bund yields, BOJ policy." "ai_context": "EFA tracks EAFE (Europe, Japan, Australia). Bullish: EUR/JPY strength, ECB/BOJ dovishness, global reflation. Bearish: USD strength, European recession, geopolitical disruption."
}, },
{ {
"id": "GLD", "id": "GLD",
@@ -145,25 +120,20 @@
"exchange": "NYSE", "exchange": "NYSE",
"category": "metal", "category": "metal",
"currency": "USD", "currency": "USD",
"description": "SPDR Gold Shares — physical gold proxy", "description": "SPDR Gold Shares — proxy physique de l'or",
"chart": { "chart": { "ma_periods": [50, 100, 200], "bollinger_period": 50, "bollinger_std": 2, "show_volume": true },
"ma_periods": [50, 100, 200],
"bollinger_period": 50,
"bollinger_std": 2,
"show_volume": true
},
"drivers": [ "drivers": [
{"key": "real_rates_us", "label": "Real Rates US", "weight": 0.95}, {"key": "real_rates_us", "label": "Taux Réels US", "weight": 0.95, "keywords": ["taux réels","TIPS","real yields","Fed","FOMC","CPI","breakeven","inflation réelle"]},
{"key": "dollar_index", "label": "Dollar Index", "weight": 0.85}, {"key": "dollar_index", "label": "Dollar Index", "weight": 0.85, "keywords": ["Dollar","DXY","dollar index","USD","faible dollar","dollar fort"]},
{"key": "inflation_expectations", "label": "Inflation Expectations", "weight": 0.75}, {"key": "inflation_expectations", "label": "Anticipations Inflation", "weight": 0.75, "keywords": ["inflation","CPI","PCE","breakeven","attentes d'inflation","stagflation"]},
{"key": "geopolitical_risk", "label": "Geopolitical Risk", "weight": 0.7}, {"key": "geopolitical_risk", "label": "Risque Géopolitique", "weight": 0.7, "keywords": ["géopolitique","guerre","Moyen-Orient","Iran","Russie","conflit","Chine","tension","crise","war"]},
{"key": "cb_buying", "label": "Central Bank Buying", "weight": 0.5} {"key": "cb_buying", "label": "Achats Banques Centrales","weight": 0.5, "keywords": ["banque centrale","central bank","réserves","gold reserves","achats d'or","PBOC gold","dédollarisation"]}
], ],
"regime_labels": ["Real Rates Falling", "Safe Haven Rally", "Inflation Hedge", "Dollar Weakness", "Range"], "regime_labels": ["Real Rates Falling", "Safe Haven Rally", "Inflation Hedge", "Dollar Weakness", "Range"],
"event_keywords": ["CPI", "Fed", "FOMC", "Dollar", "Geopolitical", "Iran", "Gold"], "event_keywords": ["CPI","Fed","FOMC","Dollar","Geopolitical","Iran","Gold","guerre","inflation"],
"related_assets": ["GLD", "GC", "Gold", "XAU"], "related_assets": ["GLD","GC","Gold","XAU"],
"correlation_instruments": ["TLT", "SLV", "EURUSD=X", "USDJPY=X"], "correlation_instruments": ["TLT","SLV","EURUSD=X","USDJPY=X"],
"ai_context": "GLD tracks physical gold. Primary driver: real interest rates (inverse). Bullish on: falling real yields, dollar weakness, central bank buying, geopolitical crises. Bearish on: rising real yields, strong dollar, risk-on environment. Watch: TIPS yields, DXY, CB reserves." "ai_context": "GLD tracks physical gold. Primary driver: real rates (inverse). Bullish: falling real yields, dollar weakness, CB buying, geopolitical crises. Bearish: rising real yields, strong dollar."
}, },
{ {
"id": "SLV", "id": "SLV",
@@ -173,25 +143,20 @@
"exchange": "NYSE", "exchange": "NYSE",
"category": "metal", "category": "metal",
"currency": "USD", "currency": "USD",
"description": "iShares Silver Trust — physical silver proxy", "description": "iShares Silver Trust — proxy physique de l'argent métal",
"chart": { "chart": { "ma_periods": [20, 50, 200], "bollinger_period": 20, "bollinger_std": 2, "show_volume": true },
"ma_periods": [20, 50, 200],
"bollinger_period": 20,
"bollinger_std": 2,
"show_volume": true
},
"drivers": [ "drivers": [
{"key": "industrial_demand", "label": "Industrial Demand", "weight": 0.8}, {"key": "industrial_demand", "label": "Demande Industrielle", "weight": 0.8, "keywords": ["industrie","manufacturing","PMI","production industrielle","fabrique","industry"]},
{"key": "real_rates", "label": "Real Rates", "weight": 0.75}, {"key": "real_rates", "label": "Taux Réels", "weight": 0.75, "keywords": ["taux réels","TIPS","Fed","FOMC","real yields"]},
{"key": "dollar", "label": "Dollar Index", "weight": 0.7}, {"key": "dollar", "label": "Dollar Index", "weight": 0.7, "keywords": ["Dollar","DXY","USD"]},
{"key": "solar_ev_demand", "label": "Solar/EV Demand", "weight": 0.65}, {"key": "solar_ev_demand", "label": "Solaire / Véhicule EV","weight": 0.65, "keywords": ["solaire","solar","EV","véhicule électrique","énergie verte","green energy","photovoltaïque","battery"]},
{"key": "gold_ratio", "label": "Gold/Silver Ratio", "weight": 0.6} {"key": "gold_ratio", "label": "Ratio Or/Argent", "weight": 0.6, "keywords": ["Gold","or","ratio Or/Argent","gold silver","argent","silver"]}
], ],
"regime_labels": ["Industrial Demand", "Safe Haven Flow", "Dollar Weakness", "Risk-Off", "Range"], "regime_labels": ["Industrial Demand", "Safe Haven Flow", "Dollar Weakness", "Risk-Off", "Range"],
"event_keywords": ["Manufacturing", "PMI", "CPI", "Dollar", "Silver", "EV", "Solar"], "event_keywords": ["Manufacturing","PMI","CPI","Dollar","Silver","EV","Solar","taux réels"],
"related_assets": ["SLV", "SI", "Silver"], "related_assets": ["SLV","SI","Silver"],
"correlation_instruments": ["GLD", "USO", "EEM"], "correlation_instruments": ["GLD","USO","EEM"],
"ai_context": "SLV tracks silver. Dual nature: monetary metal (like gold) + industrial metal (solar panels, EVs, electronics). Bullish on: green energy demand, falling real rates, weak dollar. Bearish on: manufacturing slowdown, strong dollar, risk-off. Watch: gold/silver ratio, PMI data." "ai_context": "SLV tracks silver. Dual nature: monetary (like gold) + industrial (solar, EVs). Bullish: green energy demand, falling real rates, weak dollar. Bearish: manufacturing slowdown, strong dollar."
}, },
{ {
"id": "USO", "id": "USO",
@@ -201,25 +166,20 @@
"exchange": "NYSE", "exchange": "NYSE",
"category": "energy", "category": "energy",
"currency": "USD", "currency": "USD",
"description": "United States Oil Fund — WTI crude proxy", "description": "United States Oil Fund — proxy WTI (pétrole brut américain)",
"chart": { "chart": { "ma_periods": [20, 50, 200], "bollinger_period": 20, "bollinger_std": 2, "show_volume": true },
"ma_periods": [20, 50, 200],
"bollinger_period": 20,
"bollinger_std": 2,
"show_volume": true
},
"drivers": [ "drivers": [
{"key": "opec_policy", "label": "OPEC Policy", "weight": 0.9}, {"key": "opec_policy", "label": "OPEP+ / Production", "weight": 0.9, "keywords": ["OPEC","OPEP","production pétrolière","quotas","Arabie Saoudite","Saudi","réduction","coupes","OPEC meeting"]},
{"key": "inventories", "label": "EIA Inventories", "weight": 0.8}, {"key": "inventories", "label": "Stocks EIA", "weight": 0.8, "keywords": ["EIA","stocks pétroliers","inventaires","inventories","crude stocks","rapport EIA","crude supply"]},
{"key": "geopolitical_risk", "label": "Geopolitical Risk", "weight": 0.75}, {"key": "geopolitical_risk","label": "Risque Géopolitique","weight": 0.75, "keywords": ["géopolitique","Moyen-Orient","Iran","Russie","conflit","guerre","war","Golfe Persique","tension"]},
{"key": "global_demand", "label": "Global Demand", "weight": 0.85}, {"key": "global_demand", "label": "Demande Mondiale", "weight": 0.85, "keywords": ["demande mondiale","China demand","IEA","croissance mondiale","global demand","reprise économique"]},
{"key": "dollar", "label": "Dollar Index", "weight": 0.7} {"key": "dollar", "label": "Dollar Index", "weight": 0.7, "keywords": ["Dollar","DXY","USD"]}
], ],
"regime_labels": ["Supply Shock", "Demand Recovery", "Inventory Draw", "Geopolitical Premium", "Neutral"], "regime_labels": ["Supply Shock", "Demand Recovery", "Inventory Draw", "Geopolitical Premium", "Neutral"],
"event_keywords": ["OPEC", "EIA", "Inventories", "Brent", "WTI", "Oil", "Middle East"], "event_keywords": ["OPEC","EIA","Inventories","Brent","WTI","Oil","Middle East","Saudi"],
"related_assets": ["USO", "CL", "WTI", "Oil", "Brent"], "related_assets": ["USO","CL","WTI","Oil","Brent"],
"correlation_instruments": ["XOM", "EEM", "GLD", "UNG"], "correlation_instruments": ["XOM","EEM","GLD","UNG"],
"ai_context": "USO tracks WTI crude oil. Bullish on: OPEC+ cuts, geopolitical supply disruption, global demand recovery, weak dollar. Bearish on: OPEC+ output increases, demand recession, strong dollar, inventory builds. Watch: EIA weekly reports, OPEC meetings, Middle East tensions." "ai_context": "USO tracks WTI crude. Bullish: OPEC+ cuts, geopolitical disruption, demand recovery, weak dollar. Bearish: OPEC output increases, demand recession, inventory builds."
}, },
{ {
"id": "UNG", "id": "UNG",
@@ -229,24 +189,19 @@
"exchange": "NYSE", "exchange": "NYSE",
"category": "energy", "category": "energy",
"currency": "USD", "currency": "USD",
"description": "United States Natural Gas Fund — Henry Hub proxy", "description": "United States Natural Gas Fund — proxy Henry Hub (gaz naturel)",
"chart": { "chart": { "ma_periods": [20, 50, 200], "bollinger_period": 20, "bollinger_std": 2, "show_volume": true },
"ma_periods": [20, 50, 200],
"bollinger_period": 20,
"bollinger_std": 2,
"show_volume": true
},
"drivers": [ "drivers": [
{"key": "weather_seasonality", "label": "Weather/Seasonality", "weight": 0.9}, {"key": "weather_seasonality", "label": "Météo / Saisonnalité","weight": 0.9, "keywords": ["météo","weather","hiver","été","froid","chaleur","El Niño","La Niña","saison","vague de froid","heat wave"]},
{"key": "storage", "label": "EIA Gas Storage", "weight": 0.85}, {"key": "storage", "label": "Stockage EIA Gaz", "weight": 0.85, "keywords": ["EIA Gas Storage","stockage gaz","gas storage","inventaires gaz","rapport stockage gaz"]},
{"key": "lng_exports", "label": "LNG Exports", "weight": 0.75}, {"key": "lng_exports", "label": "Exports LNG", "weight": 0.75, "keywords": ["LNG","GNL","exportations gaz","terminal LNG","gazoduc","liquefied natural gas"]},
{"key": "production", "label": "US Production", "weight": 0.7} {"key": "production", "label": "Production US", "weight": 0.7, "keywords": ["production gaz","shale gas","fracking","puits gaz","Marcellus","Appalachian","US gas output"]}
], ],
"regime_labels": ["Winter Storage Fill", "Supply Glut", "Demand Surge", "Export LNG Lift", "Neutral"], "regime_labels": ["Winter Storage Fill", "Supply Glut", "Demand Surge", "Export LNG Lift", "Neutral"],
"event_keywords": ["EIA Gas Storage", "LNG", "Gas", "Natural Gas", "Weather"], "event_keywords": ["EIA Gas Storage","LNG","Gas","Natural Gas","Weather","stockage gaz"],
"related_assets": ["UNG", "NG", "Natgas"], "related_assets": ["UNG","NG","Natgas"],
"correlation_instruments": ["USO", "XOM"], "correlation_instruments": ["USO","XOM"],
"ai_context": "UNG tracks natural gas (Henry Hub). Highly seasonal and weather-driven. Bullish on: cold winter demand, low storage levels, LNG export growth, production cuts. Bearish on: warm weather, record storage builds, weak LNG prices. Watch: weekly EIA storage report, weather forecasts." "ai_context": "UNG tracks natural gas (Henry Hub). Highly seasonal and weather-driven. Bullish: cold winter, low storage, LNG export growth. Bearish: warm weather, record storage builds."
}, },
{ {
"id": "TLT", "id": "TLT",
@@ -256,25 +211,20 @@
"exchange": "NASDAQ", "exchange": "NASDAQ",
"category": "bond", "category": "bond",
"currency": "USD", "currency": "USD",
"description": "iShares 20+ Year Treasury Bond ETF", "description": "iShares 20+ Year Treasury — proxy du rendement US 10Y/30Y",
"chart": { "chart": { "ma_periods": [20, 50, 200], "bollinger_period": 20, "bollinger_std": 2, "show_volume": true },
"ma_periods": [20, 50, 200],
"bollinger_period": 20,
"bollinger_std": 2,
"show_volume": true
},
"drivers": [ "drivers": [
{"key": "fed_path", "label": "Fed Path", "weight": 0.9}, {"key": "fed_path", "label": "Trajectoire Fed", "weight": 0.9, "keywords": ["FOMC","Fed","taux directeur","Fed hike","Fed cut","pivot","dot plot","Federal Reserve"]},
{"key": "inflation_expectations", "label": "Inflation Expectations", "weight": 0.85}, {"key": "inflation_expectations", "label": "Inflation / CPI", "weight": 0.85, "keywords": ["CPI","PCE","inflation","déflation","attentes","breakeven","inflation US"]},
{"key": "growth", "label": "Economic Growth", "weight": 0.75}, {"key": "growth", "label": "Croissance / NFP", "weight": 0.75, "keywords": ["GDP","NFP","récession","croissance","emploi","PMI","chômage","payrolls"]},
{"key": "deficit_supply", "label": "Deficit/Supply", "weight": 0.7}, {"key": "deficit_supply", "label": "Déficit / Offre Treasury","weight": 0.7, "keywords": ["déficit","dette publique","Treasury auction","enchères","budget","fiscal","dette fédérale"]},
{"key": "risk_aversion", "label": "Risk Aversion", "weight": 0.65} {"key": "risk_aversion", "label": "Fuite Vers Qualité", "weight": 0.65, "keywords": ["risk-off","crise","fuite vers la qualité","safe haven","vol spike","contagion","crise bancaire"]}
], ],
"regime_labels": ["Safe Haven Bid", "Rate Shock Selloff", "QE Expectations", "Inflation Selloff", "Range"], "regime_labels": ["Safe Haven Bid", "Rate Shock Selloff", "QE Expectations", "Inflation Selloff", "Range"],
"event_keywords": ["FOMC", "CPI", "Fed", "Treasury", "Yield", "Deficit"], "event_keywords": ["FOMC","CPI","Fed","Treasury","Yield","Deficit","NFP"],
"related_assets": ["TLT", "TNX", "UST", "Bonds"], "related_assets": ["TLT","TNX","UST","Bonds"],
"correlation_instruments": ["SPY", "GLD", "HYG", "USDJPY=X"], "correlation_instruments": ["SPY","GLD","HYG","USDJPY=X"],
"ai_context": "TLT tracks 20+ year US Treasuries. Bullish on: recession fears, Fed rate cuts, falling inflation, risk-off flows, QE expectations. Bearish on: rising inflation, Fed hikes, fiscal deficit expansion, strong growth. Watch: 10Y yield, breakevens, FOMC dots." "ai_context": "TLT tracks 20Y US Treasuries. Bullish: recession fears, Fed cuts, falling inflation, risk-off. Bearish: rising inflation, Fed hikes, fiscal deficit expansion, strong growth."
}, },
{ {
"id": "HYG", "id": "HYG",
@@ -284,24 +234,19 @@
"exchange": "NYSE", "exchange": "NYSE",
"category": "credit", "category": "credit",
"currency": "USD", "currency": "USD",
"description": "iShares iBoxx USD High Yield Corporate Bond ETF", "description": "iShares iBoxx USD High Yield Corporate Bond — baromètre du crédit HY",
"chart": { "chart": { "ma_periods": [20, 50, 200], "bollinger_period": 20, "bollinger_std": 2, "show_volume": true },
"ma_periods": [20, 50, 200],
"bollinger_period": 20,
"bollinger_std": 2,
"show_volume": true
},
"drivers": [ "drivers": [
{"key": "default_rate", "label": "Default Rate", "weight": 0.85}, {"key": "default_rate", "label": "Taux de Défaut", "weight": 0.85, "keywords": ["défauts","default","faillite","bankruptcy","spreads HY","taux de défaut","HY spreads"]},
{"key": "growth", "label": "Economic Growth", "weight": 0.8}, {"key": "growth", "label": "Croissance Économique", "weight": 0.8, "keywords": ["GDP","récession","croissance","PMI","emploi","payrolls","activité"]},
{"key": "liquidity", "label": "Liquidity Conditions", "weight": 0.8}, {"key": "liquidity", "label": "Conditions de Liquidité","weight": 0.8, "keywords": ["liquidité","crédit","QT","QE","conditions financières","FCO","financial conditions"]},
{"key": "risk_appetite", "label": "Risk Appetite", "weight": 0.75} {"key": "risk_appetite", "label": "Appétit pour le Risque", "weight": 0.75, "keywords": ["risk-on","risk-off","appétit pour le risque","flux crédit","sentiment marché"]}
], ],
"regime_labels": ["Credit Expansion", "Credit Crunch", "Spread Compression", "Recession Fear", "Neutral"], "regime_labels": ["Credit Expansion", "Credit Crunch", "Spread Compression", "Recession Fear", "Neutral"],
"event_keywords": ["Credit", "Default", "GDP", "FOMC", "Recession", "HY"], "event_keywords": ["Credit","Default","GDP","FOMC","Recession","HY","spreads"],
"related_assets": ["HYG", "LQD", "Credit", "HY"], "related_assets": ["HYG","LQD","Credit","HY"],
"correlation_instruments": ["SPY", "IWM", "TLT"], "correlation_instruments": ["SPY","IWM","TLT"],
"ai_context": "HYG tracks US high-yield corporate bonds. A key credit stress barometer. Bullish on: strong growth, low defaults, liquidity expansion, risk appetite. Bearish on: recession, default cycle, credit crunch, liquidity withdrawal. Watch: OAS spreads vs Treasuries, default rates, IWM correlation." "ai_context": "HYG tracks US high-yield corporate bonds. Key credit stress barometer. Bullish: strong growth, low defaults, liquidity expansion. Bearish: recession, default cycle, credit crunch."
}, },
{ {
"id": "EURUSD=X", "id": "EURUSD=X",
@@ -311,24 +256,19 @@
"exchange": "FX", "exchange": "FX",
"category": "fx", "category": "fx",
"currency": "USD", "currency": "USD",
"description": "Euro vs US Dollar spot rate", "description": "Taux spot Euro / Dollar américain",
"chart": { "chart": { "ma_periods": [20, 50, 200], "bollinger_period": 20, "bollinger_std": 2, "show_volume": false },
"ma_periods": [20, 50, 200],
"bollinger_period": 20,
"bollinger_std": 2,
"show_volume": false
},
"drivers": [ "drivers": [
{"key": "fed_ecb_differential", "label": "Fed vs ECB Differential", "weight": 0.95}, {"key": "fed_ecb_differential", "label": "Différentiel Fed/BCE", "weight": 0.95, "keywords": ["BCE","ECB","Fed","FOMC","différentiel","taux directeurs","politique monétaire","rate differential"]},
{"key": "rate_differential_2y", "label": "Rate Differential 2Y", "weight": 0.85}, {"key": "rate_differential_2y", "label": "Taux 2 ans US-EUR", "weight": 0.85, "keywords": ["taux 2 ans","Bund 2Y","Treasury 2Y","swap","différentiel de taux","2Y spread"]},
{"key": "growth_differential", "label": "Growth Differential", "weight": 0.75}, {"key": "inflation_differential", "label": "Inflation US vs EUR", "weight": 0.75, "keywords": ["CPI","inflation","PCE","HICP","zone euro","inflation différentiel"]},
{"key": "risk_appetite", "label": "Risk Appetite", "weight": 0.7} {"key": "dxy", "label": "Dollar Index DXY", "weight": 0.7, "keywords": ["Dollar","DXY","dollar index","USD","dollar fort","faible dollar"]}
], ],
"regime_labels": ["EUR Strength (ECB Hawkish)", "USD Strength (Fed Hawkish)", "Risk-On EUR Rally", "Risk-Off USD Safe", "Range"], "regime_labels": ["EUR Strength (ECB Hawkish)", "USD Strength (Fed Hawkish)", "Risk-On EUR Rally", "Risk-Off USD Safe", "Range"],
"event_keywords": ["ECB", "FOMC", "CPI", "EUR", "Dollar", "Fed", "Eurozone"], "event_keywords": ["ECB","FOMC","CPI","EUR","Dollar","Fed","Eurozone","BCE"],
"related_assets": ["EUR", "USD", "EURUSD", "6E"], "related_assets": ["EUR","USD","EURUSD","6E"],
"correlation_instruments": ["GLD", "EFA", "TLT"], "correlation_instruments": ["GLD","EFA","TLT"],
"ai_context": "EUR/USD is driven by the Fed-ECB rate differential. EUR bullish on: ECB hawkishness relative to Fed, European growth surprise, risk-on flows. EUR bearish on: Fed hawkishness, European recession, risk-off USD demand. Watch: 2Y Bund-Treasury spread, ECB/Fed meeting dates." "ai_context": "EUR/USD driven by Fed-ECB rate differential. EUR bullish: ECB hawkishness, European growth surprise, risk-on. EUR bearish: Fed hawkishness, European recession, risk-off USD demand."
}, },
{ {
"id": "USDJPY=X", "id": "USDJPY=X",
@@ -338,24 +278,19 @@
"exchange": "FX", "exchange": "FX",
"category": "fx", "category": "fx",
"currency": "JPY", "currency": "JPY",
"description": "US Dollar vs Japanese Yen spot rate", "description": "Taux spot Dollar américain / Yen japonais",
"chart": { "chart": { "ma_periods": [20, 50, 200], "bollinger_period": 20, "bollinger_std": 2, "show_volume": false },
"ma_periods": [20, 50, 200],
"bollinger_period": 20,
"bollinger_std": 2,
"show_volume": false
},
"drivers": [ "drivers": [
{"key": "boj_policy", "label": "BOJ Policy", "weight": 0.95}, {"key": "boj_policy", "label": "BOJ Policy", "weight": 0.95, "keywords": ["BOJ","Banque du Japon","Ueda","YCC","yield curve control","politique monétaire japonaise","BOJ rate","JGB"]},
{"key": "rate_diff_us_jp", "label": "Rate Differential US-JP", "weight": 0.9}, {"key": "rate_diff_us_jp","label": "Différentiel US-JP","weight": 0.9, "keywords": ["différentiel US-Japon","JGB","UST","rendements US-JP","US Japan spread","10Y spread"]},
{"key": "fed_policy", "label": "Fed Policy", "weight": 0.85}, {"key": "fed_policy", "label": "Fed Policy", "weight": 0.85, "keywords": ["Fed","FOMC","taux directeur US","hawkish Fed","Fed hike","Federal Reserve"]},
{"key": "risk_appetite", "label": "Risk Appetite", "weight": 0.7} {"key": "risk_appetite", "label": "Risk Appetite / Carry","weight": 0.7, "keywords": ["risk-off","carry trade","yen safe haven","crise","carry unwind","yen refuge"]}
], ],
"regime_labels": ["Carry Trade On", "BOJ Tightening Surprise", "Risk-Off JPY Safe Haven", "Dollar Bull", "YCC Adjustment"], "regime_labels": ["Carry Trade On", "BOJ Tightening Surprise", "Risk-Off JPY Safe Haven", "Dollar Bull", "YCC Adjustment"],
"event_keywords": ["BOJ", "Fed", "Japan", "YCC", "Carry", "JPY", "Yen"], "event_keywords": ["BOJ","Fed","Japan","YCC","Carry","JPY","Yen","taux Japon"],
"related_assets": ["JPY", "USDJPY", "6J", "Yen"], "related_assets": ["JPY","USDJPY","6J","Yen"],
"correlation_instruments": ["TLT", "GLD", "VXX"], "correlation_instruments": ["TLT","GLD","VXX"],
"ai_context": "USD/JPY is primarily driven by the US-Japan rate differential and BOJ yield curve control. USD bullish on: Fed hikes, BOJ ultra-dovish, risk-on carry. JPY bullish on: BOJ tightening surprise, risk-off crisis, US yield decline. Watch: BOJ meetings, 10Y UST-JGB spread, carry unwind signals." "ai_context": "USD/JPY driven by US-Japan rate differential and BOJ yield curve control. USD bullish: Fed hikes, BOJ ultra-dovish. JPY bullish: BOJ tightening surprise, risk-off. Watch: BOJ meetings, 10Y UST-JGB spread."
}, },
{ {
"id": "GBPUSD=X", "id": "GBPUSD=X",
@@ -365,24 +300,19 @@
"exchange": "FX", "exchange": "FX",
"category": "fx", "category": "fx",
"currency": "USD", "currency": "USD",
"description": "British Pound vs US Dollar spot rate", "description": "Taux spot Livre Sterling / Dollar américain",
"chart": { "chart": { "ma_periods": [20, 50, 200], "bollinger_period": 20, "bollinger_std": 2, "show_volume": false },
"ma_periods": [20, 50, 200],
"bollinger_period": 20,
"bollinger_std": 2,
"show_volume": false
},
"drivers": [ "drivers": [
{"key": "boe_policy", "label": "BOE Policy", "weight": 0.9}, {"key": "boe_policy", "label": "BOE Policy", "weight": 0.9, "keywords": ["BOE","Bank of England","Bailey","taux UK","Royaume-Uni","Angleterre","UK rate","BOE meeting"]},
{"key": "fed_policy", "label": "Fed Policy", "weight": 0.85}, {"key": "uk_inflation", "label": "Inflation UK", "weight": 0.8, "keywords": ["inflation UK","CPI UK","CPIH","RPI","inflation Angleterre"]},
{"key": "uk_growth", "label": "UK Growth", "weight": 0.75}, {"key": "uk_growth", "label": "Croissance UK", "weight": 0.75, "keywords": ["GDP UK","PMI UK","croissance UK","récession UK","UK economy","UK GDP"]},
{"key": "political_risk", "label": "Political Risk UK", "weight": 0.65} {"key": "political_risk","label": "Risque Politique UK","weight": 0.65, "keywords": ["politique UK","budget UK","Labour","Tories","fiscal UK","gilt","UK budget","Reeves"]}
], ],
"regime_labels": ["GBP Strength (BOE Hawkish)", "USD Dominance", "Political Risk GBP", "UK Recession Pressure", "Range"], "regime_labels": ["GBP Strength (BOE Hawkish)", "USD Dominance", "Political Risk GBP", "UK Recession Pressure", "Range"],
"event_keywords": ["BOE", "Fed", "UK", "Britain", "Brexit", "GBP", "Pound"], "event_keywords": ["BOE","Fed","UK","Britain","GBP","Pound","UK CPI"],
"related_assets": ["GBP", "GBPUSD", "6B", "Pound"], "related_assets": ["GBP","GBPUSD","6B","Pound"],
"correlation_instruments": ["EURUSD=X", "TLT"], "correlation_instruments": ["EURUSD=X","TLT"],
"ai_context": "GBP/USD reflects the BOE vs Fed rate differential plus UK political risk premium. GBP bullish on: BOE hawkishness, strong UK data, political stability. GBP bearish on: UK recession, political uncertainty, Fed dominance, risk-off. Watch: UK CPI, BOE meetings, UK gilt yields." "ai_context": "GBP/USD reflects BOE vs Fed differential plus UK political risk premium. GBP bullish: BOE hawkishness, strong UK data, political stability. GBP bearish: UK recession, political uncertainty."
}, },
{ {
"id": "VXX", "id": "VXX",
@@ -392,24 +322,19 @@
"exchange": "CBOE", "exchange": "CBOE",
"category": "volatility", "category": "volatility",
"currency": "USD", "currency": "USD",
"description": "iPath Series B S&P 500 VIX Short-Term Futures ETN", "description": "iPath VIX Short-Term Futures ETN — proxy de la peur et de la volatilité implicite",
"chart": { "chart": { "ma_periods": [10, 20, 50], "bollinger_period": 20, "bollinger_std": 2, "show_volume": true },
"ma_periods": [10, 20, 50],
"bollinger_period": 20,
"bollinger_std": 2,
"show_volume": true
},
"drivers": [ "drivers": [
{"key": "market_stress", "label": "Market Stress", "weight": 0.9}, {"key": "market_stress", "label": "Stress de Marché", "weight": 0.9, "keywords": ["krach","crash","crise","choc","panic","volatilité","vol spike","selloff","capitulation","contagion"]},
{"key": "risk_appetite", "label": "Risk Appetite", "weight": 0.85}, {"key": "risk_appetite", "label": "Peur / Risk-Off", "weight": 0.85, "keywords": ["risk-off","risk-on","sentiment","fear","peur","greed","appétit risque","VIX spike"]},
{"key": "options_demand", "label": "Options Demand", "weight": 0.75}, {"key": "options_demand", "label": "Demande Options (Puts)", "weight": 0.75, "keywords": ["options","puts","hedging","protection","skew","IV","couverture","implied vol","volatilité implicite"]},
{"key": "macro_uncertainty", "label": "Macro Uncertainty", "weight": 0.7} {"key": "macro_uncertainty", "label": "Incertitude Macro", "weight": 0.7, "keywords": ["FOMC","surprise","incertitude","géopolitique","macro","inflation choc","data surprise"]}
], ],
"regime_labels": ["Low Vol Complacency", "Vol Spike Crisis", "Vol Expansion", "Vol Compression", "Neutral"], "regime_labels": ["Low Vol Complacency", "Vol Spike Crisis", "Vol Expansion", "Vol Compression", "Neutral"],
"event_keywords": ["VIX", "Volatility", "Crisis", "FOMC", "Market Stress"], "event_keywords": ["VIX","Volatility","Crisis","FOMC","Market Stress","peur","vol spike"],
"related_assets": ["VXX", "VIX", "UVXY", "SVXY"], "related_assets": ["VXX","VIX","UVXY","SVXY"],
"correlation_instruments": ["SPY", "TLT", "GLD"], "correlation_instruments": ["SPY","TLT","GLD"],
"ai_context": "VXX tracks short-term VIX futures (fear gauge). Spikes on: market crashes, geopolitical shocks, FOMC surprises, credit events. Mean-reverts in calm markets due to futures roll decay. Useful for hedging equity drawdowns. Watch: VIX term structure, SPX put skew, macro event calendar." "ai_context": "VXX tracks short-term VIX futures. Spikes on: market crashes, geopolitical shocks, FOMC surprises. Mean-reverts due to futures roll decay. Watch: VIX term structure, SPX put skew."
}, },
{ {
"id": "AAPL", "id": "AAPL",
@@ -419,25 +344,20 @@
"exchange": "NASDAQ", "exchange": "NASDAQ",
"category": "stock", "category": "stock",
"currency": "USD", "currency": "USD",
"description": "Apple Inc. — consumer tech, services ecosystem", "description": "Apple Inc. — ecosystème consommateur tech et services",
"chart": { "chart": { "ma_periods": [20, 50, 200], "bollinger_period": 20, "bollinger_std": 2, "show_volume": true },
"ma_periods": [20, 50, 200],
"bollinger_period": 20,
"bollinger_std": 2,
"show_volume": true
},
"drivers": [ "drivers": [
{"key": "iphone_cycle", "label": "iPhone Cycle", "weight": 0.85}, {"key": "iphone_cycle", "label": "Cycle iPhone", "weight": 0.85, "keywords": ["iPhone","Apple","AAPL earnings","supercycle","launch","iPhone 16","hardware"]},
{"key": "china_sales", "label": "China Sales", "weight": 0.8}, {"key": "china_sales", "label": "Ventes Chine", "weight": 0.8, "keywords": ["Chine","China","Huawei","ban Apple","restrictions Apple","China sales","marché chinois"]},
{"key": "fed_policy", "label": "Fed Policy", "weight": 0.8}, {"key": "ai_integration","label": "Apple Intelligence / IA","weight": 0.75, "keywords": ["Apple Intelligence","AI","IA","Siri","machine learning","IA Apple","generative AI"]},
{"key": "ai_integration", "label": "AI Integration", "weight": 0.75}, {"key": "margins", "label": "Services & Marges", "weight": 0.7, "keywords": ["services","App Store","iCloud","marge Apple","services revenue","abonnements"]},
{"key": "margins", "label": "Services Margins", "weight": 0.7} {"key": "fed_policy", "label": "Valorisation / Taux", "weight": 0.65, "keywords": ["Fed","taux","valorisation","taux d'intérêt","discount rate"]}
], ],
"regime_labels": ["Growth Bull", "Rate Squeeze", "Earnings Beat", "Tech Selloff", "Neutral"], "regime_labels": ["Growth Bull", "Rate Squeeze", "Earnings Beat", "Tech Selloff", "Neutral"],
"event_keywords": ["Apple Earnings", "iPhone", "Services", "China", "Fed", "AI", "AAPL"], "event_keywords": ["Apple Earnings","iPhone","Services","China","Fed","AI","AAPL"],
"related_assets": ["AAPL", "Apple"], "related_assets": ["AAPL","Apple"],
"correlation_instruments": ["QQQ", "NVDA", "SPY"], "correlation_instruments": ["QQQ","NVDA","SPY"],
"ai_context": "AAPL is the world's largest company by market cap. Key drivers: iPhone supercycle, Services revenue growth (highest-margin), China exposure. Bullish on: new product launches, AI integration (Apple Intelligence), services acceleration. Bearish on: China ban risk, rate hikes, iPhone demand slowdown. Watch: quarterly earnings, China sales data." "ai_context": "AAPL is world's largest market cap. Key drivers: iPhone cycle, Services revenue growth, China exposure, AI integration. Watch: quarterly earnings, China sales, Apple Intelligence traction."
}, },
{ {
"id": "NVDA", "id": "NVDA",
@@ -447,25 +367,20 @@
"exchange": "NASDAQ", "exchange": "NASDAQ",
"category": "stock", "category": "stock",
"currency": "USD", "currency": "USD",
"description": "NVIDIA Corporation — AI chips and data center GPU leader", "description": "NVIDIA Corporation — leader mondial des GPU pour IA et data centers",
"chart": { "chart": { "ma_periods": [20, 50, 200], "bollinger_period": 20, "bollinger_std": 2, "show_volume": true },
"ma_periods": [20, 50, 200],
"bollinger_period": 20,
"bollinger_std": 2,
"show_volume": true
},
"drivers": [ "drivers": [
{"key": "ai_capex_cycle", "label": "AI Capex Cycle", "weight": 0.95}, {"key": "ai_capex_cycle", "label": "Capex IA / Hyperscalers","weight": 0.95, "keywords": ["IA","AI","data center","capex","Nvidia","GPU","H100","Blackwell","NVLink","hyperscalers","infrastructure IA"]},
{"key": "earnings_growth", "label": "Earnings Growth", "weight": 0.9}, {"key": "earnings_growth", "label": "Croissance Bénéfices", "weight": 0.9, "keywords": ["NVDA earnings","résultats Nvidia","revenues","data center revenue","beat","guidance"]},
{"key": "data_center_demand", "label": "Data Center Demand", "weight": 0.85}, {"key": "data_center_demand","label": "Demande Data Center", "weight": 0.85, "keywords": ["Amazon AWS","Microsoft Azure","Google Cloud","data center","cloud","computing","GPU demand"]},
{"key": "export_restrictions", "label": "Export Restrictions", "weight": 0.7}, {"key": "export_restrictions","label": "Restrictions Export", "weight": 0.7, "keywords": ["export control","restrictions export","Chine","China ban","BIS","CISA","H20","chip ban"]},
{"key": "rates", "label": "Interest Rates", "weight": 0.6} {"key": "rates", "label": "Taux / Valorisation", "weight": 0.6, "keywords": ["taux","valorisation","growth stock","multiples","discount rate","Fed"]}
], ],
"regime_labels": ["AI Bull Run", "AI Hype Correction", "Earnings Catalyst", "Rate Compression", "Consolidation"], "regime_labels": ["AI Bull Run", "AI Hype Correction", "Earnings Catalyst", "Rate Compression", "Consolidation"],
"event_keywords": ["NVIDIA Earnings", "AI", "Data Center", "Export", "GPU", "NVDA"], "event_keywords": ["NVIDIA Earnings","AI","Data Center","Export","GPU","NVDA","Blackwell"],
"related_assets": ["NVDA", "NVIDIA", "AI"], "related_assets": ["NVDA","NVIDIA","AI"],
"correlation_instruments": ["QQQ", "AAPL", "SPY"], "correlation_instruments": ["QQQ","AAPL","SPY"],
"ai_context": "NVDA is the dominant AI infrastructure supplier (H100/H200/Blackwell GPUs). Bullish on: AI capex spending by hyperscalers, earnings beats, new GPU architecture launches. Bearish on: US-China export restrictions, AI spending slowdown, competitive pressure from AMD/Intel. Watch: quarterly data center revenue, export control updates." "ai_context": "NVDA dominates AI infrastructure (H100/Blackwell GPUs). Bullish: AI capex spending, earnings beats, new GPU launches. Bearish: US-China export restrictions, AI spending slowdown."
}, },
{ {
"id": "GS", "id": "GS",
@@ -475,24 +390,19 @@
"exchange": "NYSE", "exchange": "NYSE",
"category": "stock", "category": "stock",
"currency": "USD", "currency": "USD",
"description": "The Goldman Sachs Group — global investment bank", "description": "The Goldman Sachs Group — banque d'investissement globale",
"chart": { "chart": { "ma_periods": [20, 50, 200], "bollinger_period": 20, "bollinger_std": 2, "show_volume": true },
"ma_periods": [20, 50, 200],
"bollinger_period": 20,
"bollinger_std": 2,
"show_volume": true
},
"drivers": [ "drivers": [
{"key": "yield_curve", "label": "Yield Curve", "weight": 0.85}, {"key": "yield_curve", "label": "Courbe des Taux", "weight": 0.85, "keywords": ["courbe des taux","2Y-10Y","steepening","inversion","yield curve","10Y-2Y","pente"]},
{"key": "ma_activity", "label": "M&A Activity", "weight": 0.8}, {"key": "ma_activity", "label": "M&A / Activité Deals", "weight": 0.8, "keywords": ["M&A","fusion-acquisition","LBO","IPO","deal","advisory","boutique","transaction"]},
{"key": "rates", "label": "Interest Rates", "weight": 0.8}, {"key": "rates", "label": "Taux d'Intérêt", "weight": 0.8, "keywords": ["taux","Fed","intérêts","NIM","net interest margin","Fed rate","interest rates"]},
{"key": "credit_conditions", "label": "Credit Conditions", "weight": 0.75} {"key": "credit_conditions","label": "Conditions de Crédit", "weight": 0.75, "keywords": ["crédit","spreads","conditions financières","leverage loans","credit tightening"]}
], ],
"regime_labels": ["Finance Bull", "Recession Fear", "Yield Curve Steepen", "M&A Boom", "Range"], "regime_labels": ["Finance Bull", "Recession Fear", "Yield Curve Steepen", "M&A Boom", "Range"],
"event_keywords": ["Goldman", "Financials", "Yield Curve", "M&A", "Banking", "Fed", "Credit"], "event_keywords": ["Goldman","Financials","Yield Curve","M&A","Banking","Fed","Credit"],
"related_assets": ["GS", "Goldman", "XLF", "Financials"], "related_assets": ["GS","Goldman","XLF","Financials"],
"correlation_instruments": ["SPY", "TLT", "HYG"], "correlation_instruments": ["SPY","TLT","HYG"],
"ai_context": "Goldman Sachs benefits from: steep yield curve (NIM expansion), M&A advisory boom, robust fixed income trading. Bullish on: rising rates + yield curve steepening, IPO/M&A surge, strong trading volumes. Bearish on: yield curve inversion, recession (deal freeze), credit crisis. Watch: 2Y-10Y spread, IPO pipeline, trading revenues." "ai_context": "Goldman benefits from: steep yield curve, M&A advisory boom, robust trading. Bullish: rate curve steepening, IPO/M&A surge. Bearish: yield curve inversion, recession, credit crisis."
}, },
{ {
"id": "XOM", "id": "XOM",
@@ -502,25 +412,20 @@
"exchange": "NYSE", "exchange": "NYSE",
"category": "stock", "category": "stock",
"currency": "USD", "currency": "USD",
"description": "Exxon Mobil Corporation — global integrated energy major", "description": "Exxon Mobil Corporation — major pétrolier intégré mondial",
"chart": { "chart": { "ma_periods": [20, 50, 200], "bollinger_period": 20, "bollinger_std": 2, "show_volume": true },
"ma_periods": [20, 50, 200],
"bollinger_period": 20,
"bollinger_std": 2,
"show_volume": true
},
"drivers": [ "drivers": [
{"key": "oil_price", "label": "Oil Price", "weight": 0.9}, {"key": "oil_price", "label": "Prix du Pétrole", "weight": 0.9, "keywords": ["WTI","Brent","pétrole","crude","oil price","prix pétrole","cours pétrole"]},
{"key": "global_demand", "label": "Global Demand", "weight": 0.8}, {"key": "global_demand", "label": "Demande Mondiale", "weight": 0.8, "keywords": ["demande mondiale","China demand","IEA","croissance","global demand","reprise"]},
{"key": "opec_policy", "label": "OPEC Policy", "weight": 0.75}, {"key": "opec_policy", "label": "OPEP+ Production", "weight": 0.75, "keywords": ["OPEC","OPEP","quotas","production","Saudi","Arabie Saoudite","réduction"]},
{"key": "capex", "label": "Capital Expenditure", "weight": 0.6}, {"key": "capex", "label": "Capex / Investissement","weight": 0.6, "keywords": ["capex","investissement","forage","upstream","exploration","rig count"]},
{"key": "dollar", "label": "Dollar Index", "weight": 0.6} {"key": "dollar", "label": "Dollar Index", "weight": 0.6, "keywords": ["Dollar","DXY","USD"]}
], ],
"regime_labels": ["Energy Bull", "Demand Slowdown", "OPEC Benefit", "Green Transition Drag", "Range"], "regime_labels": ["Energy Bull", "Demand Slowdown", "OPEC Benefit", "Green Transition Drag", "Range"],
"event_keywords": ["Oil", "OPEC", "Energy", "XOM", "EIA", "Crude", "Exxon"], "event_keywords": ["Oil","OPEC","Energy","XOM","EIA","Crude","Exxon","WTI"],
"related_assets": ["XOM", "Exxon", "CL", "Oil", "Energy"], "related_assets": ["XOM","Exxon","CL","Oil","Energy"],
"correlation_instruments": ["USO", "UNG", "EEM"], "correlation_instruments": ["USO","UNG","EEM"],
"ai_context": "XOM is the largest US oil major. Revenues are highly correlated to oil and gas prices. Bullish on: OPEC cuts, geopolitical supply risk, refining margin expansion. Bearish on: oil price collapse, global recession, accelerating energy transition. Watch: WTI/Brent prices, OPEC+ decisions, quarterly earnings." "ai_context": "XOM revenues highly correlated to oil/gas prices. Bullish: OPEC cuts, geopolitical supply risk, refining margins. Bearish: oil price collapse, global recession, energy transition."
}, },
{ {
"id": "BTC-USD", "id": "BTC-USD",
@@ -530,25 +435,20 @@
"exchange": "Crypto", "exchange": "Crypto",
"category": "crypto", "category": "crypto",
"currency": "USD", "currency": "USD",
"description": "Bitcoin — leading decentralized digital asset", "description": "Bitcoin — premier actif décentralisé, indicateur de liquidité et Risk-On",
"chart": { "chart": { "ma_periods": [20, 50, 200], "bollinger_period": 20, "bollinger_std": 2, "show_volume": true },
"ma_periods": [20, 50, 200],
"bollinger_period": 20,
"bollinger_std": 2,
"show_volume": true
},
"drivers": [ "drivers": [
{"key": "global_liquidity", "label": "Global Liquidity", "weight": 0.85}, {"key": "global_liquidity","label": "Liquidité Mondiale", "weight": 0.85, "keywords": ["liquidité mondiale","M2","Fed","QE","QT","conditions financières","liquidity","money supply"]},
{"key": "etf_flows", "label": "ETF Flows", "weight": 0.85}, {"key": "etf_flows", "label": "Flux ETF Bitcoin", "weight": 0.85, "keywords": ["ETF Bitcoin","spot BTC ETF","BlackRock","Fidelity","flux ETF","ETF approval","IBIT","FBTC"]},
{"key": "risk_appetite", "label": "Risk Appetite", "weight": 0.8}, {"key": "risk_appetite", "label": "Sentiment Risk-On", "weight": 0.8, "keywords": ["risk-on","risk-off","crypto","sentiment","appétit pour le risque","halving","cycle crypto"]},
{"key": "regulatory", "label": "Regulatory Environment", "weight": 0.7}, {"key": "regulatory", "label": "Réglementation Crypto", "weight": 0.7, "keywords": ["réglementation crypto","SEC","CFTC","crypto ban","ETF approval","régulation","MiCA"]},
{"key": "dollar", "label": "Dollar Index", "weight": 0.65} {"key": "dollar", "label": "Dollar Index", "weight": 0.65, "keywords": ["Dollar","DXY","USD","dédollarisation"]}
], ],
"regime_labels": ["Crypto Bull (ETF Inflows)", "Risk-Off Crypto Selloff", "Liquidity Expansion", "Regulatory Fear", "Consolidation"], "regime_labels": ["Crypto Bull (ETF Inflows)", "Risk-Off Crypto Selloff", "Liquidity Expansion", "Regulatory Fear", "Consolidation"],
"event_keywords": ["Bitcoin", "BTC", "Crypto", "ETF", "Fed", "Liquidity", "Regulation"], "event_keywords": ["Bitcoin","BTC","Crypto","ETF","Fed","Liquidity","Regulation","halving"],
"related_assets": ["BTC", "Bitcoin", "Crypto"], "related_assets": ["BTC","Bitcoin","Crypto"],
"correlation_instruments": ["SPY", "QQQ", "GLD"], "correlation_instruments": ["SPY","QQQ","GLD"],
"ai_context": "Bitcoin is the dominant crypto asset. Acts as a high-beta risk asset and an emerging macro hedge. Bullish on: Fed liquidity expansion, spot ETF inflows, halving cycle, institutional adoption. Bearish on: regulatory crackdown, risk-off markets, Fed tightening, crypto-specific contagion. Watch: spot BTC ETF flows, M2 money supply, halving cycle timing." "ai_context": "Bitcoin: high-beta risk asset + emerging macro hedge. Bullish: Fed liquidity expansion, spot ETF inflows, halving cycle. Bearish: regulatory crackdown, risk-off, Fed tightening."
} }
] ]
} }

View File

@@ -3,6 +3,7 @@ Instrument Dashboard Router.
Exposes per-instrument snapshot (price, indicators, regime, trend, events) and AI narrative. Exposes per-instrument snapshot (price, indicators, regime, trend, events) and AI narrative.
""" """
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
from services.instrument_service import ( from services.instrument_service import (
@@ -10,8 +11,12 @@ from services.instrument_service import (
get_instrument, get_instrument,
get_snapshot, get_snapshot,
get_narrative, get_narrative,
update_instrument_drivers,
) )
class DriverUpdate(BaseModel):
drivers: List[Dict[str, Any]]
router = APIRouter(prefix="/api/instruments", tags=["instruments"]) router = APIRouter(prefix="/api/instruments", tags=["instruments"])
@@ -63,3 +68,20 @@ async def generate_narrative(
"instrument_name": config.get("name", instrument_id), "instrument_name": config.get("name", instrument_id),
"narrative": narrative, "narrative": narrative,
} }
@router.put("/{instrument_id}/drivers")
def update_drivers(instrument_id: str, body: DriverUpdate) -> Dict[str, Any]:
"""
Persist updated drivers (label, weight, keywords) for an instrument to instruments.json.
"""
config = get_instrument(instrument_id)
if not config:
raise HTTPException(status_code=404, detail=f"Instrument '{instrument_id}' not found")
try:
update_instrument_drivers(instrument_id, body.drivers)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
return {"ok": True, "instrument_id": instrument_id.upper(), "drivers_count": len(body.drivers)}

View File

@@ -42,6 +42,33 @@ def get_instrument(instrument_id: str) -> Optional[Dict]:
return _configs.get(instrument_id.upper()) return _configs.get(instrument_id.upper())
def update_instrument_drivers(instrument_id: str, drivers: List[Dict]) -> None:
"""Persist updated drivers to instruments.json and refresh in-memory config."""
global _configs
if _configs is None:
_load_configs()
uid = instrument_id.upper()
if uid not in _configs:
raise ValueError(f"Instrument {uid} not found")
# Load raw JSON, update the matching instrument, save back
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
raw = json.load(f)
for inst in raw["instruments"]:
if inst["id"] == uid:
inst["drivers"] = drivers
break
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
json.dump(raw, f, ensure_ascii=False, indent=2)
# Refresh in-memory cache
_configs[uid]["drivers"] = drivers
logger.info(f"[instrument_service] Updated drivers for {uid} ({len(drivers)} drivers)")
# ── DataFrame helpers ────────────────────────────────────────────────────────── # ── DataFrame helpers ──────────────────────────────────────────────────────────
def _ohlcv_to_df(records: List[Dict]) -> pd.DataFrame: def _ohlcv_to_df(records: List[Dict]) -> pd.DataFrame:

View File

@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useMemo } from 'react'
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import { import {
Sparkles, RefreshCw, ChevronDown, TrendingUp, TrendingDown, Sparkles, RefreshCw, ChevronDown, TrendingUp, TrendingDown,
Minus, BarChart2, Clock, Calendar, AlertCircle, Minus, BarChart2, Clock, Calendar, AlertCircle, Pencil, Save, X, Plus, Trash2,
} from 'lucide-react' } from 'lucide-react'
import axios from 'axios' import axios from 'axios'
import clsx from 'clsx' import clsx from 'clsx'
@@ -12,10 +12,13 @@ const api = axios.create({ baseURL: '/api' })
// ── Types ───────────────────────────────────────────────────────────────────── // ── Types ─────────────────────────────────────────────────────────────────────
interface Driver {
key: string; label: string; weight: number; keywords: string[]
}
interface InstrumentConfig { interface InstrumentConfig {
id: string; name: string; yf_ticker: string; category: string; currency: string id: string; name: string; yf_ticker: string; category: string; currency: string
description: string description: string; drivers: Driver[]
drivers: { key: string; label: string; weight: number }[]
regime_labels: string[] regime_labels: string[]
chart: { ma_periods: number[]; show_volume: boolean } chart: { ma_periods: number[]; show_volume: boolean }
correlation_instruments: string[] correlation_instruments: string[]
@@ -45,10 +48,7 @@ interface Snapshot {
instrument: InstrumentConfig instrument: InstrumentConfig
price_data: PriceCandle[] price_data: PriceCandle[]
indicators: Record<string, LinePoint[]> indicators: Record<string, LinePoint[]>
regime: { regime: { current: string; confidence: number; scores: Record<string, number>; signals: RegimeSignals }
current: string; confidence: number; scores: Record<string, number>
signals: RegimeSignals
}
trend: TrendMetrics trend: TrendMetrics
events: SnapshotEvent[] events: SnapshotEvent[]
current_price: number; change_pct: number; change_abs: number; period: string current_price: number; change_pct: number; change_abs: number; period: string
@@ -98,148 +98,101 @@ function fmtDateFR(s: string | null): string {
function dateToMs(s: string) { return new Date(s).getTime() } function dateToMs(s: string) { return new Date(s).getTime() }
// ── EventTimelineStrip ──────────────────────────────────────────────────────── function pctN(a: number | undefined, b: number | undefined): number {
if (a === undefined || b === undefined || b === 0) return 0
const LEVEL_STRIP = ['long', 'medium', 'short'] as const return ((a - b) / b) * 100
type LevelKey = typeof LEVEL_STRIP[number]
const STRIP_COLORS: Record<LevelKey, { bg: string; border: string; text: string; label: string }> = {
long: { bg: 'bg-violet-800/60', border: 'border-violet-700/60', text: 'text-violet-100', label: 'text-violet-400' },
medium: { bg: 'bg-blue-800/60', border: 'border-blue-700/60', text: 'text-blue-100', label: 'text-blue-400' },
short: { bg: 'bg-emerald-800/60',border: 'border-emerald-700/60',text: 'text-emerald-100', label: 'text-emerald-400'},
}
const STRIP_LABELS = { long: 'LT', medium: 'MT', short: 'CT' }
const DEFAULT_DAYS: Record<LevelKey, number> = { long: 60, medium: 21, short: 10 }
function EventTimelineStrip({ events, priceData }: { events: SnapshotEvent[]; priceData: PriceCandle[] }) {
if (priceData.length < 2) return null
const chartStart = dateToMs(priceData[0].time)
const chartEnd = dateToMs(priceData[priceData.length - 1].time)
const totalMs = chartEnd - chartStart
if (totalMs <= 0) return null
function leftPct(dateStr: string): number {
return Math.max(0, Math.min(99, ((dateToMs(dateStr) - chartStart) / totalMs) * 100))
}
function widthPct(startStr: string, endStr: string | null, level: LevelKey): number {
const endMs = endStr
? dateToMs(endStr)
: dateToMs(startStr) + DEFAULT_DAYS[level] * 86400000
const w = ((endMs - dateToMs(startStr)) / totalMs) * 100
return Math.min(100, Math.max(0.4, w))
}
return (
<div className="bg-dark-900/40 rounded-xl border border-slate-700/40 px-4 py-3">
<div className="text-xs text-slate-500 uppercase tracking-wide mb-2.5">Événements sur la période</div>
<div className="space-y-1.5">
{LEVEL_STRIP.map(level => {
const evs = events.filter(e => e.level === level)
const cols = STRIP_COLORS[level]
return (
<div key={level} className="flex items-center gap-2 h-6">
<span className={clsx('text-xs font-bold w-5 shrink-0', cols.label)}>{STRIP_LABELS[level]}</span>
{/* paddingRight aligns with lightweight-charts right price scale (~60px) */}
<div className="relative flex-1 h-full" style={{ paddingRight: 62 }}>
{evs.map((ev, i) => {
const left = leftPct(ev.date)
const width = widthPct(ev.date, ev.end_date, level)
return (
<div
key={i}
title={`${ev.title}\n${ev.date}${ev.end_date ? ' → ' + ev.end_date : ''}`}
className={clsx('absolute top-0 h-full rounded border overflow-hidden', cols.bg, cols.border)}
style={{ left: `${left}%`, width: `${width}%` }}
>
{width > 3 && (
<span className={clsx('absolute inset-0 flex items-center px-1 truncate pointer-events-none', cols.text)}
style={{ fontSize: 9 }}>
{ev.title}
</span>
)}
</div>
)
})}
{evs.length === 0 && (
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-dashed border-slate-700/40" />
</div>
)}
</div>
</div>
)
})}
</div>
</div>
)
} }
// ── RegimeCard ──────────────────────────────────────────────────────────────── // ── RegimeCard (métriques de signaux) ─────────────────────────────────────────
function RegimeCard({ function RegimeCard({
regime, config, signalsAt, dateLabel, regime, signalsAt, dateLabel,
}: { }: {
regime: Snapshot['regime']; config: InstrumentConfig regime: Snapshot['regime']
signalsAt: RegimeSignals | null; dateLabel: string signalsAt: RegimeSignals | null
dateLabel: string
}) { }) {
const signals = signalsAt ?? regime.signals const signals = signalsAt ?? regime.signals
const col = regimeColor(regime.current) const col = regimeColor(regime.current)
const colorMap: Record<string, string> = {
emerald: 'text-emerald-400 border-emerald-700/40 bg-emerald-950/30', const borderMap: Record<string, string> = {
red: 'text-red-400 border-red-700/40 bg-red-950/30', emerald: 'border-emerald-700/40 bg-emerald-950/30',
orange: 'text-orange-400 border-orange-700/40 bg-orange-950/30', red: 'border-red-700/40 bg-red-950/30',
slate: 'text-slate-400 border-slate-700/40 bg-slate-900/30', orange: 'border-orange-700/40 bg-orange-950/30',
blue: 'text-blue-400 border-blue-700/40 bg-blue-950/30', slate: 'border-slate-700/40 bg-slate-900/30',
blue: 'border-blue-700/40 bg-blue-950/30',
} }
const barColorMap: Record<string, string> = {
emerald: 'bg-emerald-500', red: 'bg-red-500', orange: 'bg-orange-500', slate: 'bg-slate-500', blue: 'bg-blue-500', // 6 signal metrics displayed as a compact grid
} const ma50Color = signals.ma50_above_ma200 ? 'text-emerald-400' : 'text-red-400'
const sigItems = [ const volColor = (signals.vol_ratio_pct ?? 0) > 130 ? 'text-orange-400' : (signals.vol_ratio_pct ?? 0) < 70 ? 'text-cyan-400' : 'text-slate-300'
{ label: 'MA50 vs MA200', value: signals.ma50_above_ma200 === true ? 'Au-dessus ↑' : signals.ma50_above_ma200 === false ? 'En-dessous ↓' : '—', color: signals.ma50_above_ma200 ? 'text-emerald-400' : 'text-red-400' },
{ label: 'Slope MA50 (10j)', value: fmt(signals.ma50_slope_pct) + '%', color: pctColor(signals.ma50_slope_pct) }, const metrics: { label: string; value: string; sub?: string; color: string }[] = [
{ label: 'Momentum 20j', value: fmt(signals.momentum_20d_pct) + '%', color: pctColor(signals.momentum_20d_pct) }, {
{ label: 'Distance MA200', value: fmt(signals.dist_ma200_pct) + '%', color: pctColor(signals.dist_ma200_pct) }, label: 'MA50 / MA200',
{ label: 'Volatilité (ATR%)', value: (signals.vol_ratio_pct ?? 0).toFixed(1) + '%', color: signals.vol_ratio_pct > 130 ? 'text-orange-400' : 'text-slate-400' }, value: signals.ma50_above_ma200 === true ? 'Au-dessus' : signals.ma50_above_ma200 === false ? 'En-dessous' : '—',
sub: signals.ma50_above_ma200 === true ? '↑ Golden cross' : signals.ma50_above_ma200 === false ? '↓ Death cross' : '',
color: ma50Color,
},
{
label: 'Slope MA50 (10j)',
value: fmt(signals.ma50_slope_pct) + '%',
color: pctColor(signals.ma50_slope_pct),
},
{
label: 'Slope MA200 (10j)',
value: fmt(signals.ma200_slope_pct) + '%',
color: pctColor(signals.ma200_slope_pct),
},
{
label: 'Momentum 20j',
value: fmt(signals.momentum_20d_pct) + '%',
color: pctColor(signals.momentum_20d_pct),
},
{
label: 'Distance MA200',
value: fmt(signals.dist_ma200_pct) + '%',
sub: signals.dist_ma200_pct > 10 ? 'Surextension' : signals.dist_ma200_pct < -10 ? 'Survendu' : 'Neutre',
color: pctColor(signals.dist_ma200_pct),
},
{
label: 'Volatilité ATR',
value: (signals.vol_ratio_pct ?? 0).toFixed(0) + '%',
sub: (signals.vol_ratio_pct ?? 0) > 130 ? 'Élevée' : (signals.vol_ratio_pct ?? 0) < 70 ? 'Comprimée' : 'Normale',
color: volColor,
},
] ]
return ( return (
<div className={clsx('rounded-xl border p-4 space-y-3', colorMap[col])}> <div className={clsx('rounded-xl border p-4 space-y-3', borderMap[col])}>
{/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<BarChart2 className={clsx('w-4 h-4', `text-${col}-400`)} /> <BarChart2 className={clsx('w-4 h-4', `text-${col}-400`)} />
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Régime</span> <span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Régime Détecté</span>
</div> </div>
<span className="text-xs text-slate-600">{dateLabel}</span> <span className="text-xs text-slate-600">{dateLabel}</span>
</div> </div>
<div>
<div className={clsx('text-lg font-bold leading-tight', `text-${col}-400`)}>{regime.current}</div> {/* Regime label + confidence */}
<div className="text-xs text-slate-500 mt-0.5">Confiance {Math.round(regime.confidence * 100)}%</div> <div className={clsx('rounded-lg px-3 py-2', `bg-${col}-900/20`)}>
<div className={clsx('text-base font-bold leading-tight', `text-${col}-300`)}>{regime.current}</div>
<div className="flex items-center gap-2 mt-1">
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div className={clsx('h-full rounded-full', `bg-${col}-500`)}
style={{ width: `${Math.round(regime.confidence * 100)}%` }} />
</div>
<span className="text-xs text-slate-500">{Math.round(regime.confidence * 100)}%</span>
</div>
</div> </div>
<div className="space-y-1.5">
{Object.entries(regime.scores).sort(([, a], [, b]) => b - a).map(([label, score]) => { {/* 6 signal metrics — 2 column grid */}
const c2 = regimeColor(label) <div className="grid grid-cols-2 gap-2">
return ( {metrics.map(m => (
<div key={label} className="space-y-0.5"> <div key={m.label} className="rounded-lg bg-dark-700/50 px-2.5 py-2">
<div className="flex items-center justify-between text-xs"> <div className="text-xs text-slate-600 mb-0.5 truncate">{m.label}</div>
<span className={label === regime.current ? `font-semibold text-${c2}-400` : 'text-slate-500'}>{label}</span> <div className={clsx('text-sm font-bold', m.color)}>{m.value}</div>
<span className="text-slate-500">{Math.round(score * 100)}%</span> {m.sub && <div className="text-xs text-slate-600 mt-0.5">{m.sub}</div>}
</div>
<div className="h-1 bg-slate-800 rounded-full overflow-hidden">
<div className={clsx('h-full rounded-full transition-all', barColorMap[c2] || 'bg-blue-500')}
style={{ width: `${Math.round(score * 100)}%` }} />
</div>
</div>
)
})}
</div>
<div className="border-t border-slate-700/30 pt-3 space-y-1.5">
<div className="text-xs text-slate-600 uppercase tracking-wide mb-1">Signaux {dateLabel}</div>
{sigItems.map(s => (
<div key={s.label} className="flex items-center justify-between text-xs">
<span className="text-slate-500">{s.label}</span>
<span className={s.color}>{s.value}</span>
</div> </div>
))} ))}
</div> </div>
@@ -255,25 +208,20 @@ function TrendCard({ trend, dateLabel }: { trend: TrendMetrics; dateLabel: strin
const rsiZone = rsi > 70 ? 'Suracheté' : rsi < 30 ? 'Survendu' : 'Neutre' const rsiZone = rsi > 70 ? 'Suracheté' : rsi < 30 ? 'Survendu' : 'Neutre'
const items = [ const items = [
{ { group: 'Tendance', rows: [
group: 'Tendance', rows: [ { label: 'Slope MA50 (5j)', value: fmt(trend.ma50_slope_5d) + '%', arrow: trend.ma50_slope_5d },
{ label: 'Slope MA50 (5j)', value: fmt(trend.ma50_slope_5d) + '%', arrow: trend.ma50_slope_5d }, { label: 'Slope MA200 (20j)', value: fmt(trend.ma200_slope_20d) + '%', arrow: trend.ma200_slope_20d },
{ label: 'Slope MA200 (20j)',value: fmt(trend.ma200_slope_20d) + '%', arrow: trend.ma200_slope_20d }, { label: 'Distance MA50', value: trend.dist_ma50_pct != null ? fmt(trend.dist_ma50_pct) + '%' : '—', arrow: trend.dist_ma50_pct ?? 0 },
{ label: 'Distance MA50', value: trend.dist_ma50_pct !== null ? fmt(trend.dist_ma50_pct) + '%' : '—', arrow: trend.dist_ma50_pct ?? 0 }, { label: 'Distance MA200', value: trend.dist_ma200_pct != null ? fmt(trend.dist_ma200_pct) + '%' : '—', arrow: trend.dist_ma200_pct ?? 0, bold: true },
{ label: 'Distance MA200', value: trend.dist_ma200_pct !== null ? fmt(trend.dist_ma200_pct) + '%' : '—', arrow: trend.dist_ma200_pct ?? 0, bold: true }, ]},
] { group: 'Momentum', rows: [
}, { label: 'Momentum 1M', value: fmt(trend.momentum_1m_pct) + '%', arrow: trend.momentum_1m_pct },
{ { label: 'Momentum 3M', value: fmt(trend.momentum_3m_pct) + '%', arrow: trend.momentum_3m_pct, bold: true },
group: 'Momentum', rows: [ ]},
{ label: 'Momentum 1M', value: fmt(trend.momentum_1m_pct) + '%', arrow: trend.momentum_1m_pct },
{ label: 'Momentum 3M', value: fmt(trend.momentum_3m_pct) + '%', arrow: trend.momentum_3m_pct, bold: true },
]
},
] ]
const pct52w = trend.high_52w && trend.low_52w && (trend.high_52w - trend.low_52w) > 0 const pct52w = trend.high_52w && trend.low_52w && (trend.high_52w - trend.low_52w) > 0
? ((trend.current_price - trend.low_52w) / (trend.high_52w - trend.low_52w)) * 100 ? ((trend.current_price - trend.low_52w) / (trend.high_52w - trend.low_52w)) * 100 : null
: null
return ( return (
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4 space-y-3"> <div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4 space-y-3">
@@ -285,7 +233,6 @@ function TrendCard({ trend, dateLabel }: { trend: TrendMetrics; dateLabel: strin
<span className="text-xs text-slate-600">{dateLabel}</span> <span className="text-xs text-slate-600">{dateLabel}</span>
</div> </div>
{/* Prix au snapshot */}
<div className="flex items-center justify-between bg-dark-700/40 rounded-lg px-3 py-1.5"> <div className="flex items-center justify-between bg-dark-700/40 rounded-lg px-3 py-1.5">
<span className="text-xs text-slate-500">Prix</span> <span className="text-xs text-slate-500">Prix</span>
<span className="text-sm font-bold text-white"> <span className="text-sm font-bold text-white">
@@ -300,8 +247,7 @@ function TrendCard({ trend, dateLabel }: { trend: TrendMetrics; dateLabel: strin
<div key={row.label} className="flex items-center justify-between text-xs py-0.5"> <div key={row.label} className="flex items-center justify-between text-xs py-0.5">
<span className="text-slate-500">{row.label}</span> <span className="text-slate-500">{row.label}</span>
<span className={clsx('flex items-center gap-1', (row as any).bold ? 'font-semibold' : '', pctColor(row.arrow ?? 0))}> <span className={clsx('flex items-center gap-1', (row as any).bold ? 'font-semibold' : '', pctColor(row.arrow ?? 0))}>
<Arrow v={row.arrow ?? 0} /> <Arrow v={row.arrow ?? 0} />{row.value}
{row.value}
</span> </span>
</div> </div>
))} ))}
@@ -334,8 +280,7 @@ function TrendCard({ trend, dateLabel }: { trend: TrendMetrics; dateLabel: strin
<div className="absolute top-0 h-full bg-blue-500/60 rounded-full" style={{ width: `${pct52w}%` }} /> <div className="absolute top-0 h-full bg-blue-500/60 rounded-full" style={{ width: `${pct52w}%` }} />
</div> </div>
<div className="flex justify-between text-xs text-slate-600 mt-0.5"> <div className="flex justify-between text-xs text-slate-600 mt-0.5">
<span>{trend.low_52w?.toFixed(2)}</span> <span>{trend.low_52w?.toFixed(2)}</span><span>{trend.high_52w?.toFixed(2)}</span>
<span>{trend.high_52w?.toFixed(2)}</span>
</div> </div>
</div> </div>
)} )}
@@ -368,7 +313,7 @@ function EventsCard({ events }: { events: SnapshotEvent[] }) {
{events.length === 0 ? ( {events.length === 0 ? (
<div className="text-xs text-slate-600 italic py-2">Aucun événement lié trouvé</div> <div className="text-xs text-slate-600 italic py-2">Aucun événement lié trouvé</div>
) : ( ) : (
<div className="space-y-2 max-h-[360px] overflow-y-auto pr-1"> <div className="space-y-2 max-h-[380px] overflow-y-auto pr-1">
{events.map((ev, i) => ( {events.map((ev, i) => (
<div key={i} <div key={i}
className="rounded-lg border border-slate-700/30 bg-dark-700/40 p-2 cursor-pointer hover:bg-dark-700/70 transition-colors" className="rounded-lg border border-slate-700/30 bg-dark-700/40 p-2 cursor-pointer hover:bg-dark-700/70 transition-colors"
@@ -396,9 +341,9 @@ function EventsCard({ events }: { events: SnapshotEvent[] }) {
// ── NarrativeCard ───────────────────────────────────────────────────────────── // ── NarrativeCard ─────────────────────────────────────────────────────────────
function NarrativeCard({ function NarrativeCard({ narrative, loading, onLoad, instrument }: {
narrative, loading, onLoad, instrument, narrative: string; loading: boolean; onLoad: () => void; instrument: InstrumentConfig
}: { narrative: string; loading: boolean; onLoad: () => void; instrument: InstrumentConfig }) { }) {
return ( return (
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4"> <div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
@@ -421,36 +366,222 @@ function NarrativeCard({
</div> </div>
) : ( ) : (
<div className="text-sm text-slate-600 italic"> <div className="text-sm text-slate-600 italic">
Cliquez "Générer" pour obtenir une analyse IA pour {instrument.name}. Cliquez "Générer" pour une analyse IA pour {instrument.name}.
</div> </div>
)} )}
</div> </div>
) )
} }
// ── EventTimelineStrip — by drivers ──────────────────────────────────────────
const DRIVER_PALETTE = [
{ bg: 'bg-violet-800/60', border: 'border-violet-700/60', text: 'text-violet-100', label: 'text-violet-400' },
{ bg: 'bg-blue-800/60', border: 'border-blue-700/60', text: 'text-blue-100', label: 'text-blue-400' },
{ bg: 'bg-emerald-800/60', border: 'border-emerald-700/60', text: 'text-emerald-100', label: 'text-emerald-400' },
{ bg: 'bg-amber-800/60', border: 'border-amber-700/60', text: 'text-amber-100', label: 'text-amber-400' },
]
function eventMatchesDriver(ev: SnapshotEvent, keywords: string[]): boolean {
const text = `${ev.title} ${ev.description ?? ''} ${ev.category ?? ''}`.toLowerCase()
return keywords.some(kw => text.includes(kw.toLowerCase()))
}
function EventTimelineStrip({
events, priceData, drivers,
}: {
events: SnapshotEvent[]; priceData: PriceCandle[]; drivers: Driver[]
}) {
if (priceData.length < 2) return null
const chartStart = dateToMs(priceData[0].time)
const chartEnd = dateToMs(priceData[priceData.length - 1].time)
const totalMs = chartEnd - chartStart
if (totalMs <= 0) return null
function leftPct(d: string) { return Math.max(0, Math.min(99, ((dateToMs(d) - chartStart) / totalMs) * 100)) }
function widthPct(start: string, end: string | null): number {
// Default width = 3% of period if no end date
const endMs = end ? dateToMs(end) : dateToMs(start) + totalMs * 0.03
return Math.min(100, Math.max(0.4, ((endMs - dateToMs(start)) / totalMs) * 100))
}
// Top 4 drivers by weight
const topDrivers = [...drivers].sort((a, b) => b.weight - a.weight).slice(0, 4)
return (
<div className="bg-dark-900/40 rounded-xl border border-slate-700/40 px-4 py-3">
<div className="text-xs text-slate-500 uppercase tracking-wide mb-2.5">Événements par driver</div>
<div className="space-y-1.5">
{topDrivers.map((driver, di) => {
const cols = DRIVER_PALETTE[di % DRIVER_PALETTE.length]
const matchedEvs = events.filter(ev => eventMatchesDriver(ev, driver.keywords ?? []))
return (
<div key={driver.key} className="flex items-center gap-2 h-6">
<span className={clsx('text-xs font-semibold shrink-0 truncate w-28', cols.label)}
style={{ fontSize: 10 }}
title={driver.label}
>
{driver.label}
</span>
{/* paddingRight aligns with chart right price scale (~60px) */}
<div className="relative flex-1 h-full" style={{ paddingRight: 62 }}>
{matchedEvs.map((ev, i) => {
const left = leftPct(ev.date)
const width = widthPct(ev.date, ev.end_date)
return (
<div key={i}
title={`${ev.title}\n${ev.date}${ev.end_date ? ' → ' + ev.end_date : ''}\n${ev.description ?? ''}`}
className={clsx('absolute top-0 h-full rounded border overflow-hidden', cols.bg, cols.border)}
style={{ left: `${left}%`, width: `${width}%` }}
>
{width > 3 && (
<span className={clsx('absolute inset-0 flex items-center px-1 truncate pointer-events-none', cols.text)}
style={{ fontSize: 9 }}>
{ev.title}
</span>
)}
</div>
)
})}
{matchedEvs.length === 0 && (
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-dashed border-slate-700/30" />
</div>
)}
</div>
</div>
)
})}
</div>
</div>
)
}
// ── DriversPanel — édition inline ────────────────────────────────────────────
function DriversPanel({ instrumentId, drivers, onSave, onClose }: {
instrumentId: string
drivers: Driver[]
onSave: (drivers: Driver[]) => void
onClose: () => void
}) {
const [local, setLocal] = useState<Driver[]>(() => drivers.map(d => ({ ...d, keywords: [...(d.keywords ?? [])] })))
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
function updateDriver(i: number, field: keyof Driver, val: any) {
setLocal(prev => prev.map((d, idx) => idx === i ? { ...d, [field]: val } : d))
}
function updateKeywords(i: number, raw: string) {
const kws = raw.split(',').map(s => s.trim()).filter(Boolean)
updateDriver(i, 'keywords', kws)
}
function addDriver() {
setLocal(prev => [...prev, { key: `driver_${Date.now()}`, label: 'Nouveau driver', weight: 0.5, keywords: [] }])
}
function removeDriver(i: number) {
setLocal(prev => prev.filter((_, idx) => idx !== i))
}
async function save() {
setSaving(true); setError('')
try {
await api.put(`/instruments/${instrumentId}/drivers`, { drivers: local })
onSave(local)
onClose()
} catch (e: any) {
setError(e?.response?.data?.detail ?? 'Erreur de sauvegarde')
} finally {
setSaving(false)
}
}
return (
<div className="rounded-xl border border-blue-700/40 bg-dark-800/80 p-4 space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-white">Éditer les drivers {instrumentId}</span>
<div className="flex items-center gap-2">
<button onClick={addDriver}
className="flex items-center gap-1 text-xs px-2.5 py-1 bg-blue-800/30 hover:bg-blue-800/50 text-blue-300 border border-blue-700/40 rounded-lg transition-colors">
<Plus className="w-3 h-3" /> Ajouter
</button>
<button onClick={save} disabled={saving}
className="flex items-center gap-1.5 text-xs px-3 py-1 bg-emerald-800/30 hover:bg-emerald-800/50 text-emerald-300 border border-emerald-700/40 rounded-lg transition-colors disabled:opacity-40">
{saving ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Save className="w-3 h-3" />}
{saving ? 'Sauvegarde...' : 'Sauvegarder'}
</button>
<button onClick={onClose} className="p-1 text-slate-500 hover:text-white transition-colors">
<X className="w-4 h-4" />
</button>
</div>
</div>
{error && <div className="text-xs text-red-400 bg-red-900/20 border border-red-700/30 rounded-lg px-3 py-1.5">{error}</div>}
<div className="space-y-2 max-h-[420px] overflow-y-auto pr-1">
{local.map((d, i) => (
<div key={i} className="rounded-lg border border-slate-700/30 bg-dark-700/40 p-3 space-y-2">
<div className="flex items-center gap-2">
<input
className="flex-1 text-xs bg-dark-600 border border-slate-700/40 rounded px-2 py-1 text-white placeholder-slate-600"
placeholder="Label"
value={d.label}
onChange={e => updateDriver(i, 'label', e.target.value)}
/>
<div className="flex items-center gap-1.5">
<span className="text-xs text-slate-500">Poids</span>
<input
type="number" min="0" max="1" step="0.05"
className="w-16 text-xs bg-dark-600 border border-slate-700/40 rounded px-2 py-1 text-white"
value={d.weight}
onChange={e => updateDriver(i, 'weight', parseFloat(e.target.value))}
/>
</div>
<button onClick={() => removeDriver(i)} className="p-1 text-slate-600 hover:text-red-400 transition-colors">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<div>
<label className="text-xs text-slate-600 mb-0.5 block">Mots-clés (séparés par virgule)</label>
<input
className="w-full text-xs bg-dark-600 border border-slate-700/40 rounded px-2 py-1 text-slate-300 placeholder-slate-600"
placeholder="Fed, FOMC, pivot, QE, taux directeur..."
value={(d.keywords ?? []).join(', ')}
onChange={e => updateKeywords(i, e.target.value)}
/>
</div>
</div>
))}
</div>
</div>
)
}
// ── Main page ───────────────────────────────────────────────────────────────── // ── Main page ─────────────────────────────────────────────────────────────────
const PERIODS = [ const PERIODS = [
{ key: '3mo', label: '3M' }, { key: '6mo', label: '6M' }, { key: '3mo', label: '3M' }, { key: '6mo', label: '6M' },
{ key: '1y', label: '1Y' }, { key: '2y', label: '2Y' }, { key: '5y', label: '5Y' }, { key: '1y', label: '1Y' }, { key: '2y', label: '2Y' }, { key: '5y', label: '5Y' },
] ]
function pctN(a: number | undefined, b: number | undefined): number {
if (a === undefined || b === undefined || b === 0) return 0
return ((a - b) / b) * 100
}
export default function InstrumentDashboard() { export default function InstrumentDashboard() {
const { id = 'SPY' } = useParams<{ id: string }>() const { id = 'SPY' } = useParams<{ id: string }>()
const navigate = useNavigate() const navigate = useNavigate()
const [period, setPeriod] = useState('1y') const [period, setPeriod] = useState('1y')
const [instruments, setInstruments] = useState<InstrumentConfig[]>([]) const [instruments, setInstruments] = useState<InstrumentConfig[]>([])
const [snapshot, setSnapshot] = useState<Snapshot | null>(null) const [snapshot, setSnapshot] = useState<Snapshot | null>(null)
const [narrative, setNarrative] = useState('') const [narrative, setNarrative] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [loadingNarr, setLoadingNarr] = useState(false) const [loadingNarr, setLoadingNarr] = useState(false)
const [selectorOpen, setSelectorOpen] = useState(false) const [selectorOpen, setSelectorOpen] = useState(false)
const [selectedDate, setSelectedDate] = useState<string | null>(null) const [selectedDate, setSelectedDate] = useState<string | null>(null)
const [editDrivers, setEditDrivers] = useState(false)
// Local drivers (updated optimistically after save)
const [localDrivers, setLocalDrivers] = useState<Driver[] | null>(null)
const instrumentId = id.toUpperCase() const instrumentId = id.toUpperCase()
@@ -463,6 +594,8 @@ export default function InstrumentDashboard() {
setSnapshot(null) setSnapshot(null)
setNarrative('') setNarrative('')
setSelectedDate(null) setSelectedDate(null)
setLocalDrivers(null)
setEditDrivers(false)
api.get(`/instruments/${instrumentId}/snapshot?period=${period}`) api.get(`/instruments/${instrumentId}/snapshot?period=${period}`)
.then(r => { .then(r => {
setSnapshot(r.data) setSnapshot(r.data)
@@ -483,138 +616,87 @@ export default function InstrumentDashboard() {
const handleDateHover = useCallback((date: string | null) => { const handleDateHover = useCallback((date: string | null) => {
if (date) setSelectedDate(date) if (date) setSelectedDate(date)
// null = mouse left chart area → keep last hovered date
}, []) }, [])
// ── Lookup maps (rebuilt only when snapshot changes) ────────────────────── // ── Lookup maps ───────────────────────────────────────────────────────────
const { priceMap, indMap, sortedDates, dateIndex } = useMemo(() => { const { priceMap, indMap, sortedDates, dateIndex } = useMemo(() => {
if (!snapshot) return { if (!snapshot) return { priceMap: {} as Record<string, PriceCandle>, indMap: {} as Record<string, Record<string, number>>, sortedDates: [] as string[], dateIndex: {} as Record<string, number> }
priceMap: {} as Record<string, PriceCandle>,
indMap: {} as Record<string, Record<string, number>>,
sortedDates: [] as string[],
dateIndex: {} as Record<string, number>,
}
const priceMap: Record<string, PriceCandle> = {} const priceMap: Record<string, PriceCandle> = {}
const indMap: Record<string, Record<string, number>> = {} const indMap: Record<string, Record<string, number>> = {}
const sortedDates: string[] = [] const sortedDates: string[] = []
const dateIndex: Record<string, number> = {} const dateIndex: Record<string, number> = {}
for (const c of snapshot.price_data) { priceMap[c.time] = c; sortedDates.push(c.time) }
for (const c of snapshot.price_data) {
priceMap[c.time] = c
sortedDates.push(c.time)
}
sortedDates.forEach((d, i) => { dateIndex[d] = i }) sortedDates.forEach((d, i) => { dateIndex[d] = i })
for (const [key, pts] of Object.entries(snapshot.indicators)) { for (const [key, pts] of Object.entries(snapshot.indicators)) {
for (const pt of pts) { for (const pt of pts) { if (!indMap[pt.time]) indMap[pt.time] = {}; indMap[pt.time][key] = pt.value }
if (!indMap[pt.time]) indMap[pt.time] = {}
indMap[pt.time][key] = pt.value
}
} }
return { priceMap, indMap, sortedDates, dateIndex } return { priceMap, indMap, sortedDates, dateIndex }
}, [snapshot]) }, [snapshot])
// Resolve effective date (fall back to last if selectedDate not in index)
const effectiveDate = useMemo(() => { const effectiveDate = useMemo(() => {
if (selectedDate && dateIndex[selectedDate] !== undefined) return selectedDate if (selectedDate && dateIndex[selectedDate] !== undefined) return selectedDate
return sortedDates[sortedDates.length - 1] ?? null return sortedDates[sortedDates.length - 1] ?? null
}, [selectedDate, sortedDates, dateIndex]) }, [selectedDate, sortedDates, dateIndex])
// ── Compute trend metrics at selectedDate ─────────────────────────────────
const dateTrend = useMemo((): TrendMetrics | null => { const dateTrend = useMemo((): TrendMetrics | null => {
if (!effectiveDate || !snapshot) return null if (!effectiveDate || !snapshot) return null
const candle = priceMap[effectiveDate] const candle = priceMap[effectiveDate]; if (!candle) return null
if (!candle) return null const idx = dateIndex[effectiveDate], price = candle.close, ind = indMap[effectiveDate] ?? {}
const ma50 = ind.ma50, ma200 = ind.ma200, atr14 = ind.atr14 ?? 0
const idx = dateIndex[effectiveDate]
const price = candle.close
const ind = indMap[effectiveDate] ?? {}
const ma50 = ind.ma50
const ma200 = ind.ma200
const d5 = idx >= 5 ? sortedDates[idx - 5] : null const d5 = idx >= 5 ? sortedDates[idx - 5] : null
const d20 = idx >= 20 ? sortedDates[idx - 20] : null const d20 = idx >= 20 ? sortedDates[idx - 20] : null
const d21 = idx >= 21 ? sortedDates[idx - 21] : null const d21 = idx >= 21 ? sortedDates[idx - 21] : null
const d63 = idx >= 63 ? sortedDates[idx - 63] : null const d63 = idx >= 63 ? sortedDates[idx - 63] : null
const ma50_slope_5d = pctN(ma50, d5 ? indMap[d5]?.ma50 : undefined)
const ma200_slope_20d = pctN(ma200, d20 ? indMap[d20]?.ma200 : undefined)
const momentum_1m_pct = d21 && priceMap[d21] ? pctN(price, priceMap[d21].close) : 0
const momentum_3m_pct = d63 && priceMap[d63] ? pctN(price, priceMap[d63].close) : 0
const dist_ma50_pct = ma50 ? pctN(price, ma50) : null
const dist_ma200_pct = ma200 ? pctN(price, ma200) : null
const atr14 = ind.atr14 ?? 0
let atrSum = 0, atrCnt = 0 let atrSum = 0, atrCnt = 0
for (let i = Math.max(0, idx - 65); i <= idx; i++) { for (let i = Math.max(0, idx - 65); i <= idx; i++) { const v = indMap[sortedDates[i]]?.atr14; if (v) { atrSum += v; atrCnt++ } }
const v = indMap[sortedDates[i]]?.atr14
if (v) { atrSum += v; atrCnt++ }
}
const atr_vs_3m_avg_pct = atrCnt > 0 && atr14 ? (atr14 / (atrSum / atrCnt)) * 100 : 100
let high52 = candle.high, low52 = candle.low let high52 = candle.high, low52 = candle.low
for (let i = Math.max(0, idx - 252); i <= idx; i++) { for (let i = Math.max(0, idx - 252); i <= idx; i++) { const c = priceMap[sortedDates[i]]; if (c) { if (c.high > high52) high52 = c.high; if (c.low < low52) low52 = c.low } }
const c = priceMap[sortedDates[i]]
if (c) { if (c.high > high52) high52 = c.high; if (c.low < low52) low52 = c.low }
}
return { return {
ma50_slope_5d, ma200_slope_20d, ma50_slope_5d: pctN(ma50, d5 ? indMap[d5]?.ma50 : undefined),
rsi14_current: ind.rsi14 ?? 50, ma200_slope_20d: pctN(ma200, d20 ? indMap[d20]?.ma200 : undefined),
atr14_current: atr14, rsi14_current: ind.rsi14 ?? 50,
atr_vs_3m_avg_pct, momentum_1m_pct, momentum_3m_pct, atr14_current: atr14,
dist_ma50_pct, dist_ma200_pct, atr_vs_3m_avg_pct: atrCnt > 0 && atr14 ? (atr14 / (atrSum / atrCnt)) * 100 : 100,
momentum_1m_pct: d21 && priceMap[d21] ? pctN(price, priceMap[d21].close) : 0,
momentum_3m_pct: d63 && priceMap[d63] ? pctN(price, priceMap[d63].close) : 0,
dist_ma50_pct: ma50 ? pctN(price, ma50) : null,
dist_ma200_pct: ma200 ? pctN(price, ma200) : null,
current_price: price, high_52w: high52, low_52w: low52, current_price: price, high_52w: high52, low_52w: low52,
} }
}, [effectiveDate, priceMap, indMap, sortedDates, dateIndex, snapshot]) }, [effectiveDate, priceMap, indMap, sortedDates, dateIndex, snapshot])
// ── Compute regime signals at selectedDate ────────────────────────────────
const dateSignals = useMemo((): RegimeSignals | null => { const dateSignals = useMemo((): RegimeSignals | null => {
if (!effectiveDate || !snapshot) return null if (!effectiveDate || !snapshot) return null
const candle = priceMap[effectiveDate] const candle = priceMap[effectiveDate]; if (!candle) return null
if (!candle) return null const idx = dateIndex[effectiveDate], price = candle.close, ind = indMap[effectiveDate] ?? {}
const ma50 = ind.ma50, ma200 = ind.ma200, atr14 = ind.atr14
const idx = dateIndex[effectiveDate]
const price = candle.close
const ind = indMap[effectiveDate] ?? {}
const ma50 = ind.ma50
const ma200 = ind.ma200
const atr14 = ind.atr14
const d10 = idx >= 10 ? sortedDates[idx - 10] : null const d10 = idx >= 10 ? sortedDates[idx - 10] : null
const d20 = idx >= 20 ? sortedDates[idx - 20] : null const d20 = idx >= 20 ? sortedDates[idx - 20] : null
const ma50_slope_pct = pctN(ma50, d10 ? indMap[d10]?.ma50 : undefined)
const ma200_slope_pct = pctN(ma200, d10 ? indMap[d10]?.ma200 : undefined)
const momentum_20d_pct = d20 && priceMap[d20] ? pctN(price, priceMap[d20].close) : 0
const dist_ma200_pct = ma200 ? pctN(price, ma200) : 0
let atrSum = 0, atrCnt = 0 let atrSum = 0, atrCnt = 0
for (let i = Math.max(0, idx - 65); i <= idx; i++) { for (let i = Math.max(0, idx - 65); i <= idx; i++) { const v = indMap[sortedDates[i]]?.atr14; if (v) { atrSum += v; atrCnt++ } }
const v = indMap[sortedDates[i]]?.atr14
if (v) { atrSum += v; atrCnt++ }
}
const vol_ratio_pct = atrCnt > 0 && atr14 ? (atr14 / (atrSum / atrCnt)) * 100 : 0
return { return {
ma50_above_ma200: ma50 !== undefined && ma200 !== undefined ? ma50 > ma200 : null, ma50_above_ma200: ma50 !== undefined && ma200 !== undefined ? ma50 > ma200 : null,
ma50_slope_pct, ma200_slope_pct, momentum_20d_pct, dist_ma200_pct, vol_ratio_pct, ma50_slope_pct: pctN(ma50, d10 ? indMap[d10]?.ma50 : undefined),
ma200_slope_pct: pctN(ma200, d10 ? indMap[d10]?.ma200 : undefined),
momentum_20d_pct: d20 && priceMap[d20] ? pctN(price, priceMap[d20].close) : 0,
dist_ma200_pct: ma200 ? pctN(price, ma200) : 0,
vol_ratio_pct: atrCnt > 0 && atr14 ? (atr14 / (atrSum / atrCnt)) * 100 : 0,
} }
}, [effectiveDate, priceMap, indMap, sortedDates, dateIndex, snapshot]) }, [effectiveDate, priceMap, indMap, sortedDates, dateIndex, snapshot])
// ── UI helpers ──────────────────────────────────────────────────────────── // ── UI ────────────────────────────────────────────────────────────────────
const grouped = CATEGORY_ORDER.map(cat => ({ const grouped = CATEGORY_ORDER.map(cat => ({
cat, label: CATEGORY_LABELS[cat] ?? cat, cat, label: CATEGORY_LABELS[cat] ?? cat,
items: instruments.filter(i => i.category === cat), items: instruments.filter(i => i.category === cat),
})).filter(g => g.items.length > 0) })).filter(g => g.items.length > 0)
const selected = instruments.find(i => i.id === instrumentId) const selected = instruments.find(i => i.id === instrumentId)
const isLastDate = effectiveDate === sortedDates[sortedDates.length - 1] const isLastDate = effectiveDate === sortedDates[sortedDates.length - 1]
const dateLabel = effectiveDate ? fmtDateFR(effectiveDate) : '—' const dateLabel = effectiveDate ? fmtDateFR(effectiveDate) : '—'
// Header price: from computed trend (crosshair date) or snapshot
const displayPrice = dateTrend?.current_price ?? snapshot?.current_price const displayPrice = dateTrend?.current_price ?? snapshot?.current_price
const displayPricePct = snapshot?.change_pct
// Drivers to use (local override after edit, else from snapshot)
const activeDrivers = localDrivers ?? snapshot?.instrument?.drivers ?? []
return ( return (
<div className="p-6 max-w-screen-xl mx-auto space-y-4"> <div className="p-6 max-w-screen-xl mx-auto space-y-4">
@@ -668,31 +750,45 @@ export default function InstrumentDashboard() {
{displayPrice.toLocaleString('fr-FR', { maximumFractionDigits: 4 })} {displayPrice.toLocaleString('fr-FR', { maximumFractionDigits: 4 })}
</span> </span>
{isLastDate && snapshot && ( {isLastDate && snapshot && (
<span className={clsx('text-sm font-semibold', (displayPricePct ?? 0) >= 0 ? 'text-emerald-400' : 'text-red-400')}> <span className={clsx('text-sm font-semibold', (snapshot.change_pct ?? 0) >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{(displayPricePct ?? 0) >= 0 ? '+' : ''}{snapshot.change_abs?.toFixed(2)} ({(displayPricePct ?? 0) >= 0 ? '+' : ''}{displayPricePct?.toFixed(2)}%) {(snapshot.change_pct ?? 0) >= 0 ? '+' : ''}{snapshot.change_abs?.toFixed(2)} ({(snapshot.change_pct ?? 0) >= 0 ? '+' : ''}{snapshot.change_pct?.toFixed(2)}%)
</span> </span>
)} )}
</div> </div>
)} )}
<div className="ml-auto flex items-center gap-1 bg-dark-800 border border-slate-700/40 rounded-xl p-1"> <div className="ml-auto flex items-center gap-2">
{PERIODS.map(p => ( {/* Drivers edit button */}
<button key={p.key} onClick={() => setPeriod(p.key)} {snapshot && (
className={clsx('px-3 py-1 text-xs rounded-lg transition-colors', <button onClick={() => setEditDrivers(e => !e)}
period === p.key ? 'bg-blue-700/50 text-blue-300' : 'text-slate-400 hover:text-white hover:bg-dark-700' className={clsx('flex items-center gap-1.5 text-xs px-3 py-1.5 border rounded-lg transition-colors',
editDrivers
? 'bg-blue-800/40 text-blue-300 border-blue-700/40'
: 'bg-dark-800 text-slate-400 border-slate-700/40 hover:text-white hover:bg-dark-700'
)} )}
> >
{p.label} <Pencil className="w-3 h-3" />
Drivers
</button> </button>
))} )}
{/* Period selector */}
<div className="flex items-center gap-1 bg-dark-800 border border-slate-700/40 rounded-xl p-1">
{PERIODS.map(p => (
<button key={p.key} onClick={() => setPeriod(p.key)}
className={clsx('px-3 py-1 text-xs rounded-lg transition-colors',
period === p.key ? 'bg-blue-700/50 text-blue-300' : 'text-slate-400 hover:text-white hover:bg-dark-700'
)}
>
{p.label}
</button>
))}
</div>
</div> </div>
{selected && ( {selected && <p className="w-full text-xs text-slate-500">{selected.description}</p>}
<p className="w-full text-xs text-slate-500">{selected.description}</p>
)}
</div> </div>
{/* ── Loading skeleton ── */} {/* ── Loading ── */}
{loading && ( {loading && (
<div className="space-y-4"> <div className="space-y-4">
<div className="bg-dark-800/60 rounded-xl border border-slate-700/40 animate-pulse" style={{ height: 420 }} /> <div className="bg-dark-800/60 rounded-xl border border-slate-700/40 animate-pulse" style={{ height: 420 }} />
@@ -706,7 +802,6 @@ export default function InstrumentDashboard() {
{/* ── Content ── */} {/* ── Content ── */}
{!loading && snapshot && ( {!loading && snapshot && (
<> <>
{/* Chart */}
<InstrumentChart <InstrumentChart
priceData={snapshot.price_data} priceData={snapshot.price_data}
indicators={snapshot.indicators} indicators={snapshot.indicators}
@@ -715,10 +810,13 @@ export default function InstrumentDashboard() {
onDateHover={handleDateHover} onDateHover={handleDateHover}
/> />
{/* Event timeline strip */} <EventTimelineStrip
<EventTimelineStrip events={snapshot.events} priceData={snapshot.price_data} /> events={snapshot.events}
priceData={snapshot.price_data}
drivers={activeDrivers}
/>
{/* Snapshot date badge */} {/* Date badge */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className={clsx( <div className={clsx(
'flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium border', 'flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium border',
@@ -736,7 +834,6 @@ export default function InstrumentDashboard() {
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">
<RegimeCard <RegimeCard
regime={snapshot.regime} regime={snapshot.regime}
config={snapshot.instrument}
signalsAt={dateSignals} signalsAt={dateSignals}
dateLabel={dateLabel} dateLabel={dateLabel}
/> />
@@ -747,6 +844,16 @@ export default function InstrumentDashboard() {
<EventsCard events={snapshot.events} /> <EventsCard events={snapshot.events} />
</div> </div>
{/* Drivers edit panel */}
{editDrivers && (
<DriversPanel
instrumentId={instrumentId}
drivers={activeDrivers}
onSave={saved => { setLocalDrivers(saved); setEditDrivers(false) }}
onClose={() => setEditDrivers(false)}
/>
)}
{/* AI Narrative */} {/* AI Narrative */}
<NarrativeCard <NarrativeCard
narrative={narrative} narrative={narrative}