"use client"; import { AreaSeries, CandlestickSeries, ColorType, createChart, HistogramSeries, LineSeries, type IChartApi, type ISeriesApi, type LineData, type Time, type UTCTimestamp } from "lightweight-charts"; import { useEffect, useRef } from "react"; import type { Candle, EquityPoint } from "@/lib/types"; const baseOptions = (h: number) => ({ height: h, layout: { background: { type: ColorType.Solid, color: "transparent" }, textColor: "#94a0b8", fontSize: 11, attributionLogo: false }, grid: { vertLines: { color: "#1a2030" }, horzLines: { color: "#1a2030" } }, rightPriceScale: { borderColor: "#232b3b" }, timeScale: { borderColor: "#232b3b", timeVisible: true, secondsVisible: false }, crosshair: { mode: 0 }, }); function useChart(height: number) { const ref = useRef(null); const chartRef = useRef(null); useEffect(() => { const el = ref.current; if (!el) return; const chart = createChart(el, { ...baseOptions(height), width: el.clientWidth }); chartRef.current = chart; const ro = new ResizeObserver(() => chart.applyOptions({ width: el.clientWidth })); ro.observe(el); return () => { ro.disconnect(); chart.remove(); chartRef.current = null; }; }, [height]); return { ref, chartRef }; } const t = (ts: number) => ts as UTCTimestamp; /** Candlestick + volume. `live` is a partial bar (1-min or ticker) merged in via series.update. */ export function CandleChart({ candles, live, height = 420 }: { candles: Candle[]; live?: Candle | null; height?: number }) { const { ref, chartRef } = useChart(height); const cs = useRef | null>(null); const vs = useRef | null>(null); useEffect(() => { const chart = chartRef.current; if (!chart) return; const c = chart.addSeries(CandlestickSeries, { upColor: "#22c55e", downColor: "#ef4444", borderVisible: false, wickUpColor: "#22c55e", wickDownColor: "#ef4444" }); const v = chart.addSeries(HistogramSeries, { priceFormat: { type: "volume" }, priceScaleId: "vol" }); chart.priceScale("vol").applyOptions({ scaleMargins: { top: 0.82, bottom: 0 } }); cs.current = c; vs.current = v; return () => { cs.current = null; vs.current = null; }; }, [chartRef]); useEffect(() => { if (!cs.current || !vs.current) return; const sorted = [...candles].sort((a, b) => a.ts - b.ts); cs.current.setData(sorted.map((c) => ({ time: t(c.ts), open: c.open, high: c.high, low: c.low, close: c.close }))); vs.current.setData(sorted.map((c) => ({ time: t(c.ts), value: c.volume, color: c.close >= c.open ? "rgba(34,197,94,0.45)" : "rgba(239,68,68,0.45)" }))); chartRef.current?.timeScale().fitContent(); }, [candles, chartRef]); useEffect(() => { if (!live || !cs.current || !vs.current) return; try { cs.current.update({ time: t(live.ts), open: live.open, high: live.high, low: live.low, close: live.close }); vs.current.update({ time: t(live.ts), value: live.volume, color: live.close >= live.open ? "rgba(34,197,94,0.45)" : "rgba(239,68,68,0.45)" }); } catch { /* out-of-order update (older than last bar) is ignored */ } }, [live]); return
; } export type LineSpec = { id: string; label: string; color: string; points: { ts: number; value: number }[]; width?: number }; /** Multi-line chart (equity curves, rolling Sharpe…). */ export function LinesChart({ series, height = 300, percentScale = false, priceFormat }: { series: LineSpec[]; height?: number; percentScale?: boolean; priceFormat?: "percent" | "price" }) { const { ref, chartRef } = useChart(height); const key = series.map((s) => `${s.id}:${s.points.length}`).join("|"); useEffect(() => { const chart = chartRef.current; if (!chart) return; const created: ISeriesApi<"Line">[] = []; for (const s of series) { const line = chart.addSeries(LineSeries, { color: s.color, lineWidth: (s.width ?? 2) as 1 | 2 | 3 | 4, title: s.label, priceFormat: priceFormat === "percent" ? { type: "percent" } : { type: "price", precision: 2, minMove: 0.01 }, priceLineVisible: false, lastValueVisible: true }); const data: LineData