@tradejs/app 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/README.md +12 -0
  2. package/bin/tradejs-app.mjs +54 -0
  3. package/next-env.d.ts +6 -0
  4. package/next.config.mjs +31 -0
  5. package/package.json +60 -0
  6. package/src/app/actions/ai.ts +33 -0
  7. package/src/app/actions/backtest.ts +55 -0
  8. package/src/app/actions/kline.ts +18 -0
  9. package/src/app/actions/scanner.ts +10 -0
  10. package/src/app/actions/signal.ts +19 -0
  11. package/src/app/api/ai/route.ts +151 -0
  12. package/src/app/api/auth/[...nextauth]/route.ts +5 -0
  13. package/src/app/api/backtest/files/route.ts +60 -0
  14. package/src/app/api/backtest/order-log/[strategy]/[name]/route.ts +47 -0
  15. package/src/app/api/backtest/result/[strategy]/[name]/route.ts +63 -0
  16. package/src/app/api/backtest/test/[strategy]/[name]/route.ts +57 -0
  17. package/src/app/api/cron/route.ts +4 -0
  18. package/src/app/api/derivatives/[symbol]/[interval]/route.ts +57 -0
  19. package/src/app/api/derivatives/summary/route.ts +20 -0
  20. package/src/app/api/files/screenshot/[name]/route.ts +42 -0
  21. package/src/app/api/indicators/route.ts +24 -0
  22. package/src/app/api/kline/[provider]/[symbol]/[interval]/route.ts +123 -0
  23. package/src/app/api/scanner/[provider]/route.ts +41 -0
  24. package/src/app/api/scanner/route.ts +31 -0
  25. package/src/app/api/signal/[symbol]/[signalId]/route.ts +42 -0
  26. package/src/app/api/spread/[symbol]/[interval]/route.ts +57 -0
  27. package/src/app/api/spread/summary/route.ts +20 -0
  28. package/src/app/auth.ts +76 -0
  29. package/src/app/components/Backtest/CompareList/index.tsx +34 -0
  30. package/src/app/components/Backtest/TestCard/Chart/index.tsx +118 -0
  31. package/src/app/components/Backtest/TestCard/Chart/utils/index.ts +81 -0
  32. package/src/app/components/Backtest/TestCard/CompareButton/index.tsx +21 -0
  33. package/src/app/components/Backtest/TestCard/ConfigDrawer/JsonCodeBlock.tsx +46 -0
  34. package/src/app/components/Backtest/TestCard/ConfigDrawer/index.tsx +94 -0
  35. package/src/app/components/Backtest/TestCard/DeleteButton/index.tsx +128 -0
  36. package/src/app/components/Backtest/TestCard/FavoriteIndicator/index.tsx +18 -0
  37. package/src/app/components/Backtest/TestCard/OpenDashboardButton/index.tsx +40 -0
  38. package/src/app/components/Backtest/TestCard/OpenReportButton/index.tsx +24 -0
  39. package/src/app/components/Backtest/TestCard/Root/index.tsx +55 -0
  40. package/src/app/components/Backtest/TestCard/Skeleton/index.tsx +21 -0
  41. package/src/app/components/Backtest/TestCard/Stat/index.tsx +119 -0
  42. package/src/app/components/Backtest/TestCard/Title/index.tsx +84 -0
  43. package/src/app/components/Backtest/TestCard/context.ts +14 -0
  44. package/src/app/components/Backtest/TestCard/index.ts +28 -0
  45. package/src/app/components/Backtest/TestList/index.tsx +124 -0
  46. package/src/app/components/Dashboard/AiDrawer/Message.tsx +34 -0
  47. package/src/app/components/Dashboard/AiDrawer/index.tsx +163 -0
  48. package/src/app/components/Dashboard/KlineChart/figures/backtestFigureTypes.ts +7 -0
  49. package/src/app/components/Dashboard/KlineChart/figures/backtestMarkersPointFigure.ts +76 -0
  50. package/src/app/components/Dashboard/KlineChart/figures/circle.ts +15 -0
  51. package/src/app/components/Dashboard/KlineChart/figures/diamond.ts +25 -0
  52. package/src/app/components/Dashboard/KlineChart/figures/entryLinePointFigure.ts +1 -0
  53. package/src/app/components/Dashboard/KlineChart/figures/entryPointsPointFigure.ts +1 -0
  54. package/src/app/components/Dashboard/KlineChart/figures/entryZonePointFigure.ts +1 -0
  55. package/src/app/components/Dashboard/KlineChart/figures/index.ts +213 -0
  56. package/src/app/components/Dashboard/KlineChart/figures/label.ts +14 -0
  57. package/src/app/components/Dashboard/KlineChart/figures/rectangle.ts +20 -0
  58. package/src/app/components/Dashboard/KlineChart/figures/square.ts +21 -0
  59. package/src/app/components/Dashboard/KlineChart/figures/star.ts +39 -0
  60. package/src/app/components/Dashboard/KlineChart/figures/tradeZonePointFigure.ts +44 -0
  61. package/src/app/components/Dashboard/KlineChart/figures/trendLinePointFigure.ts +37 -0
  62. package/src/app/components/Dashboard/KlineChart/figures/trendLinePointsPointFigure.ts +26 -0
  63. package/src/app/components/Dashboard/KlineChart/figures/triangle.ts +23 -0
  64. package/src/app/components/Dashboard/KlineChart/hooks/index.ts +14 -0
  65. package/src/app/components/Dashboard/KlineChart/hooks/indicatorShared.ts +30 -0
  66. package/src/app/components/Dashboard/KlineChart/hooks/useAtrIndicator.ts +75 -0
  67. package/src/app/components/Dashboard/KlineChart/hooks/useBacktest.ts +533 -0
  68. package/src/app/components/Dashboard/KlineChart/hooks/useBbIndicator.ts +74 -0
  69. package/src/app/components/Dashboard/KlineChart/hooks/useBtcCorrelation.ts +155 -0
  70. package/src/app/components/Dashboard/KlineChart/hooks/useBtcIndicator.ts +185 -0
  71. package/src/app/components/Dashboard/KlineChart/hooks/useEmaIndicator.ts +62 -0
  72. package/src/app/components/Dashboard/KlineChart/hooks/useMaIndicator.ts +62 -0
  73. package/src/app/components/Dashboard/KlineChart/hooks/useManagedIndicator.ts +140 -0
  74. package/src/app/components/Dashboard/KlineChart/hooks/usePluginIndicators.ts +212 -0
  75. package/src/app/components/Dashboard/KlineChart/hooks/useResize.ts +29 -0
  76. package/src/app/components/Dashboard/KlineChart/hooks/useSetup.ts +122 -0
  77. package/src/app/components/Dashboard/KlineChart/hooks/useSignal.ts +85 -0
  78. package/src/app/components/Dashboard/KlineChart/hooks/useSpreadIndicator.ts +243 -0
  79. package/src/app/components/Dashboard/KlineChart/hooks/useSupportResistanceLines.ts +125 -0
  80. package/src/app/components/Dashboard/KlineChart/hooks/useTrendLine.ts +139 -0
  81. package/src/app/components/Dashboard/KlineChart/hooks/useVolIndicator.ts +18 -0
  82. package/src/app/components/Dashboard/KlineChart/hooks/useWmaIndicator.ts +62 -0
  83. package/src/app/components/Dashboard/KlineChart/index.tsx +169 -0
  84. package/src/app/components/Dashboard/KlineChart/styles.ts +70 -0
  85. package/src/app/components/Dashboard/MainChart/index.tsx +35 -0
  86. package/src/app/components/Shared/AppShell.tsx +28 -0
  87. package/src/app/components/Shared/FavoriteButton/index.tsx +23 -0
  88. package/src/app/components/Shared/Filters/Backtest/index.tsx +164 -0
  89. package/src/app/components/Shared/Filters/FavoriteIndicator/index.tsx +18 -0
  90. package/src/app/components/Shared/Filters/Indicators/index.tsx +21 -0
  91. package/src/app/components/Shared/Filters/Interval/index.tsx +31 -0
  92. package/src/app/components/Shared/Filters/Interval/intervals.ts +6 -0
  93. package/src/app/components/Shared/Filters/Provider/index.tsx +32 -0
  94. package/src/app/components/Shared/Filters/Root/index.tsx +28 -0
  95. package/src/app/components/Shared/Filters/Symbol/index.tsx +49 -0
  96. package/src/app/components/Shared/Filters/context.ts +17 -0
  97. package/src/app/components/Shared/Filters/index.ts +17 -0
  98. package/src/app/components/Shared/Sidebar/index.tsx +72 -0
  99. package/src/app/components/UI/ColorMode/index.tsx +112 -0
  100. package/src/app/components/UI/EmptyState/index.tsx +28 -0
  101. package/src/app/components/UI/OverlaySpinner/index.tsx +23 -0
  102. package/src/app/components/UI/Segment/index.tsx +23 -0
  103. package/src/app/components/UI/Select/index.tsx +81 -0
  104. package/src/app/components/UI/SelectWithSearch/index.tsx +104 -0
  105. package/src/app/components/UI/Switcher/index.tsx +24 -0
  106. package/src/app/components/UI/Toaster/index.tsx +45 -0
  107. package/src/app/components/UI/index.ts +8 -0
  108. package/src/app/favicon.ico +0 -0
  109. package/src/app/globals.css +5 -0
  110. package/src/app/layout.tsx +31 -0
  111. package/src/app/page.tsx +14 -0
  112. package/src/app/provider.tsx +39 -0
  113. package/src/app/routes/backtest/[test]/page.tsx +33 -0
  114. package/src/app/routes/backtest/page.tsx +374 -0
  115. package/src/app/routes/dashboard/[provider]/[symbol]/[interval]/page.tsx +124 -0
  116. package/src/app/routes/dashboard/page.tsx +20 -0
  117. package/src/app/routes/derivatives/page.tsx +202 -0
  118. package/src/app/routes/signin/page.tsx +155 -0
  119. package/src/app/store/data.ts +144 -0
  120. package/src/app/store/filters.ts +29 -0
  121. package/src/app/store/index.ts +13 -0
  122. package/src/app/store/indicators.ts +229 -0
  123. package/src/app/store/tests.ts +464 -0
  124. package/src/app/store/tickers.ts +89 -0
  125. package/src/proxy.ts +142 -0
  126. package/tsconfig.json +40 -0
@@ -0,0 +1,212 @@
1
+ import { useEffect, useMemo, useRef } from 'react';
2
+ import { Chart, registerIndicator } from 'klinecharts';
3
+ import { Indicator, KlineChartData } from '@tradejs/types';
4
+ import { IndicatorRendererConfig } from '@store';
5
+
6
+ type RenderersMap = Record<string, IndicatorRendererConfig>;
7
+ type RuntimeEntry = {
8
+ indicatorId: string;
9
+ enabled: boolean;
10
+ indicatorName: string;
11
+ indicatorRuntimeId: string;
12
+ paneId: string;
13
+ minHeight: number;
14
+ shortName: string;
15
+ figures: IndicatorRendererConfig['figures'];
16
+ calc: ReturnType<typeof createCalc>;
17
+ };
18
+
19
+ const getRuntimeIds = (
20
+ indicatorId: string,
21
+ renderer: IndicatorRendererConfig,
22
+ ) => {
23
+ const normalized = indicatorId.replace(/[^a-zA-Z0-9_]/g, '_');
24
+ return {
25
+ indicatorName:
26
+ renderer.indicatorName || `PLUGIN_${normalized.toUpperCase()}`,
27
+ indicatorRuntimeId: `plugin_indicator_${normalized}`,
28
+ paneId: renderer.paneId || `plugin_indicator_${normalized}_pane`,
29
+ };
30
+ };
31
+
32
+ const createFigureStyles = (
33
+ figure: IndicatorRendererConfig['figures'][number],
34
+ ) => {
35
+ if (!figure.color && !figure.lineWidth && !figure.dashed) {
36
+ return undefined;
37
+ }
38
+
39
+ return () =>
40
+ ({
41
+ color: figure.color,
42
+ size: figure.lineWidth,
43
+ style: figure.dashed ? 'dashed' : undefined,
44
+ dashedValue: figure.dashed ? [4, 4] : undefined,
45
+ }) as any;
46
+ };
47
+
48
+ const createCalc = (renderer: IndicatorRendererConfig) => {
49
+ const figures = renderer.figures || [];
50
+
51
+ return (
52
+ kLineDataList: Array<{ timestamp?: number } & Record<string, unknown>>,
53
+ ) => {
54
+ return kLineDataList.reduce<
55
+ Record<number, Record<string, number | undefined>>
56
+ >((acc, candle) => {
57
+ const timestampRaw = candle.timestamp;
58
+ const timestamp = Number(timestampRaw);
59
+ if (!Number.isFinite(timestamp)) {
60
+ return acc;
61
+ }
62
+
63
+ const point: Record<string, number | undefined> = {};
64
+
65
+ for (const figure of figures) {
66
+ if (
67
+ typeof figure.constant === 'number' &&
68
+ Number.isFinite(figure.constant)
69
+ ) {
70
+ point[figure.key] = figure.constant;
71
+ continue;
72
+ }
73
+
74
+ const raw = candle[figure.key];
75
+ const value = Number(raw);
76
+ point[figure.key] = Number.isFinite(value) ? value : undefined;
77
+ }
78
+
79
+ acc[timestamp] = point;
80
+ return acc;
81
+ }, {});
82
+ };
83
+ };
84
+
85
+ const removeIndicator = (
86
+ chart: Chart,
87
+ indicatorName: string,
88
+ indicatorRuntimeId: string,
89
+ ) => {
90
+ chart.removeIndicator({ id: indicatorRuntimeId });
91
+ chart.removeIndicator({ name: indicatorName });
92
+ };
93
+
94
+ const ensureIndicator = (
95
+ chart: Chart,
96
+ indicatorName: string,
97
+ indicatorRuntimeId: string,
98
+ paneId: string,
99
+ minHeight = 100,
100
+ ) => {
101
+ if (chart.getIndicators({ id: indicatorRuntimeId }).length > 0) {
102
+ return;
103
+ }
104
+
105
+ chart.createIndicator({ name: indicatorName, id: indicatorRuntimeId }, true, {
106
+ id: paneId,
107
+ minHeight,
108
+ });
109
+ };
110
+
111
+ export const usePluginIndicators = (
112
+ chart: Chart | null,
113
+ indicators: Record<string, Indicator>,
114
+ indicatorRenderers: RenderersMap,
115
+ data: KlineChartData,
116
+ ) => {
117
+ const registeredIndicatorsRef = useRef(new Set<string>());
118
+
119
+ const runtimeEntries = useMemo<RuntimeEntry[]>(
120
+ () =>
121
+ Object.entries(indicatorRenderers || {})
122
+ .filter(([, renderer]) => renderer?.figures?.length)
123
+ .map(([indicatorId, renderer]) => {
124
+ const ids = getRuntimeIds(indicatorId, renderer);
125
+ return {
126
+ indicatorId,
127
+ enabled: Boolean(indicators[indicatorId]?.enabled),
128
+ indicatorName: ids.indicatorName,
129
+ indicatorRuntimeId: ids.indicatorRuntimeId,
130
+ paneId: ids.paneId,
131
+ minHeight: renderer.minHeight ?? 100,
132
+ shortName:
133
+ renderer.shortName ||
134
+ indicators[indicatorId]?.label ||
135
+ indicatorId,
136
+ figures: renderer.figures,
137
+ calc: createCalc(renderer),
138
+ };
139
+ }),
140
+ [indicatorRenderers, indicators],
141
+ );
142
+
143
+ useEffect(() => {
144
+ for (const entry of runtimeEntries) {
145
+ if (registeredIndicatorsRef.current.has(entry.indicatorName)) {
146
+ continue;
147
+ }
148
+
149
+ registerIndicator({
150
+ name: entry.indicatorName,
151
+ shortName: entry.shortName,
152
+ calcParams: [],
153
+ figures: entry.figures.map((figure) => ({
154
+ key: figure.key,
155
+ title: figure.title || `${figure.key}: `,
156
+ type: figure.type || 'line',
157
+ styles: createFigureStyles(figure),
158
+ })) as any,
159
+ calc: entry.calc as any,
160
+ });
161
+
162
+ registeredIndicatorsRef.current.add(entry.indicatorName);
163
+ }
164
+ }, [runtimeEntries]);
165
+
166
+ useEffect(() => {
167
+ if (!chart) {
168
+ return;
169
+ }
170
+
171
+ for (const entry of runtimeEntries) {
172
+ if (!entry.enabled) {
173
+ removeIndicator(chart, entry.indicatorName, entry.indicatorRuntimeId);
174
+ continue;
175
+ }
176
+
177
+ ensureIndicator(
178
+ chart,
179
+ entry.indicatorName,
180
+ entry.indicatorRuntimeId,
181
+ entry.paneId,
182
+ entry.minHeight,
183
+ );
184
+
185
+ const updated = chart.overrideIndicator({
186
+ name: entry.indicatorName,
187
+ calc: entry.calc as any,
188
+ });
189
+
190
+ if (!updated) {
191
+ ensureIndicator(
192
+ chart,
193
+ entry.indicatorName,
194
+ entry.indicatorRuntimeId,
195
+ entry.paneId,
196
+ entry.minHeight,
197
+ );
198
+ }
199
+ }
200
+ }, [chart, runtimeEntries, data]);
201
+
202
+ useEffect(() => {
203
+ return () => {
204
+ if (!chart) {
205
+ return;
206
+ }
207
+ for (const entry of runtimeEntries) {
208
+ removeIndicator(chart, entry.indicatorName, entry.indicatorRuntimeId);
209
+ }
210
+ };
211
+ }, [chart, runtimeEntries]);
212
+ };
@@ -0,0 +1,29 @@
1
+ import { useEffect } from 'react';
2
+ import { Chart } from 'klinecharts';
3
+
4
+ export const useResize = (chart: Chart | null, id: string) => {
5
+ useEffect(() => {
6
+ const chartElement = document.getElementById(id);
7
+
8
+ const resize = () => {
9
+ if (!chartElement) return;
10
+
11
+ const parent = chartElement.parentElement;
12
+
13
+ if (!parent) return;
14
+
15
+ const parentStyles = window.getComputedStyle(parent);
16
+ chartElement.style.width = parentStyles.width;
17
+ chartElement.style.height = parentStyles.height;
18
+ chart?.resize();
19
+ };
20
+
21
+ const resizeObserver = new ResizeObserver(resize);
22
+
23
+ resizeObserver.observe(document.body);
24
+
25
+ return () => {
26
+ resizeObserver.disconnect();
27
+ };
28
+ }, [chart, id]);
29
+ };
@@ -0,0 +1,122 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useMemo, useState } from 'react';
4
+ import { useSearchParams } from 'next/navigation';
5
+ import { Chart, registerOverlay } from 'klinecharts';
6
+ import { getSignal } from '@actions/signal';
7
+ import { toMs } from '@tradejs/core/time';
8
+ import { Signal } from '@tradejs/types';
9
+ import { createTradeZonePointFigure } from '../figures/tradeZonePointFigure';
10
+
11
+ const SETUP = 'Setup';
12
+ const SETUP_START = 'Setup-start';
13
+
14
+ const FALLBACK_WIDTH_MS = 24 * 60 * 60_000;
15
+
16
+ type Point = { timestamp: number; value: number };
17
+
18
+ export const useSetup = (chart: Chart | null, enabled: boolean) => {
19
+ const [signal, setSignal] = useState<Signal | null>(null);
20
+ const searchParams = useSearchParams();
21
+ const signalId = searchParams.get('signalId');
22
+
23
+ const symbol = chart?.getSymbol()?.ticker || '';
24
+
25
+ useEffect(() => {
26
+ if (!signalId || !symbol) {
27
+ setSignal(null);
28
+ return;
29
+ }
30
+ getSignal(symbol, signalId).then(setSignal);
31
+ }, [symbol, signalId]);
32
+
33
+ useEffect(() => {
34
+ registerOverlay({
35
+ name: SETUP,
36
+ totalStep: 2,
37
+ needDefaultPointFigure: false,
38
+ needDefaultXAxisFigure: false,
39
+ needDefaultYAxisFigure: false,
40
+ createPointFigures: createTradeZonePointFigure,
41
+ });
42
+
43
+ registerOverlay({
44
+ name: SETUP_START,
45
+ totalStep: 1,
46
+ needDefaultPointFigure: true,
47
+ needDefaultXAxisFigure: false,
48
+ needDefaultYAxisFigure: false,
49
+ createPointFigures: ({ coordinates }) => {
50
+ const { x, y } = coordinates[0];
51
+
52
+ return [
53
+ {
54
+ type: 'circle',
55
+ attrs: { x, y, r: 5 },
56
+ styles: {
57
+ style: 'fill',
58
+ color: '#9333ea',
59
+ },
60
+ ignoreEvent: true,
61
+ },
62
+ ];
63
+ },
64
+ });
65
+ }, []);
66
+
67
+ const setupPoints = useMemo(() => {
68
+ if (!signal) return null;
69
+
70
+ const { timestamp, prices } = signal;
71
+
72
+ const startTsMs = toMs(timestamp);
73
+ const endTsMs = startTsMs + FALLBACK_WIDTH_MS;
74
+
75
+ const start: Point = { timestamp: startTsMs, value: prices.currentPrice };
76
+ const tpEnd: Point = { timestamp: endTsMs, value: prices.takeProfitPrice };
77
+ const slEnd: Point = { timestamp: endTsMs, value: prices.stopLossPrice };
78
+
79
+ return { start, tpEnd, slEnd };
80
+ }, [signal]);
81
+
82
+ useEffect(() => {
83
+ if (!chart || !enabled || !signalId || !signal || !setupPoints) return;
84
+
85
+ const currentSymbol = chart.getSymbol()?.ticker;
86
+ if (signal.symbol !== currentSymbol) return;
87
+
88
+ const tpId = `${signal.signalId}-tp`;
89
+ const slId = `${signal.signalId}-sl`;
90
+
91
+ chart.createOverlay({
92
+ name: SETUP,
93
+ id: tpId,
94
+ points: [setupPoints.start, setupPoints.tpEnd],
95
+ extendData: {
96
+ mode: 'TP',
97
+ },
98
+ });
99
+
100
+ chart.createOverlay({
101
+ name: SETUP,
102
+ id: slId,
103
+ points: [setupPoints.start, setupPoints.slEnd],
104
+ extendData: {
105
+ mode: 'SL',
106
+ },
107
+ });
108
+
109
+ chart.createOverlay({
110
+ name: SETUP_START,
111
+ points: [setupPoints.start],
112
+ });
113
+
114
+ return () => {
115
+ chart.removeOverlay({ id: tpId, name: SETUP });
116
+ chart.removeOverlay({ id: slId, name: SETUP });
117
+ chart.removeOverlay({ name: SETUP_START });
118
+ };
119
+ }, [chart, enabled, signalId, signal, setupPoints]);
120
+
121
+ return { signal };
122
+ };
@@ -0,0 +1,85 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import { useSearchParams } from 'next/navigation';
5
+ import _ from 'lodash';
6
+ import { Chart } from 'klinecharts';
7
+ import { getSignal } from '@actions/signal';
8
+ import { Signal } from '@tradejs/types';
9
+ import {
10
+ drawSignalFigures,
11
+ ensureBaseFigureOverlaysRegistered,
12
+ normalizeSignalFigures,
13
+ removeSignalFigures,
14
+ } from '@tradejs/core/figures';
15
+ import { toMs } from '@tradejs/core/time';
16
+
17
+ const fitKeepRightZoom = (chart: Chart, lastDataTsMs: number) => {
18
+ if (!Number.isFinite(lastDataTsMs)) return;
19
+
20
+ const size = chart.getSize?.();
21
+ const width = size?.width ?? 0;
22
+
23
+ if (!width) return;
24
+
25
+ const MAX_STEPS = 15;
26
+ const SCALE = 0.85;
27
+ const RIGHT_MARGIN_RATIO = 0.1;
28
+
29
+ chart.scrollToTimestamp(lastDataTsMs);
30
+
31
+ for (let i = 0; i < MAX_STEPS; i++) {
32
+ chart.zoomAtTimestamp(SCALE, lastDataTsMs);
33
+ }
34
+
35
+ const rightOffsetPx = width * RIGHT_MARGIN_RATIO;
36
+
37
+ chart.setOffsetRightDistance?.(rightOffsetPx);
38
+ };
39
+
40
+ export const useSignal = (chart: Chart | null, enabled: boolean) => {
41
+ const [signal, setSignal] = useState<Signal | null>(null);
42
+ const searchParams = useSearchParams();
43
+ const signalId = searchParams.get('signalId');
44
+ const autoZoom = Boolean(searchParams.get('autoZoom')) ?? false;
45
+
46
+ const data = chart?.getDataList();
47
+ const symbol = chart?.getSymbol()?.ticker || '';
48
+
49
+ useEffect(() => {
50
+ if (!signalId || !symbol) {
51
+ setSignal(null);
52
+ return;
53
+ }
54
+ getSignal(symbol, signalId).then(setSignal);
55
+ }, [signalId, symbol]);
56
+
57
+ useEffect(() => {
58
+ if (!chart || !enabled || !data || _.isEmpty(data) || !signal) return;
59
+
60
+ const currentSymbol = chart.getSymbol()?.ticker;
61
+ if (signal.symbol !== currentSymbol) return;
62
+
63
+ const normalized = normalizeSignalFigures(signal);
64
+ if (!normalized) return;
65
+
66
+ ensureBaseFigureOverlaysRegistered();
67
+
68
+ const overlays = drawSignalFigures({
69
+ chart,
70
+ idPrefix: `signal-${signal.signalId}`,
71
+ figures: normalized,
72
+ });
73
+
74
+ if (autoZoom) {
75
+ const lastDataTsMs = toMs(data[data.length - 1].timestamp);
76
+ if (Number.isFinite(lastDataTsMs)) {
77
+ fitKeepRightZoom(chart, lastDataTsMs);
78
+ }
79
+ }
80
+
81
+ return () => {
82
+ removeSignalFigures(chart, overlays);
83
+ };
84
+ }, [autoZoom, chart, data, enabled, signal]);
85
+ };
@@ -0,0 +1,243 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import _ from 'lodash';
3
+ import { Chart } from 'klinecharts';
4
+ import { Filters, Interval, Provider } from '@tradejs/types';
5
+ import { smoothSpreadSeries } from '@tradejs/indicators';
6
+ import { API } from '@tradejs/core/api';
7
+ import { useData } from '@store';
8
+ import { grayDashedLineStyle } from './indicatorShared';
9
+ import { useManagedIndicator } from './useManagedIndicator';
10
+
11
+ type SpreadPoint = {
12
+ ts: string | number | Date;
13
+ spread: number | null;
14
+ binance_price?: number | null;
15
+ coinbase_price?: number | null;
16
+ binancePrice?: number | null;
17
+ coinbasePrice?: number | null;
18
+ };
19
+
20
+ const toSpreadInterval = (interval: string) => {
21
+ if (interval === '15') return '15m';
22
+ if (interval === '60' || interval === '240' || interval === '720')
23
+ return '1h';
24
+ return null;
25
+ };
26
+
27
+ const toDataInterval = (interval: string): Interval | null => {
28
+ if (interval === '15') return '15' as Interval;
29
+ if (interval === '60' || interval === '240' || interval === '720') {
30
+ return '60' as Interval;
31
+ }
32
+ return null;
33
+ };
34
+
35
+ const toTimestampMs = (value: SpreadPoint['ts']) => {
36
+ if (typeof value === 'number') {
37
+ return value < 1e12 ? value * 1000 : value;
38
+ }
39
+
40
+ if (value instanceof Date) {
41
+ return value.getTime();
42
+ }
43
+
44
+ const parsed = new Date(value).getTime();
45
+ return Number.isFinite(parsed) ? parsed : Number.NaN;
46
+ };
47
+
48
+ const getSpreadAtOrBefore = (
49
+ points: Array<{ timestamp: number; spread: number | null }>,
50
+ timestamp: number,
51
+ ) => {
52
+ let left = 0;
53
+ let right = points.length - 1;
54
+ let result: number | null | undefined;
55
+
56
+ while (left <= right) {
57
+ const mid = Math.floor((left + right) / 2);
58
+ const point = points[mid];
59
+
60
+ if (point.timestamp <= timestamp) {
61
+ result = point.spread;
62
+ left = mid + 1;
63
+ } else {
64
+ right = mid - 1;
65
+ }
66
+ }
67
+
68
+ return result;
69
+ };
70
+
71
+ const buildSpreadValues = (
72
+ kLineDataList: Array<{ timestamp: number }>,
73
+ spreadByTs: Record<number, { spread: number | null }>,
74
+ sortedSpread: Array<{ timestamp: number; spread: number | null }>,
75
+ ) => {
76
+ return kLineDataList.reduce<
77
+ Record<number, Record<string, number | undefined>>
78
+ >((acc, { timestamp }) => {
79
+ const value =
80
+ spreadByTs[timestamp]?.spread ??
81
+ getSpreadAtOrBefore(sortedSpread, timestamp);
82
+
83
+ acc[timestamp] = {
84
+ SPREAD: Number.isFinite(Number(value)) ? Number(value) : undefined,
85
+ SPREAD_ZERO: 0,
86
+ };
87
+
88
+ return acc;
89
+ }, {});
90
+ };
91
+
92
+ const getBinancePrice = (row: SpreadPoint) =>
93
+ row.binancePrice ?? row.binance_price ?? null;
94
+
95
+ const getCoinbasePrice = (row: SpreadPoint) =>
96
+ row.coinbasePrice ?? row.coinbase_price ?? null;
97
+
98
+ export const useSpreadIndicator = (
99
+ chart: Chart | null,
100
+ enabled: boolean,
101
+ filters: Filters,
102
+ ) => {
103
+ const indicatorId = 'spread_indicator';
104
+ const paneId = 'spread_indicator_pane';
105
+ const [rows, setRows] = useState<SpreadPoint[]>([]);
106
+ const spreadByTsRef = useRef<Record<number, { spread: number | null }>>({});
107
+ const sortedSpreadRef = useRef<
108
+ Array<{ timestamp: number; spread: number | null }>
109
+ >([]);
110
+
111
+ const spreadInterval = useMemo(
112
+ () => toSpreadInterval(String(filters.interval)),
113
+ [filters.interval],
114
+ );
115
+ const dataInterval = useMemo(
116
+ () => toDataInterval(String(filters.interval)),
117
+ [filters.interval],
118
+ );
119
+
120
+ const fallbackFilters = useMemo(
121
+ () => ({
122
+ ...filters,
123
+ interval: dataInterval ?? filters.interval,
124
+ symbol: enabled ? filters.symbol.toUpperCase() : '',
125
+ }),
126
+ [filters, dataInterval, enabled],
127
+ );
128
+
129
+ const { data: binanceData } = useData({
130
+ ...fallbackFilters,
131
+ provider: 'binance' as Provider,
132
+ });
133
+ const { data: coinbaseData } = useData({
134
+ ...fallbackFilters,
135
+ provider: 'coinbase' as Provider,
136
+ });
137
+
138
+ const fallbackRows = useMemo(() => {
139
+ if (!dataInterval) return [] as SpreadPoint[];
140
+
141
+ const coinbaseByTs = _.keyBy(coinbaseData, 'timestamp');
142
+ const result: SpreadPoint[] = [];
143
+
144
+ for (const binanceCandle of binanceData) {
145
+ const coinbaseCandle = coinbaseByTs[binanceCandle.timestamp];
146
+ if (!coinbaseCandle || binanceCandle.close <= 0) continue;
147
+
148
+ result.push({
149
+ ts: binanceCandle.timestamp,
150
+ spread:
151
+ (coinbaseCandle.close - binanceCandle.close) / binanceCandle.close,
152
+ binancePrice: binanceCandle.close,
153
+ coinbasePrice: coinbaseCandle.close,
154
+ });
155
+ }
156
+
157
+ return result;
158
+ }, [dataInterval, binanceData, coinbaseData]);
159
+
160
+ useEffect(() => {
161
+ if (!enabled) {
162
+ setRows([]);
163
+ return;
164
+ }
165
+ if (!spreadInterval || !filters.symbol) {
166
+ setRows([]);
167
+ return;
168
+ }
169
+
170
+ API.get<{ rows?: SpreadPoint[] }>(
171
+ `/api/spread/${filters.symbol}/${spreadInterval}?from=${filters.start}&to=${filters.end}`,
172
+ )
173
+ .then((res) => {
174
+ setRows(res.rows ?? []);
175
+ })
176
+ .catch(() => setRows([]));
177
+ }, [enabled, filters.symbol, filters.start, filters.end, spreadInterval]);
178
+
179
+ useEffect(() => {
180
+ const sourceRows = rows.length ? rows : fallbackRows;
181
+ const normalizedRows = sourceRows
182
+ .map((row) => ({
183
+ timestamp: toTimestampMs(row.ts),
184
+ spread: row.spread,
185
+ binancePrice: getBinancePrice(row),
186
+ coinbasePrice: getCoinbasePrice(row),
187
+ }))
188
+ .filter((row) => Number.isFinite(row.timestamp))
189
+ .sort((a, b) => a.timestamp - b.timestamp);
190
+
191
+ const smoothedRows = smoothSpreadSeries(normalizedRows);
192
+
193
+ sortedSpreadRef.current = smoothedRows;
194
+ spreadByTsRef.current = smoothedRows.reduce<
195
+ Record<number, { spread: number | null }>
196
+ >((acc, row) => {
197
+ acc[row.timestamp] = { spread: row.spread };
198
+ return acc;
199
+ }, {});
200
+ }, [rows, fallbackRows]);
201
+
202
+ const calc = useCallback(
203
+ (kLineDataList: Array<{ timestamp: number }>) =>
204
+ buildSpreadValues(
205
+ kLineDataList,
206
+ spreadByTsRef.current,
207
+ sortedSpreadRef.current,
208
+ ),
209
+ [],
210
+ );
211
+
212
+ const template = useMemo(
213
+ () => ({
214
+ shortName: 'SPREAD',
215
+ calcParams: [],
216
+ figures: [
217
+ {
218
+ key: 'SPREAD',
219
+ title: 'Spread: ',
220
+ type: 'line',
221
+ },
222
+ {
223
+ key: 'SPREAD_ZERO',
224
+ title: 'Zero: ',
225
+ type: 'line',
226
+ styles: grayDashedLineStyle,
227
+ },
228
+ ],
229
+ }),
230
+ [],
231
+ );
232
+
233
+ useManagedIndicator({
234
+ chart,
235
+ enabled,
236
+ indicatorName: 'SPREAD',
237
+ indicatorId,
238
+ paneId,
239
+ template,
240
+ calc,
241
+ updateDeps: [rows, fallbackRows],
242
+ });
243
+ };