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:
554
backend/data/instruments.json
Normal file
554
backend/data/instruments.json
Normal 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."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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("/")
|
||||
|
||||
65
backend/routers/instruments.py
Normal file
65
backend/routers/instruments.py
Normal 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,
|
||||
}
|
||||
699
backend/services/instrument_service.py
Normal file
699
backend/services/instrument_service.py
Normal 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__})."
|
||||
16
frontend/package-lock.json
generated
16
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
212
frontend/src/components/InstrumentChart.tsx
Normal file
212
frontend/src/components/InstrumentChart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
566
frontend/src/pages/InstrumentDashboard.tsx
Normal file
566
frontend/src/pages/InstrumentDashboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user