465 lines
18 KiB
TypeScript
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>
|
|
)
|
|
}
|