feat: Instrument Snapshot Dashboard — 5-layer synchronized view for 20 instruments

- 20 instruments configured (equity indices, metals, energy, bonds, FX, stocks, crypto)
  each with custom drivers, regime labels, MA periods, event keywords, ai_context
- InstrumentChart: TradingView lightweight-charts candlesticks + MA lines + Bollinger
  + volume histogram + macro event markers overlaid on price
- InstrumentDashboard: regime detection card (scores + signals), trend indicators
  (RSI gauge, MA slopes, momentum, 52W range), events card (links to Timeline),
  AI narrative via GPT-4o-mini (cached by day)
- Backend: instrument_service (OHLCV fetch, indicators, regime scoring, GPT narrative)
  + /api/instruments router (3 endpoints)
- Route: /instruments/:id with selector dropdown, period buttons (3M/6M/1Y/2Y/5Y)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-24 21:36:39 +02:00
parent edfa90c9b7
commit 537fea8148
10 changed files with 2127 additions and 7 deletions

View File

@@ -0,0 +1,554 @@
{
"instruments": [
{
"id": "SPY",
"name": "S&P 500 ETF",
"yf_ticker": "SPY",
"ib_ticker": "SPY",
"exchange": "CBOE",
"category": "equity_index",
"currency": "USD",
"description": "US large-cap equity benchmark",
"chart": {
"ma_periods": [20, 50, 200],
"bollinger_period": 20,
"bollinger_std": 2,
"show_volume": true
},
"drivers": [
{"key": "fed_policy", "label": "Fed Policy", "weight": 0.9},
{"key": "earnings_growth", "label": "Earnings Growth", "weight": 0.85},
{"key": "real_rates", "label": "Real Rates", "weight": 0.75},
{"key": "liquidity", "label": "Global Liquidity", "weight": 0.7},
{"key": "credit_spreads", "label": "Credit Spreads HY", "weight": 0.6}
],
"regime_labels": ["Risk-On Bull", "Risk-Off Bear", "Stagflation", "Rate Shock", "Recovery"],
"event_keywords": ["FOMC", "CPI", "NFP", "GDP", "Earnings Season", "Fed Chair"],
"related_assets": ["SPX", "SPY", "QQQ", "ES"],
"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."
},
{
"id": "QQQ",
"name": "Nasdaq 100 ETF",
"yf_ticker": "QQQ",
"ib_ticker": "QQQ",
"exchange": "NASDAQ",
"category": "equity_index",
"currency": "USD",
"description": "US large-cap tech and growth benchmark",
"chart": {
"ma_periods": [20, 50, 200],
"bollinger_period": 20,
"bollinger_std": 2,
"show_volume": true
},
"drivers": [
{"key": "fed_policy", "label": "Fed Policy", "weight": 0.9},
{"key": "earnings_tech", "label": "Tech Earnings", "weight": 0.85},
{"key": "real_rates", "label": "Real Rates", "weight": 0.8},
{"key": "ai_capex", "label": "AI Capex", "weight": 0.8},
{"key": "dollar", "label": "Dollar Index", "weight": 0.5}
],
"regime_labels": ["Tech Bull (AI-Driven)", "Tech Bear", "Rate Compression", "Earnings Crash", "Recovery"],
"event_keywords": ["FOMC", "Big Tech Earnings", "CPI", "AI", "Rate"],
"related_assets": ["QQQ", "NDX", "NQ"],
"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."
},
{
"id": "IWM",
"name": "Russell 2000 ETF",
"yf_ticker": "IWM",
"ib_ticker": "IWM",
"exchange": "NYSE",
"category": "equity_index",
"currency": "USD",
"description": "US small-cap equity benchmark",
"chart": {
"ma_periods": [20, 50, 200],
"bollinger_period": 20,
"bollinger_std": 2,
"show_volume": true
},
"drivers": [
{"key": "credit_conditions", "label": "Credit Conditions", "weight": 0.85},
{"key": "fed_policy", "label": "Fed Policy", "weight": 0.8},
{"key": "gdp_growth", "label": "GDP Growth", "weight": 0.75},
{"key": "dollar", "label": "Dollar Index", "weight": 0.6}
],
"regime_labels": ["Small Cap Expansion", "Credit Crunch Squeeze", "Risk-Off", "Recovery", "Neutral"],
"event_keywords": ["FOMC", "Credit", "GDP", "NFP", "Rates"],
"related_assets": ["IWM", "RTY", "RUT"],
"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."
},
{
"id": "EEM",
"name": "Emerging Markets ETF",
"yf_ticker": "EEM",
"ib_ticker": "EEM",
"exchange": "NYSE",
"category": "equity_intl",
"currency": "USD",
"description": "MSCI Emerging Markets large-cap benchmark",
"chart": {
"ma_periods": [20, 50, 200],
"bollinger_period": 20,
"bollinger_std": 2,
"show_volume": true
},
"drivers": [
{"key": "dollar_index", "label": "Dollar Index", "weight": 0.9},
{"key": "china_growth", "label": "China Growth", "weight": 0.85},
{"key": "em_flows", "label": "EM Flows", "weight": 0.75},
{"key": "commodities", "label": "Commodities", "weight": 0.7}
],
"regime_labels": ["EM Growth Rally", "Dollar Squeeze", "China Risk-Off", "Commodity Lift", "Neutral"],
"event_keywords": ["China GDP", "Dollar", "EM", "PBOC", "Commodities"],
"related_assets": ["EEM", "EM", "CNY", "China"],
"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."
},
{
"id": "EFA",
"name": "MSCI EAFE ETF",
"yf_ticker": "EFA",
"ib_ticker": "EFA",
"exchange": "NYSE",
"category": "equity_intl",
"currency": "USD",
"description": "MSCI EAFE (Europe, Australasia, Far East) benchmark",
"chart": {
"ma_periods": [20, 50, 200],
"bollinger_period": 20,
"bollinger_std": 2,
"show_volume": true
},
"drivers": [
{"key": "ecb_policy", "label": "ECB Policy", "weight": 0.85},
{"key": "dollar", "label": "Dollar Index", "weight": 0.8},
{"key": "european_growth", "label": "European Growth", "weight": 0.75},
{"key": "global_trade", "label": "Global Trade", "weight": 0.6}
],
"regime_labels": ["Global Risk-On", "USD Strength Drag", "Europe Recession", "Recovery", "Neutral"],
"event_keywords": ["ECB", "EU GDP", "Europe", "Germany", "BOJ", "Japan"],
"related_assets": ["EFA", "EAFE", "EUR", "EZU"],
"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."
},
{
"id": "GLD",
"name": "Gold ETF",
"yf_ticker": "GLD",
"ib_ticker": "GLD",
"exchange": "NYSE",
"category": "metal",
"currency": "USD",
"description": "SPDR Gold Shares — physical gold proxy",
"chart": {
"ma_periods": [50, 100, 200],
"bollinger_period": 50,
"bollinger_std": 2,
"show_volume": true
},
"drivers": [
{"key": "real_rates_us", "label": "Real Rates US", "weight": 0.95},
{"key": "dollar_index", "label": "Dollar Index", "weight": 0.85},
{"key": "inflation_expectations", "label": "Inflation Expectations", "weight": 0.75},
{"key": "geopolitical_risk", "label": "Geopolitical Risk", "weight": 0.7},
{"key": "cb_buying", "label": "Central Bank Buying", "weight": 0.5}
],
"regime_labels": ["Real Rates Falling", "Safe Haven Rally", "Inflation Hedge", "Dollar Weakness", "Range"],
"event_keywords": ["CPI", "Fed", "FOMC", "Dollar", "Geopolitical", "Iran", "Gold"],
"related_assets": ["GLD", "GC", "Gold", "XAU"],
"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."
},
{
"id": "SLV",
"name": "Silver ETF",
"yf_ticker": "SLV",
"ib_ticker": "SLV",
"exchange": "NYSE",
"category": "metal",
"currency": "USD",
"description": "iShares Silver Trust — physical silver proxy",
"chart": {
"ma_periods": [20, 50, 200],
"bollinger_period": 20,
"bollinger_std": 2,
"show_volume": true
},
"drivers": [
{"key": "industrial_demand", "label": "Industrial Demand", "weight": 0.8},
{"key": "real_rates", "label": "Real Rates", "weight": 0.75},
{"key": "dollar", "label": "Dollar Index", "weight": 0.7},
{"key": "solar_ev_demand", "label": "Solar/EV Demand", "weight": 0.65},
{"key": "gold_ratio", "label": "Gold/Silver Ratio", "weight": 0.6}
],
"regime_labels": ["Industrial Demand", "Safe Haven Flow", "Dollar Weakness", "Risk-Off", "Range"],
"event_keywords": ["Manufacturing", "PMI", "CPI", "Dollar", "Silver", "EV", "Solar"],
"related_assets": ["SLV", "SI", "Silver"],
"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."
},
{
"id": "USO",
"name": "WTI Oil ETF",
"yf_ticker": "USO",
"ib_ticker": "USO",
"exchange": "NYSE",
"category": "energy",
"currency": "USD",
"description": "United States Oil Fund — WTI crude proxy",
"chart": {
"ma_periods": [20, 50, 200],
"bollinger_period": 20,
"bollinger_std": 2,
"show_volume": true
},
"drivers": [
{"key": "opec_policy", "label": "OPEC Policy", "weight": 0.9},
{"key": "inventories", "label": "EIA Inventories", "weight": 0.8},
{"key": "geopolitical_risk", "label": "Geopolitical Risk", "weight": 0.75},
{"key": "global_demand", "label": "Global Demand", "weight": 0.85},
{"key": "dollar", "label": "Dollar Index", "weight": 0.7}
],
"regime_labels": ["Supply Shock", "Demand Recovery", "Inventory Draw", "Geopolitical Premium", "Neutral"],
"event_keywords": ["OPEC", "EIA", "Inventories", "Brent", "WTI", "Oil", "Middle East"],
"related_assets": ["USO", "CL", "WTI", "Oil", "Brent"],
"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."
},
{
"id": "UNG",
"name": "Natural Gas ETF",
"yf_ticker": "UNG",
"ib_ticker": "UNG",
"exchange": "NYSE",
"category": "energy",
"currency": "USD",
"description": "United States Natural Gas Fund — Henry Hub proxy",
"chart": {
"ma_periods": [20, 50, 200],
"bollinger_period": 20,
"bollinger_std": 2,
"show_volume": true
},
"drivers": [
{"key": "weather_seasonality", "label": "Weather/Seasonality", "weight": 0.9},
{"key": "storage", "label": "EIA Gas Storage", "weight": 0.85},
{"key": "lng_exports", "label": "LNG Exports", "weight": 0.75},
{"key": "production", "label": "US Production", "weight": 0.7}
],
"regime_labels": ["Winter Storage Fill", "Supply Glut", "Demand Surge", "Export LNG Lift", "Neutral"],
"event_keywords": ["EIA Gas Storage", "LNG", "Gas", "Natural Gas", "Weather"],
"related_assets": ["UNG", "NG", "Natgas"],
"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."
},
{
"id": "TLT",
"name": "US 20Y Treasury ETF",
"yf_ticker": "TLT",
"ib_ticker": "TLT",
"exchange": "NASDAQ",
"category": "bond",
"currency": "USD",
"description": "iShares 20+ Year Treasury Bond ETF",
"chart": {
"ma_periods": [20, 50, 200],
"bollinger_period": 20,
"bollinger_std": 2,
"show_volume": true
},
"drivers": [
{"key": "fed_path", "label": "Fed Path", "weight": 0.9},
{"key": "inflation_expectations", "label": "Inflation Expectations", "weight": 0.85},
{"key": "growth", "label": "Economic Growth", "weight": 0.75},
{"key": "deficit_supply", "label": "Deficit/Supply", "weight": 0.7},
{"key": "risk_aversion", "label": "Risk Aversion", "weight": 0.65}
],
"regime_labels": ["Safe Haven Bid", "Rate Shock Selloff", "QE Expectations", "Inflation Selloff", "Range"],
"event_keywords": ["FOMC", "CPI", "Fed", "Treasury", "Yield", "Deficit"],
"related_assets": ["TLT", "TNX", "UST", "Bonds"],
"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."
},
{
"id": "HYG",
"name": "High Yield Bond ETF",
"yf_ticker": "HYG",
"ib_ticker": "HYG",
"exchange": "NYSE",
"category": "credit",
"currency": "USD",
"description": "iShares iBoxx USD High Yield Corporate Bond ETF",
"chart": {
"ma_periods": [20, 50, 200],
"bollinger_period": 20,
"bollinger_std": 2,
"show_volume": true
},
"drivers": [
{"key": "default_rate", "label": "Default Rate", "weight": 0.85},
{"key": "growth", "label": "Economic Growth", "weight": 0.8},
{"key": "liquidity", "label": "Liquidity Conditions", "weight": 0.8},
{"key": "risk_appetite", "label": "Risk Appetite", "weight": 0.75}
],
"regime_labels": ["Credit Expansion", "Credit Crunch", "Spread Compression", "Recession Fear", "Neutral"],
"event_keywords": ["Credit", "Default", "GDP", "FOMC", "Recession", "HY"],
"related_assets": ["HYG", "LQD", "Credit", "HY"],
"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."
},
{
"id": "EURUSD=X",
"name": "EUR/USD",
"yf_ticker": "EURUSD=X",
"ib_ticker": "EUR.USD",
"exchange": "FX",
"category": "fx",
"currency": "USD",
"description": "Euro vs US Dollar spot rate",
"chart": {
"ma_periods": [20, 50, 200],
"bollinger_period": 20,
"bollinger_std": 2,
"show_volume": false
},
"drivers": [
{"key": "fed_ecb_differential", "label": "Fed vs ECB Differential", "weight": 0.95},
{"key": "rate_differential_2y", "label": "Rate Differential 2Y", "weight": 0.85},
{"key": "growth_differential", "label": "Growth Differential", "weight": 0.75},
{"key": "risk_appetite", "label": "Risk Appetite", "weight": 0.7}
],
"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"],
"related_assets": ["EUR", "USD", "EURUSD", "6E"],
"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."
},
{
"id": "USDJPY=X",
"name": "USD/JPY",
"yf_ticker": "USDJPY=X",
"ib_ticker": "USD.JPY",
"exchange": "FX",
"category": "fx",
"currency": "JPY",
"description": "US Dollar vs Japanese Yen spot rate",
"chart": {
"ma_periods": [20, 50, 200],
"bollinger_period": 20,
"bollinger_std": 2,
"show_volume": false
},
"drivers": [
{"key": "boj_policy", "label": "BOJ Policy", "weight": 0.95},
{"key": "rate_diff_us_jp", "label": "Rate Differential US-JP", "weight": 0.9},
{"key": "fed_policy", "label": "Fed Policy", "weight": 0.85},
{"key": "risk_appetite", "label": "Risk Appetite", "weight": 0.7}
],
"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"],
"related_assets": ["JPY", "USDJPY", "6J", "Yen"],
"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."
},
{
"id": "GBPUSD=X",
"name": "GBP/USD",
"yf_ticker": "GBPUSD=X",
"ib_ticker": "GBP.USD",
"exchange": "FX",
"category": "fx",
"currency": "USD",
"description": "British Pound vs US Dollar spot rate",
"chart": {
"ma_periods": [20, 50, 200],
"bollinger_period": 20,
"bollinger_std": 2,
"show_volume": false
},
"drivers": [
{"key": "boe_policy", "label": "BOE Policy", "weight": 0.9},
{"key": "fed_policy", "label": "Fed Policy", "weight": 0.85},
{"key": "uk_growth", "label": "UK Growth", "weight": 0.75},
{"key": "political_risk", "label": "Political Risk UK", "weight": 0.65}
],
"regime_labels": ["GBP Strength (BOE Hawkish)", "USD Dominance", "Political Risk GBP", "UK Recession Pressure", "Range"],
"event_keywords": ["BOE", "Fed", "UK", "Britain", "Brexit", "GBP", "Pound"],
"related_assets": ["GBP", "GBPUSD", "6B", "Pound"],
"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."
},
{
"id": "VXX",
"name": "VIX Tracker",
"yf_ticker": "VXX",
"ib_ticker": "VXX",
"exchange": "CBOE",
"category": "volatility",
"currency": "USD",
"description": "iPath Series B S&P 500 VIX Short-Term Futures ETN",
"chart": {
"ma_periods": [10, 20, 50],
"bollinger_period": 20,
"bollinger_std": 2,
"show_volume": true
},
"drivers": [
{"key": "market_stress", "label": "Market Stress", "weight": 0.9},
{"key": "risk_appetite", "label": "Risk Appetite", "weight": 0.85},
{"key": "options_demand", "label": "Options Demand", "weight": 0.75},
{"key": "macro_uncertainty", "label": "Macro Uncertainty", "weight": 0.7}
],
"regime_labels": ["Low Vol Complacency", "Vol Spike Crisis", "Vol Expansion", "Vol Compression", "Neutral"],
"event_keywords": ["VIX", "Volatility", "Crisis", "FOMC", "Market Stress"],
"related_assets": ["VXX", "VIX", "UVXY", "SVXY"],
"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."
},
{
"id": "AAPL",
"name": "Apple Inc",
"yf_ticker": "AAPL",
"ib_ticker": "AAPL",
"exchange": "NASDAQ",
"category": "stock",
"currency": "USD",
"description": "Apple Inc. — consumer tech, services ecosystem",
"chart": {
"ma_periods": [20, 50, 200],
"bollinger_period": 20,
"bollinger_std": 2,
"show_volume": true
},
"drivers": [
{"key": "iphone_cycle", "label": "iPhone Cycle", "weight": 0.85},
{"key": "china_sales", "label": "China Sales", "weight": 0.8},
{"key": "fed_policy", "label": "Fed Policy", "weight": 0.8},
{"key": "ai_integration", "label": "AI Integration", "weight": 0.75},
{"key": "margins", "label": "Services Margins", "weight": 0.7}
],
"regime_labels": ["Growth Bull", "Rate Squeeze", "Earnings Beat", "Tech Selloff", "Neutral"],
"event_keywords": ["Apple Earnings", "iPhone", "Services", "China", "Fed", "AI", "AAPL"],
"related_assets": ["AAPL", "Apple"],
"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."
},
{
"id": "NVDA",
"name": "NVIDIA Corp",
"yf_ticker": "NVDA",
"ib_ticker": "NVDA",
"exchange": "NASDAQ",
"category": "stock",
"currency": "USD",
"description": "NVIDIA Corporation — AI chips and data center GPU leader",
"chart": {
"ma_periods": [20, 50, 200],
"bollinger_period": 20,
"bollinger_std": 2,
"show_volume": true
},
"drivers": [
{"key": "ai_capex_cycle", "label": "AI Capex Cycle", "weight": 0.95},
{"key": "earnings_growth", "label": "Earnings Growth", "weight": 0.9},
{"key": "data_center_demand", "label": "Data Center Demand", "weight": 0.85},
{"key": "export_restrictions", "label": "Export Restrictions", "weight": 0.7},
{"key": "rates", "label": "Interest Rates", "weight": 0.6}
],
"regime_labels": ["AI Bull Run", "AI Hype Correction", "Earnings Catalyst", "Rate Compression", "Consolidation"],
"event_keywords": ["NVIDIA Earnings", "AI", "Data Center", "Export", "GPU", "NVDA"],
"related_assets": ["NVDA", "NVIDIA", "AI"],
"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."
},
{
"id": "GS",
"name": "Goldman Sachs",
"yf_ticker": "GS",
"ib_ticker": "GS",
"exchange": "NYSE",
"category": "stock",
"currency": "USD",
"description": "The Goldman Sachs Group — global investment bank",
"chart": {
"ma_periods": [20, 50, 200],
"bollinger_period": 20,
"bollinger_std": 2,
"show_volume": true
},
"drivers": [
{"key": "yield_curve", "label": "Yield Curve", "weight": 0.85},
{"key": "ma_activity", "label": "M&A Activity", "weight": 0.8},
{"key": "rates", "label": "Interest Rates", "weight": 0.8},
{"key": "credit_conditions", "label": "Credit Conditions", "weight": 0.75}
],
"regime_labels": ["Finance Bull", "Recession Fear", "Yield Curve Steepen", "M&A Boom", "Range"],
"event_keywords": ["Goldman", "Financials", "Yield Curve", "M&A", "Banking", "Fed", "Credit"],
"related_assets": ["GS", "Goldman", "XLF", "Financials"],
"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."
},
{
"id": "XOM",
"name": "ExxonMobil",
"yf_ticker": "XOM",
"ib_ticker": "XOM",
"exchange": "NYSE",
"category": "stock",
"currency": "USD",
"description": "Exxon Mobil Corporation — global integrated energy major",
"chart": {
"ma_periods": [20, 50, 200],
"bollinger_period": 20,
"bollinger_std": 2,
"show_volume": true
},
"drivers": [
{"key": "oil_price", "label": "Oil Price", "weight": 0.9},
{"key": "global_demand", "label": "Global Demand", "weight": 0.8},
{"key": "opec_policy", "label": "OPEC Policy", "weight": 0.75},
{"key": "capex", "label": "Capital Expenditure", "weight": 0.6},
{"key": "dollar", "label": "Dollar Index", "weight": 0.6}
],
"regime_labels": ["Energy Bull", "Demand Slowdown", "OPEC Benefit", "Green Transition Drag", "Range"],
"event_keywords": ["Oil", "OPEC", "Energy", "XOM", "EIA", "Crude", "Exxon"],
"related_assets": ["XOM", "Exxon", "CL", "Oil", "Energy"],
"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."
},
{
"id": "BTC-USD",
"name": "Bitcoin",
"yf_ticker": "BTC-USD",
"ib_ticker": "BTC",
"exchange": "Crypto",
"category": "crypto",
"currency": "USD",
"description": "Bitcoin — leading decentralized digital asset",
"chart": {
"ma_periods": [20, 50, 200],
"bollinger_period": 20,
"bollinger_std": 2,
"show_volume": true
},
"drivers": [
{"key": "global_liquidity", "label": "Global Liquidity", "weight": 0.85},
{"key": "etf_flows", "label": "ETF Flows", "weight": 0.85},
{"key": "risk_appetite", "label": "Risk Appetite", "weight": 0.8},
{"key": "regulatory", "label": "Regulatory Environment", "weight": 0.7},
{"key": "dollar", "label": "Dollar Index", "weight": 0.65}
],
"regime_labels": ["Crypto Bull (ETF Inflows)", "Risk-Off Crypto Selloff", "Liquidity Expansion", "Regulatory Fear", "Consolidation"],
"event_keywords": ["Bitcoin", "BTC", "Crypto", "ETF", "Fed", "Liquidity", "Regulation"],
"related_assets": ["BTC", "Bitcoin", "Crypto"],
"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."
}
]
}

View File

@@ -4,6 +4,7 @@ from routers import market_data, geopolitical, options, backtest, ai, portfolio,
from routers import pattern_lab as pattern_lab_router
from routers import specialist_desks as specialist_desks_router
from routers import timeline as timeline_router
from routers import instruments as instruments_router
from routers import logs as logs_router
from routers import var as var_router
from routers import reports as reports_router
@@ -123,6 +124,7 @@ app.include_router(institutional_router.router)
app.include_router(pattern_lab_router.router)
app.include_router(specialist_desks_router.router)
app.include_router(timeline_router.router)
app.include_router(instruments_router.router)
@app.get("/")

View File

@@ -0,0 +1,65 @@
"""
Instrument Dashboard Router.
Exposes per-instrument snapshot (price, indicators, regime, trend, events) and AI narrative.
"""
from fastapi import APIRouter, HTTPException, Query
from typing import List, Dict, Any, Optional
from services.instrument_service import (
get_all_instruments,
get_instrument,
get_snapshot,
get_narrative,
)
router = APIRouter(prefix="/api/instruments", tags=["instruments"])
@router.get("", response_model=List[Dict[str, Any]])
def list_instruments() -> List[Dict[str, Any]]:
"""
Return all instrument configurations (no price data).
"""
return get_all_instruments()
@router.get("/{instrument_id}/snapshot")
async def instrument_snapshot(
instrument_id: str,
period: str = Query(default="1y", description="yfinance period string (e.g. 1y, 6mo, 3mo)"),
interval: str = Query(default="1d", description="yfinance interval string (e.g. 1d, 1wk)"),
) -> Dict[str, Any]:
"""
Full snapshot for a single instrument: price data, indicators, regime, trend, events.
"""
config = get_instrument(instrument_id)
if not config:
raise HTTPException(status_code=404, detail=f"Instrument '{instrument_id}' not found")
snapshot = await get_snapshot(instrument_id, period=period, interval=interval)
if "error" in snapshot:
raise HTTPException(status_code=500, detail=snapshot["error"])
return snapshot
@router.post("/{instrument_id}/narrative")
async def generate_narrative(
instrument_id: str,
force: bool = Query(default=False, description="Force regeneration, bypass cache"),
) -> Dict[str, Any]:
"""
Generate (or return cached) a French AI narrative for the instrument.
"""
config = get_instrument(instrument_id)
if not config:
raise HTTPException(status_code=404, detail=f"Instrument '{instrument_id}' not found")
narrative = await get_narrative(instrument_id, force=force)
return {
"instrument_id": instrument_id.upper(),
"instrument_name": config.get("name", instrument_id),
"narrative": narrative,
}

View File

@@ -0,0 +1,699 @@
"""
Instrument Dashboard Service.
Provides per-instrument snapshots with price data, technical indicators,
regime detection, trend summary, event filtering, and AI narrative.
"""
import json
import os
import logging
import numpy as np
import pandas as pd
from pathlib import Path
from typing import Dict, Any, List, Optional, Tuple
from datetime import datetime, date
logger = logging.getLogger(__name__)
# ── Config loading ─────────────────────────────────────────────────────────────
CONFIG_PATH = Path(__file__).parent.parent / "data" / "instruments.json"
_configs: Optional[Dict[str, Any]] = None
# In-memory narrative cache: key = (instrument_id, iso_date) → str
_narrative_cache: Dict[Tuple[str, str], str] = {}
def _load_configs() -> None:
global _configs
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
data = json.load(f)
_configs = {inst["id"]: inst for inst in data["instruments"]}
logger.info(f"[instrument_service] Loaded {len(_configs)} instrument configs")
def get_all_instruments() -> List[Dict]:
if _configs is None:
_load_configs()
return list(_configs.values())
def get_instrument(instrument_id: str) -> Optional[Dict]:
if _configs is None:
_load_configs()
return _configs.get(instrument_id.upper())
# ── DataFrame helpers ──────────────────────────────────────────────────────────
def _ohlcv_to_df(records: List[Dict]) -> pd.DataFrame:
"""Convert list of OHLCV dicts (with 'date' key) to a DataFrame indexed by date."""
if not records:
return pd.DataFrame()
df = pd.DataFrame(records)
# Normalise date column — may come as ISO string with or without time component
df["date"] = pd.to_datetime(df["date"], utc=True, errors="coerce")
df = df.dropna(subset=["date"])
df = df.sort_values("date")
df = df.set_index("date")
for col in ("open", "high", "low", "close"):
if col in df.columns:
df[col] = pd.to_numeric(df[col], errors="coerce")
if "volume" in df.columns:
df["volume"] = pd.to_numeric(df["volume"], errors="coerce").fillna(0)
return df
def _safe_float(val) -> Optional[float]:
"""Return a Python float or None for NaN/inf values."""
try:
f = float(val)
return f if np.isfinite(f) else None
except (TypeError, ValueError):
return None
def _round(val, decimals: int = 4) -> Optional[float]:
f = _safe_float(val)
return round(f, decimals) if f is not None else None
# ── Technical indicators ───────────────────────────────────────────────────────
def _compute_indicators(df: pd.DataFrame, config: Dict) -> Dict[str, Any]:
"""
Compute moving averages, Bollinger Bands, RSI14, ATR14, volume MA20.
Returns a dict with time-series lists and scalar latest values.
"""
chart_cfg = config.get("chart", {})
ma_periods = chart_cfg.get("ma_periods", [20, 50, 200])
bb_period = chart_cfg.get("bollinger_period", 20)
bb_std = chart_cfg.get("bollinger_std", 2)
result: Dict[str, Any] = {}
if df.empty or "close" not in df.columns:
return result
close = df["close"]
high = df["high"] if "high" in df.columns else close
low = df["low"] if "low" in df.columns else close
volume = df["volume"] if "volume" in df.columns else pd.Series(dtype=float)
# Moving averages — time-series format for charting
for period in ma_periods:
if len(close) >= period:
ma = close.rolling(period).mean()
valid = ma.dropna()
result[f"ma{period}"] = [
{"time": idx.strftime("%Y-%m-%d"), "value": _round(val)}
for idx, val in valid.items()
]
else:
result[f"ma{period}"] = []
# Bollinger Bands
if len(close) >= bb_period:
bb_ma = close.rolling(bb_period).mean()
bb_sigma = close.rolling(bb_period).std()
bb_upper = bb_ma + bb_std * bb_sigma
bb_lower = bb_ma - bb_std * bb_sigma
valid_idx = bb_ma.dropna().index
result["bb_upper"] = [
{"time": idx.strftime("%Y-%m-%d"), "value": _round(bb_upper[idx])}
for idx in valid_idx
]
result["bb_lower"] = [
{"time": idx.strftime("%Y-%m-%d"), "value": _round(bb_lower[idx])}
for idx in valid_idx
]
result["bb_mid"] = [
{"time": idx.strftime("%Y-%m-%d"), "value": _round(bb_ma[idx])}
for idx in valid_idx
]
else:
result["bb_upper"] = []
result["bb_lower"] = []
result["bb_mid"] = []
# RSI 14
if len(close) >= 15:
delta = close.diff()
gain = delta.clip(lower=0)
loss = (-delta).clip(lower=0)
avg_gain = gain.ewm(com=13, adjust=False).mean()
avg_loss = loss.ewm(com=13, adjust=False).mean()
rs = avg_gain / avg_loss.replace(0, np.nan)
rsi = 100 - (100 / (1 + rs))
valid_rsi = rsi.dropna()
result["rsi14"] = [
{"time": idx.strftime("%Y-%m-%d"), "value": _round(val, 2)}
for idx, val in valid_rsi.items()
]
else:
result["rsi14"] = []
# ATR 14
if len(close) >= 15:
prev_close = close.shift(1)
tr = pd.concat([
high - low,
(high - prev_close).abs(),
(low - prev_close).abs(),
], axis=1).max(axis=1)
atr = tr.ewm(span=14, adjust=False).mean()
valid_atr = atr.dropna()
result["atr14"] = [
{"time": idx.strftime("%Y-%m-%d"), "value": _round(val)}
for idx, val in valid_atr.items()
]
else:
result["atr14"] = []
# Volume MA20
if len(volume) >= 20 and volume.sum() > 0:
vol_ma = volume.rolling(20).mean()
valid_vma = vol_ma.dropna()
result["volume_ma20"] = [
{"time": idx.strftime("%Y-%m-%d"), "value": int(val) if np.isfinite(val) else None}
for idx, val in valid_vma.items()
]
else:
result["volume_ma20"] = []
return result
# ── Regime detection ───────────────────────────────────────────────────────────
def _detect_regime(df: pd.DataFrame, config: Dict) -> Dict[str, Any]:
"""
Score-based regime detection mapped to config.regime_labels.
Labels index: 0=bullish, 1=bearish, 2=transition, 3=volatile, 4=late cycle / consolidation.
"""
regime_labels = config.get("regime_labels", ["Bull", "Bear", "Transition", "Volatile", "Consolidation"])
default = {
"current": regime_labels[0] if regime_labels else "Unknown",
"confidence": 0.0,
"scores": {label: 0.0 for label in regime_labels},
"signals": {},
}
if df.empty or len(df) < 20 or "close" not in df.columns:
return default
close = df["close"]
high = df["high"] if "high" in df.columns else close
low = df["low"] if "low" in df.columns else close
# Moving averages
ma50 = close.rolling(50).mean() if len(close) >= 50 else pd.Series(dtype=float)
ma200 = close.rolling(200).mean() if len(close) >= 200 else pd.Series(dtype=float)
current_price = _safe_float(close.iloc[-1])
current_ma50 = _safe_float(ma50.iloc[-1]) if not ma50.empty else None
current_ma200 = _safe_float(ma200.iloc[-1]) if not ma200.empty else None
# MA slopes (% over n bars)
def _slope_pct(series: pd.Series, lookback: int) -> Optional[float]:
s = series.dropna()
if len(s) < lookback + 1:
return None
v_now = _safe_float(s.iloc[-1])
v_old = _safe_float(s.iloc[-lookback - 1])
if v_now is None or v_old is None or v_old == 0:
return None
return (v_now - v_old) / abs(v_old) * 100
ma50_slope = _slope_pct(ma50, 5) if not ma50.empty else None
ma200_slope = _slope_pct(ma200, 20) if not ma200.empty else None
# Momentum 20d
momentum_20d = None
if len(close) >= 21:
p0 = _safe_float(close.iloc[-21])
p1 = _safe_float(close.iloc[-1])
if p0 and p0 != 0:
momentum_20d = (p1 - p0) / abs(p0) * 100
# Distance from MA200
dist_ma200 = None
if current_price and current_ma200 and current_ma200 != 0:
dist_ma200 = (current_price - current_ma200) / abs(current_ma200) * 100
# ATR vs price (volatility ratio)
atr_pct = None
if len(close) >= 15:
prev_close = close.shift(1)
tr = pd.concat([
high - low,
(high - prev_close).abs(),
(low - prev_close).abs(),
], axis=1).max(axis=1)
atr14 = tr.ewm(span=14, adjust=False).mean().iloc[-1]
if current_price and current_price != 0:
atr_pct = _safe_float(atr14) / current_price * 100 if _safe_float(atr14) else None
# MA50 above MA200 flag
ma50_above_ma200 = None
if current_ma50 is not None and current_ma200 is not None:
ma50_above_ma200 = current_ma50 > current_ma200
signals = {
"ma50_above_ma200": ma50_above_ma200,
"ma50_slope_pct": _round(ma50_slope, 3),
"ma200_slope_pct": _round(ma200_slope, 3),
"momentum_20d_pct": _round(momentum_20d, 3),
"dist_ma200_pct": _round(dist_ma200, 3),
"vol_ratio_pct": _round(atr_pct, 3),
}
# ── Score computation ──────────────────────────────────────────────────────
# bull_score aggregates trend-following signals
bull_score = 0.0
if ma50_above_ma200 is True:
bull_score += 0.4
elif ma50_above_ma200 is False:
bull_score -= 0.4
if ma50_slope is not None:
bull_score += 0.2 if ma50_slope > 0 else -0.2
if momentum_20d is not None:
bull_score += 0.2 if momentum_20d > 0 else -0.2
if ma200_slope is not None:
bull_score += 0.1 if ma200_slope > 0 else -0.1
# Extra penalty/boost for distance extremes
if dist_ma200 is not None:
if dist_ma200 > 15:
bull_score += 0.1 # strong uptrend extension
elif dist_ma200 < -15:
bull_score -= 0.1
# Volatile flag: ATR/price > 3%
is_volatile = (atr_pct is not None and atr_pct > 3.0)
# Transition flag: weak slope + weak momentum
is_transition = (
(ma50_slope is not None and abs(ma50_slope) < 0.1) and
(momentum_20d is not None and abs(momentum_20d) < 1.0)
)
# ── Map to 5 regime slots ──────────────────────────────────────────────────
# Slot 0 = bullish, 1 = bearish, 2 = transition, 3 = volatile, 4 = late/consolidation
raw_scores = [0.0] * 5
# Bullish
raw_scores[0] = max(0.0, bull_score)
# Bearish
raw_scores[1] = max(0.0, -bull_score)
# Transition
raw_scores[2] = 0.6 if is_transition else 0.0
# Volatile
raw_scores[3] = 0.7 if is_volatile else 0.0
# Late cycle / consolidation — moderate bull_score but high dist_ma200
if 0.0 < bull_score < 0.3 and dist_ma200 is not None and dist_ma200 > 5:
raw_scores[4] = 0.5
else:
raw_scores[4] = max(0.0, 0.3 - abs(bull_score)) if not is_transition else 0.0
# If volatile, suppress the others a bit
if is_volatile:
raw_scores[0] *= 0.5
raw_scores[1] *= 0.5
raw_scores[2] *= 0.5
# Normalise to sum=1
total = sum(raw_scores)
if total > 0:
norm_scores = [s / total for s in raw_scores]
else:
norm_scores = [1.0 / 5] * 5
# Pad / truncate to match the number of provided labels
n_labels = len(regime_labels)
while len(norm_scores) < n_labels:
norm_scores.append(0.0)
norm_scores = norm_scores[:n_labels]
best_idx = int(np.argmax(norm_scores))
confidence = _round(norm_scores[best_idx], 3) or 0.0
scores_dict = {}
for i, label in enumerate(regime_labels):
scores_dict[label] = _round(norm_scores[i], 3) or 0.0
return {
"current": regime_labels[best_idx],
"confidence": confidence,
"scores": scores_dict,
"signals": signals,
}
# ── Trend summary ──────────────────────────────────────────────────────────────
def _get_trend_summary(df: pd.DataFrame) -> Dict[str, Any]:
"""
Return key trend metrics as a flat dict of scalars.
"""
result: Dict[str, Any] = {}
if df.empty or "close" not in df.columns:
return result
close = df["close"]
high = df["high"] if "high" in df.columns else close
low = df["low"] if "low" in df.columns else close
# Current price
result["current_price"] = _round(close.iloc[-1])
# 52-week high / low
n_252 = min(252, len(close))
result["high_52w"] = _round(close.tail(n_252).max())
result["low_52w"] = _round(close.tail(n_252).min())
# MA slopes
def _slope_pct(series: pd.Series, lookback: int) -> Optional[float]:
s = series.dropna()
if len(s) < lookback + 1:
return None
v_now = _safe_float(s.iloc[-1])
v_old = _safe_float(s.iloc[-lookback - 1])
if v_now is None or v_old is None or v_old == 0:
return None
return round((v_now - v_old) / abs(v_old) * 100, 4)
ma50 = close.rolling(50).mean() if len(close) >= 50 else pd.Series(dtype=float)
ma200 = close.rolling(200).mean() if len(close) >= 200 else pd.Series(dtype=float)
result["ma50_slope_5d"] = _slope_pct(ma50, 5)
result["ma200_slope_20d"] = _slope_pct(ma200, 20)
# RSI 14 current
if len(close) >= 15:
delta = close.diff()
gain = delta.clip(lower=0)
loss = (-delta).clip(lower=0)
avg_gain = gain.ewm(com=13, adjust=False).mean()
avg_loss = loss.ewm(com=13, adjust=False).mean()
rs = avg_gain / avg_loss.replace(0, np.nan)
rsi = 100 - (100 / (1 + rs))
result["rsi14_current"] = _round(rsi.iloc[-1], 2)
else:
result["rsi14_current"] = None
# ATR 14 current and vs 3-month average
if len(close) >= 15:
prev_close = close.shift(1)
tr = pd.concat([
high - low,
(high - prev_close).abs(),
(low - prev_close).abs(),
], axis=1).max(axis=1)
atr_series = tr.ewm(span=14, adjust=False).mean()
atr_current = _safe_float(atr_series.iloc[-1])
result["atr14_current"] = _round(atr_current)
# ATR vs 63-day average
if len(atr_series) >= 63:
atr_3m_avg = _safe_float(atr_series.tail(63).mean())
if atr_3m_avg and atr_3m_avg != 0:
result["atr_vs_3m_avg_pct"] = _round((atr_current - atr_3m_avg) / atr_3m_avg * 100, 2)
else:
result["atr_vs_3m_avg_pct"] = None
else:
result["atr_vs_3m_avg_pct"] = None
else:
result["atr14_current"] = None
result["atr_vs_3m_avg_pct"] = None
# Momentum
def _momentum(lookback: int) -> Optional[float]:
if len(close) <= lookback:
return None
p0 = _safe_float(close.iloc[-lookback - 1])
p1 = _safe_float(close.iloc[-1])
if p0 and p0 != 0:
return _round((p1 - p0) / abs(p0) * 100, 3)
return None
result["momentum_1m_pct"] = _momentum(21)
result["momentum_3m_pct"] = _momentum(63)
# Distance from MAs
current_price = _safe_float(close.iloc[-1])
if not ma50.empty and current_price:
ma50_val = _safe_float(ma50.iloc[-1])
if ma50_val and ma50_val != 0:
result["dist_ma50_pct"] = _round((current_price - ma50_val) / abs(ma50_val) * 100, 3)
else:
result["dist_ma50_pct"] = None
else:
result["dist_ma50_pct"] = None
if not ma200.empty and current_price:
ma200_val = _safe_float(ma200.iloc[-1])
if ma200_val and ma200_val != 0:
result["dist_ma200_pct"] = _round((current_price - ma200_val) / abs(ma200_val) * 100, 3)
else:
result["dist_ma200_pct"] = None
else:
result["dist_ma200_pct"] = None
return result
# ── Event filtering ────────────────────────────────────────────────────────────
def _get_relevant_events(
config: Dict,
from_date: Optional[str] = None,
to_date: Optional[str] = None,
) -> List[Dict]:
"""
Filter market_events DB rows relevant to the instrument, by date range and keyword/asset match.
Returns max 15 events sorted by start_date descending.
"""
try:
from services.database import get_all_market_events
all_events = get_all_market_events()
except Exception as e:
logger.warning(f"[instrument_service] Could not load market events: {e}")
return []
keywords = [kw.lower() for kw in config.get("event_keywords", [])]
related = [ra.lower() for ra in config.get("related_assets", [])]
filtered = []
for ev in all_events:
ev_start = str(ev.get("start_date", "") or "")
ev_end = str(ev.get("end_date", "") or ev_start)
# Date range filter
if from_date and ev_start and ev_start < from_date:
continue
if to_date and ev_start and ev_start > to_date:
continue
# Keyword / asset relevance
ev_name = (ev.get("name") or ev.get("event_name") or "").lower()
ev_desc = (ev.get("description") or "").lower()
ev_assets = (ev.get("affected_assets") or "").lower()
keyword_hit = any(kw in ev_name or kw in ev_desc for kw in keywords)
asset_hit = any(ra in ev_assets for ra in related)
if keyword_hit or asset_hit:
filtered.append(ev)
# Sort by start_date desc, cap at 15
filtered.sort(key=lambda e: str(e.get("start_date", "") or ""), reverse=True)
return filtered[:15]
# ── Main snapshot ──────────────────────────────────────────────────────────────
async def get_snapshot(
instrument_id: str,
period: str = "1y",
interval: str = "1d",
) -> Dict[str, Any]:
"""
Build the full instrument snapshot:
price_data + indicators + regime + trend + events + current price/change.
"""
config = get_instrument(instrument_id)
if not config:
return {"error": f"Unknown instrument: {instrument_id}"}
yf_ticker = config.get("yf_ticker", instrument_id)
# Fetch OHLCV
try:
from services.data_fetcher import get_historical
records = get_historical(yf_ticker, period=period, interval=interval)
except Exception as e:
logger.error(f"[instrument_service] Data fetch failed for {instrument_id}: {e}")
records = []
df = _ohlcv_to_df(records)
# Compute everything
indicators = _compute_indicators(df, config) if not df.empty else {}
regime = _detect_regime(df, config) if not df.empty else {}
trend = _get_trend_summary(df) if not df.empty else {}
# Current price and 1-day change
current_price: Optional[float] = None
change_pct: Optional[float] = None
change_abs: Optional[float] = None
if not df.empty and "close" in df.columns and len(df) >= 2:
last = _safe_float(df["close"].iloc[-1])
prev = _safe_float(df["close"].iloc[-2])
current_price = _round(last)
if last is not None and prev is not None and prev != 0:
change_abs = _round(last - prev)
change_pct = _round((last - prev) / abs(prev) * 100, 3)
# Events (last 1 year)
try:
from_date = (pd.Timestamp.now() - pd.Timedelta(days=365)).strftime("%Y-%m-%d")
to_date = pd.Timestamp.now().strftime("%Y-%m-%d")
events = _get_relevant_events(config, from_date=from_date, to_date=to_date)
except Exception as e:
logger.warning(f"[instrument_service] Event filtering error: {e}")
events = []
# Price data for chart (time + ohlcv)
price_data = []
if records:
for r in records:
price_data.append({
"time": str(r.get("date", ""))[:10],
"open": r.get("open"),
"high": r.get("high"),
"low": r.get("low"),
"close": r.get("close"),
"volume": r.get("volume", 0),
})
return {
"instrument": config,
"price_data": price_data,
"indicators": indicators,
"regime": regime,
"trend": trend,
"events": events,
"current_price": current_price,
"change_pct": change_pct,
"change_abs": change_abs,
"period": period,
}
# ── AI Narrative ───────────────────────────────────────────────────────────────
async def get_narrative(
instrument_id: str,
snapshot_data: Optional[Dict] = None,
force: bool = False,
) -> str:
"""
Generate (or return cached) a 3-4 sentence French narrative for the instrument.
Uses gpt-4o-mini via the existing OpenAI client pattern.
Cache key: (instrument_id, today_iso_date).
"""
today_str = date.today().isoformat()
cache_key = (instrument_id.upper(), today_str)
if not force and cache_key in _narrative_cache:
return _narrative_cache[cache_key]
config = get_instrument(instrument_id)
if not config:
return f"Instrument {instrument_id} non reconnu."
# Fetch snapshot if not provided
if snapshot_data is None:
snapshot_data = await get_snapshot(instrument_id)
trend = snapshot_data.get("trend", {})
regime = snapshot_data.get("regime", {})
drivers = config.get("drivers", [])
ai_ctx = config.get("ai_context", "")
# Build concise prompt data
drivers_str = ", ".join(
f"{d['label']} (poids {d['weight']})" for d in drivers[:5]
)
regime_str = regime.get("current", "N/A")
regime_conf = regime.get("confidence", 0)
rsi = trend.get("rsi14_current")
dist50 = trend.get("dist_ma50_pct")
dist200 = trend.get("dist_ma200_pct")
mom1m = trend.get("momentum_1m_pct")
mom3m = trend.get("momentum_3m_pct")
current_price = trend.get("current_price") or snapshot_data.get("current_price")
change_pct = snapshot_data.get("change_pct")
atr = trend.get("atr14_current")
atr_vs_avg = trend.get("atr_vs_3m_avg_pct")
system_prompt = (
"Tu es un analyste financier quantitatif senior spécialisé en options et macro. "
"Tu génères des narratives d'analyse courtes, précises et actionnables en français."
)
user_prompt = f"""Analyse l'instrument {config['name']} ({instrument_id}) et génère une narrative de 3-4 phrases en français.
## Contexte instrument
{ai_ctx}
## Données techniques actuelles
- Prix actuel : {current_price} | Variation 1j : {change_pct}%
- Momentum 1 mois : {mom1m}% | Momentum 3 mois : {mom3m}%
- Distance MA50 : {dist50}% | Distance MA200 : {dist200}%
- RSI14 : {rsi}
- ATR14 : {atr} | ATR vs moy. 3 mois : {atr_vs_avg}%
## Régime détecté
- Régime : {regime_str} (confiance : {regime_conf:.0%})
## Drivers principaux
{drivers_str}
## Format requis (4 phrases en français, sans bullet points) :
1. Situation technique actuelle (tendance, niveaux clés, momentum)
2. Contexte macro dominant et driver principal
3. Niveaux clés à surveiller (support, résistance, MA critique)
4. Implication pour le trading d'options (vol implicite, stratégie suggérée)"""
# Call OpenAI
try:
import os as _os
from openai import OpenAI
api_key = _os.environ.get("OPENAI_API_KEY", "")
if not api_key:
from services.database import get_config
api_key = get_config("openai_api_key") or ""
if not api_key:
return "Clé API OpenAI non configurée — narrative indisponible."
client = OpenAI(api_key=api_key)
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
temperature=0.3,
max_tokens=400,
)
narrative = resp.choices[0].message.content.strip()
_narrative_cache[cache_key] = narrative
return narrative
except Exception as e:
logger.error(f"[instrument_service] Narrative generation failed for {instrument_id}: {e}")
return f"Narrative indisponible ({type(e).__name__})."

View File

@@ -12,6 +12,7 @@
"axios": "^1.7.7",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lightweight-charts": "^4.2.3",
"lucide-react": "^0.447.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -2023,6 +2024,12 @@
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/fancy-canvas": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fancy-canvas/-/fancy-canvas-2.1.0.tgz",
"integrity": "sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ==",
"license": "MIT"
},
"node_modules/fast-equals": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
@@ -2396,6 +2403,15 @@
"node": ">=6"
}
},
"node_modules/lightweight-charts": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/lightweight-charts/-/lightweight-charts-4.2.3.tgz",
"integrity": "sha512-5kS/2hY3wNYNzhnS8Gb+GAS07DX8GPF2YVDnd2NMC85gJVQ6RLU6YrXNgNJ6eg0AnWPwCnvaGtYmGky3HiLQEw==",
"license": "Apache-2.0",
"dependencies": {
"fancy-canvas": "2.1.0"
}
},
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",

View File

@@ -9,16 +9,17 @@
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.59.0",
"axios": "^1.7.7",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lightweight-charts": "^4.2.3",
"lucide-react": "^0.447.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2",
"recharts": "^2.13.0",
"@tanstack/react-query": "^5.59.0",
"zustand": "^5.0.0",
"axios": "^1.7.7",
"date-fns": "^4.1.0",
"lucide-react": "^0.447.0",
"clsx": "^2.1.1"
"zustand": "^5.0.0"
},
"devDependencies": {
"@types/react": "^18.3.11",

View File

@@ -25,6 +25,8 @@ import InstitutionalReports from './pages/InstitutionalReports'
import SpecialistDesks from './pages/SpecialistDesks'
import Timeline from './pages/Timeline'
import ExternalSnapshot from './pages/ExternalSnapshot'
import InstrumentDashboard from './pages/InstrumentDashboard'
import { Navigate } from 'react-router-dom'
import { useCycleWatcher } from './hooks/useApi'
function GlobalWatcher() {
@@ -65,6 +67,8 @@ export default function App() {
<Route path="/specialist-desks" element={<SpecialistDesks />} />
<Route path="/timeline" element={<Timeline />} />
<Route path="/snapshot" element={<ExternalSnapshot />} />
<Route path="/instruments" element={<Navigate to="/instruments/SPY" replace />} />
<Route path="/instruments/:id" element={<InstrumentDashboard />} />
</Routes>
</main>
</div>

View File

@@ -0,0 +1,212 @@
import { useRef, useEffect } from 'react'
interface PriceCandle {
time: string
open: number
high: number
low: number
close: number
volume: number
}
interface LinePoint {
time: string
value: number
}
interface ChartEvent {
date: string
title: string
level: string
impact_score?: number
}
interface Props {
priceData: PriceCandle[]
indicators: {
ma20?: LinePoint[]
ma50?: LinePoint[]
ma100?: LinePoint[]
ma200?: LinePoint[]
bb_upper?: LinePoint[]
bb_lower?: LinePoint[]
}
events?: ChartEvent[]
height?: number
}
const MA_COLORS: Record<string, string> = {
ma20: '#f59e0b',
ma50: '#3b82f6',
ma100: '#22d3ee',
ma200: '#a855f7',
}
const LEVEL_COLORS: Record<string, string> = {
long: '#7c3aed',
medium: '#3b82f6',
short: '#10b981',
}
export default function InstrumentChart({ priceData, indicators, events = [], height = 420 }: Props) {
const containerRef = useRef<HTMLDivElement>(null)
const cleanupRef = useRef<(() => void) | null>(null)
useEffect(() => {
cleanupRef.current?.()
cleanupRef.current = null
if (!containerRef.current || !priceData.length) return
let active = true
import('lightweight-charts').then((lc: any) => {
if (!active || !containerRef.current) return
const { createChart, CrosshairMode, ColorType, LineStyle } = lc
const chart = createChart(containerRef.current, {
width: containerRef.current.clientWidth,
height,
layout: {
background: { type: ColorType.Solid, color: 'transparent' },
textColor: '#64748b',
fontSize: 11,
},
grid: {
vertLines: { color: 'rgba(255,255,255,0.04)' },
horzLines: { color: 'rgba(255,255,255,0.04)' },
},
crosshair: { mode: CrosshairMode.Normal },
rightPriceScale: { borderColor: 'rgba(255,255,255,0.1)' },
timeScale: {
borderColor: 'rgba(255,255,255,0.1)',
timeVisible: true,
secondsVisible: false,
fixLeftEdge: true,
fixRightEdge: true,
},
handleScroll: true,
handleScale: true,
})
// Volume histogram (bottom 18% of chart)
const totalVol = priceData.reduce((s, d) => s + d.volume, 0)
if (totalVol > 0) {
const volSeries = chart.addHistogramSeries({
color: 'rgba(148,163,184,0.2)',
priceFormat: { type: 'volume' },
priceScaleId: 'volume',
})
chart.priceScale('volume').applyOptions({ scaleMargins: { top: 0.82, bottom: 0 } })
volSeries.setData(priceData.map(d => ({
time: d.time,
value: d.volume,
color: d.close >= d.open ? 'rgba(16,185,129,0.25)' : 'rgba(239,68,68,0.25)',
})))
}
// Candlestick series
const candleSeries = chart.addCandlestickSeries({
upColor: '#10b981',
downColor: '#ef4444',
borderUpColor: '#10b981',
borderDownColor:'#ef4444',
wickUpColor: '#6ee7b7',
wickDownColor: '#fca5a5',
})
candleSeries.setData(priceData)
// MA lines
for (const [key, color] of Object.entries(MA_COLORS)) {
const data = indicators[key as keyof typeof indicators]
if (data?.length) {
const s = chart.addLineSeries({
color,
lineWidth: key === 'ma200' ? 1.5 : 1,
priceLineVisible: false,
lastValueVisible: true,
crosshairMarkerVisible: false,
})
s.setData(data)
}
}
// Bollinger Bands
if (indicators.bb_upper?.length && indicators.bb_lower?.length) {
const bbOpts = {
color: 'rgba(148,163,184,0.32)',
lineWidth: 1,
lineStyle: LineStyle.Dashed,
priceLineVisible: false,
lastValueVisible: false,
crosshairMarkerVisible: false,
}
chart.addLineSeries(bbOpts).setData(indicators.bb_upper)
chart.addLineSeries(bbOpts).setData(indicators.bb_lower)
}
// Event markers
const timeSet = new Set(priceData.map(d => d.time))
const markers = events
.filter(ev => timeSet.has(ev.date))
.map(ev => ({
time: ev.date,
position: 'aboveBar' as const,
color: LEVEL_COLORS[ev.level] ?? '#f59e0b',
shape: 'arrowDown' as const,
text: ev.title.slice(0, 20),
}))
.sort((a, b) => a.time.localeCompare(b.time))
if (markers.length) candleSeries.setMarkers(markers)
chart.timeScale().fitContent()
const ro = new ResizeObserver(() => {
if (containerRef.current) chart.applyOptions({ width: containerRef.current.clientWidth })
})
ro.observe(containerRef.current)
cleanupRef.current = () => { ro.disconnect(); chart.remove() }
})
return () => {
active = false
cleanupRef.current?.()
cleanupRef.current = null
}
}, [priceData, indicators, events, height])
return (
<div className="bg-dark-900/60 rounded-xl border border-slate-700/40 overflow-hidden">
<div className="flex items-center gap-5 px-4 py-2 border-b border-slate-700/30 text-xs text-slate-500">
{Object.entries(MA_COLORS).map(([k, c]) => (
<span key={k} className="flex items-center gap-1.5">
<span className="inline-block w-5 h-0.5 rounded" style={{ background: c }} />
{k.toUpperCase()}
</span>
))}
<span className="flex items-center gap-1.5">
<span className="inline-block w-5 h-0.5 rounded opacity-40 border-t border-dashed border-slate-400" />
BB(20,2)
</span>
<span className="ml-auto flex items-center gap-3">
{Object.entries(LEVEL_COLORS).map(([l, c]) => (
<span key={l} className="flex items-center gap-1">
<span style={{ color: c }}></span>
<span>{l === 'long' ? 'LT' : l === 'medium' ? 'MT' : 'CT'}</span>
</span>
))}
</span>
</div>
{!priceData.length ? (
<div className="flex items-center justify-center" style={{ height }}>
<span className="text-slate-500 text-sm">Chargement...</span>
</div>
) : (
<div ref={containerRef} style={{ height: `${height}px`, width: '100%' }} />
)}
</div>
)
}

View File

@@ -1,7 +1,7 @@
import { NavLink } from 'react-router-dom'
import {
LayoutDashboard, Globe, BarChart2, FlaskConical,
History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText, Gauge, GitCompare, Building2, Users, Layers, ScanEye
History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText, Gauge, GitCompare, Building2, Users, Layers, ScanEye, CandlestickChart
} from 'lucide-react'
import { useGeoRiskScore, useAiStatus, usePortfolioSummary } from '../../hooks/useApi'
import clsx from 'clsx'
@@ -27,6 +27,7 @@ const nav = [
{ to: '/calendar', icon: Calendar, label: 'Calendar' },
{ to: '/institutional', icon: Building2, label: 'Inst. Reports' },
{ to: '/specialist-desks', icon: Users, label: 'Specialist Desks' },
{ to: '/instruments', icon: CandlestickChart, label: 'Instrument Snap.' },
{ to: '/snapshot', icon: ScanEye, label: 'Snapshot Externe' },
{ to: '/timeline', icon: Layers, label: 'Timeline' },
{ to: '/logs', icon: ScrollText, label: 'System Logs' },

View File

@@ -0,0 +1,566 @@
import { useState, useEffect, useCallback } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import {
Sparkles, RefreshCw, ChevronDown, TrendingUp, TrendingDown,
Minus, BarChart2, Clock, Calendar, AlertCircle,
} from 'lucide-react'
import axios from 'axios'
import clsx from 'clsx'
import InstrumentChart from '../components/InstrumentChart'
const api = axios.create({ baseURL: '/api' })
// ── Types ────────────────────────────────────────────────────────────────────
interface InstrumentConfig {
id: string
name: string
yf_ticker: string
category: string
currency: string
description: string
drivers: { key: string; label: string; weight: number }[]
regime_labels: string[]
chart: { ma_periods: number[]; show_volume: boolean }
correlation_instruments: string[]
}
interface PriceCandle { time: string; open: number; high: number; low: number; close: number; volume: number }
interface LinePoint { time: string; value: number }
interface Snapshot {
instrument: InstrumentConfig
price_data: PriceCandle[]
indicators: Record<string, LinePoint[]>
regime: {
current: string
confidence: number
scores: Record<string, number>
signals: {
ma50_above_ma200: boolean | null
ma50_slope_pct: number
ma200_slope_pct: number
momentum_20d_pct: number
dist_ma200_pct: number
vol_ratio_pct: number
}
}
trend: {
ma50_slope_5d: number
ma200_slope_20d: number
rsi14_current: number
atr14_current: number
atr_vs_3m_avg_pct: number
momentum_1m_pct: number
momentum_3m_pct: number
dist_ma50_pct: number | null
dist_ma200_pct: number | null
current_price: number
high_52w: number
low_52w: number
}
events: { date: string; title: string; level: string; category: string; description: string; impact_score: number }[]
current_price: number
change_pct: number
change_abs: number
period: string
}
// ── Helpers ──────────────────────────────────────────────────────────────────
const CATEGORY_ORDER = ['equity_index', 'equity_intl', 'metal', 'energy', 'bond', 'credit', 'fx', 'volatility', 'stock', 'crypto']
const CATEGORY_LABELS: Record<string, string> = {
equity_index: 'Indices US', equity_intl: 'Intl Equity', metal: 'Métaux',
energy: 'Énergie', bond: 'Obligataire', credit: 'Crédit',
fx: 'Forex', volatility: 'Volatilité', stock: 'Actions', crypto: 'Crypto',
}
function regimeColor(label: string): string {
const l = label.toLowerCase()
if (/bull|expan|recov|rally|strength|risk.on|inflow|demand|falling|weakness/i.test(l)) return 'emerald'
if (/bear|crash|crunch|shock|squeeze|fear|risk.off|selloff|crisis|crunch|deficit/i.test(l)) return 'red'
if (/volatil|spike|stress/i.test(l)) return 'orange'
if (/range|neutral|consolid|compress/i.test(l)) return 'slate'
return 'blue'
}
function pctColor(v: number | null, inverse = false): string {
if (v === null || v === undefined) return 'text-slate-400'
const pos = inverse ? v < 0 : v > 0
const neg = inverse ? v > 0 : v < 0
if (pos) return 'text-emerald-400'
if (neg) return 'text-red-400'
return 'text-slate-400'
}
function Arrow({ v }: { v: number }) {
if (v > 0.3) return <TrendingUp className="w-3.5 h-3.5 text-emerald-400" />
if (v < -0.3) return <TrendingDown className="w-3.5 h-3.5 text-red-400" />
return <Minus className="w-3.5 h-3.5 text-slate-500" />
}
function fmt(v: number | null, digits = 2): string {
if (v === null || v === undefined) return '—'
return (v >= 0 ? '+' : '') + v.toFixed(digits)
}
// ── Sub-components ────────────────────────────────────────────────────────────
function RegimeCard({ regime, config }: { regime: Snapshot['regime']; config: InstrumentConfig }) {
const col = regimeColor(regime.current)
const colorMap: Record<string, string> = {
emerald: 'text-emerald-400 border-emerald-700/40 bg-emerald-950/30',
red: 'text-red-400 border-red-700/40 bg-red-950/30',
orange: 'text-orange-400 border-orange-700/40 bg-orange-950/30',
slate: 'text-slate-400 border-slate-700/40 bg-slate-900/30',
blue: 'text-blue-400 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',
}
const { signals } = regime
const sigItems = [
{ 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) },
{ 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: 'Volatilité (ATR%)', value: (signals.vol_ratio_pct ?? 0).toFixed(1) + '%', color: signals.vol_ratio_pct > 3 ? 'text-orange-400' : 'text-slate-400' },
]
return (
<div className={clsx('rounded-xl border p-4 space-y-3', colorMap[col])}>
<div className="flex items-center gap-2">
<BarChart2 className={clsx('w-4 h-4', `text-${col}-400`)} />
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Régime Détecté</span>
</div>
<div>
<div className={clsx('text-lg font-bold leading-tight', `text-${col}-400`)}>{regime.current}</div>
<div className="text-xs text-slate-500 mt-0.5">Confiance {Math.round(regime.confidence * 100)}%</div>
</div>
{/* All regime scores */}
<div className="space-y-1.5">
{Object.entries(regime.scores)
.sort(([, a], [, b]) => b - a)
.map(([label, score]) => {
const c2 = regimeColor(label)
return (
<div key={label} className="space-y-0.5">
<div className="flex items-center justify-between text-xs">
<span className={label === regime.current ? `font-semibold text-${c2}-400` : 'text-slate-500'}>
{label}
</span>
<span className="text-slate-500">{Math.round(score * 100)}%</span>
</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>
{/* Signals */}
<div className="border-t border-slate-700/30 pt-3 space-y-1.5">
{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>
)
}
function TrendCard({ trend }: { trend: Snapshot['trend'] }) {
const rsi = trend.rsi14_current ?? 50
const rsiColor = rsi > 70 ? 'text-orange-400' : rsi < 30 ? 'text-cyan-400' : 'text-slate-300'
const rsiZone = rsi > 70 ? 'Suracheté' : rsi < 30 ? 'Survendu' : 'Neutre'
const items = [
{
group: 'Tendance', rows: [
{ label: 'Slope MA50 (5j)', value: fmt(trend.ma50_slope_5d) + '%', arrow: trend.ma50_slope_5d, bold: false },
{ label: 'Slope MA200 (20j)', value: fmt(trend.ma200_slope_20d) + '%', arrow: trend.ma200_slope_20d, bold: false },
{ label: 'Distance MA50', value: trend.dist_ma50_pct !== null ? fmt(trend.dist_ma50_pct) + '%' : '—', arrow: trend.dist_ma50_pct ?? 0, bold: false },
{ 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, bold: false },
{ 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.current_price - trend.low_52w) / (trend.high_52w - trend.low_52w)) * 100
: null
return (
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4 space-y-3">
<div className="flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-blue-400" />
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Indicateurs de Tendance</span>
</div>
{items.map(group => (
<div key={group.group}>
<div className="text-xs text-slate-600 uppercase tracking-wide mb-1.5">{group.group}</div>
{group.rows.map(row => (
<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={clsx('flex items-center gap-1', row.bold ? 'font-semibold' : '', pctColor(row.arrow ?? 0))}>
<Arrow v={row.arrow ?? 0} />
{row.value}
</span>
</div>
))}
</div>
))}
{/* RSI gauge */}
<div className="border-t border-slate-700/30 pt-3">
<div className="flex items-center justify-between text-xs mb-1.5">
<span className="text-slate-500">RSI(14)</span>
<span className={clsx('font-semibold', rsiColor)}>{rsi.toFixed(0)} {rsiZone}</span>
</div>
<div className="relative h-2 bg-slate-800 rounded-full overflow-hidden">
<div className="absolute left-0 top-0 h-full w-[30%] bg-cyan-900/50" />
<div className="absolute right-0 top-0 h-full w-[30%] bg-orange-900/50" />
<div
className="absolute top-0 h-full w-2 bg-white rounded-full transition-all"
style={{ left: `calc(${rsi}% - 4px)` }}
/>
</div>
<div className="flex justify-between text-xs text-slate-600 mt-0.5">
<span>0 Survendu</span><span>50</span><span>Suracheté 100</span>
</div>
</div>
{/* 52W range */}
{pct52w !== null && (
<div>
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-slate-500">Range 52 semaines</span>
<span className="text-slate-400">{Math.round(pct52w)}e percentile</span>
</div>
<div className="relative h-2 bg-slate-800 rounded-full overflow-hidden">
<div
className="absolute top-0 h-full bg-blue-500/60 rounded-full"
style={{ width: `${pct52w}%` }}
/>
</div>
<div className="flex justify-between text-xs text-slate-600 mt-0.5">
<span>{trend.low_52w?.toFixed(2)}</span>
<span>{trend.high_52w?.toFixed(2)}</span>
</div>
</div>
)}
{/* Volatility */}
<div className="flex items-center justify-between text-xs">
<span className="text-slate-500">ATR(14) vs moy. 3M</span>
<span className={clsx((trend.atr_vs_3m_avg_pct ?? 100) > 130 ? 'text-orange-400' : (trend.atr_vs_3m_avg_pct ?? 100) < 70 ? 'text-cyan-400' : 'text-slate-400')}>
{(trend.atr_vs_3m_avg_pct ?? 100).toFixed(0)}%
</span>
</div>
</div>
)
}
function EventsCard({ events }: { events: Snapshot['events'] }) {
const navigate = useNavigate()
const LEVEL_C: Record<string, string> = { long: 'text-violet-400 bg-violet-900/30 border-violet-700/30', medium: 'text-blue-400 bg-blue-900/30 border-blue-700/30', short: 'text-emerald-400 bg-emerald-900/30 border-emerald-700/30' }
return (
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4 space-y-3">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-amber-400" />
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Événements Macro</span>
</div>
{events.length === 0 ? (
<div className="text-xs text-slate-600 italic py-2">Aucun événement lié trouvé</div>
) : (
<div className="space-y-2 max-h-[320px] overflow-y-auto pr-1">
{events.map((ev, 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"
onClick={() => navigate(`/timeline?date=${ev.date}`)}
>
<div className="flex items-start justify-between gap-2">
<span className="text-xs font-medium text-slate-300 leading-tight">{ev.title}</span>
<span className={clsx('shrink-0 text-xs px-1.5 py-0.5 rounded border', LEVEL_C[ev.level] ?? 'text-slate-400 bg-slate-800 border-slate-700/30')}>
{ev.level === 'long' ? 'LT' : ev.level === 'medium' ? 'MT' : 'CT'}
</span>
</div>
<div className="flex items-center gap-2 mt-1 text-xs text-slate-500">
<Clock className="w-3 h-3" />
{ev.date}
{ev.impact_score > 0.7 && <AlertCircle className="w-3 h-3 text-orange-400" />}
</div>
{ev.description && (
<p className="text-xs text-slate-600 mt-1 line-clamp-1">{ev.description}</p>
)}
</div>
))}
</div>
)}
</div>
)
}
function NarrativeCard({
narrative, loading, onLoad, instrument,
}: {
narrative: string; loading: boolean; onLoad: () => void; instrument: InstrumentConfig
}) {
return (
<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 gap-2">
<Sparkles className="w-4 h-4 text-violet-400" />
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Narration IA {instrument.name}</span>
</div>
<button
onClick={onLoad}
disabled={loading}
className="flex items-center gap-1.5 text-xs px-3 py-1.5 bg-violet-800/30 hover:bg-violet-800/50 text-violet-300 border border-violet-700/40 rounded-lg transition-colors disabled:opacity-40"
>
{loading ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Sparkles className="w-3 h-3" />}
{loading ? 'Génération...' : narrative ? 'Actualiser' : 'Générer'}
</button>
</div>
{narrative ? (
<p className="text-sm text-slate-300 leading-relaxed">{narrative}</p>
) : loading ? (
<div className="space-y-2">
{[1, 2, 3].map(i => (
<div key={i} className="h-3 bg-slate-800 rounded animate-pulse" style={{ width: `${[95, 88, 70][i - 1]}%` }} />
))}
</div>
) : (
<div className="text-sm text-slate-600 italic">
Cliquez "Générer" pour obtenir une analyse IA du contexte macro + technique pour {instrument.name}.
</div>
)}
</div>
)
}
// ── Main page ─────────────────────────────────────────────────────────────────
const PERIODS = [
{ key: '3mo', label: '3M' },
{ key: '6mo', label: '6M' },
{ key: '1y', label: '1Y' },
{ key: '2y', label: '2Y' },
{ key: '5y', label: '5Y' },
]
export default function InstrumentDashboard() {
const { id = 'SPY' } = useParams<{ id: string }>()
const navigate = useNavigate()
const [period, setPeriod] = useState('1y')
const [instruments, setInstruments] = useState<InstrumentConfig[]>([])
const [snapshot, setSnapshot] = useState<Snapshot | null>(null)
const [narrative, setNarrative] = useState('')
const [loading, setLoading] = useState(false)
const [loadingNarr, setLoadingNarr] = useState(false)
const [selectorOpen, setSelectorOpen] = useState(false)
const instrumentId = id.toUpperCase()
// Load instrument list
useEffect(() => {
api.get('/instruments').then(r => setInstruments(r.data)).catch(() => {})
}, [])
// Load snapshot when instrument or period changes
useEffect(() => {
setLoading(true)
setSnapshot(null)
setNarrative('')
api.get(`/instruments/${instrumentId}/snapshot?period=${period}`)
.then(r => setSnapshot(r.data))
.catch(() => {})
.finally(() => setLoading(false))
}, [instrumentId, period])
const loadNarrative = useCallback(() => {
setLoadingNarr(true)
api.post(`/instruments/${instrumentId}/narrative`)
.then(r => setNarrative(r.data.narrative))
.catch(() => {})
.finally(() => setLoadingNarr(false))
}, [instrumentId])
// Group instruments by category
const grouped = CATEGORY_ORDER.map(cat => ({
cat,
label: CATEGORY_LABELS[cat] ?? cat,
items: instruments.filter(i => i.category === cat),
})).filter(g => g.items.length > 0)
const selected = instruments.find(i => i.id === instrumentId)
const changeAbs = snapshot?.change_abs
const changePct = snapshot?.change_pct
return (
<div className="p-6 max-w-screen-xl mx-auto space-y-4">
{/* ── Header ── */}
<div className="flex flex-wrap items-center gap-3">
{/* Instrument selector */}
<div className="relative">
<button
onClick={() => setSelectorOpen(s => !s)}
className="flex items-center gap-2 px-4 py-2 bg-dark-800 border border-slate-700/40 rounded-xl text-white hover:bg-dark-700 transition-colors"
>
<BarChart2 className="w-4 h-4 text-blue-400" />
<span className="font-semibold">{selected?.name ?? instrumentId}</span>
<span className="text-xs text-slate-500">{selected?.id}</span>
<ChevronDown className="w-3.5 h-3.5 text-slate-500" />
</button>
{selectorOpen && (
<div className="absolute top-full left-0 mt-1 z-50 bg-dark-800 border border-slate-700/40 rounded-xl shadow-2xl p-3 w-[480px] max-h-[60vh] overflow-y-auto">
{grouped.map(g => (
<div key={g.cat} className="mb-3">
<div className="text-xs text-slate-500 uppercase tracking-wide mb-1.5 px-1">{g.label}</div>
<div className="grid grid-cols-3 gap-1">
{g.items.map(inst => (
<button
key={inst.id}
onClick={() => { navigate(`/instruments/${inst.id}`); setSelectorOpen(false) }}
className={clsx(
'text-left px-2.5 py-1.5 rounded-lg text-xs transition-colors',
inst.id === instrumentId
? 'bg-blue-800/40 text-blue-300 border border-blue-700/40'
: 'text-slate-400 hover:bg-dark-700 hover:text-white'
)}
>
<div className="font-semibold">{inst.id}</div>
<div className="text-slate-500 truncate">{inst.name}</div>
</button>
))}
</div>
</div>
))}
</div>
)}
</div>
{/* Category badge */}
{selected && (
<span className="text-xs px-2 py-1 bg-dark-700 border border-slate-700/30 rounded-lg text-slate-400">
{CATEGORY_LABELS[selected.category] ?? selected.category}
</span>
)}
{/* Current price */}
{snapshot && (
<div className="flex items-center gap-3">
<span className="text-xl font-bold text-white">
{snapshot.current_price?.toLocaleString('fr-FR', { maximumFractionDigits: 4 })}
</span>
<span className={clsx('text-sm font-semibold', (changePct ?? 0) >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{(changePct ?? 0) >= 0 ? '+' : ''}{changeAbs?.toFixed(2)} ({(changePct ?? 0) >= 0 ? '+' : ''}{changePct?.toFixed(2)}%)
</span>
</div>
)}
{/* Period selector */}
<div className="ml-auto 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>
{/* Description */}
{selected && (
<p className="w-full text-xs text-slate-500">{selected.description}</p>
)}
</div>
{/* ── Loading state ── */}
{loading && (
<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="grid grid-cols-3 gap-4">
{[1, 2, 3].map(i => (
<div key={i} className="bg-dark-800/60 rounded-xl border border-slate-700/40 animate-pulse h-64" />
))}
</div>
</div>
)}
{/* ── Chart ── */}
{!loading && snapshot && (
<>
<InstrumentChart
priceData={snapshot.price_data}
indicators={snapshot.indicators}
events={snapshot.events}
height={420}
/>
{/* ── 3-column grid ── */}
<div className="grid grid-cols-3 gap-4">
<RegimeCard regime={snapshot.regime} config={snapshot.instrument} />
<TrendCard trend={snapshot.trend} />
<EventsCard events={snapshot.events} />
</div>
{/* ── AI Narrative ── */}
<NarrativeCard
narrative={narrative}
loading={loadingNarr}
onLoad={loadNarrative}
instrument={snapshot.instrument}
/>
</>
)}
{/* ── Empty/error state ── */}
{!loading && !snapshot && (
<div className="text-center py-20 text-slate-500">
<BarChart2 className="w-8 h-8 mx-auto mb-3 opacity-30" />
<p>Aucune donnée disponible pour {instrumentId}</p>
</div>
)}
{/* Close dropdown on outside click */}
{selectorOpen && (
<div className="fixed inset-0 z-40" onClick={() => setSelectorOpen(false)} />
)}
</div>
)
}