feat: InstrumentDashboard — event timeline strip + crosshair date-aware cards
- InstrumentChart: onDateHover callback via subscribeCrosshairMove (useRef pattern) - EventTimelineStrip: 3 rows LT/MT/CT with CSS-% bars aligned to chart X axis - Cards date-aware: crosshair drives selectedDate; dateTrend + dateSignals computed client-side from lookup maps (priceMap/indMap/sortedDates) without extra API calls - TrendCard: price, RSI, ATR, slopes, momentum, 52W range all at selected date - RegimeCard: 5 signals recomputed at selected date; regime label from server - Date badge above cards; blue tint when browsing history, grey on last date - instrument_service.py: end_date in events; price_data built before events block Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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<string, LinePoint[]>
|
||||
events?: ChartEvent[]
|
||||
height?: number
|
||||
onDateHover?: (date: string | null) => void
|
||||
}
|
||||
|
||||
const MA_COLORS: Record<string, string> = {
|
||||
@@ -48,9 +41,13 @@ const LEVEL_COLORS: Record<string, string> = {
|
||||
short: '#10b981',
|
||||
}
|
||||
|
||||
export default function InstrumentChart({ priceData, indicators, events = [], height = 420 }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const cleanupRef = useRef<(() => void) | null>(null)
|
||||
export default function InstrumentChart({ priceData, indicators, events = [], height = 420, onDateHover }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(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()}
|
||||
</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 className="flex items-center gap-1.5 opacity-40">
|
||||
<span className="inline-block w-5 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]) => (
|
||||
|
||||
@@ -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<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
|
||||
}
|
||||
current: string; confidence: number; scores: Record<string, number>
|
||||
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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<LevelKey, { bg: string; border: string; text: string; label: string }> = {
|
||||
long: { bg: 'bg-violet-800/60', border: 'border-violet-700/60', text: 'text-violet-100', label: 'text-violet-400' },
|
||||
medium: { bg: 'bg-blue-800/60', border: 'border-blue-700/60', text: 'text-blue-100', label: 'text-blue-400' },
|
||||
short: { bg: 'bg-emerald-800/60',border: 'border-emerald-700/60',text: 'text-emerald-100', label: 'text-emerald-400'},
|
||||
}
|
||||
const STRIP_LABELS = { long: 'LT', medium: 'MT', short: 'CT' }
|
||||
const DEFAULT_DAYS: Record<LevelKey, number> = { long: 60, medium: 21, short: 10 }
|
||||
|
||||
function EventTimelineStrip({ events, priceData }: { events: SnapshotEvent[]; priceData: PriceCandle[] }) {
|
||||
if (priceData.length < 2) return null
|
||||
|
||||
const chartStart = dateToMs(priceData[0].time)
|
||||
const chartEnd = dateToMs(priceData[priceData.length - 1].time)
|
||||
const totalMs = chartEnd - chartStart
|
||||
if (totalMs <= 0) return null
|
||||
|
||||
function leftPct(dateStr: string): number {
|
||||
return Math.max(0, Math.min(99, ((dateToMs(dateStr) - chartStart) / totalMs) * 100))
|
||||
}
|
||||
|
||||
function widthPct(startStr: string, endStr: string | null, level: LevelKey): number {
|
||||
const endMs = endStr
|
||||
? dateToMs(endStr)
|
||||
: dateToMs(startStr) + DEFAULT_DAYS[level] * 86400000
|
||||
const w = ((endMs - dateToMs(startStr)) / totalMs) * 100
|
||||
return Math.min(100, Math.max(0.4, w))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-dark-900/40 rounded-xl border border-slate-700/40 px-4 py-3">
|
||||
<div className="text-xs text-slate-500 uppercase tracking-wide mb-2.5">Événements sur la période</div>
|
||||
<div className="space-y-1.5">
|
||||
{LEVEL_STRIP.map(level => {
|
||||
const evs = events.filter(e => e.level === level)
|
||||
const cols = STRIP_COLORS[level]
|
||||
return (
|
||||
<div key={level} className="flex items-center gap-2 h-6">
|
||||
<span className={clsx('text-xs font-bold w-5 shrink-0', cols.label)}>{STRIP_LABELS[level]}</span>
|
||||
{/* paddingRight aligns with lightweight-charts right price scale (~60px) */}
|
||||
<div className="relative flex-1 h-full" style={{ paddingRight: 62 }}>
|
||||
{evs.map((ev, i) => {
|
||||
const left = leftPct(ev.date)
|
||||
const width = widthPct(ev.date, ev.end_date, level)
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
title={`${ev.title}\n${ev.date}${ev.end_date ? ' → ' + ev.end_date : ''}`}
|
||||
className={clsx('absolute top-0 h-full rounded border overflow-hidden', cols.bg, cols.border)}
|
||||
style={{ left: `${left}%`, width: `${width}%` }}
|
||||
>
|
||||
{width > 3 && (
|
||||
<span className={clsx('absolute inset-0 flex items-center px-1 truncate pointer-events-none', cols.text)}
|
||||
style={{ fontSize: 9 }}>
|
||||
{ev.title}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{evs.length === 0 && (
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-dashed border-slate-700/40" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── RegimeCard ────────────────────────────────────────────────────────────────
|
||||
|
||||
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<string, string> = {
|
||||
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<string, string> = {
|
||||
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 (
|
||||
<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 className="flex items-center justify-between">
|
||||
<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</span>
|
||||
</div>
|
||||
<span className="text-xs text-slate-600">{dateLabel}</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>
|
||||
{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">
|
||||
<div className="text-xs text-slate-600 uppercase tracking-wide mb-1">Signaux — {dateLabel}</div>
|
||||
{sigItems.map(s => (
|
||||
<div key={s.label} className="flex items-center justify-between text-xs">
|
||||
<span className="text-slate-500">{s.label}</span>
|
||||
@@ -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 (
|
||||
<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 className="flex items-center justify-between">
|
||||
<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>
|
||||
<span className="text-xs text-slate-600">{dateLabel}</span>
|
||||
</div>
|
||||
|
||||
{/* Prix au snapshot */}
|
||||
<div className="flex items-center justify-between bg-dark-700/40 rounded-lg px-3 py-1.5">
|
||||
<span className="text-xs text-slate-500">Prix</span>
|
||||
<span className="text-sm font-bold text-white">
|
||||
{trend.current_price?.toLocaleString('fr-FR', { maximumFractionDigits: 4 })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{items.map(group => (
|
||||
@@ -222,7 +299,7 @@ function TrendCard({ trend }: { trend: Snapshot['trend'] }) {
|
||||
{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))}>
|
||||
<span className={clsx('flex items-center gap-1', (row as any).bold ? 'font-semibold' : '', pctColor(row.arrow ?? 0))}>
|
||||
<Arrow v={row.arrow ?? 0} />
|
||||
{row.value}
|
||||
</span>
|
||||
@@ -231,7 +308,6 @@ function TrendCard({ trend }: { trend: Snapshot['trend'] }) {
|
||||
</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>
|
||||
@@ -240,17 +316,14 @@ function TrendCard({ trend }: { trend: Snapshot['trend'] }) {
|
||||
<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 className="absolute top-0 h-full w-2 bg-white rounded-full transition-all"
|
||||
style={{ left: `calc(${Math.min(98, 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">
|
||||
@@ -258,10 +331,7 @@ function TrendCard({ trend }: { trend: Snapshot['trend'] }) {
|
||||
<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 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>
|
||||
@@ -270,7 +340,6 @@ function TrendCard({ trend }: { trend: Snapshot['trend'] }) {
|
||||
</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')}>
|
||||
@@ -281,24 +350,27 @@ function TrendCard({ trend }: { trend: Snapshot['trend'] }) {
|
||||
)
|
||||
}
|
||||
|
||||
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' }
|
||||
// ── EventsCard ────────────────────────────────────────────────────────────────
|
||||
|
||||
function EventsCard({ events }: { events: SnapshotEvent[] }) {
|
||||
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">
|
||||
<div className="space-y-2 max-h-[360px] overflow-y-auto pr-1">
|
||||
{events.map((ev, i) => (
|
||||
<div
|
||||
key={i}
|
||||
<div key={i}
|
||||
className="rounded-lg border border-slate-700/30 bg-dark-700/40 p-2 cursor-pointer hover:bg-dark-700/70 transition-colors"
|
||||
onClick={() => navigate(`/timeline?date=${ev.date}`)}
|
||||
>
|
||||
@@ -310,12 +382,10 @@ function EventsCard({ events }: { events: Snapshot['events'] }) {
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1 text-xs text-slate-500">
|
||||
<Clock className="w-3 h-3" />
|
||||
{ev.date}
|
||||
{ev.date}{ev.end_date ? ` → ${ev.end_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>
|
||||
)}
|
||||
{ev.description && <p className="text-xs text-slate-600 mt-1 line-clamp-1">{ev.description}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -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 (
|
||||
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
@@ -336,27 +406,22 @@ function NarrativeCard({
|
||||
<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}
|
||||
<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]}%` }} />
|
||||
))}
|
||||
{[95, 88, 70].map(w => <div key={w} className="h-3 bg-slate-800 rounded animate-pulse" style={{ width: `${w}%` }} />)}
|
||||
</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}.
|
||||
Cliquez "Générer" pour obtenir une analyse IA pour {instrument.name}.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -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<InstrumentConfig[]>([])
|
||||
const [snapshot, setSnapshot] = useState<Snapshot | null>(null)
|
||||
const [narrative, setNarrative] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
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 [selectedDate, setSelectedDate] = useState<string | null>(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<string, PriceCandle>,
|
||||
indMap: {} as Record<string, Record<string, number>>,
|
||||
sortedDates: [] as string[],
|
||||
dateIndex: {} as Record<string, number>,
|
||||
}
|
||||
const priceMap: Record<string, PriceCandle> = {}
|
||||
const indMap: Record<string, Record<string, number>> = {}
|
||||
const sortedDates: string[] = []
|
||||
const dateIndex: Record<string, number> = {}
|
||||
|
||||
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 (
|
||||
<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)}
|
||||
<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" />
|
||||
@@ -438,7 +630,6 @@ export default function InstrumentDashboard() {
|
||||
<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 => (
|
||||
@@ -446,11 +637,9 @@ export default function InstrumentDashboard() {
|
||||
<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}
|
||||
<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',
|
||||
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'
|
||||
@@ -467,36 +656,30 @@ export default function InstrumentDashboard() {
|
||||
)}
|
||||
</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 && (
|
||||
{displayPrice !== undefined && (
|
||||
<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)}%)
|
||||
{displayPrice.toLocaleString('fr-FR', { maximumFractionDigits: 4 })}
|
||||
</span>
|
||||
{isLastDate && snapshot && (
|
||||
<span className={clsx('text-sm font-semibold', (displayPricePct ?? 0) >= 0 ? 'text-emerald-400' : 'text-red-400')}>
|
||||
{(displayPricePct ?? 0) >= 0 ? '+' : ''}{snapshot.change_abs?.toFixed(2)} ({(displayPricePct ?? 0) >= 0 ? '+' : ''}{displayPricePct?.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'
|
||||
<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}
|
||||
@@ -504,42 +687,67 @@ export default function InstrumentDashboard() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{selected && (
|
||||
<p className="w-full text-xs text-slate-500">{selected.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Loading state ── */}
|
||||
{/* ── Loading skeleton ── */}
|
||||
{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="bg-dark-800/60 rounded-xl border border-slate-700/40 animate-pulse h-20" />
|
||||
<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" />
|
||||
))}
|
||||
{[1, 2, 3].map(i => <div key={i} className="bg-dark-800/60 rounded-xl border border-slate-700/40 animate-pulse h-72" />)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Chart ── */}
|
||||
{/* ── Content ── */}
|
||||
{!loading && snapshot && (
|
||||
<>
|
||||
{/* Chart */}
|
||||
<InstrumentChart
|
||||
priceData={snapshot.price_data}
|
||||
indicators={snapshot.indicators}
|
||||
events={snapshot.events}
|
||||
height={420}
|
||||
onDateHover={handleDateHover}
|
||||
/>
|
||||
|
||||
{/* ── 3-column grid ── */}
|
||||
{/* Event timeline strip */}
|
||||
<EventTimelineStrip events={snapshot.events} priceData={snapshot.price_data} />
|
||||
|
||||
{/* Snapshot date badge */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={clsx(
|
||||
'flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium border',
|
||||
isLastDate
|
||||
? 'bg-slate-800 border-slate-700/40 text-slate-400'
|
||||
: 'bg-blue-900/40 border-blue-700/50 text-blue-300'
|
||||
)}>
|
||||
<Calendar className="w-3 h-3" />
|
||||
Snapshot au {dateLabel}
|
||||
{!isLastDate && <span className="text-blue-500 ml-1">← survol du graphe</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3-column grid */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<RegimeCard regime={snapshot.regime} config={snapshot.instrument} />
|
||||
<TrendCard trend={snapshot.trend} />
|
||||
<RegimeCard
|
||||
regime={snapshot.regime}
|
||||
config={snapshot.instrument}
|
||||
signalsAt={dateSignals}
|
||||
dateLabel={dateLabel}
|
||||
/>
|
||||
<TrendCard
|
||||
trend={dateTrend ?? snapshot.trend}
|
||||
dateLabel={dateLabel}
|
||||
/>
|
||||
<EventsCard events={snapshot.events} />
|
||||
</div>
|
||||
|
||||
{/* ── AI Narrative ── */}
|
||||
{/* AI Narrative */}
|
||||
<NarrativeCard
|
||||
narrative={narrative}
|
||||
loading={loadingNarr}
|
||||
@@ -549,7 +757,6 @@ export default function InstrumentDashboard() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── 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" />
|
||||
@@ -557,10 +764,7 @@ export default function InstrumentDashboard() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Close dropdown on outside click */}
|
||||
{selectorOpen && (
|
||||
<div className="fixed inset-0 z-40" onClick={() => setSelectorOpen(false)} />
|
||||
)}
|
||||
{selectorOpen && <div className="fixed inset-0 z-40" onClick={() => setSelectorOpen(false)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user