SPB Git forge
1commits 1branches 0releases
189.0 KBsize
maindefault branch
5 h agolast push
Python 54.7% TypeScript 42.5% SQL 1.9%
6.3 KB · 124 lines tsx
Raw Blame History
1"use client";2import { AreaSeries, CandlestickSeries, ColorType, createChart, HistogramSeries, LineSeries, type IChartApi, type ISeriesApi, type LineData, type Time, type UTCTimestamp } from "lightweight-charts";3import { useEffect, useRef } from "react";4import type { Candle, EquityPoint } from "@/lib/types";56const baseOptions = (h: number) => ({7  height: h,8  layout: { background: { type: ColorType.Solid, color: "transparent" }, textColor: "#94a0b8", fontSize: 11, attributionLogo: false },9  grid: { vertLines: { color: "#1a2030" }, horzLines: { color: "#1a2030" } },10  rightPriceScale: { borderColor: "#232b3b" },11  timeScale: { borderColor: "#232b3b", timeVisible: true, secondsVisible: false },12  crosshair: { mode: 0 },13});1415function useChart(height: number) {16  const ref = useRef<HTMLDivElement>(null);17  const chartRef = useRef<IChartApi | null>(null);18  useEffect(() => {19    const el = ref.current;20    if (!el) return;21    const chart = createChart(el, { ...baseOptions(height), width: el.clientWidth });22    chartRef.current = chart;23    const ro = new ResizeObserver(() => chart.applyOptions({ width: el.clientWidth }));24    ro.observe(el);25    return () => {26      ro.disconnect();27      chart.remove();28      chartRef.current = null;29    };30  }, [height]);31  return { ref, chartRef };32}3334const t = (ts: number) => ts as UTCTimestamp;3536/** Candlestick + volume. `live` is a partial bar (1-min or ticker) merged in via series.update. */37export function CandleChart({ candles, live, height = 420 }: { candles: Candle[]; live?: Candle | null; height?: number }) {38  const { ref, chartRef } = useChart(height);39  const cs = useRef<ISeriesApi<"Candlestick"> | null>(null);40  const vs = useRef<ISeriesApi<"Histogram"> | null>(null);41  useEffect(() => {42    const chart = chartRef.current;43    if (!chart) return;44    const c = chart.addSeries(CandlestickSeries, { upColor: "#22c55e", downColor: "#ef4444", borderVisible: false, wickUpColor: "#22c55e", wickDownColor: "#ef4444" });45    const v = chart.addSeries(HistogramSeries, { priceFormat: { type: "volume" }, priceScaleId: "vol" });46    chart.priceScale("vol").applyOptions({ scaleMargins: { top: 0.82, bottom: 0 } });47    cs.current = c;48    vs.current = v;49    return () => {50      cs.current = null;51      vs.current = null;52    };53  }, [chartRef]);54  useEffect(() => {55    if (!cs.current || !vs.current) return;56    const sorted = [...candles].sort((a, b) => a.ts - b.ts);57    cs.current.setData(sorted.map((c) => ({ time: t(c.ts), open: c.open, high: c.high, low: c.low, close: c.close })));58    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)" })));59    chartRef.current?.timeScale().fitContent();60  }, [candles, chartRef]);61  useEffect(() => {62    if (!live || !cs.current || !vs.current) return;63    try {64      cs.current.update({ time: t(live.ts), open: live.open, high: live.high, low: live.low, close: live.close });65      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)" });66    } catch {67      /* out-of-order update (older than last bar) is ignored */68    }69  }, [live]);70  return <div ref={ref} className="w-full" />;71}7273export type LineSpec = { id: string; label: string; color: string; points: { ts: number; value: number }[]; width?: number };7475/** Multi-line chart (equity curves, rolling Sharpe…). */76export function LinesChart({ series, height = 300, percentScale = false, priceFormat }: { series: LineSpec[]; height?: number; percentScale?: boolean; priceFormat?: "percent" | "price" }) {77  const { ref, chartRef } = useChart(height);78  const key = series.map((s) => `${s.id}:${s.points.length}`).join("|");79  useEffect(() => {80    const chart = chartRef.current;81    if (!chart) return;82    const created: ISeriesApi<"Line">[] = [];83    for (const s of series) {84      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 });85      const data: LineData<Time>[] = [...s.points].sort((a, b) => a.ts - b.ts).filter((p) => Number.isFinite(p.value)).map((p) => ({ time: t(p.ts), value: p.value }));86      line.setData(data);87      created.push(line);88    }89    chart.applyOptions({ rightPriceScale: { mode: percentScale ? 2 : 0, borderColor: "#232b3b" } });90    chart.timeScale().fitContent();91    return () => created.forEach((l) => chart.removeSeries(l));92    // eslint-disable-next-line react-hooks/exhaustive-deps93  }, [key, chartRef, percentScale, priceFormat]);94  return <div ref={ref} className="w-full" />;95}9697/** Underwater (drawdown) area chart. */98export function DrawdownChart({ points, height = 180 }: { points: EquityPoint[]; height?: number }) {99  const { ref, chartRef } = useChart(height);100  useEffect(() => {101    const chart = chartRef.current;102    if (!chart) return;103    const s = chart.addSeries(AreaSeries, { lineColor: "#ef4444", topColor: "rgba(239,68,68,0.05)", bottomColor: "rgba(239,68,68,0.45)", lineWidth: 1, priceFormat: { type: "percent" }, priceLineVisible: false, invertFilledArea: true });104    s.setData([...points].sort((a, b) => a.ts - b.ts).map((p) => ({ time: t(p.ts), value: p.drawdown * 100 })));105    chart.timeScale().fitContent();106    return () => chart.removeSeries(s);107  }, [points, chartRef]);108  return <div ref={ref} className="w-full" />;109}110111/** Tiny inline sparkline (SVG) for microstructure panels. */112export function Sparkline({ values, color = "#4f8cff", height = 36, width = 160 }: { values: (number | null)[]; color?: string; height?: number; width?: number }) {113  const v = values.filter((x): x is number => x !== null && Number.isFinite(x));114  if (v.length < 2) return <svg width={width} height={height} aria-hidden />;115  const min = Math.min(...v), max = Math.max(...v);116  const span = max - min || 1;117  const pts = v.map((y, i) => `${(i / (v.length - 1)) * width},${height - ((y - min) / span) * (height - 2) - 1}`).join(" ");118  return (119    <svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} className="overflow-visible" aria-hidden>120      <polyline points={pts} fill="none" stroke={color} strokeWidth={1.25} />121    </svg>122  );123}124