Files
OpenFin/frontend/src/components/InstrumentChart.tsx
OpenSquared 537fea8148 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>
2026-06-24 21:36:39 +02:00

213 lines
6.3 KiB
TypeScript

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>
)
}