diff --git a/backend/services/instrument_service.py b/backend/services/instrument_service.py index e59255b..9527b7a 100644 --- a/backend/services/instrument_service.py +++ b/backend/services/instrument_service.py @@ -505,11 +505,19 @@ def _get_relevant_events( asset_hit = any(ra in ev_assets for ra in related) if keyword_hit or asset_hit: - filtered.append(ev) + filtered.append({ + "date": ev_start, + "end_date": ev.get("end_date") or None, + "title": ev.get("name") or ev.get("event_name") or "", + "level": ev.get("level", "medium"), + "category": ev.get("category", ""), + "description": (ev.get("description") or "")[:120], + "impact_score": float(ev.get("impact_score") or 0.5), + }) - # Sort by start_date desc, cap at 15 - filtered.sort(key=lambda e: str(e.get("start_date", "") or ""), reverse=True) - return filtered[:15] + # Sort by start_date asc for timeline display, cap at 30 + filtered.sort(key=lambda e: str(e.get("date", "") or "")) + return filtered[:30] # ── Main snapshot ────────────────────────────────────────────────────────────── @@ -557,15 +565,6 @@ async def get_snapshot( 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: @@ -579,6 +578,15 @@ async def get_snapshot( "volume": r.get("volume", 0), }) + # Events spanning the chart period (built after price_data so dates are known) + try: + chart_start = price_data[0]["time"] if price_data else None + chart_end = price_data[-1]["time"] if price_data else None + events = _get_relevant_events(config, from_date=chart_start, to_date=chart_end) + except Exception as e: + logger.warning(f"[instrument_service] Event filtering error: {e}") + events = [] + return { "instrument": config, "price_data": price_data, diff --git a/frontend/src/components/InstrumentChart.tsx b/frontend/src/components/InstrumentChart.tsx index f3d7087..34ccc0a 100644 --- a/frontend/src/components/InstrumentChart.tsx +++ b/frontend/src/components/InstrumentChart.tsx @@ -18,21 +18,14 @@ 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 + priceData: PriceCandle[] + indicators: Record + events?: ChartEvent[] + height?: number + onDateHover?: (date: string | null) => void } const MA_COLORS: Record = { @@ -48,9 +41,13 @@ const LEVEL_COLORS: Record = { short: '#10b981', } -export default function InstrumentChart({ priceData, indicators, events = [], height = 420 }: Props) { - const containerRef = useRef(null) - const cleanupRef = useRef<(() => void) | null>(null) +export default function InstrumentChart({ priceData, indicators, events = [], height = 420, onDateHover }: Props) { + const containerRef = useRef(null) + const cleanupRef = useRef<(() => void) | null>(null) + const onHoverRef = useRef(onDateHover) + + // Keep callback ref fresh without triggering chart rebuild + useEffect(() => { onHoverRef.current = onDateHover }, [onDateHover]) useEffect(() => { cleanupRef.current?.() @@ -90,8 +87,8 @@ export default function InstrumentChart({ priceData, indicators, events = [], he handleScale: true, }) - // Volume histogram (bottom 18% of chart) - const totalVol = priceData.reduce((s, d) => s + d.volume, 0) + // Volume (bottom 18%) + const totalVol = priceData.reduce((s, d) => s + (d.volume || 0), 0) if (totalVol > 0) { const volSeries = chart.addHistogramSeries({ color: 'rgba(148,163,184,0.2)', @@ -106,29 +103,28 @@ export default function InstrumentChart({ priceData, indicators, events = [], he }))) } - // Candlestick series + // Candlesticks const candleSeries = chart.addCandlestickSeries({ - upColor: '#10b981', - downColor: '#ef4444', - borderUpColor: '#10b981', - borderDownColor:'#ef4444', - wickUpColor: '#6ee7b7', - wickDownColor: '#fca5a5', + 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] + const data = indicators[key] if (data?.length) { - const s = chart.addLineSeries({ + chart.addLineSeries({ color, lineWidth: key === 'ma200' ? 1.5 : 1, priceLineVisible: false, lastValueVisible: true, crosshairMarkerVisible: false, - }) - s.setData(data) + }).setData(data) } } @@ -155,7 +151,7 @@ export default function InstrumentChart({ priceData, indicators, events = [], he position: 'aboveBar' as const, color: LEVEL_COLORS[ev.level] ?? '#f59e0b', shape: 'arrowDown' as const, - text: ev.title.slice(0, 20), + text: ev.title.slice(0, 18), })) .sort((a, b) => a.time.localeCompare(b.time)) @@ -163,6 +159,15 @@ export default function InstrumentChart({ priceData, indicators, events = [], he chart.timeScale().fitContent() + // Crosshair → date callback + chart.subscribeCrosshairMove((param: any) => { + if (param?.time && typeof param.time === 'string') { + onHoverRef.current?.(param.time) + } else if (!param?.point) { + onHoverRef.current?.(null) // mouse left chart + } + }) + const ro = new ResizeObserver(() => { if (containerRef.current) chart.applyOptions({ width: containerRef.current.clientWidth }) }) @@ -187,9 +192,8 @@ export default function InstrumentChart({ priceData, indicators, events = [], he {k.toUpperCase()} ))} - - - BB(20,2) + + BB(20,2) {Object.entries(LEVEL_COLORS).map(([l, c]) => ( diff --git a/frontend/src/pages/InstrumentDashboard.tsx b/frontend/src/pages/InstrumentDashboard.tsx index 9cc4ecd..94bf833 100644 --- a/frontend/src/pages/InstrumentDashboard.tsx +++ b/frontend/src/pages/InstrumentDashboard.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useMemo } from 'react' import { useParams, useNavigate } from 'react-router-dom' import { Sparkles, RefreshCw, ChevronDown, TrendingUp, TrendingDown, @@ -10,14 +10,10 @@ import InstrumentChart from '../components/InstrumentChart' const api = axios.create({ baseURL: '/api' }) -// ── Types ──────────────────────────────────────────────────────────────────── +// ── Types ───────────────────────────────────────────────────────────────────── interface InstrumentConfig { - id: string - name: string - yf_ticker: string - category: string - currency: string + id: string; name: string; yf_ticker: string; category: string; currency: string description: string drivers: { key: string; label: string; weight: number }[] regime_labels: string[] @@ -28,48 +24,39 @@ interface InstrumentConfig { interface PriceCandle { time: string; open: number; high: number; low: number; close: number; volume: number } interface LinePoint { time: string; value: number } +interface SnapshotEvent { + date: string; end_date: string | null; title: string; level: string + category: string; description: string; impact_score: number +} + +interface RegimeSignals { + 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 +} + +interface TrendMetrics { + 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 +} + interface Snapshot { instrument: InstrumentConfig price_data: PriceCandle[] indicators: Record regime: { - current: string - confidence: number - scores: Record - signals: { - ma50_above_ma200: boolean | null - ma50_slope_pct: number - ma200_slope_pct: number - momentum_20d_pct: number - dist_ma200_pct: number - vol_ratio_pct: number - } + current: string; confidence: number; scores: Record + signals: RegimeSignals } - 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 + trend: TrendMetrics + events: SnapshotEvent[] + current_price: number; change_pct: number; change_abs: number; period: string } -// ── Helpers ────────────────────────────────────────────────────────────────── +// ── Helpers ─────────────────────────────────────────────────────────────────── const CATEGORY_ORDER = ['equity_index', 'equity_intl', 'metal', 'energy', 'bond', 'credit', 'fx', 'volatility', 'stock', 'crypto'] - const CATEGORY_LABELS: Record = { equity_index: 'Indices US', equity_intl: 'Intl Equity', metal: 'Métaux', energy: 'Énergie', bond: 'Obligataire', credit: 'Crédit', @@ -79,7 +66,7 @@ const CATEGORY_LABELS: Record = { 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 (/bear|crash|crunch|shock|squeeze|fear|risk.off|selloff|crisis|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' @@ -89,9 +76,7 @@ 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' + return pos ? 'text-emerald-400' : neg ? 'text-red-400' : 'text-slate-400' } function Arrow({ v }: { v: number }) { @@ -105,9 +90,101 @@ function fmt(v: number | null, digits = 2): string { return (v >= 0 ? '+' : '') + v.toFixed(digits) } -// ── Sub-components ──────────────────────────────────────────────────────────── +function fmtDateFR(s: string | null): string { + if (!s) return '—' + const [y, m, d] = s.split('-') + return `${d}/${m}/${y}` +} -function RegimeCard({ regime, config }: { regime: Snapshot['regime']; config: InstrumentConfig }) { +function dateToMs(s: string) { return new Date(s).getTime() } + +// ── EventTimelineStrip ──────────────────────────────────────────────────────── + +const LEVEL_STRIP = ['long', 'medium', 'short'] as const +type LevelKey = typeof LEVEL_STRIP[number] + +const STRIP_COLORS: Record = { + long: { bg: 'bg-violet-800/60', border: 'border-violet-700/60', text: 'text-violet-100', label: 'text-violet-400' }, + medium: { bg: 'bg-blue-800/60', border: 'border-blue-700/60', text: 'text-blue-100', label: 'text-blue-400' }, + short: { bg: 'bg-emerald-800/60',border: 'border-emerald-700/60',text: 'text-emerald-100', label: 'text-emerald-400'}, +} +const STRIP_LABELS = { long: 'LT', medium: 'MT', short: 'CT' } +const DEFAULT_DAYS: Record = { long: 60, medium: 21, short: 10 } + +function EventTimelineStrip({ events, priceData }: { events: SnapshotEvent[]; priceData: PriceCandle[] }) { + if (priceData.length < 2) return null + + const chartStart = dateToMs(priceData[0].time) + const chartEnd = dateToMs(priceData[priceData.length - 1].time) + const totalMs = chartEnd - chartStart + if (totalMs <= 0) return null + + function leftPct(dateStr: string): number { + return Math.max(0, Math.min(99, ((dateToMs(dateStr) - chartStart) / totalMs) * 100)) + } + + function widthPct(startStr: string, endStr: string | null, level: LevelKey): number { + const endMs = endStr + ? dateToMs(endStr) + : dateToMs(startStr) + DEFAULT_DAYS[level] * 86400000 + const w = ((endMs - dateToMs(startStr)) / totalMs) * 100 + return Math.min(100, Math.max(0.4, w)) + } + + return ( +
+
Événements sur la période
+
+ {LEVEL_STRIP.map(level => { + const evs = events.filter(e => e.level === level) + const cols = STRIP_COLORS[level] + return ( +
+ {STRIP_LABELS[level]} + {/* paddingRight aligns with lightweight-charts right price scale (~60px) */} +
+ {evs.map((ev, i) => { + const left = leftPct(ev.date) + const width = widthPct(ev.date, ev.end_date, level) + return ( +
+ {width > 3 && ( + + {ev.title} + + )} +
+ ) + })} + {evs.length === 0 && ( +
+
+
+ )} +
+
+ ) + })} +
+
+ ) +} + +// ── RegimeCard ──────────────────────────────────────────────────────────────── + +function RegimeCard({ + regime, config, signalsAt, dateLabel, +}: { + regime: Snapshot['regime']; config: InstrumentConfig + signalsAt: RegimeSignals | null; dateLabel: string +}) { + const signals = signalsAt ?? regime.signals const col = regimeColor(regime.current) const colorMap: Record = { emerald: 'text-emerald-400 border-emerald-700/40 bg-emerald-950/30', @@ -117,61 +194,48 @@ function RegimeCard({ regime, config }: { regime: Snapshot['regime']; config: In blue: 'text-blue-400 border-blue-700/40 bg-blue-950/30', } const barColorMap: Record = { - emerald: 'bg-emerald-500', - red: 'bg-red-500', - orange: 'bg-orange-500', - slate: 'bg-slate-500', - blue: 'bg-blue-500', + 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' }, + { 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 > 130 ? 'text-orange-400' : 'text-slate-400' }, ] return (
-
- - Régime Détecté +
+
+ + Régime +
+ {dateLabel}
-
{regime.current}
Confiance {Math.round(regime.confidence * 100)}%
- - {/* All regime scores */}
- {Object.entries(regime.scores) - .sort(([, a], [, b]) => b - a) - .map(([label, score]) => { - const c2 = regimeColor(label) - return ( -
-
- - {label} - - {Math.round(score * 100)}% -
-
-
-
+ {Object.entries(regime.scores).sort(([, a], [, b]) => b - a).map(([label, score]) => { + const c2 = regimeColor(label) + return ( +
+
+ {label} + {Math.round(score * 100)}%
- ) - })} +
+
+
+
+ ) + })}
- - {/* Signals */}
+
Signaux — {dateLabel}
{sigItems.map(s => (
{s.label} @@ -183,37 +247,50 @@ function RegimeCard({ regime, config }: { regime: Snapshot['regime']; config: In ) } -function TrendCard({ trend }: { trend: Snapshot['trend'] }) { +// ── TrendCard ───────────────────────────────────────────────────────────────── + +function TrendCard({ trend, dateLabel }: { trend: TrendMetrics; dateLabel: string }) { 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 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 }, + { label: 'Slope MA50 (5j)', value: fmt(trend.ma50_slope_5d) + '%', arrow: trend.ma50_slope_5d }, + { label: 'Slope MA200 (20j)',value: fmt(trend.ma200_slope_20d) + '%', arrow: trend.ma200_slope_20d }, + { label: 'Distance MA50', value: trend.dist_ma50_pct !== null ? fmt(trend.dist_ma50_pct) + '%' : '—', arrow: trend.dist_ma50_pct ?? 0 }, + { label: 'Distance MA200', value: trend.dist_ma200_pct !== null ? fmt(trend.dist_ma200_pct) + '%' : '—', arrow: trend.dist_ma200_pct ?? 0, bold: true }, ] }, { group: 'Momentum', rows: [ - { label: 'Momentum 1M', value: fmt(trend.momentum_1m_pct) + '%', arrow: trend.momentum_1m_pct, bold: false }, + { label: 'Momentum 1M', value: fmt(trend.momentum_1m_pct) + '%', arrow: trend.momentum_1m_pct }, { label: 'Momentum 3M', value: fmt(trend.momentum_3m_pct) + '%', arrow: trend.momentum_3m_pct, bold: true }, ] }, ] - const pct52w = trend.high_52w && trend.low_52w + const pct52w = trend.high_52w && trend.low_52w && (trend.high_52w - trend.low_52w) > 0 ? ((trend.current_price - trend.low_52w) / (trend.high_52w - trend.low_52w)) * 100 : null return (
-
- - Indicateurs de Tendance +
+
+ + Indicateurs de Tendance +
+ {dateLabel} +
+ + {/* Prix au snapshot */} +
+ Prix + + {trend.current_price?.toLocaleString('fr-FR', { maximumFractionDigits: 4 })} +
{items.map(group => ( @@ -222,7 +299,7 @@ function TrendCard({ trend }: { trend: Snapshot['trend'] }) { {group.rows.map(row => (
{row.label} - + {row.value} @@ -231,7 +308,6 @@ function TrendCard({ trend }: { trend: Snapshot['trend'] }) {
))} - {/* RSI gauge */}
RSI(14) @@ -240,17 +316,14 @@ function TrendCard({ trend }: { trend: Snapshot['trend'] }) {
-
+
0 Survendu50Suracheté 100
- {/* 52W range */} {pct52w !== null && (
@@ -258,10 +331,7 @@ function TrendCard({ trend }: { trend: Snapshot['trend'] }) { {Math.round(pct52w)}e percentile
-
+
{trend.low_52w?.toFixed(2)} @@ -270,7 +340,6 @@ function TrendCard({ trend }: { trend: Snapshot['trend'] }) {
)} - {/* Volatility */}
ATR(14) vs moy. 3M 130 ? 'text-orange-400' : (trend.atr_vs_3m_avg_pct ?? 100) < 70 ? 'text-cyan-400' : 'text-slate-400')}> @@ -281,24 +350,27 @@ function TrendCard({ trend }: { trend: Snapshot['trend'] }) { ) } -function EventsCard({ events }: { events: Snapshot['events'] }) { - const navigate = useNavigate() - const LEVEL_C: Record = { long: 'text-violet-400 bg-violet-900/30 border-violet-700/30', medium: 'text-blue-400 bg-blue-900/30 border-blue-700/30', short: 'text-emerald-400 bg-emerald-900/30 border-emerald-700/30' } +// ── EventsCard ──────────────────────────────────────────────────────────────── +function EventsCard({ events }: { events: SnapshotEvent[] }) { + const navigate = useNavigate() + const LEVEL_C: Record = { + long: 'text-violet-400 bg-violet-900/30 border-violet-700/30', + medium: 'text-blue-400 bg-blue-900/30 border-blue-700/30', + short: 'text-emerald-400 bg-emerald-900/30 border-emerald-700/30', + } return (
Événements Macro
- {events.length === 0 ? (
Aucun événement lié trouvé
) : ( -
+
{events.map((ev, i) => ( -
navigate(`/timeline?date=${ev.date}`)} > @@ -310,12 +382,10 @@ function EventsCard({ events }: { events: Snapshot['events'] }) {
- {ev.date} + {ev.date}{ev.end_date ? ` → ${ev.end_date}` : ''} {ev.impact_score > 0.7 && }
- {ev.description && ( -

{ev.description}

- )} + {ev.description &&

{ev.description}

}
))}
@@ -324,11 +394,11 @@ function EventsCard({ events }: { events: Snapshot['events'] }) { ) } +// ── NarrativeCard ───────────────────────────────────────────────────────────── + function NarrativeCard({ narrative, loading, onLoad, instrument, -}: { - narrative: string; loading: boolean; onLoad: () => void; instrument: InstrumentConfig -}) { +}: { narrative: string; loading: boolean; onLoad: () => void; instrument: InstrumentConfig }) { return (
@@ -336,27 +406,22 @@ function NarrativeCard({ Narration IA — {instrument.name}
-
- {narrative ? (

{narrative}

) : loading ? (
- {[1, 2, 3].map(i => ( -
- ))} + {[95, 88, 70].map(w =>
)}
) : (
- Cliquez "Générer" pour obtenir une analyse IA du contexte macro + technique pour {instrument.name}. + Cliquez "Générer" pour obtenir une analyse IA pour {instrument.name}.
)}
@@ -366,38 +431,44 @@ function NarrativeCard({ // ── Main page ───────────────────────────────────────────────────────────────── const PERIODS = [ - { key: '3mo', label: '3M' }, - { key: '6mo', label: '6M' }, - { key: '1y', label: '1Y' }, - { key: '2y', label: '2Y' }, - { key: '5y', label: '5Y' }, + { key: '3mo', label: '3M' }, { key: '6mo', label: '6M' }, + { key: '1y', label: '1Y' }, { key: '2y', label: '2Y' }, { key: '5y', label: '5Y' }, ] +function pctN(a: number | undefined, b: number | undefined): number { + if (a === undefined || b === undefined || b === 0) return 0 + return ((a - b) / b) * 100 +} + export default function InstrumentDashboard() { - const { id = 'SPY' } = useParams<{ id: string }>() - const navigate = useNavigate() - const [period, setPeriod] = useState('1y') + const { id = 'SPY' } = useParams<{ id: string }>() + const navigate = useNavigate() + const [period, setPeriod] = useState('1y') const [instruments, setInstruments] = useState([]) - const [snapshot, setSnapshot] = useState(null) - const [narrative, setNarrative] = useState('') - const [loading, setLoading] = useState(false) + const [snapshot, setSnapshot] = useState(null) + const [narrative, setNarrative] = useState('') + const [loading, setLoading] = useState(false) const [loadingNarr, setLoadingNarr] = useState(false) const [selectorOpen, setSelectorOpen] = useState(false) + const [selectedDate, setSelectedDate] = useState(null) 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('') + setSelectedDate(null) api.get(`/instruments/${instrumentId}/snapshot?period=${period}`) - .then(r => setSnapshot(r.data)) + .then(r => { + setSnapshot(r.data) + const pd: PriceCandle[] = r.data.price_data + if (pd?.length) setSelectedDate(pd[pd.length - 1].time) + }) .catch(() => {}) .finally(() => setLoading(false)) }, [instrumentId, period]) @@ -410,27 +481,148 @@ export default function InstrumentDashboard() { .finally(() => setLoadingNarr(false)) }, [instrumentId]) - // Group instruments by category + const handleDateHover = useCallback((date: string | null) => { + if (date) setSelectedDate(date) + // null = mouse left chart area → keep last hovered date + }, []) + + // ── Lookup maps (rebuilt only when snapshot changes) ────────────────────── + const { priceMap, indMap, sortedDates, dateIndex } = useMemo(() => { + if (!snapshot) return { + priceMap: {} as Record, + indMap: {} as Record>, + sortedDates: [] as string[], + dateIndex: {} as Record, + } + const priceMap: Record = {} + const indMap: Record> = {} + const sortedDates: string[] = [] + const dateIndex: Record = {} + + for (const c of snapshot.price_data) { + priceMap[c.time] = c + sortedDates.push(c.time) + } + sortedDates.forEach((d, i) => { dateIndex[d] = i }) + + for (const [key, pts] of Object.entries(snapshot.indicators)) { + for (const pt of pts) { + if (!indMap[pt.time]) indMap[pt.time] = {} + indMap[pt.time][key] = pt.value + } + } + return { priceMap, indMap, sortedDates, dateIndex } + }, [snapshot]) + + // Resolve effective date (fall back to last if selectedDate not in index) + const effectiveDate = useMemo(() => { + if (selectedDate && dateIndex[selectedDate] !== undefined) return selectedDate + return sortedDates[sortedDates.length - 1] ?? null + }, [selectedDate, sortedDates, dateIndex]) + + // ── Compute trend metrics at selectedDate ───────────────────────────────── + const dateTrend = useMemo((): TrendMetrics | null => { + if (!effectiveDate || !snapshot) return null + const candle = priceMap[effectiveDate] + if (!candle) return null + + const idx = dateIndex[effectiveDate] + const price = candle.close + const ind = indMap[effectiveDate] ?? {} + const ma50 = ind.ma50 + const ma200 = ind.ma200 + + const d5 = idx >= 5 ? sortedDates[idx - 5] : null + const d20 = idx >= 20 ? sortedDates[idx - 20] : null + const d21 = idx >= 21 ? sortedDates[idx - 21] : null + const d63 = idx >= 63 ? sortedDates[idx - 63] : null + + const ma50_slope_5d = pctN(ma50, d5 ? indMap[d5]?.ma50 : undefined) + const ma200_slope_20d = pctN(ma200, d20 ? indMap[d20]?.ma200 : undefined) + const momentum_1m_pct = d21 && priceMap[d21] ? pctN(price, priceMap[d21].close) : 0 + const momentum_3m_pct = d63 && priceMap[d63] ? pctN(price, priceMap[d63].close) : 0 + const dist_ma50_pct = ma50 ? pctN(price, ma50) : null + const dist_ma200_pct = ma200 ? pctN(price, ma200) : null + + const atr14 = ind.atr14 ?? 0 + let atrSum = 0, atrCnt = 0 + for (let i = Math.max(0, idx - 65); i <= idx; i++) { + const v = indMap[sortedDates[i]]?.atr14 + if (v) { atrSum += v; atrCnt++ } + } + const atr_vs_3m_avg_pct = atrCnt > 0 && atr14 ? (atr14 / (atrSum / atrCnt)) * 100 : 100 + + let high52 = candle.high, low52 = candle.low + for (let i = Math.max(0, idx - 252); i <= idx; i++) { + const c = priceMap[sortedDates[i]] + if (c) { if (c.high > high52) high52 = c.high; if (c.low < low52) low52 = c.low } + } + + return { + ma50_slope_5d, ma200_slope_20d, + rsi14_current: ind.rsi14 ?? 50, + atr14_current: atr14, + atr_vs_3m_avg_pct, momentum_1m_pct, momentum_3m_pct, + dist_ma50_pct, dist_ma200_pct, + current_price: price, high_52w: high52, low_52w: low52, + } + }, [effectiveDate, priceMap, indMap, sortedDates, dateIndex, snapshot]) + + // ── Compute regime signals at selectedDate ──────────────────────────────── + const dateSignals = useMemo((): RegimeSignals | null => { + if (!effectiveDate || !snapshot) return null + const candle = priceMap[effectiveDate] + if (!candle) return null + + const idx = dateIndex[effectiveDate] + const price = candle.close + const ind = indMap[effectiveDate] ?? {} + const ma50 = ind.ma50 + const ma200 = ind.ma200 + const atr14 = ind.atr14 + + const d10 = idx >= 10 ? sortedDates[idx - 10] : null + const d20 = idx >= 20 ? sortedDates[idx - 20] : null + + const ma50_slope_pct = pctN(ma50, d10 ? indMap[d10]?.ma50 : undefined) + const ma200_slope_pct = pctN(ma200, d10 ? indMap[d10]?.ma200 : undefined) + const momentum_20d_pct = d20 && priceMap[d20] ? pctN(price, priceMap[d20].close) : 0 + const dist_ma200_pct = ma200 ? pctN(price, ma200) : 0 + + let atrSum = 0, atrCnt = 0 + for (let i = Math.max(0, idx - 65); i <= idx; i++) { + const v = indMap[sortedDates[i]]?.atr14 + if (v) { atrSum += v; atrCnt++ } + } + const vol_ratio_pct = atrCnt > 0 && atr14 ? (atr14 / (atrSum / atrCnt)) * 100 : 0 + + return { + ma50_above_ma200: ma50 !== undefined && ma200 !== undefined ? ma50 > ma200 : null, + ma50_slope_pct, ma200_slope_pct, momentum_20d_pct, dist_ma200_pct, vol_ratio_pct, + } + }, [effectiveDate, priceMap, indMap, sortedDates, dateIndex, snapshot]) + + // ── UI helpers ──────────────────────────────────────────────────────────── const grouped = CATEGORY_ORDER.map(cat => ({ - cat, - label: CATEGORY_LABELS[cat] ?? cat, + cat, label: CATEGORY_LABELS[cat] ?? cat, items: instruments.filter(i => i.category === cat), })).filter(g => g.items.length > 0) const selected = instruments.find(i => i.id === instrumentId) + const isLastDate = effectiveDate === sortedDates[sortedDates.length - 1] + const dateLabel = effectiveDate ? fmtDateFR(effectiveDate) : '—' - const changeAbs = snapshot?.change_abs - const changePct = snapshot?.change_pct + // Header price: from computed trend (crosshair date) or snapshot + const displayPrice = dateTrend?.current_price ?? snapshot?.current_price + const displayPricePct = snapshot?.change_pct return (
{/* ── Header ── */}
- {/* Instrument selector */}
- - {selectorOpen && (
{grouped.map(g => ( @@ -446,11 +637,9 @@ export default function InstrumentDashboard() {
{g.label}
{g.items.map(inst => ( -
- {/* Category badge */} {selected && ( {CATEGORY_LABELS[selected.category] ?? selected.category} )} - {/* Current price */} - {snapshot && ( + {displayPrice !== undefined && (
- {snapshot.current_price?.toLocaleString('fr-FR', { maximumFractionDigits: 4 })} - - = 0 ? 'text-emerald-400' : 'text-red-400')}> - {(changePct ?? 0) >= 0 ? '+' : ''}{changeAbs?.toFixed(2)} ({(changePct ?? 0) >= 0 ? '+' : ''}{changePct?.toFixed(2)}%) + {displayPrice.toLocaleString('fr-FR', { maximumFractionDigits: 4 })} + {isLastDate && snapshot && ( + = 0 ? 'text-emerald-400' : 'text-red-400')}> + {(displayPricePct ?? 0) >= 0 ? '+' : ''}{snapshot.change_abs?.toFixed(2)} ({(displayPricePct ?? 0) >= 0 ? '+' : ''}{displayPricePct?.toFixed(2)}%) + + )}
)} - {/* Period selector */}
{PERIODS.map(p => ( -
- {/* Description */} {selected && (

{selected.description}

)}
- {/* ── Loading state ── */} + {/* ── Loading skeleton ── */} {loading && (
+
- {[1, 2, 3].map(i => ( -
- ))} + {[1, 2, 3].map(i =>
)}
)} - {/* ── Chart ── */} + {/* ── Content ── */} {!loading && snapshot && ( <> + {/* Chart */} - {/* ── 3-column grid ── */} + {/* Event timeline strip */} + + + {/* Snapshot date badge */} +
+
+ + Snapshot au {dateLabel} + {!isLastDate && ← survol du graphe} +
+
+ + {/* 3-column grid */}
- - + +
- {/* ── AI Narrative ── */} + {/* AI Narrative */} )} - {/* ── Empty/error state ── */} {!loading && !snapshot && (
@@ -557,10 +764,7 @@ export default function InstrumentDashboard() {
)} - {/* Close dropdown on outside click */} - {selectorOpen && ( -
setSelectorOpen(false)} /> - )} + {selectorOpen &&
setSelectorOpen(false)} />}
) }