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:
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' },
|
||||
|
||||
Reference in New Issue
Block a user