Files
OpenFin/frontend/src/components/InstrumentChart.tsx
2026-07-19 11:08:17 +02:00

465 lines
18 KiB
TypeScript

import { useRef, useEffect, useState } 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
category?: string
impact_score?: number
end_date?: string | null
description?: string
}
interface TheoPoint {
date: string
cumulative_pips: number
contributions: { template_name: string; event_name: string; event_date: string; pips: number; decay_factor: number }[]
}
interface Props {
priceData: PriceCandle[]
indicators: Record<string, LinePoint[]>
events?: ChartEvent[]
height?: number
chartType?: 'candles' | 'line'
onDateHover?: (date: string | null) => void
theoryCurve?: TheoPoint[]
/** Called once the chart is ready with (dateToCoord, canvasPageLeft) — lets external components align with the chart's x-axis */
onChartReady?: (dateToCoord: (d: string) => number | null, canvasPageLeft: number) => void
}
export type { TheoPoint }
const MA_COLORS: Record<string, string> = {
ma20: '#f59e0b',
ma50: '#3b82f6',
ma100: '#22d3ee',
ma200: '#a855f7',
}
// Every overlay a user can show/hide independently. 'bb' toggles the upper+lower band pair
// together (they're one visual concept); 'volatility' gets its own price scale since
// annualized % lives on a totally different axis than the price series.
type OverlayKey = 'ma20' | 'ma50' | 'ma100' | 'ma200' | 'bb' | 'volatility'
const OVERLAY_KEYS: OverlayKey[] = ['ma20', 'ma50', 'ma100', 'ma200', 'bb', 'volatility']
const OVERLAY_META: Record<OverlayKey, { label: string; color: string; dashed?: boolean; defaultVisible: boolean }> = {
ma20: { label: 'MA20', color: MA_COLORS.ma20, defaultVisible: true },
ma50: { label: 'MA50', color: MA_COLORS.ma50, defaultVisible: true },
ma100: { label: 'MA100', color: MA_COLORS.ma100, defaultVisible: true },
ma200: { label: 'MA200', color: MA_COLORS.ma200, defaultVisible: true },
bb: { label: 'BB(20,2)', color: '#94a3b8', dashed: true, defaultVisible: true },
volatility: { label: 'Volatilité', color: '#ec4899', defaultVisible: false },
}
const CAT_COLORS: Record<string, string> = {
event_calendar: '#f59e0b',
geopolitical: '#ef4444',
fundamental: '#10b981',
report: '#3b82f6',
sentiment: '#8b5cf6',
technical: '#06b6d4',
}
const CAT_LABELS: Record<string, string> = {
event_calendar: 'Cal.',
geopolitical: 'Géo.',
fundamental: 'Fund.',
report: 'Report',
sentiment: 'Sent.',
technical: 'Tech.',
}
export default function InstrumentChart({ priceData, indicators, events = [], height = 420, chartType = 'candles', onDateHover, theoryCurve, onChartReady }: Props) {
const containerRef = useRef<HTMLDivElement>(null)
const cleanupRef = useRef<(() => void) | null>(null)
const onHoverRef = useRef(onDateHover)
const onChartReadyRef = useRef(onChartReady)
const chartRef = useRef<any>(null)
const seriesRefs = useRef<Partial<Record<OverlayKey, any[]>>>({})
// Which overlays have any data at all for THIS instrument — only these get a toggle pill.
const availableOverlays = OVERLAY_KEYS.filter(k =>
k === 'bb' ? (indicators.bb_upper?.length && indicators.bb_lower?.length) : indicators[k]?.length
)
const [visible, setVisible] = useState<Record<OverlayKey, boolean>>(() =>
Object.fromEntries(OVERLAY_KEYS.map(k => [k, OVERLAY_META[k].defaultVisible])) as Record<OverlayKey, boolean>
)
// Read at chart-(re)build time without forcing a rebuild on every toggle — toggling
// itself is handled cheaply below via each series' own applyOptions({visible}).
const visibleRef = useRef(visible)
useEffect(() => { visibleRef.current = visible }, [visible])
const toggleOverlay = (key: OverlayKey) => {
setVisible(v => {
const next = { ...v, [key]: !v[key] }
seriesRefs.current[key]?.forEach(s => s.applyOptions({ visible: next[key] }))
if (key === 'volatility') {
chartRef.current?.priceScale('volatility').applyOptions({ visible: next[key] })
}
return next
})
}
// Keep callback refs fresh without triggering chart rebuild
useEffect(() => { onHoverRef.current = onDateHover }, [onDateHover])
useEffect(() => { onChartReadyRef.current = onChartReady }, [onChartReady])
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,
})
chartRef.current = chart
seriesRefs.current = {}
// 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)',
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)',
})))
}
// Prix principal — chandeliers ou courbe lisse
if (chartType === 'line') {
const areaSeries = chart.addAreaSeries({
lineColor: '#3b82f6',
topColor: 'rgba(59,130,246,0.18)',
bottomColor: 'rgba(59,130,246,0.01)',
lineWidth: 2,
priceLineVisible: true,
lastValueVisible: true,
crosshairMarkerVisible: true,
crosshairMarkerRadius: 4,
})
areaSeries.setData(priceData.map(d => ({ time: d.time as any, value: d.close })))
} else {
const candleSeries = chart.addCandlestickSeries({
upColor: '#10b981',
downColor: '#ef4444',
borderUpColor: '#10b981',
borderDownColor: '#ef4444',
wickUpColor: '#6ee7b7',
wickDownColor: '#fca5a5',
})
candleSeries.setData(priceData)
}
// MA lines — each individually toggleable
for (const [key, color] of Object.entries(MA_COLORS)) {
const data = indicators[key]
if (data?.length) {
const series = chart.addLineSeries({
color,
lineWidth: key === 'ma200' ? 1.5 : 1,
priceLineVisible: false,
lastValueVisible: true,
crosshairMarkerVisible: false,
visible: visibleRef.current[key as OverlayKey],
})
series.setData(data)
seriesRefs.current[key as OverlayKey] = [series]
}
}
// Bollinger Bands — upper+lower toggle together as one 'bb' overlay
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,
visible: visibleRef.current.bb,
}
const bbUpper = chart.addLineSeries(bbOpts)
bbUpper.setData(indicators.bb_upper)
const bbLower = chart.addLineSeries(bbOpts)
bbLower.setData(indicators.bb_lower)
seriesRefs.current.bb = [bbUpper, bbLower]
}
// Volatilité réalisée (annualisée, %) — échelle dédiée, cachée par défaut
if (indicators.volatility?.length) {
chart.priceScale('volatility').applyOptions({
visible: visibleRef.current.volatility,
borderColor: 'rgba(236,72,153,0.25)',
scaleMargins: { top: 0.78, bottom: 0.02 },
})
const volatilitySeries = chart.addLineSeries({
color: '#ec4899',
lineWidth: 1,
priceScaleId: 'volatility',
title: 'Vol. réalisée %',
priceLineVisible: false,
lastValueVisible: true,
crosshairMarkerVisible: true,
crosshairMarkerRadius: 3,
visible: visibleRef.current.volatility,
})
volatilitySeries.setData(indicators.volatility)
seriesRefs.current.volatility = [volatilitySeries]
}
// ── Theoretical curve — left scale (pips) ─────────────────────────────
if (theoryCurve?.length) {
chart.priceScale('left').applyOptions({
visible: true,
borderColor: 'rgba(167,139,250,0.25)',
textColor: '#a78bfa',
scaleMargins: { top: 0.1, bottom: 0.1 },
})
const theorySeries = chart.addLineSeries({
color: 'rgba(167,139,250,0.75)',
lineWidth: 1.5,
priceScaleId: 'left',
title: 'Δ pips théorique',
priceLineVisible: false,
lastValueVisible: true,
crosshairMarkerVisible: true,
crosshairMarkerRadius: 3,
})
const theoryData = theoryCurve
.filter(p => p.contributions.length > 0)
.map(p => ({ time: p.date as any, value: p.cumulative_pips }))
if (theoryData.length) theorySeries.setData(theoryData)
}
chart.timeScale().fitContent()
// Expose coordinate mapping so siblings can align with the chart x-axis
if (onChartReadyRef.current && containerRef.current) {
const canvasLeft = containerRef.current.getBoundingClientRect().left
onChartReadyRef.current((d: string) => chart.timeScale().timeToCoordinate(d as any), canvasLeft)
}
// ── Star overlay — ★ icons positioned via chart coordinate API ────────
const candleMap: Record<string, number> = {}
for (const c of priceData) candleMap[c.time] = c.high
const starsOverlay = document.createElement('div')
starsOverlay.style.cssText = [
'position:absolute', 'top:0', 'left:0', 'width:100%', 'height:100%',
'pointer-events:none', 'overflow:hidden', 'z-index:10',
].join(';')
containerRef.current!.appendChild(starsOverlay)
const renderStars = () => {
if (!active) return
starsOverlay.innerHTML = ''
const cw = containerRef.current?.clientWidth ?? 0
// ── Collect visible events with their x coords ──────────────────────
type Placed = { xCoord: number; halfW: number; ev: typeof events[0] }
const visible: Placed[] = []
for (const ev of events) {
const xCoord = chart.timeScale().timeToCoordinate(ev.date as any)
if (xCoord === null || xCoord < 0 || xCoord > cw) continue
const title = ev.title.slice(0, 20)
const halfW = Math.max(30, title.length * 6 + 12) // approx px half-width
visible.push({ xCoord, halfW, ev })
}
// ── Greedy row placement (up to 4 rows, 20px apart) ─────────────────
const ROW_H = 20 // pixels between rows
const MAX_ROWS = 4
// rowEnds[r] = rightmost x already occupied in row r
const rowEnds: number[] = Array(MAX_ROWS).fill(-Infinity)
// Sort by x so greedy works left-to-right
visible.sort((a, b) => a.xCoord - b.xCoord)
for (const { xCoord, halfW, ev } of visible) {
const left = xCoord - halfW
const right = xCoord + halfW + 4
// Find first row where the label fits (no overlap)
let row = 0
for (let r = 0; r < MAX_ROWS; r++) {
if (rowEnds[r] <= left) { row = r; break }
if (r === MAX_ROWS - 1) row = r // last resort: use bottom row
}
rowEnds[row] = right
const yBase = 6 + row * ROW_H
const cat = ev.category ?? 'fundamental'
const color = CAT_COLORS[cat] ?? '#94a3b8'
const score = ev.impact_score ?? 0.5
const sz = score >= 0.8 ? 16 : score >= 0.6 ? 13 : 11
const wrap = document.createElement('div')
wrap.style.cssText = [
`position:absolute`, `left:${xCoord}px`, `top:${yBase}px`,
`transform:translateX(-50%)`,
`display:flex`, `align-items:center`, `gap:3px`,
`pointer-events:auto`, `cursor:default`,
].join(';')
wrap.title = `${ev.title}\n${ev.date}${ev.end_date ? ' → ' + ev.end_date : ''}`
const star = document.createElement('span')
star.style.cssText = [
`font-size:${sz}px`, `color:${color}`, `line-height:1`,
`filter:drop-shadow(0 0 3px ${color}80)`,
].join(';')
star.textContent = '★'
const lbl = document.createElement('span')
lbl.style.cssText = [
`font-size:10px`, `font-family:ui-monospace,monospace`,
`color:${color}`, `white-space:nowrap`,
`text-shadow:0 1px 4px #000,0 0 6px #000`,
`line-height:1`, `max-width:100px`,
`overflow:hidden`, `text-overflow:ellipsis`,
`font-weight:600`,
].join(';')
lbl.textContent = ev.title.slice(0, 20)
wrap.appendChild(star)
wrap.appendChild(lbl)
starsOverlay.appendChild(wrap)
}
}
renderStars()
chart.timeScale().subscribeVisibleLogicalRangeChange(renderStars)
// 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 })
})
ro.observe(containerRef.current)
cleanupRef.current = () => {
ro.disconnect()
chart.timeScale().unsubscribeVisibleLogicalRangeChange(renderStars)
if (starsOverlay.parentNode) starsOverlay.parentNode.removeChild(starsOverlay)
chart.remove()
chartRef.current = null
seriesRefs.current = {}
onChartReadyRef.current?.(null as any, 0)
}
})
return () => {
active = false
cleanupRef.current?.()
cleanupRef.current = null
}
// `visible` intentionally excluded — toggling is handled directly via series refs
// above, without tearing down and rebuilding the whole chart.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [priceData, indicators, events, height, chartType, theoryCurve])
return (
<div className="bg-dark-900/60 rounded-xl border border-slate-700/40 overflow-hidden">
<div className="flex items-center gap-2 px-4 py-2 border-b border-slate-700/30 text-xs flex-wrap">
{availableOverlays.map(key => {
const meta = OVERLAY_META[key]
const on = visible[key]
return (
<button
key={key}
onClick={() => toggleOverlay(key)}
title={on ? `Masquer ${meta.label}` : `Afficher ${meta.label}`}
className="flex items-center gap-1.5 px-1.5 py-0.5 rounded transition-opacity hover:bg-slate-700/30"
style={{ opacity: on ? 1 : 0.35 }}
>
<span
className={meta.dashed ? 'inline-block w-5 border-t border-dashed' : 'inline-block w-5 h-0.5 rounded'}
style={meta.dashed ? { borderColor: meta.color } : { background: meta.color }}
/>
<span className="text-slate-400">{meta.label}</span>
</button>
)
})}
{theoryCurve?.some(p => p.contributions.length > 0) && (
<span className="flex items-center gap-1.5 px-1.5" style={{ color: '#a78bfa' }}>
<span className="inline-block w-5 h-0.5 rounded" style={{ background: '#a78bfa' }} />
Théorie (pips)
</span>
)}
<span className="ml-auto flex items-center gap-2.5">
{Object.entries(CAT_COLORS).map(([cat, c]) => (
<span key={cat} className="flex items-center gap-0.5" style={{ color: c }}>
<span style={{ fontSize: 10 }}></span>
<span style={{ fontSize: 9 }}>{CAT_LABELS[cat]}</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%', position: 'relative' }} />
)}
</div>
)
}