chore: initial import

This commit is contained in:
u1
2026-01-06 12:33:47 +01:00
commit ed37565e25
38 changed files with 5707 additions and 0 deletions

View File

@@ -0,0 +1,182 @@
import type { SVGAttributes } from 'react';
type IconProps = SVGAttributes<SVGSVGElement> & { title?: string };
function Svg({ title, children, ...rest }: IconProps) {
return (
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden={title ? undefined : true}
role={title ? 'img' : 'presentation'}
{...rest}
>
{title ? <title>{title}</title> : null}
{children}
</svg>
);
}
export function IconCursor(props: IconProps) {
return (
<Svg title={props.title ?? 'Cursor'} {...props}>
<path d="M4 2.5L13 9.2L9.1 10.2L11.3 15.5L9.6 16.3L7.4 11L4.8 14.3L4 2.5Z" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round" />
</Svg>
);
}
export function IconCrosshair(props: IconProps) {
return (
<Svg title={props.title ?? 'Crosshair'} {...props}>
<path d="M9 2.2V5.0M9 13.0V15.8M2.2 9H5.0M13.0 9H15.8" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
<circle cx="9" cy="9" r="2.3" stroke="currentColor" strokeWidth="1.3" />
</Svg>
);
}
export function IconPlus(props: IconProps) {
return (
<Svg title={props.title ?? 'Plus'} {...props}>
<path d="M9 3.2V14.8M3.2 9H14.8" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
</Svg>
);
}
export function IconTrendline(props: IconProps) {
return (
<Svg title={props.title ?? 'Trendline'} {...props}>
<path d="M3 12.8L7.2 9.1L10.2 11L15 5.2" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round" strokeLinecap="round" />
<circle cx="3" cy="12.8" r="1" fill="currentColor" />
<circle cx="7.2" cy="9.1" r="1" fill="currentColor" />
<circle cx="10.2" cy="11" r="1" fill="currentColor" />
<circle cx="15" cy="5.2" r="1" fill="currentColor" />
</Svg>
);
}
export function IconFib(props: IconProps) {
return (
<Svg title={props.title ?? 'Fibonacci'} {...props}>
<path
d="M3 13.8C5.4 13.8 6.9 12.5 7.8 10.9C8.8 9.2 9.9 7.2 15 7.2"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
/>
<path d="M3.2 10.4H14.8" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" opacity="0.8" />
<path d="M3.2 7.0H14.8" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" opacity="0.65" />
</Svg>
);
}
export function IconBrush(props: IconProps) {
return (
<Svg title={props.title ?? 'Brush'} {...props}>
<path
d="M12.8 3.6C13.9 4.7 13.9 6.4 12.8 7.5L8.5 11.8L6.2 12.2L6.6 9.9L10.9 5.6C12 4.5 12 3.6 12.8 3.6Z"
stroke="currentColor"
strokeWidth="1.2"
strokeLinejoin="round"
/>
<path d="M5.8 12.2C5.8 14.0 4.6 15.2 2.8 15.2C4.0 14.6 4.6 14.0 4.6 13.2C4.6 12.4 5.1 12.0 5.8 12.2Z" fill="currentColor" opacity="0.9" />
</Svg>
);
}
export function IconText(props: IconProps) {
return (
<Svg title={props.title ?? 'Text'} {...props}>
<path d="M4 4.2H14M9 4.2V14.2" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
</Svg>
);
}
export function IconSmile(props: IconProps) {
return (
<Svg title={props.title ?? 'Emoji'} {...props}>
<circle cx="9" cy="9" r="6.2" stroke="currentColor" strokeWidth="1.2" />
<path d="M6.6 7.6H6.7M11.3 7.6H11.4" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
<path d="M6.4 10.5C7.2 11.8 8.2 12.4 9 12.4C9.8 12.4 10.8 11.8 11.6 10.5" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
</Svg>
);
}
export function IconRuler(props: IconProps) {
return (
<Svg title={props.title ?? 'Ruler'} {...props}>
<path d="M4.2 12.8L12.8 4.2L14.8 6.2L6.2 14.8L4.2 12.8Z" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" />
<path d="M7.0 12.2L5.8 11.0M8.6 10.6L7.8 9.8M10.2 9.0L9.4 8.2M11.8 7.4L10.6 6.2" stroke="currentColor" strokeWidth="1.1" strokeLinecap="round" opacity="0.75" />
</Svg>
);
}
export function IconZoom(props: IconProps) {
return (
<Svg title={props.title ?? 'Zoom'} {...props}>
<circle cx="8" cy="8" r="4.6" stroke="currentColor" strokeWidth="1.2" />
<path d="M11.4 11.4L15.0 15.0" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
<path d="M8 6.3V9.7M6.3 8H9.7" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
</Svg>
);
}
export function IconZoomOut(props: IconProps) {
return (
<Svg title={props.title ?? 'Zoom Out'} {...props}>
<circle cx="8" cy="8" r="4.6" stroke="currentColor" strokeWidth="1.2" />
<path d="M11.4 11.4L15.0 15.0" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
<path d="M6.3 8H9.7" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
</Svg>
);
}
export function IconLock(props: IconProps) {
return (
<Svg title={props.title ?? 'Lock'} {...props}>
<path d="M5.6 8.0V6.6C5.6 4.7 7.1 3.2 9 3.2C10.9 3.2 12.4 4.7 12.4 6.6V8.0" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
<path d="M5.0 8.0H13.0V14.8H5.0V8.0Z" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" />
</Svg>
);
}
export function IconEye(props: IconProps) {
return (
<Svg title={props.title ?? 'Visibility'} {...props}>
<path
d="M1.8 9.0C3.5 6.0 6.1 4.2 9.0 4.2C11.9 4.2 14.5 6.0 16.2 9.0C14.5 12.0 11.9 13.8 9.0 13.8C6.1 13.8 3.5 12.0 1.8 9.0Z"
stroke="currentColor"
strokeWidth="1.2"
strokeLinejoin="round"
/>
<circle cx="9" cy="9" r="2.1" stroke="currentColor" strokeWidth="1.2" />
</Svg>
);
}
export function IconTrash(props: IconProps) {
return (
<Svg title={props.title ?? 'Delete'} {...props}>
<path d="M5.2 5.6H12.8" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
<path d="M7.0 5.6V4.6C7.0 3.8 7.6 3.2 8.4 3.2H9.6C10.4 3.2 11.0 3.8 11.0 4.6V5.6" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
<path d="M6.0 6.6L6.6 14.6H11.4L12.0 6.6" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" />
<path d="M7.8 8.3V13.0M10.2 8.3V13.0" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" opacity="0.85" />
</Svg>
);
}
export function IconResetView(props: IconProps) {
return (
<Svg title={props.title ?? 'Reset View'} {...props}>
<path
d="M13.8 8.0A5.8 5.8 0 1 0 9.0 14.8"
stroke="currentColor"
strokeWidth="1.2"
strokeLinecap="round"
/>
<path d="M13.9 3.8V8.0H9.7" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
</Svg>
);
}

View File

@@ -0,0 +1,177 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { Candle, ChartIndicators } from '../../lib/api';
import Card from '../../ui/Card';
import ChartSideToolbar from './ChartSideToolbar';
import ChartToolbar from './ChartToolbar';
import TradingChart from './TradingChart';
import type { FibAnchor, FibRetracement } from './FibRetracementPrimitive';
import type { IChartApi } from 'lightweight-charts';
type Props = {
candles: Candle[];
indicators: ChartIndicators;
timeframe: string;
onTimeframeChange: (tf: string) => void;
showIndicators: boolean;
onToggleIndicators: () => void;
seriesLabel: string;
};
export default function ChartPanel({
candles,
indicators,
timeframe,
onTimeframeChange,
showIndicators,
onToggleIndicators,
seriesLabel,
}: Props) {
const [isFullscreen, setIsFullscreen] = useState(false);
const [activeTool, setActiveTool] = useState<'cursor' | 'fib-retracement'>('cursor');
const [fibStart, setFibStart] = useState<FibAnchor | null>(null);
const [fib, setFib] = useState<FibRetracement | null>(null);
const [fibDraft, setFibDraft] = useState<FibRetracement | null>(null);
const chartApiRef = useRef<IChartApi | null>(null);
const activeToolRef = useRef(activeTool);
const fibStartRef = useRef<FibAnchor | null>(fibStart);
const pendingMoveRef = useRef<FibAnchor | null>(null);
const rafRef = useRef<number | null>(null);
useEffect(() => {
if (!isFullscreen) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setIsFullscreen(false);
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [isFullscreen]);
useEffect(() => {
document.body.classList.toggle('chartFullscreen', isFullscreen);
return () => document.body.classList.remove('chartFullscreen');
}, [isFullscreen]);
const cardClassName = useMemo(() => {
return ['chartCard', isFullscreen ? 'chartCard--fullscreen' : null].filter(Boolean).join(' ');
}, [isFullscreen]);
useEffect(() => {
activeToolRef.current = activeTool;
if (activeTool !== 'fib-retracement') {
setFibStart(null);
setFibDraft(null);
}
}, [activeTool]);
useEffect(() => {
fibStartRef.current = fibStart;
}, [fibStart]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
if (activeToolRef.current !== 'fib-retracement') return;
setFibStart(null);
setFibDraft(null);
setActiveTool('cursor');
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, []);
useEffect(() => {
return () => {
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
};
}, []);
function zoomTime(factor: number) {
const chart = chartApiRef.current;
if (!chart) return;
const ts = chart.timeScale();
const range = ts.getVisibleLogicalRange();
if (!range) return;
const from = range.from as number;
const to = range.to as number;
const span = Math.max(5, (to - from) * factor);
const center = (from + to) / 2;
ts.setVisibleLogicalRange({ from: center - span / 2, to: center + span / 2 });
}
return (
<>
{isFullscreen ? <div className="chartBackdrop" onClick={() => setIsFullscreen(false)} /> : null}
<Card className={cardClassName}>
<div className="chartCard__toolbar">
<ChartToolbar
timeframe={timeframe}
onTimeframeChange={onTimeframeChange}
showIndicators={showIndicators}
onToggleIndicators={onToggleIndicators}
seriesLabel={seriesLabel}
isFullscreen={isFullscreen}
onToggleFullscreen={() => setIsFullscreen((v) => !v)}
/>
</div>
<div className="chartCard__content">
<ChartSideToolbar
timeframe={timeframe}
activeTool={activeTool}
hasFib={fib != null || fibDraft != null}
onToolChange={setActiveTool}
onZoomIn={() => zoomTime(0.8)}
onZoomOut={() => zoomTime(1.25)}
onResetView={() => chartApiRef.current?.timeScale().resetTimeScale()}
onClearFib={() => {
setFib(null);
setFibStart(null);
setFibDraft(null);
}}
/>
<div className="chartCard__chart">
<TradingChart
candles={candles}
oracle={indicators.oracle}
sma20={indicators.sma20}
ema20={indicators.ema20}
bb20={indicators.bb20}
showIndicators={showIndicators}
fib={fibDraft ?? fib}
onReady={({ chart }) => {
chartApiRef.current = chart;
}}
onChartClick={(p) => {
if (activeTool !== 'fib-retracement') return;
if (!fibStartRef.current) {
fibStartRef.current = p;
setFibStart(p);
setFibDraft({ a: p, b: p });
return;
}
setFib({ a: fibStartRef.current, b: p });
setFibStart(null);
fibStartRef.current = null;
setFibDraft(null);
setActiveTool('cursor');
}}
onChartCrosshairMove={(p) => {
if (activeToolRef.current !== 'fib-retracement') return;
const start = fibStartRef.current;
if (!start) return;
pendingMoveRef.current = p;
if (rafRef.current != null) return;
rafRef.current = window.requestAnimationFrame(() => {
rafRef.current = null;
const move = pendingMoveRef.current;
const start2 = fibStartRef.current;
if (!move || !start2) return;
setFibDraft({ a: start2, b: move });
});
}}
/>
</div>
</div>
</Card>
</>
);
}

View File

@@ -0,0 +1,220 @@
import { useEffect, useMemo, useState } from 'react';
import ChartToolMenu, { type ToolMenuSection } from './ChartToolMenu';
import {
IconBrush,
IconCrosshair,
IconCursor,
IconEye,
IconFib,
IconLock,
IconPlus,
IconRuler,
IconSmile,
IconText,
IconResetView,
IconTrash,
IconTrendline,
IconZoom,
IconZoomOut,
} from './ChartIcons';
type ActiveTool = 'cursor' | 'fib-retracement';
type Props = {
timeframe: string;
activeTool: ActiveTool;
hasFib: boolean;
onToolChange: (tool: ActiveTool) => void;
onZoomIn: () => void;
onZoomOut: () => void;
onResetView: () => void;
onClearFib: () => void;
};
export default function ChartSideToolbar({
timeframe,
activeTool,
hasFib,
onToolChange,
onZoomIn,
onZoomOut,
onResetView,
onClearFib,
}: Props) {
const [openMenu, setOpenMenu] = useState<'fib' | null>(null);
useEffect(() => {
if (!openMenu) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpenMenu(null);
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [openMenu]);
const fibMenuSections: ToolMenuSection[] = useMemo(
() => [
{
id: 'fib',
title: 'FIBONACCI',
items: [
{ id: 'fib-retracement', label: 'Fib Retracement', icon: <IconFib />, shortcut: 'Click 2 points' },
{ id: 'fib-tb-ext', label: 'Trend-Based Fib Extension', icon: <IconTrendline /> },
{ id: 'fib-channel', label: 'Fib Channel', icon: <IconFib /> },
{ id: 'fib-time-zone', label: 'Fib Time Zone', icon: <IconFib /> },
{ id: 'fib-speed-fan', label: 'Fib Speed Resistance Fan', icon: <IconFib /> },
{ id: 'fib-tb-time', label: 'Trend-Based Fib Time', icon: <IconTrendline /> },
{ id: 'fib-circles', label: 'Fib Circles', icon: <IconFib /> },
{ id: 'fib-spiral', label: 'Fib Spiral', icon: <IconFib /> },
{ id: 'fib-speed-arcs', label: 'Fib Speed Resistance Arcs', icon: <IconFib /> },
{ id: 'fib-wedge', label: 'Fib Wedge', icon: <IconFib /> },
{ id: 'pitchfan', label: 'Pitchfan', icon: <IconTrendline /> },
],
},
{
id: 'gann',
title: 'GANN',
items: [
{ id: 'gann-box', label: 'Gann Box', icon: <IconTrendline /> },
{ id: 'gann-square-fixed', label: 'Gann Square Fixed', icon: <IconTrendline /> },
{ id: 'gann-fan', label: 'Gann Fan', icon: <IconTrendline /> },
],
},
],
[]
);
return (
<div className="chartTools">
<div className="chartSideToolbar">
<button
type="button"
className={['chartToolBtn', activeTool === 'cursor' ? 'chartToolBtn--active' : ''].filter(Boolean).join(' ')}
title="Cursor"
aria-label="Cursor"
onClick={() => {
onToolChange('cursor');
setOpenMenu(null);
}}
>
<span className="chartToolBtn__icon" aria-hidden="true">
<IconCursor />
</span>
</button>
<button type="button" className="chartToolBtn" title="Crosshair" aria-label="Crosshair" disabled>
<span className="chartToolBtn__icon" aria-hidden="true">
<IconCrosshair />
</span>
</button>
<button type="button" className="chartToolBtn" title="Add" aria-label="Add" disabled>
<span className="chartToolBtn__icon" aria-hidden="true">
<IconPlus />
</span>
</button>
<button type="button" className="chartToolBtn" title="Trendline" aria-label="Trendline" disabled>
<span className="chartToolBtn__icon" aria-hidden="true">
<IconTrendline />
</span>
</button>
<button
type="button"
className={['chartToolBtn', activeTool === 'fib-retracement' ? 'chartToolBtn--active' : ''].filter(Boolean).join(' ')}
title="Fibonacci"
aria-label="Fibonacci"
onClick={() => setOpenMenu((m) => (m === 'fib' ? null : 'fib'))}
>
<span className="chartToolBtn__icon" aria-hidden="true">
<IconFib />
</span>
</button>
<button type="button" className="chartToolBtn" title="Brush" aria-label="Brush" disabled>
<span className="chartToolBtn__icon" aria-hidden="true">
<IconBrush />
</span>
</button>
<button type="button" className="chartToolBtn" title="Text" aria-label="Text" disabled>
<span className="chartToolBtn__icon" aria-hidden="true">
<IconText />
</span>
</button>
<button type="button" className="chartToolBtn" title="Emoji" aria-label="Emoji" disabled>
<span className="chartToolBtn__icon" aria-hidden="true">
<IconSmile />
</span>
</button>
<button type="button" className="chartToolBtn" title="Ruler" aria-label="Ruler" disabled>
<span className="chartToolBtn__icon" aria-hidden="true">
<IconRuler />
</span>
</button>
<div className="chartToolBtn__spacer" />
<button type="button" className="chartToolBtn" title="Zoom In" aria-label="Zoom In" onClick={onZoomIn}>
<span className="chartToolBtn__icon" aria-hidden="true">
<IconZoom />
</span>
</button>
<button type="button" className="chartToolBtn" title="Zoom Out" aria-label="Zoom Out" onClick={onZoomOut}>
<span className="chartToolBtn__icon" aria-hidden="true">
<IconZoomOut />
</span>
</button>
<button type="button" className="chartToolBtn" title="Reset View" aria-label="Reset View" onClick={onResetView}>
<span className="chartToolBtn__icon" aria-hidden="true">
<IconResetView />
</span>
</button>
<button type="button" className="chartToolBtn" title="Lock" aria-label="Lock" disabled>
<span className="chartToolBtn__icon" aria-hidden="true">
<IconLock />
</span>
</button>
<button
type="button"
className="chartToolBtn"
title="Clear Fib"
aria-label="Clear Fib"
onClick={onClearFib}
disabled={!hasFib}
>
<span className="chartToolBtn__icon" aria-hidden="true">
<IconTrash />
</span>
</button>
<button type="button" className="chartToolBtn" title="Visibility" aria-label="Visibility" disabled>
<span className="chartToolBtn__icon" aria-hidden="true">
<IconEye />
</span>
</button>
</div>
{openMenu === 'fib' ? (
<>
<div className="chartToolMenuBackdrop" onClick={() => setOpenMenu(null)} />
<ChartToolMenu
timeframeLabel={timeframe}
sections={fibMenuSections}
onSelectItem={(id) => {
if (id === 'fib-retracement') onToolChange('fib-retracement');
setOpenMenu(null);
}}
/>
</>
) : null}
</div>
);
}

View File

@@ -0,0 +1,54 @@
import type { ReactNode } from 'react';
export type ToolMenuItem = {
id: string;
label: string;
icon: ReactNode;
shortcut?: string;
};
export type ToolMenuSection = {
id: string;
title: string;
items: ToolMenuItem[];
};
type Props = {
timeframeLabel: string;
sections: ToolMenuSection[];
onSelectItem?: (id: string) => void;
};
export default function ChartToolMenu({ timeframeLabel, sections, onSelectItem }: Props) {
return (
<div className="chartToolMenu" role="dialog" aria-label="Chart tools">
<div className="chartToolMenu__top">
<div className="chartToolMenu__tf">{timeframeLabel}</div>
</div>
<div className="chartToolMenu__body">
{sections.map((s) => (
<div key={s.id} className="chartToolMenu__section">
<div className="chartToolMenu__sectionTitle">{s.title}</div>
<div className="chartToolMenu__items">
{s.items.map((it) => (
<button
key={it.id}
className="chartToolMenuItem"
type="button"
onClick={() => onSelectItem?.(it.id)}
>
<span className="chartToolMenuItem__icon" aria-hidden="true">
{it.icon}
</span>
<span className="chartToolMenuItem__label">{it.label}</span>
{it.shortcut ? <span className="chartToolMenuItem__shortcut">{it.shortcut}</span> : null}
</button>
))}
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,51 @@
import Button from '../../ui/Button';
type Props = {
timeframe: string;
onTimeframeChange: (tf: string) => void;
showIndicators: boolean;
onToggleIndicators: () => void;
seriesLabel: string;
isFullscreen: boolean;
onToggleFullscreen: () => void;
};
const timeframes = ['1m', '5m', '15m', '1h', '4h', '1D'] as const;
export default function ChartToolbar({
timeframe,
onTimeframeChange,
showIndicators,
onToggleIndicators,
seriesLabel,
isFullscreen,
onToggleFullscreen,
}: Props) {
return (
<div className="chartToolbar">
<div className="chartToolbar__group">
{timeframes.map((tf) => (
<Button
key={tf}
size="sm"
variant={tf === timeframe ? 'primary' : 'ghost'}
onClick={() => onTimeframeChange(tf)}
type="button"
>
{tf}
</Button>
))}
</div>
<div className="chartToolbar__group">
<Button size="sm" variant={showIndicators ? 'primary' : 'ghost'} onClick={onToggleIndicators} type="button">
Indicators
</Button>
<Button size="sm" variant={isFullscreen ? 'primary' : 'ghost'} onClick={onToggleFullscreen} type="button">
{isFullscreen ? 'Exit' : 'Fullscreen'}
</Button>
<div className="chartToolbar__meta">{seriesLabel}</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,199 @@
import type {
IPrimitivePaneRenderer,
IPrimitivePaneView,
ISeriesApi,
ISeriesPrimitive,
SeriesAttachedParameter,
Time,
} from 'lightweight-charts';
export type FibAnchor = {
logical: number;
price: number;
};
export type FibRetracement = {
a: FibAnchor;
b: FibAnchor;
};
type FibLevel = {
ratio: number;
line: string;
fill: string;
};
const LEVELS: FibLevel[] = [
{ ratio: 4.236, line: 'rgba(255, 45, 85, 0.95)', fill: 'rgba(255, 45, 85, 0.16)' },
{ ratio: 3.618, line: 'rgba(192, 132, 252, 0.95)', fill: 'rgba(192, 132, 252, 0.14)' },
{ ratio: 2.618, line: 'rgba(239, 68, 68, 0.92)', fill: 'rgba(239, 68, 68, 0.14)' },
{ ratio: 1.618, line: 'rgba(59, 130, 246, 0.92)', fill: 'rgba(59, 130, 246, 0.14)' },
{ ratio: 1.0, line: 'rgba(148, 163, 184, 0.92)', fill: 'rgba(59, 130, 246, 0.10)' },
{ ratio: 0.786, line: 'rgba(96, 165, 250, 0.92)', fill: 'rgba(96, 165, 250, 0.10)' },
{ ratio: 0.618, line: 'rgba(6, 182, 212, 0.92)', fill: 'rgba(6, 182, 212, 0.10)' },
{ ratio: 0.5, line: 'rgba(34, 197, 94, 0.92)', fill: 'rgba(34, 197, 94, 0.09)' },
{ ratio: 0.382, line: 'rgba(245, 158, 11, 0.92)', fill: 'rgba(245, 158, 11, 0.10)' },
{ ratio: 0.236, line: 'rgba(249, 115, 22, 0.92)', fill: 'rgba(249, 115, 22, 0.10)' },
{ ratio: 0.0, line: 'rgba(163, 163, 163, 0.85)', fill: 'rgba(163, 163, 163, 0.06)' },
];
function formatRatio(r: number): string {
if (Number.isInteger(r)) return String(r);
const s = r.toFixed(3);
return s.replace(/0+$/, '').replace(/\.$/, '');
}
function formatPrice(p: number): string {
if (!Number.isFinite(p)) return '—';
if (Math.abs(p) >= 1000) return p.toFixed(0);
if (Math.abs(p) >= 1) return p.toFixed(2);
return p.toPrecision(4);
}
type State = {
fib: FibRetracement | null;
series: ISeriesApi<'Candlestick', Time> | null;
chart: SeriesAttachedParameter<Time>['chart'] | null;
};
class FibPaneRenderer implements IPrimitivePaneRenderer {
private readonly _getState: () => State;
constructor(getState: () => State) {
this._getState = getState;
}
draw(target: any) {
const { fib, series, chart } = this._getState();
if (!fib || !series || !chart) return;
const x1 = chart.timeScale().logicalToCoordinate(fib.a.logical as any);
const x2 = chart.timeScale().logicalToCoordinate(fib.b.logical as any);
if (x1 == null || x2 == null) return;
const xLeftMedia = Math.min(x1, x2);
const xRightMedia = Math.max(x1, x2);
const p0 = fib.a.price;
const p1 = fib.b.price;
const delta = p1 - p0;
target.useBitmapCoordinateSpace(({ context, bitmapSize, horizontalPixelRatio, verticalPixelRatio }: any) => {
const xStart = Math.max(0, Math.round(xLeftMedia * horizontalPixelRatio));
let xEnd = Math.min(bitmapSize.width, Math.round(xRightMedia * horizontalPixelRatio));
if (xEnd <= xStart) xEnd = Math.min(bitmapSize.width, xStart + Math.max(1, Math.round(1 * horizontalPixelRatio)));
const w = xEnd - xStart;
const points = LEVELS.map((l) => {
const price = p0 + delta * l.ratio;
const y = series.priceToCoordinate(price);
return { ...l, price, y };
}).filter((p) => p.y != null) as Array<FibLevel & { price: number; y: number }>;
if (!points.length) return;
for (let i = 0; i < points.length - 1; i += 1) {
const a = points[i];
const b = points[i + 1];
const ya = Math.round(a.y * verticalPixelRatio);
const yb = Math.round(b.y * verticalPixelRatio);
const top = Math.min(ya, yb);
const h = Math.abs(yb - ya);
if (h <= 0) continue;
context.fillStyle = a.fill;
context.fillRect(xStart, top, w, h);
}
const lineW = Math.max(1, Math.round(1 * horizontalPixelRatio));
context.lineWidth = lineW;
const labelX = Math.round(Math.max(6, xLeftMedia - 8) * horizontalPixelRatio);
context.font = `${Math.max(10, Math.round(11 * verticalPixelRatio))}px system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial, sans-serif`;
context.textAlign = 'right';
context.textBaseline = 'middle';
for (const pt of points) {
const y = Math.round(pt.y * verticalPixelRatio);
context.strokeStyle = pt.line;
context.beginPath();
context.moveTo(xStart, y);
context.lineTo(xEnd, y);
context.stroke();
const label = `${formatRatio(pt.ratio)} (${formatPrice(pt.price)})`;
context.fillStyle = pt.line;
context.fillText(label, labelX, y);
}
const y0 = series.priceToCoordinate(p0);
const y1 = series.priceToCoordinate(p1);
if (y0 != null && y1 != null) {
const ax = Math.round(x1 * horizontalPixelRatio);
const ay = Math.round(y0 * verticalPixelRatio);
const bx = Math.round(x2 * horizontalPixelRatio);
const by = Math.round(y1 * verticalPixelRatio);
context.strokeStyle = 'rgba(226,232,240,0.55)';
context.lineWidth = Math.max(1, Math.round(1 * horizontalPixelRatio));
context.setLineDash([Math.max(2, Math.round(5 * horizontalPixelRatio)), Math.max(2, Math.round(5 * horizontalPixelRatio))]);
context.beginPath();
context.moveTo(ax, ay);
context.lineTo(bx, by);
context.stroke();
context.setLineDash([]);
const r = Math.max(2, Math.round(3 * horizontalPixelRatio));
context.fillStyle = 'rgba(147,197,253,0.95)';
context.beginPath();
context.arc(ax, ay, r, 0, Math.PI * 2);
context.fill();
context.beginPath();
context.arc(bx, by, r, 0, Math.PI * 2);
context.fill();
}
});
}
}
class FibPaneView implements IPrimitivePaneView {
private readonly _renderer: FibPaneRenderer;
constructor(getState: () => State) {
this._renderer = new FibPaneRenderer(getState);
}
renderer() {
return this._renderer;
}
}
export class FibRetracementPrimitive implements ISeriesPrimitive<Time> {
private _param: SeriesAttachedParameter<Time> | null = null;
private _series: ISeriesApi<'Candlestick', Time> | null = null;
private _fib: FibRetracement | null = null;
private readonly _paneView: FibPaneView;
private readonly _paneViews: readonly IPrimitivePaneView[];
constructor() {
this._paneView = new FibPaneView(() => ({ fib: this._fib, series: this._series, chart: this._param?.chart ?? null }));
this._paneViews = [this._paneView];
}
attached(param: SeriesAttachedParameter<Time>) {
this._param = param;
this._series = param.series as ISeriesApi<'Candlestick', Time>;
}
detached() {
this._param = null;
this._series = null;
}
paneViews() {
return this._paneViews;
}
setFib(next: FibRetracement | null) {
this._fib = next;
this._param?.requestUpdate();
}
}

View File

@@ -0,0 +1,259 @@
import { useEffect, useMemo, useRef } from 'react';
import {
CandlestickSeries,
ColorType,
CrosshairMode,
HistogramSeries,
type IChartApi,
type ISeriesApi,
LineStyle,
LineSeries,
createChart,
type UTCTimestamp,
type CandlestickData,
type HistogramData,
type LineData,
type WhitespaceData,
} from 'lightweight-charts';
import type { Candle, SeriesPoint } from '../../lib/api';
import { FibRetracementPrimitive, type FibAnchor, type FibRetracement } from './FibRetracementPrimitive';
type Props = {
candles: Candle[];
oracle?: SeriesPoint[];
sma20?: SeriesPoint[];
ema20?: SeriesPoint[];
bb20?: { upper: SeriesPoint[]; lower: SeriesPoint[]; mid: SeriesPoint[] };
showIndicators: boolean;
fib?: FibRetracement | null;
onReady?: (api: { chart: IChartApi; candles: ISeriesApi<'Candlestick', UTCTimestamp> }) => void;
onChartClick?: (p: FibAnchor) => void;
onChartCrosshairMove?: (p: FibAnchor) => void;
};
type LinePoint = LineData | WhitespaceData;
function toTime(t: number): UTCTimestamp {
return t as UTCTimestamp;
}
function toCandleData(candles: Candle[]): CandlestickData[] {
return candles.map((c) => ({
time: toTime(c.time),
open: c.open,
high: c.high,
low: c.low,
close: c.close,
}));
}
function toVolumeData(candles: Candle[]): HistogramData[] {
return candles.map((c) => {
const up = c.close >= c.open;
return {
time: toTime(c.time),
value: c.volume ?? 0,
color: up ? 'rgba(34,197,94,0.35)' : 'rgba(239,68,68,0.35)',
};
});
}
function toLineSeries(points: SeriesPoint[] | undefined): LinePoint[] {
if (!points?.length) return [];
return points.map((p) => (p.value == null ? ({ time: toTime(p.time) } as WhitespaceData) : { time: toTime(p.time), value: p.value }));
}
export default function TradingChart({
candles,
oracle,
sma20,
ema20,
bb20,
showIndicators,
fib,
onReady,
onChartClick,
onChartCrosshairMove,
}: Props) {
const containerRef = useRef<HTMLDivElement | null>(null);
const chartRef = useRef<IChartApi | null>(null);
const fibPrimitiveRef = useRef<FibRetracementPrimitive | null>(null);
const onReadyRef = useRef<Props['onReady']>(onReady);
const onChartClickRef = useRef<Props['onChartClick']>(onChartClick);
const onChartCrosshairMoveRef = useRef<Props['onChartCrosshairMove']>(onChartCrosshairMove);
const seriesRef = useRef<{
candles?: ISeriesApi<'Candlestick'>;
volume?: ISeriesApi<'Histogram'>;
oracle?: ISeriesApi<'Line'>;
sma20?: ISeriesApi<'Line'>;
ema20?: ISeriesApi<'Line'>;
bbUpper?: ISeriesApi<'Line'>;
bbLower?: ISeriesApi<'Line'>;
bbMid?: ISeriesApi<'Line'>;
}>({});
const candleData = useMemo(() => toCandleData(candles), [candles]);
const volumeData = useMemo(() => toVolumeData(candles), [candles]);
const oracleData = useMemo(() => toLineSeries(oracle), [oracle]);
const smaData = useMemo(() => toLineSeries(sma20), [sma20]);
const emaData = useMemo(() => toLineSeries(ema20), [ema20]);
const bbUpper = useMemo(() => toLineSeries(bb20?.upper), [bb20?.upper]);
const bbLower = useMemo(() => toLineSeries(bb20?.lower), [bb20?.lower]);
const bbMid = useMemo(() => toLineSeries(bb20?.mid), [bb20?.mid]);
useEffect(() => {
onReadyRef.current = onReady;
}, [onReady]);
useEffect(() => {
onChartClickRef.current = onChartClick;
}, [onChartClick]);
useEffect(() => {
onChartCrosshairMoveRef.current = onChartCrosshairMove;
}, [onChartCrosshairMove]);
useEffect(() => {
if (!containerRef.current) return;
if (chartRef.current) return;
const chart = createChart(containerRef.current, {
layout: {
background: { type: ColorType.Solid, color: 'rgba(0,0,0,0)' },
textColor: '#e6e9ef',
fontFamily:
'system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial, sans-serif',
},
grid: {
vertLines: { color: 'rgba(255,255,255,0.06)' },
horzLines: { color: 'rgba(255,255,255,0.06)' },
},
crosshair: {
mode: CrosshairMode.Normal,
vertLine: { color: 'rgba(255,255,255,0.18)', style: LineStyle.Dashed },
horzLine: { color: 'rgba(255,255,255,0.18)', style: LineStyle.Dashed },
},
rightPriceScale: { borderColor: 'rgba(255,255,255,0.08)' },
timeScale: { borderColor: 'rgba(255,255,255,0.08)', timeVisible: true, secondsVisible: false },
handleScale: { mouseWheel: true, pinch: true },
handleScroll: { mouseWheel: true, pressedMouseMove: true, horzTouchDrag: true, vertTouchDrag: true },
});
chartRef.current = chart;
const candleSeries = chart.addSeries(CandlestickSeries, {
upColor: '#22c55e',
downColor: '#ef4444',
borderVisible: false,
wickUpColor: '#22c55e',
wickDownColor: '#ef4444',
});
const fibPrimitive = new FibRetracementPrimitive();
candleSeries.attachPrimitive(fibPrimitive);
fibPrimitiveRef.current = fibPrimitive;
fibPrimitive.setFib(fib ?? null);
const volumeSeries = chart.addSeries(HistogramSeries, {
priceFormat: { type: 'volume' },
priceScaleId: '',
color: 'rgba(255,255,255,0.15)',
});
volumeSeries.priceScale().applyOptions({
scaleMargins: { top: 0.82, bottom: 0 },
});
const oracleSeries = chart.addSeries(LineSeries, {
color: 'rgba(251,146,60,0.9)',
lineWidth: 1,
lineStyle: LineStyle.Dotted,
});
const smaSeries = chart.addSeries(LineSeries, { color: 'rgba(248,113,113,0.9)', lineWidth: 1 });
const emaSeries = chart.addSeries(LineSeries, { color: 'rgba(52,211,153,0.9)', lineWidth: 1 });
const bbUpperSeries = chart.addSeries(LineSeries, { color: 'rgba(250,204,21,0.6)', lineWidth: 1 });
const bbLowerSeries = chart.addSeries(LineSeries, { color: 'rgba(163,163,163,0.6)', lineWidth: 1 });
const bbMidSeries = chart.addSeries(LineSeries, {
color: 'rgba(250,204,21,0.35)',
lineWidth: 1,
lineStyle: LineStyle.Dashed,
});
seriesRef.current = {
candles: candleSeries,
volume: volumeSeries,
oracle: oracleSeries,
sma20: smaSeries,
ema20: emaSeries,
bbUpper: bbUpperSeries,
bbLower: bbLowerSeries,
bbMid: bbMidSeries,
};
onReadyRef.current?.({ chart, candles: candleSeries as ISeriesApi<'Candlestick', UTCTimestamp> });
const onClick = (param: any) => {
if (!param?.point) return;
const logical = param.logical ?? chart.timeScale().coordinateToLogical(param.point.x);
if (logical == null) return;
const price = candleSeries.coordinateToPrice(param.point.y);
if (price == null) return;
onChartClickRef.current?.({ logical: Number(logical), price: Number(price) });
};
chart.subscribeClick(onClick);
const onCrosshairMove = (param: any) => {
if (!param?.point) return;
const logical = param.logical ?? chart.timeScale().coordinateToLogical(param.point.x);
if (logical == null) return;
const price = candleSeries.coordinateToPrice(param.point.y);
if (price == null) return;
onChartCrosshairMoveRef.current?.({ logical: Number(logical), price: Number(price) });
};
chart.subscribeCrosshairMove(onCrosshairMove);
const ro = new ResizeObserver(() => {
if (!containerRef.current) return;
const { width, height } = containerRef.current.getBoundingClientRect();
chart.applyOptions({ width: Math.floor(width), height: Math.floor(height) });
});
ro.observe(containerRef.current);
return () => {
chart.unsubscribeClick(onClick);
chart.unsubscribeCrosshairMove(onCrosshairMove);
ro.disconnect();
if (fibPrimitiveRef.current) {
candleSeries.detachPrimitive(fibPrimitiveRef.current);
fibPrimitiveRef.current = null;
}
chart.remove();
chartRef.current = null;
seriesRef.current = {};
};
}, []);
useEffect(() => {
const s = seriesRef.current;
if (!s.candles || !s.volume) return;
s.candles.setData(candleData);
s.volume.setData(volumeData);
s.oracle?.setData(oracleData);
s.sma20?.setData(smaData);
s.ema20?.setData(emaData);
s.bbUpper?.setData(bbUpper);
s.bbLower?.setData(bbLower);
s.bbMid?.setData(bbMid);
s.sma20?.applyOptions({ visible: showIndicators });
s.ema20?.applyOptions({ visible: showIndicators });
s.bbUpper?.applyOptions({ visible: showIndicators });
s.bbLower?.applyOptions({ visible: showIndicators });
s.bbMid?.applyOptions({ visible: showIndicators });
}, [candleData, volumeData, oracleData, smaData, emaData, bbUpper, bbLower, bbMid, showIndicators]);
useEffect(() => {
fibPrimitiveRef.current?.setFib(fib ?? null);
}, [fib]);
return <div className="tradingChart" ref={containerRef} />;
}

View File

@@ -0,0 +1,57 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { Candle, ChartIndicators } from '../../lib/api';
import { fetchChart } from '../../lib/api';
import { useInterval } from '../../app/hooks/useInterval';
type Params = {
symbol: string;
source?: string;
tf: string;
limit: number;
pollMs: number;
};
type Result = {
candles: Candle[];
indicators: ChartIndicators;
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
};
export function useChartData({ symbol, source, tf, limit, pollMs }: Params): Result {
const [candles, setCandles] = useState<Candle[]>([]);
const [indicators, setIndicators] = useState<ChartIndicators>({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const inFlight = useRef(false);
const fetchOnce = useCallback(async () => {
if (inFlight.current) return;
inFlight.current = true;
setLoading(true);
try {
const res = await fetchChart({ symbol, source, tf, limit });
setCandles(res.candles);
setIndicators(res.indicators);
setError(null);
} catch (e: any) {
setError(String(e?.message || e));
} finally {
setLoading(false);
inFlight.current = false;
}
}, [symbol, source, tf, limit]);
useEffect(() => {
void fetchOnce();
}, [fetchOnce]);
useInterval(() => void fetchOnce(), pollMs);
return useMemo(
() => ({ candles, indicators, loading, error, refresh: fetchOnce }),
[candles, indicators, loading, error, fetchOnce]
);
}