# Trading Chart

> Candlestick chart with trade markers, clustering, and live Binance updates.

A shadcn/ui block from the [@voraui](https://voraui.vercel.app) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/voraui/trading-chart).

- Page, with a live preview: https://shadcn.exchange/r/voraui/trading-chart
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/voraui/trading-chart-light-b775c77dda5fe8b1.png
- Registry item: https://voraui.vercel.app/r/trading-chart.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://voraui.vercel.app/r/trading-chart.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lightweight-charts`, `next-themes`
- Registry items: `utils`

## Files

### `registry/voraui/trading-chart/index.ts`

```ts
export { TradingChart } from "./trading-chart";
export { TradingChartSkeleton } from "./components/skeleton";
export type {
  TradingChartProps,
  OhlcvCandle,
  TradeSignal,
  TradeSide,
  Timeframe,
} from "./types";
```

### `registry/voraui/trading-chart/trading-chart.tsx`

```tsx
"use client";

import { cn } from "@/lib/utils";
import { TradingChartSkeleton } from "./components/skeleton";
import type { TradingChartProps } from "./types";
import { useKlines } from "./hooks/use-klines";
import { useChartInstance } from "./hooks/use-chart-instance";
import { useChartTheme } from "./hooks/use-chart-theme";
import { useChartData } from "./hooks/use-chart-data";
import { useLiveCandles } from "./hooks/use-live-candles";
import { useBoundaryNotification } from "./hooks/use-boundary-notification";

function TradingChart({
  symbol = "BTCUSDT",
  timeframe = "1h",
  trades = [],
  candles: candlesProp,
  live = true,
  height = 500,
  limit = 500,
  showTooltips = true,
  className,
}: TradingChartProps) {
  const hasOverrideCandles = candlesProp !== undefined;

  const { candles, setCandles, loading, error, fetchMore, hasMoreHistory } = useKlines(
    symbol,
    timeframe,
    { limit, enabled: !hasOverrideCandles },
  );

  const effectiveCandles = candlesProp ?? candles;

  const { containerRef, stateRef, chartEpoch } = useChartInstance({
    symbol,
    height,
    candles: effectiveCandles,
    trades,
    timeframe,
    showTooltips,
    loading,
    hasOverrideCandles,
    // With prop-supplied candles there is no more history to fetch.
    hasMoreHistory: hasOverrideCandles ? false : hasMoreHistory,
    fetchMore,
  });

  useChartTheme(stateRef);

  useChartData({ stateRef, chartEpoch, candles: effectiveCandles, trades, symbol, timeframe });

  useLiveCandles({
    stateRef,
    symbol,
    timeframe,
    enabled: live && !hasOverrideCandles,
    setCandles,
  });

  const showNoMoreData = useBoundaryNotification({
    hasMoreHistory,
    loading,
    candlesCount: candles.length,
    hasOverrideCandles,
    symbol,
    timeframe,
  });

  return (
    <div className={cn("relative w-full overflow-hidden rounded-xl bg-card", className)}>
      {loading && effectiveCandles.length === 0 && (
        <TradingChartSkeleton height={height} className="absolute inset-0 z-50" />
      )}

      {error && effectiveCandles.length === 0 && !loading && (
        <div role="alert" className="absolute inset-0 z-50 flex items-center justify-center">
          <p className="text-xs text-muted-foreground">Market data is unavailable ({error}).</p>
        </div>
      )}

      {showNoMoreData && (
        <div className="absolute left-3 top-3 z-40 duration-300 animate-in fade-in slide-in-from-left-2">
          <div className="flex items-center gap-2 rounded-lg border border-border bg-card/90 px-3 py-1.5 shadow-lg backdrop-blur-sm">
            <div className="h-1.5 w-1.5 shrink-0 rounded-full bg-amber-400" />
            <span className="whitespace-nowrap text-xs font-medium text-muted-foreground">
              No more historical data
            </span>
          </div>
        </div>
      )}

      <div ref={containerRef} className="relative w-full" style={{ height }} />
    </div>
  );
}

export { TradingChart };
```

### `registry/voraui/trading-chart/types.ts`

```ts
export type Timeframe = "1m" | "5m" | "15m" | "1h" | "4h" | "8h" | "1d" | "1w" | "1M";

export interface OhlcvCandle {
  /** Unix seconds (candle open time). */
  time: number;
  open: number;
  high: number;
  low: number;
  close: number;
  volume: number;
  /** True while the candle is still open (live updates). */
  isForming?: boolean;
}

export type TradeSide = "BUY" | "SELL";

export interface TradeSignal {
  id: string;
  /** Execution time in epoch milliseconds. */
  ts: number;
  side: TradeSide;
  /** Execution price; with quantity set, tooltips show price * quantity as position size. */
  price: number;
  quantity?: number;
  note?: string;
  category?: "trade" | "indicator";
  indicator?: string;
  value?: number;
  /** Links buy/sell pairs, e.g. for PnL grouping. */
  tradeId?: string;
}

export interface TradingChartProps {
  /** Binance spot symbol, e.g. "BTCUSDT". */
  symbol?: string;
  timeframe?: Timeframe;
  /** Trade/indicator markers to draw on the chart. Always the user's own data. */
  trades?: TradeSignal[];
  /** Provide your own candles to bypass the bundled Binance fetcher. */
  candles?: OhlcvCandle[];
  /** Poll Binance for live candle updates. Ignored when candles is set. */
  live?: boolean;
  height?: number;
  /** Number of candles for the initial load (Binance max 1000). */
  limit?: number;
  showTooltips?: boolean;
  className?: string;
}
```

### `registry/voraui/trading-chart/components/skeleton.tsx`

```tsx
"use client";

import { useMemo } from "react";
import { cn } from "@/lib/utils";

export interface TradingChartSkeletonProps {
  className?: string;
  /** Matches TradingChartProps["height"]. */
  height?: number;
}

export interface GhostCandle {
  up: boolean;
  bodyHeightPct: number;
  wickHeightPct: number;
}

const GHOST_CANDLE_COUNT = 48;

/** Deterministic heights; Math.random would cause a hydration mismatch. */
export function generateGhostCandles(count: number): GhostCandle[] {
  return Array.from({ length: count }, (_, i) => {
    const bodyHeightPct = 25 + 35 * Math.abs(Math.sin(i * 0.7));
    const wickHeightPct = Math.min(bodyHeightPct + 12 + (i % 3) * 4, 100);
    return { up: i % 2 === 0, bodyHeightPct, wickHeightPct };
  });
}

export function TradingChartSkeleton({ className, height = 500 }: TradingChartSkeletonProps) {
  const candles = useMemo(() => generateGhostCandles(GHOST_CANDLE_COUNT), []);

  return (
    <div
      role="status"
      className={cn("voraui-trading-chart-skeleton-shimmer relative w-full overflow-hidden rounded-xl bg-card", className)}
      style={{ height }}
    >
      <style href="voraui-trading-chart-skeleton" precedence="low">{`
        @keyframes voraui-trading-chart-skeleton-shimmer {
          from { transform: translateX(-100%); }
          to { transform: translateX(100%); }
        }
        .voraui-trading-chart-skeleton-shimmer::after {
          content: "";
          position: absolute;
          inset: 0;
          background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.15), transparent);
          animation: voraui-trading-chart-skeleton-shimmer 1.8s ease-in-out infinite;
        }
        @media (prefers-reduced-motion: reduce) {
          .voraui-trading-chart-skeleton-shimmer::after {
            animation: none;
          }
        }
      `}</style>
      {/* pr-14 keeps the candles clear of the price-axis placeholder column. */}
      <div className="absolute inset-0 flex items-end gap-[2px] pb-8 pl-3 pr-14 pt-4">
        {candles.map((candle, i) => (
          <div key={i} className="relative flex-1" style={{ height: `${candle.wickHeightPct}%` }}>
            <div className="absolute left-1/2 top-0 h-full w-px -translate-x-1/2 bg-muted-foreground/20" />
            <div
              className={cn(
                "absolute left-1/2 w-full max-w-[6px] -translate-x-1/2 rounded-[1px]",
                candle.up ? "bg-emerald-500/15" : "bg-rose-500/15",
              )}
              style={{
                height: `${candle.bodyHeightPct}%`,
                top: `${(candle.wickHeightPct - candle.bodyHeightPct) / 2}%`,
              }}
            />
          </div>
        ))}
      </div>
      <div className="absolute right-0 top-0 flex h-full w-12 flex-col justify-between gap-2 bg-background/30 py-4 pr-2">
        {Array.from({ length: 5 }, (_, i) => (
          <div key={i} className="h-2 w-8 self-end rounded bg-muted-foreground/15" />
        ))}
      </div>
      <span className="sr-only">Loading market data</span>
    </div>
  );
}
```

### `registry/voraui/trading-chart/hooks/use-chart-instance.ts`

```ts
"use client";

import { useEffect, useRef, useState } from "react";
import type { RefObject } from "react";
import { useTheme } from "next-themes";
import type {
  IChartApi,
  ISeriesApi,
  ISeriesMarkersPluginApi,
  LogicalRange,
  Time,
} from "lightweight-charts";
import type { OhlcvCandle, Timeframe, TradeSignal } from "../types";
import { CANDLE_SERIES_OPTIONS, getChartOptions } from "../lib/chart-options";
import { createSignalTooltip, type SignalTooltip } from "../lib/signal-tooltip";

/** Props snapshot for the imperative chart handlers, so they never see stale closures. */
export interface LatestChartProps {
  candles: OhlcvCandle[];
  trades: TradeSignal[];
  timeframe: Timeframe;
  showTooltips: boolean;
  loading: boolean;
  hasOverrideCandles: boolean;
  hasMoreHistory: boolean;
  fetchMore: (beforeSec: number) => Promise<OhlcvCandle[]>;
}

/** Mutable chart runtime shared by the trading chart hooks. */
export interface ChartState {
  chart: IChartApi | null;
  series: ISeriesApi<"Candlestick"> | null;
  markers: ISeriesMarkersPluginApi<Time> | null;
  latest: LatestChartProps;
  /** True once the initial fitContent ran for the current chart instance. */
  autoFitDone: boolean;
  lastCandlesLength: number;
  /** Last seen `${symbol}-${timeframe}`, to detect dataset resets. */
  lastKey: string;
  /** Open time of the newest closed bar merged from live polling. */
  lastClosedTime: number;
  /** True between a symbol/timeframe reset and the next full setData. */
  isResetting: boolean;
  /** Set by pagination, consumed by the next data-sync run (survives until read). */
  isPrependingHistory: boolean;
  oldestTimestamp: number | null;
}

export interface UseChartInstanceOptions extends LatestChartProps {
  symbol: string;
  height: number;
}

/** Owns the chart lifecycle: lazy init, tooltip, pagination, resize, disposal. */
export function useChartInstance(options: UseChartInstanceOptions): {
  containerRef: RefObject<HTMLDivElement | null>;
  stateRef: RefObject<ChartState>;
  /** Bumped when a freshly initialized chart instance is ready for data. */
  chartEpoch: number;
} {
  const { symbol, height, ...latest } = options;
  const { resolvedTheme } = useTheme();
  const containerRef = useRef<HTMLDivElement | null>(null);
  const tooltipRef = useRef<SignalTooltip | null>(null);
  const [chartEpoch, setChartEpoch] = useState(0);

  const stateRef = useRef<ChartState>({
    chart: null,
    series: null,
    markers: null,
    latest,
    autoFitDone: false,
    lastCandlesLength: 0,
    lastKey: `${symbol}-${latest.timeframe}`,
    lastClosedTime: 0,
    isResetting: false,
    isPrependingHistory: false,
    oldestTimestamp: null,
  });

  useEffect(() => {
    stateRef.current.latest = latest;
  });

  useEffect(() => {
    if (!latest.showTooltips) tooltipRef.current?.hide();
  }, [latest.showTooltips]);

  useEffect(() => {
    const state = stateRef.current;
    let disposed = false;
    let resizeObserver: ResizeObserver | null = null;
    let chartInitialized = false;
    let isLoadingMore = false;

    const handleVisibleRangeChange = async (range: LogicalRange | null) => {
      if (!range || isLoadingMore || !state.oldestTimestamp || !state.latest.hasMoreHistory) {
        return;
      }

      // A full-dataset range is the initial fitContent or a zoom-out, not a
      // scroll toward history; only paginate on a real move to the left edge.
      if (range.to >= state.latest.candles.length - 2) return;
      if (range.from >= 50) return;

      isLoadingMore = true;
      try {
        const older = await state.latest.fetchMore(state.oldestTimestamp);
        // Only flag a prepend when candles were actually fetched.
        state.isPrependingHistory = older.length > 0;
      } finally {
        isLoadingMore = false;
      }
    };

    const initChart = async (container: HTMLDivElement) => {
      const { createChart, CandlestickSeries, createSeriesMarkers } = await import(
        "lightweight-charts"
      );
      if (disposed) return;

      const chart = createChart(container, {
        width: container.clientWidth,
        ...getChartOptions(resolvedTheme, height),
      });
      const series = chart.addSeries(CandlestickSeries, CANDLE_SERIES_OPTIONS);

      state.chart = chart;
      state.series = series;
      state.markers = createSeriesMarkers(series, []);

      const tooltip = createSignalTooltip({
        container,
        getCandles: () => state.latest.candles,
        getTrades: () => state.latest.trades,
        getTimeframe: () => state.latest.timeframe,
        isEnabled: () => state.latest.showTooltips,
      });
      tooltipRef.current = tooltip;
      chart.subscribeCrosshairMove(tooltip.onCrosshairMove);

      chart.timeScale().fitContent();
      chart.timeScale().subscribeVisibleLogicalRangeChange(handleVisibleRangeChange);

      setChartEpoch((epoch) => epoch + 1);
    };

    if (containerRef.current) {
      resizeObserver = new ResizeObserver((entries) => {
        const { width, height: rectHeight } = entries[0].contentRect;
        if (width > 0 && rectHeight > 0 && !chartInitialized) {
          chartInitialized = true;
          initChart(containerRef.current!);
        } else if (width > 0 && chartInitialized && state.chart) {
          state.chart.applyOptions({ width });
        }
      });
      resizeObserver.observe(containerRef.current);
    }

    return () => {
      disposed = true;
      resizeObserver?.disconnect();
      tooltipRef.current?.dispose();
      tooltipRef.current = null;
      state.chart?.remove();
      state.chart = null;
      state.series = null;
      state.markers = null;
      // Forces the next data-sync run to setData + fitContent the new instance.
      state.lastCandlesLength = 0;
      state.autoFitDone = false;
    };
    // Only reinitialize if height changes.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [height]);

  return { containerRef, stateRef, chartEpoch };
}
```

### `registry/voraui/trading-chart/hooks/use-chart-theme.ts`

```ts
"use client";

import { useEffect } from "react";
import type { RefObject } from "react";
import { useTheme } from "next-themes";
import { getThemeOptions } from "../lib/chart-options";
import type { ChartState } from "./use-chart-instance";

/** Restyle the chart when the next-themes theme changes. */
export function useChartTheme(stateRef: RefObject<ChartState>) {
  const { resolvedTheme } = useTheme();

  useEffect(() => {
    stateRef.current.chart?.applyOptions(getThemeOptions(resolvedTheme));
  }, [stateRef, resolvedTheme]);
}
```

### `registry/voraui/trading-chart/hooks/use-chart-data.ts`

```ts
"use client";

import { useEffect } from "react";
import type { RefObject } from "react";
import type { LogicalRange } from "lightweight-charts";
import type { OhlcvCandle, Timeframe, TradeSignal } from "../types";
import { sanitizeCandles } from "../lib/candle-validation";
import { toChartTime } from "../lib/chart-time";
import { alignSignalsToBars, buildSeriesMarkers } from "../lib/markers";
import type { ChartState } from "./use-chart-instance";

export interface UseChartDataOptions {
  stateRef: RefObject<ChartState>;
  /** Bumped by useChartInstance when a fresh chart instance is ready for data. */
  chartEpoch: number;
  candles: OhlcvCandle[];
  trades: TradeSignal[];
  symbol: string;
  timeframe: Timeframe;
}

/** Push candles and trade markers into the chart whenever they change. */
export function useChartData({
  stateRef,
  chartEpoch,
  candles,
  trades,
  symbol,
  timeframe,
}: UseChartDataOptions) {
  // Sync candle data.
  useEffect(() => {
    const state = stateRef.current;
    if (!state.series || !state.chart) return;

    const key = `${symbol}-${timeframe}`;
    const keyChanged = state.lastKey !== key;
    state.lastKey = key;

    if (candles.length === 0) {
      if (keyChanged) {
        state.series.setData([]);
        state.chart.timeScale().resetTimeScale();
        state.autoFitDone = false;
        state.lastCandlesLength = 0;
        state.lastClosedTime = 0;
        state.isResetting = true;
      }
      return;
    }

    const sanitized = sanitizeCandles(candles);
    if (sanitized.length === 0) return;

    // setData only on resets/history injection; streaming goes through series.update().
    const numCandlesAdded = sanitized.length - state.lastCandlesLength;
    const isMajorChange =
      keyChanged || state.lastCandlesLength === 0 || Math.abs(numCandlesAdded) > 2;

    if (isMajorChange) {
      state.isResetting = false;
      const timeScale = state.chart.timeScale();
      const prevLogicalRange: LogicalRange | null = state.autoFitDone
        ? timeScale.getVisibleLogicalRange()
        : null;

      const mapped = sanitized.map((c) => ({
        time: toChartTime(c.time, timeframe),
        open: c.open,
        high: c.high,
        low: c.low,
        close: c.close,
      }));
      const deduped = mapped.filter((c, i, arr) => i === 0 || c.time !== arr[i - 1].time);
      state.series.setData(deduped);

      if (!state.autoFitDone) {
        timeScale.fitContent();
        state.autoFitDone = true;
      }

      const isHistoryPrepend = state.isPrependingHistory;
      state.isPrependingHistory = false;
      if (prevLogicalRange && isHistoryPrepend) {
        // Shift the view by the prepended count so the visible bars stay put.
        const prependedCount = state.oldestTimestamp
          ? sanitized.findIndex((c) => c.time === state.oldestTimestamp)
          : 0;
        const shift = prependedCount > 0 ? prependedCount : 0;
        timeScale.setVisibleLogicalRange({
          from: prevLogicalRange.from + shift,
          to: prevLogicalRange.to + shift,
        });
      }
    }

    state.lastCandlesLength = sanitized.length;
    state.oldestTimestamp = sanitized[0].time;
  }, [stateRef, candles, symbol, timeframe, chartEpoch]);

  // Sync trade/indicator markers.
  useEffect(() => {
    const markers = stateRef.current.markers;
    if (!markers) return;
    const aligned = alignSignalsToBars(trades, candles, timeframe);
    markers.setMarkers(buildSeriesMarkers(aligned));
  }, [stateRef, candles, trades, timeframe, chartEpoch]);
}
```

### `registry/voraui/trading-chart/hooks/use-live-candles.ts`

```ts
"use client";

import { useCallback } from "react";
import type { Dispatch, RefObject, SetStateAction } from "react";
import type { OhlcvCandle, Timeframe } from "../types";
import { isValidCandle } from "../lib/candle-validation";
import { normalizeToSeconds, toChartTime } from "../lib/chart-time";
import { useLatestCandlePolling } from "./use-klines";
import type { ChartState } from "./use-chart-instance";

export interface UseLiveCandlesOptions {
  stateRef: RefObject<ChartState>;
  symbol: string;
  timeframe: Timeframe;
  enabled: boolean;
  setCandles: Dispatch<SetStateAction<OhlcvCandle[]>>;
}

/** Merge polled live candles into the chart series and the candle state. */
export function useLiveCandles({
  stateRef,
  symbol,
  timeframe,
  enabled,
  setCandles,
}: UseLiveCandlesOptions) {
  const handleNewCandle = useCallback(
    (candle: OhlcvCandle) => {
      const state = stateRef.current;
      if (!state.series || !state.chart) return;
      if (state.isResetting) return;

      // Never mix live candles into a static prop-supplied dataset.
      if (state.latest.hasOverrideCandles) return;

      // Wait for the initial history, or fitContent would zoom into a 1-2 candle stub.
      if (state.latest.loading) return;

      if (!isValidCandle(candle)) return;

      const normalizedTime = normalizeToSeconds(Number(candle.time));
      if (!Number.isFinite(normalizedTime) || normalizedTime <= 0) return;

      const isForming = candle.isForming === true;

      // Skip closed bars we already saw.
      if (!isForming && normalizedTime <= state.lastClosedTime) return;

      try {
        state.series.update({
          time: toChartTime(normalizedTime, state.latest.timeframe),
          open: candle.open,
          high: candle.high,
          low: candle.low,
          close: candle.close,
        });
      } catch {
        // Thrown for updates older than the earliest bar (timeframe transitions).
        return;
      }

      if (!isForming) state.lastClosedTime = normalizedTime;

      setCandles((prev) => {
        if (prev.length === 0) return [candle];
        const last = prev[prev.length - 1];
        const lastTime = Number(last.time);

        if (normalizedTime === lastTime) {
          const copy = [...prev];
          copy[copy.length - 1] = candle;
          return copy;
        } else if (normalizedTime > lastTime) {
          return [...prev, candle];
        }
        return prev;
      });
    },
    [stateRef, setCandles],
  );

  useLatestCandlePolling(symbol, timeframe, enabled, handleNewCandle);
}
```

### `registry/voraui/trading-chart/hooks/use-boundary-notification.ts`

```ts
"use client";

import { useEffect, useState } from "react";
import type { Timeframe } from "../types";

export interface UseBoundaryNotificationOptions {
  hasMoreHistory: boolean;
  loading: boolean;
  candlesCount: number;
  hasOverrideCandles: boolean;
  symbol: string;
  timeframe: Timeframe;
}

/** "No more historical data" toast: auto-dismisses after 4s, re-arms on key change. */
export function useBoundaryNotification({
  hasMoreHistory,
  loading,
  candlesCount,
  hasOverrideCandles,
  symbol,
  timeframe,
}: UseBoundaryNotificationOptions): boolean {
  const atHistoryBoundary = !hasMoreHistory && !loading && candlesCount > 0 && !hasOverrideCandles;
  const [dismissedKey, setDismissedKey] = useState<string | null>(null);

  useEffect(() => {
    if (!atHistoryBoundary) return;
    const timer = setTimeout(() => setDismissedKey(`${symbol}-${timeframe}`), 4000);
    return () => clearTimeout(timer);
  }, [atHistoryBoundary, symbol, timeframe]);

  return atHistoryBoundary && dismissedKey !== `${symbol}-${timeframe}`;
}
```

### `registry/voraui/trading-chart/hooks/use-klines.ts`

```ts
"use client";

import { useCallback, useEffect, useRef, useState } from "react";
import type { OhlcvCandle, Timeframe } from "../types";
import { sanitizeCandles } from "../lib/candle-validation";

// Binance public spot klines. No API key, CORS-enabled, ~1200 weight/min/IP.
const BINANCE_KLINES_URL = "https://api.binance.com/api/v3/klines";

/** Binance kline row: [openTimeMs, open, high, low, close, volume, closeTimeMs, ...]. */
export type RawKline = [number, string, string, string, string, string, number, ...unknown[]];

export function normalizeKlines(raw: RawKline[], nowMs: number = Date.now()): OhlcvCandle[] {
  const out: OhlcvCandle[] = [];
  for (const k of raw ?? []) {
    const openMs = Number(k[0]);
    const open = Number(k[1]);
    const high = Number(k[2]);
    const low = Number(k[3]);
    const close = Number(k[4]);
    const volume = Number(k[5]);
    const closeMs = Number(k[6]);
    if (![openMs, open, high, low, close, volume, closeMs].every(Number.isFinite)) continue;
    out.push({
      time: Math.floor(openMs / 1000),
      open,
      high,
      low,
      close,
      volume,
      isForming: closeMs > nowMs,
    });
  }
  return out;
}

export async function fetchKlines(
  symbol: string,
  interval: Timeframe,
  options: { limit?: number; startTime?: number; endTime?: number; signal?: AbortSignal } = {},
): Promise<OhlcvCandle[]> {
  const params = new URLSearchParams({
    symbol: symbol.toUpperCase(),
    interval,
    limit: String(options.limit ?? 500),
  });
  if (options.startTime !== undefined) params.set("startTime", String(options.startTime));
  if (options.endTime !== undefined) params.set("endTime", String(options.endTime));
  const res = await fetch(`${BINANCE_KLINES_URL}?${params}`, { signal: options.signal });
  if (!res.ok) throw new Error(`Binance klines request failed: ${res.status}`);
  const raw = (await res.json()) as RawKline[];
  return normalizeKlines(raw);
}

/** Fetch historical candles with backward pagination support. */
export function useKlines(
  symbol: string,
  timeframe: Timeframe,
  options: { limit?: number; enabled?: boolean } = {},
) {
  const { limit = 500, enabled = true } = options;
  const [candles, setCandles] = useState<OhlcvCandle[]>([]);
  const [loading, setLoading] = useState(enabled);
  const [error, setError] = useState<string | null>(null);
  const [hasMoreHistory, setHasMoreHistory] = useState(true);
  const lastOldestRef = useRef<number | null>(null);

  const fetchMore = useCallback(
    async (beforeSec: number) => {
      try {
        const older = await fetchKlines(symbol, timeframe, {
          limit: 500,
          endTime: beforeSec * 1000 - 1,
        });
        if (older.length === 0) {
          setHasMoreHistory(false);
          return [];
        }
        const newOldest = older[0].time;
        // Oldest timestamp didn't advance: we hit the listing boundary.
        if (lastOldestRef.current !== null && newOldest >= lastOldestRef.current) {
          setHasMoreHistory(false);
          return [];
        }
        lastOldestRef.current = newOldest;
        setCandles((prev) => sanitizeCandles([...older, ...prev]));
        return older;
      } catch {
        setHasMoreHistory(false);
        return [];
      }
    },
    [symbol, timeframe],
  );

  useEffect(() => {
    if (!enabled) return;

    const controller = new AbortController();

    const load = async () => {
      // Reset state on key param change.
      setCandles([]);
      setHasMoreHistory(true);
      setError(null);
      setLoading(true);
      lastOldestRef.current = null;

      try {
        const initial = await fetchKlines(symbol, timeframe, { limit, signal: controller.signal });
        if (controller.signal.aborted) return;
        setCandles(sanitizeCandles(initial));
        setLoading(false);
      } catch (err: unknown) {
        if (controller.signal.aborted || (err as Error).name === "AbortError") return;
        setError((err as Error).message);
        setLoading(false);
      }
    };

    load();

    return () => controller.abort();
  }, [symbol, timeframe, limit, enabled]);

  // Forced false while disabled so a mid-fetch toggle can't leave loading stuck.
  return { candles, setCandles, loading: enabled ? loading : false, error, fetchMore, hasMoreHistory };
}

/** Poll Binance for the latest (closed + forming) candles and push them to the chart. */
export function useLatestCandlePolling(
  symbol: string,
  timeframe: Timeframe,
  enabled: boolean,
  onUpdate: (candle: OhlcvCandle) => void,
) {
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  useEffect(() => {
    if (!enabled || !symbol) return;
    let active = true;

    const intervalFor = (tf: Timeframe): number => {
      switch (tf) {
        case "1m":
          return 4_000;
        case "5m":
        case "15m":
          return 12_000;
        case "1h":
        case "4h":
        case "8h":
          return 25_000;
        case "1d":
        case "1w":
        case "1M":
          return 60_000;
        default:
          return 30_000;
      }
    };

    const poll = async () => {
      try {
        const latest = await fetchKlines(symbol, timeframe, { limit: 2 });
        if (!active) return;
        for (const candle of latest) onUpdate(candle);
      } catch {
        // Retried on the next tick.
      }
      if (active) timerRef.current = setTimeout(poll, intervalFor(timeframe));
    };

    poll();

    return () => {
      active = false;
      if (timerRef.current) clearTimeout(timerRef.current);
    };
  }, [symbol, timeframe, enabled, onUpdate]);
}
```

### `registry/voraui/trading-chart/lib/chart-options.ts`

```ts
import type { ChartOptions, ColorType, DeepPartial } from "lightweight-charts";
import { BUY_COLOR, SELL_COLOR } from "./markers";

const WICK_COLOR = "#838ca1";

export const CANDLE_SERIES_OPTIONS = {
  upColor: BUY_COLOR,
  downColor: SELL_COLOR,
  borderUpColor: BUY_COLOR,
  borderDownColor: SELL_COLOR,
  wickUpColor: WICK_COLOR,
  wickDownColor: WICK_COLOR,
};

/** Theme-dependent chart options, applied at creation and on theme changes. */
export function getThemeOptions(resolvedTheme: string | undefined): DeepPartial<ChartOptions> {
  const isDark = resolvedTheme === "dark";
  const gridColor = isDark ? "rgba(148,163,184,0.12)" : "rgba(0,0,0,0.05)";

  return {
    layout: {
      // "solid" = ColorType.Solid; importing the enum would eagerly bundle the library.
      background: { type: "solid" as ColorType.Solid, color: "rgba(0,0,0,0)" },
      textColor: isDark ? "#9CA3AF" : "#374151",
    },
    grid: {
      vertLines: { color: gridColor },
      horzLines: { color: gridColor },
    },
  };
}

/** Full options for createChart. */
export function getChartOptions(
  resolvedTheme: string | undefined,
  height: number,
): DeepPartial<ChartOptions> {
  return {
    height,
    ...getThemeOptions(resolvedTheme),
    rightPriceScale: { borderVisible: false },
    timeScale: {
      borderVisible: false,
      timeVisible: true,
      fixLeftEdge: true,
      fixRightEdge: true,
    },
    crosshair: { mode: 0 },
  };
}
```

### `registry/voraui/trading-chart/lib/markers.ts`

```ts
import type { SeriesMarker, UTCTimestamp } from "lightweight-charts";
import type { OhlcvCandle, TradeSignal } from "../types";

export const BUY_COLOR = "#00c176";
export const SELL_COLOR = "#cf304a";
export const INDICATOR_COLOR = "#8b5cf6";

export type AlignedSignal = TradeSignal & { alignedTime: UTCTimestamp };

export function floorToIntervalSeconds(tsMs: number, interval: string): number {
  const d = new Date(tsMs);
  const ms = d.getTime();
  const oneMinute = 60 * 1000;
  const Y = d.getUTCFullYear(),
    M = d.getUTCMonth(),
    D = d.getUTCDate(),
    h = d.getUTCHours();
  switch (interval) {
    case "1m":
      return Math.floor(ms / oneMinute) * 60;
    case "5m":
      return Math.floor(ms / (5 * oneMinute)) * 5 * 60;
    case "15m":
      return Math.floor(ms / (15 * oneMinute)) * 15 * 60;
    case "1h":
      return Date.UTC(Y, M, D, h, 0, 0) / 1000;
    case "4h":
      return Date.UTC(Y, M, D, Math.floor(h / 4) * 4, 0, 0) / 1000;
    case "8h":
      return Date.UTC(Y, M, D, Math.floor(h / 8) * 8, 0, 0) / 1000;
    case "1d":
      return Date.UTC(Y, M, D, 0, 0, 0) / 1000;
    case "1w": {
      // Floor to start of the week (Monday).
      const dayOfWeek = d.getUTCDay(); // 0=Sun, 1=Mon, ...
      const daysToMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
      return Date.UTC(Y, M, D - daysToMonday, 0, 0, 0) / 1000;
    }
    case "1M":
      return Date.UTC(Y, M, 1, 0, 0, 0) / 1000;
    default:
      return Math.floor(ms / 1000);
  }
}

export function findClosestBarTime(target: number, orderedTimes: number[]): number {
  if (!orderedTimes.length) return target;
  let closest = orderedTimes[0];
  let diff = Math.abs(target - closest);
  for (let i = 1; i < orderedTimes.length; i++) {
    const cand = orderedTimes[i];
    const candDiff = Math.abs(target - cand);
    if (candDiff < diff) {
      closest = cand;
      diff = candDiff;
    } else if (cand > target && candDiff > diff) {
      break;
    }
  }
  return closest;
}

/** Approximate seconds per interval, for the out-of-range tolerance below. */
const INTERVAL_SECONDS: Record<string, number> = {
  "1m": 60,
  "5m": 300,
  "15m": 900,
  "1h": 3_600,
  "4h": 14_400,
  "8h": 28_800,
  "1d": 86_400,
  "1w": 604_800,
  "1M": 2_678_400,
};

export function alignSignalsToBars(
  signals: TradeSignal[],
  candles: OhlcvCandle[],
  interval: string,
): AlignedSignal[] {
  if (!signals?.length || !candles?.length) return [];

  const barTimes = candles.map((c) => c.time);
  const barSet = new Set(barTimes);
  // Drop signals whose bar isn't loaded yet; they'd pile up on the edge bar.
  const tolerance = (INTERVAL_SECONDS[interval] ?? 3_600) * 2;

  return signals.flatMap((signal) => {
    const snapped = floorToIntervalSeconds(signal.ts, interval);
    const aligned = barSet.has(snapped) ? snapped : findClosestBarTime(snapped, barTimes);
    if (Math.abs(aligned - snapped) > tolerance) return [];

    return [
      {
        ...signal,
        alignedTime: aligned as UTCTimestamp,
      },
    ];
  });
}

export function buildSeriesMarkers(aligned: AlignedSignal[]): SeriesMarker<UTCTimestamp>[] {
  const tradeGrouped = new Map<string, AlignedSignal[]>();
  const indicatorGrouped = new Map<string, AlignedSignal[]>();

  for (const signal of aligned) {
    if (signal.category === "indicator") {
      const indicatorKey = `${signal.alignedTime}`;
      if (!indicatorGrouped.has(indicatorKey)) indicatorGrouped.set(indicatorKey, []);
      indicatorGrouped.get(indicatorKey)!.push(signal);
      continue;
    }

    const tradeKey = `${signal.alignedTime}_${signal.side}`;
    if (!tradeGrouped.has(tradeKey)) tradeGrouped.set(tradeKey, []);
    tradeGrouped.get(tradeKey)!.push(signal);
  }

  const markers: SeriesMarker<UTCTimestamp>[] = [];

  for (const [, signals] of tradeGrouped.entries()) {
    const isBuy = signals[0].side === "BUY";
    const count = signals.length;

    markers.push({
      time: signals[0].alignedTime,
      position: isBuy ? "belowBar" : "aboveBar",
      color: isBuy ? BUY_COLOR : SELL_COLOR,
      shape: isBuy ? "arrowUp" : "arrowDown",
      size: count >= 3 ? 2 : 1.5,
      text: count > 1 ? `${count}${isBuy ? "B" : "S"}` : isBuy ? "B" : "S",
    });
  }

  for (const [, signals] of indicatorGrouped.entries()) {
    markers.push({
      time: signals[0].alignedTime,
      position: "inBar",
      color: INDICATOR_COLOR,
      shape: "circle",
      size: 1.6,
      text: "I",
    });
  }

  return markers.sort((a, b) => (a.time as number) - (b.time as number));
}
```

### `registry/voraui/trading-chart/lib/candle-validation.ts`

```ts
export function isValidCandle(candle: {
  open: number;
  high: number;
  low: number;
  close: number;
  time: number;
}): boolean {
  const { open, high, low, close, time } = candle;

  if (!isFinite(open) || !isFinite(high) || !isFinite(low) || !isFinite(close)) {
    return false;
  }
  if (open <= 0 || high <= 0 || low <= 0 || close <= 0) {
    return false;
  }

  // OHLC consistency: high must be the highest, low the lowest.
  if (high < Math.max(open, close)) return false;
  if (low > Math.min(open, close)) return false;
  if (high < low) return false;

  // Reject candles whose range exceeds 90% of the close price.
  if ((high - low) / close > 0.9) {
    return false;
  }

  // Timestamp must be in seconds, not milliseconds.
  const MAX_REASONABLE_TS = 9_999_999_999;
  if (time > MAX_REASONABLE_TS) {
    return false;
  }

  return true;
}

export function sanitizeCandles<
  T extends { time: number; open: number; high: number; low: number; close: number },
>(candles: T[]): T[] {
  return candles
    .filter(isValidCandle)
    .sort((a, b) => a.time - b.time)
    .filter((c, i, arr) => i === 0 || c.time !== arr[i - 1].time);
}
```

### `registry/voraui/trading-chart/lib/chart-time.ts`

```ts
import type { Time, UTCTimestamp } from "lightweight-charts";

/** Timeframes lightweight-charts renders as business days rather than timestamps. */
const DAILY_PLUS: ReadonlySet<string> = new Set(["1d", "1w", "1M"]);

export function isDailyOrHigher(timeframe: string): boolean {
  return DAILY_PLUS.has(timeframe);
}

/** Accepts epoch seconds or milliseconds and returns epoch seconds. */
export function normalizeToSeconds(ts: number): number {
  return ts > 9_999_999_999 ? Math.floor(ts / 1000) : ts;
}

/** Unix seconds to a chart Time: "YYYY-MM-DD" for daily+, raw number for intraday. */
export function toChartTime(timeSec: number, timeframe: string): Time {
  return isDailyOrHigher(timeframe)
    ? new Date(timeSec * 1000).toISOString().split("T")[0]
    : (timeSec as UTCTimestamp);
}

/** A chart event time (number, "YYYY-MM-DD", or BusinessDay) back to unix seconds. */
export function normalizeParamTime(t: unknown): number | undefined {
  if (t == null) return undefined;
  if (typeof t === "number") return t;
  if (typeof t === "string") return Math.floor(new Date(t + "T00:00:00Z").getTime() / 1000);
  if (typeof t === "object" && "year" in t) {
    const bd = t as { year: number; month: number; day: number };
    return Math.floor(Date.UTC(bd.year, bd.month - 1, bd.day) / 1000);
  }
  return undefined;
}
```

### `registry/voraui/trading-chart/lib/signal-tooltip.ts`

```ts
import type { MouseEventParams } from "lightweight-charts";
import type { OhlcvCandle, TradeSignal } from "../types";
import {
  alignSignalsToBars,
  BUY_COLOR,
  SELL_COLOR,
  INDICATOR_COLOR,
  type AlignedSignal,
} from "./markers";
import { normalizeParamTime } from "./chart-time";

export interface SignalTooltipDeps {
  container: HTMLElement;
  getCandles: () => OhlcvCandle[];
  getTrades: () => TradeSignal[];
  getTimeframe: () => string;
  isEnabled: () => boolean;
}

export interface SignalTooltip {
  /** Pass to chart.subscribeCrosshairMove. */
  onCrosshairMove: (param: MouseEventParams) => void;
  hide: () => void;
  dispose: () => void;
}

const STYLE_ID = "voraui-tooltip-styles";
const TOOLTIP_EDGE_INSET = 8;
const TOOLTIP_OFFSET = 12;

interface TooltipPoint {
  x: number;
  y: number;
}

interface TooltipPosition {
  left: number;
  top: number;
}

interface TooltipBox {
  width: number;
  height: number;
}

function clamp(value: number, min: number, max: number): number {
  if (max < min) return min;
  return Math.min(Math.max(value, min), max);
}

/** Keeps the tooltip inside the chart while preferring the familiar above-cursor placement. */
export function getSignalTooltipPosition(
  point: TooltipPoint,
  tooltip: TooltipBox,
  container: TooltipBox,
): TooltipPosition {
  const maxLeft = Math.max(TOOLTIP_EDGE_INSET, container.width - tooltip.width - TOOLTIP_EDGE_INSET);
  const maxTop = Math.max(TOOLTIP_EDGE_INSET, container.height - tooltip.height - TOOLTIP_EDGE_INSET);
  const preferredTop = point.y - tooltip.height - TOOLTIP_OFFSET;
  const belowTop = point.y + TOOLTIP_OFFSET;
  const top =
    preferredTop >= TOOLTIP_EDGE_INSET
      ? preferredTop
      : belowTop + tooltip.height <= container.height - TOOLTIP_EDGE_INSET
        ? belowTop
        : preferredTop;

  return {
    left: clamp(point.x - tooltip.width / 2, TOOLTIP_EDGE_INSET, maxLeft),
    top: clamp(top, TOOLTIP_EDGE_INSET, maxTop),
  };
}

function escapeHtml(value: string): string {
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}

function renderSignalHtml(s: AlignedSignal): string {
  if (s.category === "indicator") {
    const indicatorLabel = escapeHtml(s.indicator || "Indicator");
    const indicatorValue =
      typeof s.value === "number" && Number.isFinite(s.value) ? ` (${s.value.toFixed(2)})` : "";
    const note = s.note ? `<div style="font-size:10px;opacity:0.7;">${escapeHtml(s.note)}</div>` : "";
    return `
      <div style="display:flex;flex-direction:column;margin-top:4px;gap:2px;">
        <div style="display:flex;justify-content:space-between;gap:12px;font-size:11px;">
          <b style="color:${s.side === "BUY" ? BUY_COLOR : SELL_COLOR}">${indicatorLabel} ${s.side}${indicatorValue}</b>
        </div>
        ${note}
      </div>
    `;
  }

  const price = Number(s.price);
  const qty = Number(s.quantity || 0);
  const amount = qty > 0 ? price * qty : price;
  const qtySuffix = qty > 0 ? ` (${qty} @ ${price.toLocaleString()})` : "";
  const note = s.note ? `<div style="font-size:10px;opacity:0.7;">${escapeHtml(s.note)}</div>` : "";

  return `
    <div style="display:flex;flex-direction:column;margin-top:4px;gap:1px;">
      <div style="display:flex;justify-content:space-between;gap:12px;font-size:11px;">
        <b style="color:${s.side === "BUY" ? BUY_COLOR : SELL_COLOR}">${amount.toLocaleString("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 0 })} usd${qtySuffix}</b>
      </div>
      ${note}
    </div>
  `;
}

/** Pure renderer for the tooltip body; exported for tests. */
export function renderTooltipHtml(list: AlignedSignal[]): string {
  const signal = list[0];
  const count = list.length;
  const indicatorSignals = list.filter((s) => s.category === "indicator");
  const tradeSignals = list.filter((s) => s.category !== "indicator");
  const buys = tradeSignals.filter((s) => s.side === "BUY");
  const sells = tradeSignals.filter((s) => s.side === "SELL");

  return `
    <div style="margin-bottom:8px;padding-bottom:6px;border-bottom:1px solid rgba(255,255,255,0.1);display:flex;justify-content:space-between;align-items:center;">
      <span style="font-weight:600;font-size:13px;">${new Date(signal.ts).toLocaleDateString("en-US", { month: "short", day: "2-digit", year: "numeric" })}</span>
      <span style="font-size:10px;opacity:0.6;background:rgba(255,255,255,0.1);padding:1px 4px;border-radius:4px;">${count} Signals</span>
    </div>

    ${indicatorSignals.length > 0 ? `
      <div style="margin-top:6px">
        <div style="color:${INDICATOR_COLOR};font-size:10px;text-transform:uppercase;font-weight:bold;margin-bottom:2px">Indicator Signals</div>
        ${indicatorSignals.slice(0, 8).map(renderSignalHtml).join("")}
      </div>
    ` : ""}
    ${buys.length > 0 ? `
      <div style="margin-top:6px">
        <div style="color:${BUY_COLOR};font-size:10px;text-transform:uppercase;font-weight:bold;margin-bottom:2px">Buy Signals</div>
        ${buys.slice(0, 5).map(renderSignalHtml).join("")}
      </div>
    ` : ""}
    ${sells.length > 0 ? `
      <div style="margin-top:8px">
        <div style="color:${SELL_COLOR};font-size:10px;text-transform:uppercase;font-weight:bold;margin-bottom:2px">Sell Signals</div>
        ${sells.slice(0, 5).map(renderSignalHtml).join("")}
      </div>
    ` : ""}
  `;
}

export function createSignalTooltip({
  container,
  getCandles,
  getTrades,
  getTimeframe,
  isEnabled,
}: SignalTooltipDeps): SignalTooltip {
  if (!document.getElementById(STYLE_ID)) {
    const style = document.createElement("style");
    style.id = STYLE_ID;
    style.textContent = `
      @keyframes vorauiTooltipIn {
        from { opacity: 0; transform: translateY(-4px) scale(0.98); }
        to   { opacity: 1; transform: translateY(0) scale(1); }
      }
    `;
    document.head.appendChild(style);
  }

  const el = document.createElement("div");
  Object.assign(el.style, {
    position: "absolute",
    pointerEvents: "none",
    zIndex: "50",
    padding: "12px",
    borderRadius: "12px",
    background: "rgba(17,17,20,0.95)",
    color: "#fff",
    fontSize: "12px",
    boxShadow: "0 8px 32px rgba(0,0,0,0.5)",
    border: "1px solid rgba(255,255,255,0.1)",
    backdropFilter: "blur(8px)",
    boxSizing: "border-box",
    display: "none",
    minWidth: "160px",
    maxWidth: `calc(100% - ${TOOLTIP_EDGE_INSET * 2}px)`,
    maxHeight: `calc(100% - ${TOOLTIP_EDGE_INSET * 2}px)`,
    overflow: "hidden",
    transition: "opacity 0.2s ease-in-out",
  } as unknown as CSSStyleDeclaration);
  container.appendChild(el);

  const hide = () => {
    el.style.display = "none";
  };

  const show = (list: AlignedSignal[], pt: { x: number; y: number }) => {
    const wasHidden = el.style.display !== "block";
    el.innerHTML = renderTooltipHtml(list);
    el.style.display = "block";
    const position = getSignalTooltipPosition(
      pt,
      { width: el.offsetWidth || 180, height: el.offsetHeight || 80 },
      { width: container.clientWidth, height: container.clientHeight },
    );
    el.style.left = `${position.left}px`;
    el.style.top = `${position.top}px`;

    // Animate only on hidden -> shown; re-animating on every move would flicker.
    if (wasHidden && !window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      el.style.animation = "none";
      void el.offsetWidth;
      el.style.animation = "vorauiTooltipIn 0.16s ease-out";
    }
  };

  const onCrosshairMove = (param: MouseEventParams) => {
    if (!isEnabled()) {
      hide();
      return;
    }
    const pt = param.point;
    const t = normalizeParamTime(param.time);
    if (!pt || t == null) {
      hide();
      return;
    }
    if (
      pt.x < 0 ||
      pt.y < 0 ||
      pt.x > container.clientWidth ||
      pt.y > container.clientHeight
    ) {
      hide();
      return;
    }

    const aligned = alignSignalsToBars(getTrades(), getCandles(), getTimeframe());
    const found = aligned.filter((s) => s.alignedTime === t);
    if (found.length > 0) {
      show(found, pt);
    } else {
      hide();
    }
  };

  return {
    onCrosshairMove,
    hide,
    dispose: () => el.remove(),
  };
}
```
