- 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>
213 lines
6.3 KiB
TypeScript
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>
|
|
)
|
|
}
|