spb/crypto-trade-bot
Public
Python 54.7%
TypeScript 42.5%
SQL 1.9%
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