diff --git a/backend/data/instruments.json b/backend/data/instruments.json new file mode 100644 index 0000000..86a8f58 --- /dev/null +++ b/backend/data/instruments.json @@ -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." + } + ] +} diff --git a/backend/main.py b/backend/main.py index 2e13d2c..58adfd3 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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("/") diff --git a/backend/routers/instruments.py b/backend/routers/instruments.py new file mode 100644 index 0000000..41a31dc --- /dev/null +++ b/backend/routers/instruments.py @@ -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, + } diff --git a/backend/services/instrument_service.py b/backend/services/instrument_service.py new file mode 100644 index 0000000..7809547 --- /dev/null +++ b/backend/services/instrument_service.py @@ -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__})." diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b565f36..8468204 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 6bfceee..2e18e51 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3f6f2b6..bb114a2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> + } /> diff --git a/frontend/src/components/InstrumentChart.tsx b/frontend/src/components/InstrumentChart.tsx new file mode 100644 index 0000000..f3d7087 --- /dev/null +++ b/frontend/src/components/InstrumentChart.tsx @@ -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 = { + ma20: '#f59e0b', + ma50: '#3b82f6', + ma100: '#22d3ee', + ma200: '#a855f7', +} + +const LEVEL_COLORS: Record = { + long: '#7c3aed', + medium: '#3b82f6', + short: '#10b981', +} + +export default function InstrumentChart({ priceData, indicators, events = [], height = 420 }: Props) { + const containerRef = useRef(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 ( +
+
+ {Object.entries(MA_COLORS).map(([k, c]) => ( + + + {k.toUpperCase()} + + ))} + + + BB(20,2) + + + {Object.entries(LEVEL_COLORS).map(([l, c]) => ( + + + {l === 'long' ? 'LT' : l === 'medium' ? 'MT' : 'CT'} + + ))} + +
+ {!priceData.length ? ( +
+ Chargement... +
+ ) : ( +
+ )} +
+ ) +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 1ca03fc..66418cb 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -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' }, diff --git a/frontend/src/pages/InstrumentDashboard.tsx b/frontend/src/pages/InstrumentDashboard.tsx new file mode 100644 index 0000000..9cc4ecd --- /dev/null +++ b/frontend/src/pages/InstrumentDashboard.tsx @@ -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 + regime: { + current: string + confidence: number + scores: Record + 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 = { + 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 + if (v < -0.3) return + return +} + +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 = { + 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 = { + 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 ( +
+
+ + Régime Détecté +
+ +
+
{regime.current}
+
Confiance {Math.round(regime.confidence * 100)}%
+
+ + {/* All regime scores */} +
+ {Object.entries(regime.scores) + .sort(([, a], [, b]) => b - a) + .map(([label, score]) => { + const c2 = regimeColor(label) + return ( +
+
+ + {label} + + {Math.round(score * 100)}% +
+
+
+
+
+ ) + })} +
+ + {/* Signals */} +
+ {sigItems.map(s => ( +
+ {s.label} + {s.value} +
+ ))} +
+
+ ) +} + +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 ( +
+
+ + Indicateurs de Tendance +
+ + {items.map(group => ( +
+
{group.group}
+ {group.rows.map(row => ( +
+ {row.label} + + + {row.value} + +
+ ))} +
+ ))} + + {/* RSI gauge */} +
+
+ RSI(14) + {rsi.toFixed(0)} — {rsiZone} +
+
+
+
+
+
+
+ 0 Survendu50Suracheté 100 +
+
+ + {/* 52W range */} + {pct52w !== null && ( +
+
+ Range 52 semaines + {Math.round(pct52w)}e percentile +
+
+
+
+
+ {trend.low_52w?.toFixed(2)} + {trend.high_52w?.toFixed(2)} +
+
+ )} + + {/* Volatility */} +
+ ATR(14) vs moy. 3M + 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)}% + +
+
+ ) +} + +function EventsCard({ events }: { events: Snapshot['events'] }) { + const navigate = useNavigate() + const LEVEL_C: Record = { 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 ( +
+
+ + Événements Macro +
+ + {events.length === 0 ? ( +
Aucun événement lié trouvé
+ ) : ( +
+ {events.map((ev, i) => ( +
navigate(`/timeline?date=${ev.date}`)} + > +
+ {ev.title} + + {ev.level === 'long' ? 'LT' : ev.level === 'medium' ? 'MT' : 'CT'} + +
+
+ + {ev.date} + {ev.impact_score > 0.7 && } +
+ {ev.description && ( +

{ev.description}

+ )} +
+ ))} +
+ )} +
+ ) +} + +function NarrativeCard({ + narrative, loading, onLoad, instrument, +}: { + narrative: string; loading: boolean; onLoad: () => void; instrument: InstrumentConfig +}) { + return ( +
+
+
+ + Narration IA — {instrument.name} +
+ +
+ + {narrative ? ( +

{narrative}

+ ) : loading ? ( +
+ {[1, 2, 3].map(i => ( +
+ ))} +
+ ) : ( +
+ Cliquez "Générer" pour obtenir une analyse IA du contexte macro + technique pour {instrument.name}. +
+ )} +
+ ) +} + +// ── 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([]) + const [snapshot, setSnapshot] = useState(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 ( +
+ + {/* ── Header ── */} +
+ {/* Instrument selector */} +
+ + + {selectorOpen && ( +
+ {grouped.map(g => ( +
+
{g.label}
+
+ {g.items.map(inst => ( + + ))} +
+
+ ))} +
+ )} +
+ + {/* Category badge */} + {selected && ( + + {CATEGORY_LABELS[selected.category] ?? selected.category} + + )} + + {/* Current price */} + {snapshot && ( +
+ + {snapshot.current_price?.toLocaleString('fr-FR', { maximumFractionDigits: 4 })} + + = 0 ? 'text-emerald-400' : 'text-red-400')}> + {(changePct ?? 0) >= 0 ? '+' : ''}{changeAbs?.toFixed(2)} ({(changePct ?? 0) >= 0 ? '+' : ''}{changePct?.toFixed(2)}%) + +
+ )} + + {/* Period selector */} +
+ {PERIODS.map(p => ( + + ))} +
+ + {/* Description */} + {selected && ( +

{selected.description}

+ )} +
+ + {/* ── Loading state ── */} + {loading && ( +
+
+
+ {[1, 2, 3].map(i => ( +
+ ))} +
+
+ )} + + {/* ── Chart ── */} + {!loading && snapshot && ( + <> + + + {/* ── 3-column grid ── */} +
+ + + +
+ + {/* ── AI Narrative ── */} + + + )} + + {/* ── Empty/error state ── */} + {!loading && !snapshot && ( +
+ +

Aucune donnée disponible pour {instrumentId}

+
+ )} + + {/* Close dropdown on outside click */} + {selectorOpen && ( +
setSelectorOpen(false)} /> + )} +
+ ) +}