voraui
blocktrading-chartsource: ok · hit
Trading Chart
Candlestick chart with trade markers, clustering, and live Binance updates.
npx shadcn add "https://voraui.vercel.app/r/trading-chart.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightlightweight-chartsnext-themes
Registry dependencies
utils
Files15 · source cached 10/14/2026, 5:33:47 PM
registry/voraui/trading-chart/index.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
"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
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
"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
"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
"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
"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
"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
"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
"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
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
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
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
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
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
}
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(),
};
}