@tradejs/app 1.0.9 → 1.0.11
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.
- package/README.md +16 -3
- package/bin/tradejs-app.mjs +146 -2
- package/next.config.mjs +0 -14
- package/package.json +35 -19
- package/src/app/actions/backtest.ts +54 -0
- package/src/app/actions/kline.ts +10 -5
- package/src/app/actions/scanner.ts +8 -3
- package/src/app/actions/strategies.ts +39 -0
- package/src/app/api/ai/route.ts +5 -4
- package/src/app/api/auth/[...nextauth]/route.ts +1 -1
- package/src/app/api/backtest/configs/route.ts +25 -0
- package/src/app/api/backtest/files/route.ts +5 -2
- package/src/app/api/backtest/order-log/[strategy]/[name]/route.ts +26 -2
- package/src/app/api/backtest/result/[strategy]/[name]/route.ts +27 -2
- package/src/app/api/backtest/runs/[jobId]/route.ts +124 -0
- package/src/app/api/backtest/runs/route.ts +45 -0
- package/src/app/api/backtest/test/[strategy]/[name]/route.ts +19 -2
- package/src/app/api/derivatives/summary/route.ts +8 -1
- package/src/app/api/install/route.ts +51 -0
- package/src/app/api/kline/[provider]/[universe]/[symbol]/[interval]/route.ts +1 -0
- package/src/app/api/kline/{[provider]/[symbol]/[interval]/route.ts → handler.ts} +61 -39
- package/src/app/api/scanner/[provider]/[universe]/route.ts +1 -0
- package/src/app/api/scanner/[provider]/route.ts +30 -12
- package/src/app/api/scanner/route.ts +3 -3
- package/src/app/api/signal/[symbol]/[signalId]/route.ts +1 -1
- package/src/app/api/strategies/ai/[cardId]/route.ts +22 -0
- package/src/app/api/strategies/ai/route.ts +139 -0
- package/src/app/api/strategies/replay/[cardId]/route.ts +18 -0
- package/src/app/api/strategies/replay/route.ts +44 -0
- package/src/app/api/strategies/runtime/route.ts +831 -0
- package/src/app/api/user/runtime-deployments/[deploymentId]/route.ts +25 -0
- package/src/app/api/user/runtime-deployments/route.ts +94 -0
- package/src/app/api/user/runtime-strategy-configs/route.ts +241 -0
- package/src/app/api/user/settings/route.ts +18 -1
- package/src/app/api/user/trading-accounts/[accountId]/route.ts +35 -0
- package/src/app/api/user/trading-accounts/route.ts +134 -0
- package/src/app/auth.ts +1 -19
- package/src/app/components/Backtest/CompareList/index.tsx +1 -1
- package/src/app/components/Backtest/ResultsPageClient.tsx +376 -0
- package/src/app/components/Backtest/TestCard/ActionsMenu/index.tsx +123 -0
- package/src/app/components/Backtest/TestCard/Chart/index.tsx +1 -1
- package/src/app/components/Backtest/TestCard/CompareButton/index.tsx +1 -1
- package/src/app/components/Backtest/TestCard/ConfigDrawer/index.tsx +28 -12
- package/src/app/components/Backtest/TestCard/DeleteButton/index.tsx +33 -17
- package/src/app/components/Backtest/TestCard/FavoriteIndicator/index.tsx +2 -2
- package/src/app/components/Backtest/TestCard/OpenDashboardButton/index.tsx +10 -1
- package/src/app/components/Backtest/TestCard/OrdersDrawer/index.tsx +128 -0
- package/src/app/components/Backtest/TestCard/Root/index.tsx +18 -4
- package/src/app/components/Backtest/TestCard/Stat/index.tsx +2 -0
- package/src/app/components/Backtest/TestCard/StatDrawer/index.tsx +66 -0
- package/src/app/components/Backtest/TestCard/index.ts +6 -2
- package/src/app/components/Backtest/TestList/index.tsx +4 -9
- package/src/app/components/Dashboard/AiDrawer/index.tsx +1 -1
- package/src/app/components/Dashboard/KlineChart/hooks/useBacktest.ts +1 -1
- package/src/app/components/Dashboard/KlineChart/hooks/useBbIndicator.ts +10 -5
- package/src/app/components/Dashboard/KlineChart/hooks/useBtcCorrelation.ts +1 -1
- package/src/app/components/Dashboard/KlineChart/hooks/useBtcIndicator.ts +4 -1
- package/src/app/components/Dashboard/KlineChart/hooks/useEmaIndicator.ts +3 -1
- package/src/app/components/Dashboard/KlineChart/hooks/usePluginIndicators.ts +1 -1
- package/src/app/components/Dashboard/KlineChart/hooks/useSetup.ts +1 -1
- package/src/app/components/Dashboard/KlineChart/hooks/useSignal.ts +1 -1
- package/src/app/components/Dashboard/KlineChart/hooks/useSpreadIndicator.ts +1 -1
- package/src/app/components/Dashboard/KlineChart/hooks/useTrendLine.ts +1 -1
- package/src/app/components/Dashboard/KlineChart/hooks/useWmaIndicator.ts +3 -1
- package/src/app/components/Dashboard/KlineChart/index.tsx +21 -7
- package/src/app/components/Dashboard/MainChart/index.tsx +3 -22
- package/src/app/components/Shared/AppShell.tsx +2 -2
- package/src/app/components/Shared/BulkSelection/index.tsx +140 -0
- package/src/app/components/Shared/BulkSelection/useBulkSelection.ts +97 -0
- package/src/app/components/Shared/BulkSelection/utils.ts +126 -0
- package/src/app/components/Shared/Filters/Backtest/index.tsx +51 -51
- package/src/app/components/Shared/Filters/FavoriteIndicator/index.tsx +5 -3
- package/src/app/components/Shared/Filters/Indicators/index.tsx +2 -2
- package/src/app/components/Shared/Filters/Interval/index.tsx +1 -1
- package/src/app/components/Shared/Filters/Provider/index.tsx +8 -2
- package/src/app/components/Shared/Filters/Symbol/index.tsx +4 -3
- package/src/app/components/Shared/Filters/Universe/index.tsx +36 -0
- package/src/app/components/Shared/Filters/index.ts +2 -0
- package/src/app/components/Shared/OrdersDrawer.tsx +629 -0
- package/src/app/components/Shared/Sidebar/AccountSettingsDrawer.tsx +55 -28
- package/src/app/components/Shared/Sidebar/TradingAccountsPanel.tsx +445 -0
- package/src/app/components/Shared/Sidebar/index.tsx +17 -3
- package/src/app/components/Strategies/AdvancedMetricsPanel.tsx +727 -0
- package/src/app/components/Strategies/RuntimeStrategyCard.tsx +2204 -0
- package/src/app/components/Strategies/RuntimeStrategyCardSkeleton.tsx +40 -0
- package/src/app/components/Strategies/RuntimeStrategyChart.tsx +121 -0
- package/src/app/components/Strategies/RuntimeStrategyConfigDrawer.tsx +458 -0
- package/src/app/components/Strategies/StrategySnapshotCard.tsx +2890 -0
- package/src/app/components/Strategies/StrategySnapshotChart.tsx +94 -0
- package/src/app/components/Strategies/StrategySnapshotList.tsx +82 -0
- package/src/app/components/UI/Segment/index.tsx +8 -1
- package/src/app/components/UI/Select/index.tsx +24 -13
- package/src/app/components/UI/SelectWithSearch/index.tsx +46 -5
- package/src/app/layout.tsx +1 -1
- package/src/app/lib/backtestJobs.ts +925 -0
- package/src/app/lib/cardPageLayout.ts +1 -0
- package/src/app/lib/connectorCreator.ts +28 -0
- package/src/app/lib/currentUser.ts +1 -1
- package/src/app/lib/errorMessage.ts +34 -0
- package/src/app/lib/installation.ts +83 -0
- package/src/app/lib/marketDefaults.ts +12 -0
- package/src/app/lib/marketRoutes.ts +58 -0
- package/src/app/lib/runtimeStrategies.ts +1376 -0
- package/src/app/lib/runtimeStrategyConfigForm.ts +86 -0
- package/src/app/provider.tsx +1 -1
- package/src/app/routes/backtest/page.tsx +1100 -302
- package/src/app/routes/dashboard/{[provider]/[symbol]/[interval]/page.tsx → Dashboard.tsx} +76 -35
- package/src/app/routes/dashboard/[provider]/[universe]/[symbol]/[interval]/page.tsx +1 -0
- package/src/app/routes/dashboard/page.tsx +15 -3
- package/src/app/routes/derivatives/page.tsx +1222 -165
- package/src/app/routes/install/page.tsx +175 -0
- package/src/app/routes/signin/page.tsx +7 -0
- package/src/app/routes/strategies/StrategiesPageClient.tsx +593 -0
- package/src/app/routes/strategies/[mode]/page.tsx +20 -0
- package/src/app/routes/strategies/page.tsx +7 -0
- package/src/app/store/ai.ts +1 -1
- package/src/app/store/data.ts +73 -17
- package/src/app/store/filters.ts +1 -0
- package/src/app/store/marketKlineStream.ts +87 -0
- package/src/app/store/tests.ts +83 -45
- package/src/app/store/tickers.ts +59 -22
- package/src/proxy.ts +4 -0
- package/tsconfig.json +17 -12
- package/src/app/components/Backtest/TestCard/OpenReportButton/index.tsx +0 -24
- package/src/app/routes/backtest/[test]/page.tsx +0 -33
package/src/app/store/data.ts
CHANGED
|
@@ -2,10 +2,20 @@ import { useEffect, useMemo, useState } from 'react';
|
|
|
2
2
|
import { create } from 'zustand';
|
|
3
3
|
import { get, set } from 'idb-keyval';
|
|
4
4
|
import { useSearchParams } from 'next/navigation';
|
|
5
|
-
import {
|
|
6
|
-
|
|
5
|
+
import {
|
|
6
|
+
KlineChartData,
|
|
7
|
+
Interval,
|
|
8
|
+
Filters,
|
|
9
|
+
MarketUniverse,
|
|
10
|
+
Provider,
|
|
11
|
+
} from '@tradejs/types';
|
|
12
|
+
import { kline } from '#actions/kline';
|
|
7
13
|
import { isWrongData, mergeData } from '@tradejs/core/data';
|
|
8
|
-
import { normalizeEndToIntervalBoundary } from '
|
|
14
|
+
import { normalizeEndToIntervalBoundary } from '#app/lib/klineWindow';
|
|
15
|
+
import {
|
|
16
|
+
buildDashboardKlineTopic,
|
|
17
|
+
subscribeMarketKline,
|
|
18
|
+
} from './marketKlineStream';
|
|
9
19
|
|
|
10
20
|
interface DataState {
|
|
11
21
|
data: Map<string, KlineChartData | null>;
|
|
@@ -14,6 +24,7 @@ interface DataState {
|
|
|
14
24
|
symbol: string,
|
|
15
25
|
interval: Interval,
|
|
16
26
|
data: KlineChartData,
|
|
27
|
+
universe?: MarketUniverse,
|
|
17
28
|
) => void;
|
|
18
29
|
}
|
|
19
30
|
|
|
@@ -22,6 +33,7 @@ interface DataRequest {
|
|
|
22
33
|
provider: Provider;
|
|
23
34
|
symbol: string;
|
|
24
35
|
interval: Interval;
|
|
36
|
+
universe: MarketUniverse;
|
|
25
37
|
start: number;
|
|
26
38
|
end: number;
|
|
27
39
|
cacheBucketEnd: number;
|
|
@@ -30,17 +42,23 @@ interface DataRequest {
|
|
|
30
42
|
|
|
31
43
|
const MIN_CACHED_CANDLES = 2;
|
|
32
44
|
|
|
33
|
-
const getKey = (
|
|
34
|
-
|
|
45
|
+
const getKey = (
|
|
46
|
+
provider: Provider,
|
|
47
|
+
universe: MarketUniverse,
|
|
48
|
+
symbol: string,
|
|
49
|
+
interval: Interval,
|
|
50
|
+
) => `${provider}_${universe}_${symbol}_${interval}`;
|
|
35
51
|
|
|
36
52
|
const getProvider = (provider?: Provider): Provider => provider || 'bybit';
|
|
37
53
|
|
|
38
54
|
const toRequest = (filters: Filters, cacheOnly: boolean): DataRequest => {
|
|
39
55
|
const provider = getProvider(filters.provider);
|
|
56
|
+
const universe = filters.universe ?? 'crypto';
|
|
40
57
|
|
|
41
58
|
return {
|
|
42
|
-
key: getKey(provider, filters.symbol, filters.interval),
|
|
59
|
+
key: getKey(provider, universe, filters.symbol, filters.interval),
|
|
43
60
|
provider,
|
|
61
|
+
universe,
|
|
44
62
|
symbol: filters.symbol,
|
|
45
63
|
interval: filters.interval,
|
|
46
64
|
start: filters.start,
|
|
@@ -75,11 +93,11 @@ const inFlightRequests = new Map<string, Promise<KlineChartData>>();
|
|
|
75
93
|
|
|
76
94
|
const useDataStore = create<DataState>((set) => ({
|
|
77
95
|
data: new Map<string, KlineChartData | null>(),
|
|
78
|
-
setData: (provider, symbol, interval, newData) =>
|
|
96
|
+
setData: (provider, symbol, interval, newData, universe = 'crypto') =>
|
|
79
97
|
set(({ data }) => {
|
|
80
98
|
const next = new Map(data);
|
|
81
99
|
|
|
82
|
-
next.set(getKey(provider, symbol, interval), newData);
|
|
100
|
+
next.set(getKey(provider, universe, symbol, interval), newData);
|
|
83
101
|
|
|
84
102
|
return {
|
|
85
103
|
data: next,
|
|
@@ -103,11 +121,12 @@ const getFetchStart = (start: number, data: KlineChartData) =>
|
|
|
103
121
|
const requestKline = async (
|
|
104
122
|
request: Pick<
|
|
105
123
|
DataRequest,
|
|
106
|
-
'provider' | 'symbol' | 'interval' | 'end' | 'cacheOnly'
|
|
124
|
+
'provider' | 'universe' | 'symbol' | 'interval' | 'end' | 'cacheOnly'
|
|
107
125
|
> & { start: number },
|
|
108
126
|
) =>
|
|
109
127
|
kline({
|
|
110
128
|
provider: request.provider,
|
|
129
|
+
universe: request.universe,
|
|
111
130
|
symbol: request.symbol,
|
|
112
131
|
interval: request.interval,
|
|
113
132
|
start: request.start,
|
|
@@ -178,13 +197,14 @@ const mergeFreshData = async (
|
|
|
178
197
|
const persistData = async (
|
|
179
198
|
{
|
|
180
199
|
provider,
|
|
200
|
+
universe,
|
|
181
201
|
symbol,
|
|
182
202
|
interval,
|
|
183
203
|
key,
|
|
184
|
-
}: Pick<DataRequest, 'provider' | 'symbol' | 'interval' | 'key'>,
|
|
204
|
+
}: Pick<DataRequest, 'provider' | 'universe' | 'symbol' | 'interval' | 'key'>,
|
|
185
205
|
data: KlineChartData,
|
|
186
206
|
) => {
|
|
187
|
-
useDataStore.getState().setData(provider, symbol, interval, data);
|
|
207
|
+
useDataStore.getState().setData(provider, symbol, interval, data, universe);
|
|
188
208
|
await set(key, data);
|
|
189
209
|
};
|
|
190
210
|
|
|
@@ -211,10 +231,10 @@ const fetchAndStoreData = async (dataRequest: DataRequest) => {
|
|
|
211
231
|
return pendingRequest;
|
|
212
232
|
};
|
|
213
233
|
|
|
214
|
-
export const useData = (filters: Filters) => {
|
|
234
|
+
export const useData = (filters: Filters, live = true) => {
|
|
215
235
|
const searchParams = useSearchParams();
|
|
216
236
|
const cacheOnly = Boolean(searchParams.get('cacheOnly')) ?? false;
|
|
217
|
-
const { end, interval, provider, start, symbol } = filters;
|
|
237
|
+
const { end, interval, provider, start, symbol, universe } = filters;
|
|
218
238
|
const dataRequest = useMemo(
|
|
219
239
|
() =>
|
|
220
240
|
toRequest(
|
|
@@ -222,22 +242,27 @@ export const useData = (filters: Filters) => {
|
|
|
222
242
|
end,
|
|
223
243
|
interval,
|
|
224
244
|
provider,
|
|
245
|
+
universe,
|
|
225
246
|
start,
|
|
226
247
|
symbol,
|
|
227
248
|
} as Filters,
|
|
228
249
|
cacheOnly,
|
|
229
250
|
),
|
|
230
|
-
[cacheOnly, end, interval, provider, start, symbol],
|
|
251
|
+
[cacheOnly, end, interval, provider, start, symbol, universe],
|
|
231
252
|
);
|
|
232
253
|
const requestKey = useMemo(() => getRequestKey(dataRequest), [dataRequest]);
|
|
233
254
|
const [fulfilledRequestKey, setFulfilledRequestKey] = useState<string | null>(
|
|
234
255
|
null,
|
|
235
256
|
);
|
|
257
|
+
const [liveWindow, setLiveWindow] = useState({ key: '', end: 0 });
|
|
236
258
|
const storedData = useDataStore((s) => s.data.get(dataRequest.key));
|
|
259
|
+
const effectiveEnd = Math.max(
|
|
260
|
+
dataRequest.end,
|
|
261
|
+
liveWindow.key === dataRequest.key ? liveWindow.end : dataRequest.end,
|
|
262
|
+
);
|
|
237
263
|
const windowedData = useMemo(
|
|
238
|
-
() =>
|
|
239
|
-
|
|
240
|
-
[dataRequest.end, dataRequest.start, storedData],
|
|
264
|
+
() => filterDataToWindow(storedData ?? [], dataRequest.start, effectiveEnd),
|
|
265
|
+
[dataRequest.start, effectiveEnd, storedData],
|
|
241
266
|
);
|
|
242
267
|
|
|
243
268
|
const fulfilled = fulfilledRequestKey === requestKey;
|
|
@@ -267,6 +292,37 @@ export const useData = (filters: Filters) => {
|
|
|
267
292
|
};
|
|
268
293
|
}, [dataRequest, requestKey]);
|
|
269
294
|
|
|
295
|
+
useEffect(() => {
|
|
296
|
+
if (!live || cacheOnly || !dataRequest.symbol) return;
|
|
297
|
+
const topic = buildDashboardKlineTopic(dataRequest);
|
|
298
|
+
const catchUp = () => {
|
|
299
|
+
const end = Date.now();
|
|
300
|
+
void fetchAndStoreData({
|
|
301
|
+
...dataRequest,
|
|
302
|
+
end,
|
|
303
|
+
cacheBucketEnd: normalizeEndToIntervalBoundary(
|
|
304
|
+
end,
|
|
305
|
+
dataRequest.interval,
|
|
306
|
+
),
|
|
307
|
+
});
|
|
308
|
+
};
|
|
309
|
+
return subscribeMarketKline({
|
|
310
|
+
topic,
|
|
311
|
+
onReconnect: catchUp,
|
|
312
|
+
onEvent: (event) => {
|
|
313
|
+
setLiveWindow((current) => ({
|
|
314
|
+
key: dataRequest.key,
|
|
315
|
+
end: Math.max(
|
|
316
|
+
current.key === dataRequest.key ? current.end : dataRequest.end,
|
|
317
|
+
event.candle.timestamp,
|
|
318
|
+
),
|
|
319
|
+
}));
|
|
320
|
+
const current = useDataStore.getState().data.get(dataRequest.key) ?? [];
|
|
321
|
+
void persistData(dataRequest, mergeData(current, [event.candle]));
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
}, [cacheOnly, dataRequest, live]);
|
|
325
|
+
|
|
270
326
|
return {
|
|
271
327
|
key: dataRequest.key,
|
|
272
328
|
data: windowedData,
|
package/src/app/store/filters.ts
CHANGED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { MarketKlineEvent } from '@tradejs/types';
|
|
2
|
+
|
|
3
|
+
type MarketKlineMessage = {
|
|
4
|
+
type: 'kline';
|
|
5
|
+
topic: string;
|
|
6
|
+
event: MarketKlineEvent;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export const buildDashboardKlineTopic = ({
|
|
10
|
+
provider,
|
|
11
|
+
universe,
|
|
12
|
+
symbol,
|
|
13
|
+
interval,
|
|
14
|
+
}: Pick<MarketKlineEvent, 'provider' | 'universe' | 'symbol' | 'interval'>) =>
|
|
15
|
+
[provider, universe, symbol.trim().toUpperCase(), interval].join(':');
|
|
16
|
+
|
|
17
|
+
export const parseMarketKlineMessage = (
|
|
18
|
+
payload: string,
|
|
19
|
+
): MarketKlineMessage | null => {
|
|
20
|
+
try {
|
|
21
|
+
const message = JSON.parse(payload) as Partial<MarketKlineMessage>;
|
|
22
|
+
if (
|
|
23
|
+
message.type !== 'kline' ||
|
|
24
|
+
typeof message.topic !== 'string' ||
|
|
25
|
+
!message.event?.candle ||
|
|
26
|
+
!Number.isFinite(message.event.candle.timestamp)
|
|
27
|
+
) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
return message as MarketKlineMessage;
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const getMarketWebSocketUrl = (location: Location) => {
|
|
37
|
+
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
38
|
+
return `${protocol}//${location.host}/ws/market`;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const subscribeMarketKline = ({
|
|
42
|
+
topic,
|
|
43
|
+
onEvent,
|
|
44
|
+
onReconnect,
|
|
45
|
+
}: {
|
|
46
|
+
topic: string;
|
|
47
|
+
onEvent: (event: MarketKlineEvent) => void;
|
|
48
|
+
onReconnect: () => void;
|
|
49
|
+
}) => {
|
|
50
|
+
let socket: WebSocket | null = null;
|
|
51
|
+
let stopped = false;
|
|
52
|
+
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
53
|
+
let reconnectAttempt = 0;
|
|
54
|
+
let openedOnce = false;
|
|
55
|
+
|
|
56
|
+
const connect = () => {
|
|
57
|
+
if (stopped) return;
|
|
58
|
+
socket = new WebSocket(getMarketWebSocketUrl(window.location));
|
|
59
|
+
socket.onopen = () => {
|
|
60
|
+
reconnectAttempt = 0;
|
|
61
|
+
socket?.send(JSON.stringify({ op: 'subscribe', topics: [topic] }));
|
|
62
|
+
if (openedOnce) onReconnect();
|
|
63
|
+
openedOnce = true;
|
|
64
|
+
};
|
|
65
|
+
socket.onmessage = ({ data }) => {
|
|
66
|
+
const message = parseMarketKlineMessage(String(data));
|
|
67
|
+
if (message?.topic === topic) onEvent(message.event);
|
|
68
|
+
};
|
|
69
|
+
socket.onclose = () => {
|
|
70
|
+
if (stopped) return;
|
|
71
|
+
const delayMs = Math.min(30_000, 1_000 * 2 ** reconnectAttempt);
|
|
72
|
+
reconnectAttempt += 1;
|
|
73
|
+
reconnectTimer = setTimeout(connect, delayMs);
|
|
74
|
+
};
|
|
75
|
+
socket.onerror = () => socket?.close();
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
connect();
|
|
79
|
+
return () => {
|
|
80
|
+
stopped = true;
|
|
81
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
82
|
+
if (socket?.readyState === WebSocket.OPEN) {
|
|
83
|
+
socket.send(JSON.stringify({ op: 'unsubscribe', topics: [topic] }));
|
|
84
|
+
}
|
|
85
|
+
socket?.close();
|
|
86
|
+
};
|
|
87
|
+
};
|
package/src/app/store/tests.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { create } from 'zustand';
|
|
|
3
3
|
import { persist } from 'zustand/middleware';
|
|
4
4
|
import _ from 'lodash';
|
|
5
5
|
import { del, get, set } from 'idb-keyval';
|
|
6
|
-
import { getBacktest, getBacktestFiles, getOrderLog } from '
|
|
6
|
+
import { getBacktest, getBacktestFiles, getOrderLog } from '#actions/backtest';
|
|
7
7
|
import {
|
|
8
8
|
TestResult,
|
|
9
9
|
TestCompareList,
|
|
@@ -18,6 +18,7 @@ const COMPARE_LOCAL_STORAGE_KEY = 'compare';
|
|
|
18
18
|
const FAVORITE_LOCAL_STORAGE_KEY = 'favorite';
|
|
19
19
|
const BACKTEST_FILES_CACHE_KEY = 'backtest-files';
|
|
20
20
|
const BACKTEST_FILES_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
21
|
+
const testLoadRequests = new Map<string, Promise<void>>();
|
|
21
22
|
|
|
22
23
|
const COLORS = [
|
|
23
24
|
'purple',
|
|
@@ -180,6 +181,7 @@ const useTestsCompareStore = create<TestsCompareState>()(
|
|
|
180
181
|
interface TestsState {
|
|
181
182
|
tests: Map<string, TestResult | null>;
|
|
182
183
|
setTest: (test: TestResult) => void;
|
|
184
|
+
setTestUnavailable: (testName: string) => void;
|
|
183
185
|
removeTest: (testName: string) => void;
|
|
184
186
|
}
|
|
185
187
|
|
|
@@ -190,6 +192,15 @@ const useTestsStore = create<TestsState>((set) => ({
|
|
|
190
192
|
const next = new Map(tests);
|
|
191
193
|
next.set(testResult.test.name, testResult);
|
|
192
194
|
|
|
195
|
+
return {
|
|
196
|
+
tests: next,
|
|
197
|
+
};
|
|
198
|
+
}),
|
|
199
|
+
setTestUnavailable: (testName) =>
|
|
200
|
+
set(({ tests }) => {
|
|
201
|
+
const next = new Map(tests);
|
|
202
|
+
next.set(testName, null);
|
|
203
|
+
|
|
193
204
|
return {
|
|
194
205
|
tests: next,
|
|
195
206
|
};
|
|
@@ -357,49 +368,73 @@ export const useTestList = (filters: TestListProps = {}) => {
|
|
|
357
368
|
|
|
358
369
|
export const useTest = (testName: string) => {
|
|
359
370
|
const testResult = useTestsStore((s) => s.tests.get(testName));
|
|
371
|
+
const hasTestResult = useTestsStore((s) => s.tests.has(testName));
|
|
360
372
|
const setTest = useTestsStore((s) => s.setTest);
|
|
373
|
+
const setTestUnavailable = useTestsStore((s) => s.setTestUnavailable);
|
|
361
374
|
const tests = useTestListStore((s) => s.tests);
|
|
362
375
|
const setTestList = useTestListStore((s) => s.setTest);
|
|
363
376
|
const testItem = tests.find((item) => item.value === testName);
|
|
364
377
|
const strategyName = testItem?.data?.strategyName as string | undefined;
|
|
365
378
|
|
|
366
379
|
useEffect(() => {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
}
|
|
380
|
+
if (hasTestResult) {
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
371
383
|
|
|
384
|
+
const loadData = async () => {
|
|
372
385
|
const key = `test-${testName}`;
|
|
373
386
|
|
|
374
|
-
|
|
387
|
+
try {
|
|
388
|
+
const cachedResult = (await get(key)) as TestResult | null;
|
|
375
389
|
|
|
376
|
-
|
|
377
|
-
|
|
390
|
+
if (!_.isEmpty(cachedResult)) {
|
|
391
|
+
setTest(cachedResult);
|
|
378
392
|
|
|
379
|
-
|
|
380
|
-
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
381
395
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
396
|
+
let resolvedStrategy = strategyName;
|
|
397
|
+
if (!resolvedStrategy) {
|
|
398
|
+
const newTests = await loadBacktestFilesList();
|
|
399
|
+
setTestList(newTests);
|
|
400
|
+
resolvedStrategy = newTests.find((item) => item.value === testName)
|
|
401
|
+
?.data?.strategyName as string | undefined;
|
|
402
|
+
}
|
|
389
403
|
|
|
390
|
-
|
|
404
|
+
const test = await getBacktest(testName, resolvedStrategy);
|
|
391
405
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
406
|
+
if (!test) {
|
|
407
|
+
setTestUnavailable(testName);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
395
410
|
|
|
396
|
-
|
|
411
|
+
setTest(test);
|
|
397
412
|
|
|
398
|
-
|
|
413
|
+
await set(key, test);
|
|
414
|
+
} catch {
|
|
415
|
+
setTestUnavailable(testName);
|
|
416
|
+
}
|
|
399
417
|
};
|
|
400
418
|
|
|
401
|
-
|
|
402
|
-
|
|
419
|
+
let request = testLoadRequests.get(testName);
|
|
420
|
+
|
|
421
|
+
if (!request) {
|
|
422
|
+
request = loadData();
|
|
423
|
+
testLoadRequests.set(testName, request);
|
|
424
|
+
void request.finally(() => {
|
|
425
|
+
if (testLoadRequests.get(testName) === request) {
|
|
426
|
+
testLoadRequests.delete(testName);
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
}, [
|
|
431
|
+
hasTestResult,
|
|
432
|
+
setTest,
|
|
433
|
+
setTestList,
|
|
434
|
+
setTestUnavailable,
|
|
435
|
+
strategyName,
|
|
436
|
+
testName,
|
|
437
|
+
]);
|
|
403
438
|
|
|
404
439
|
return testResult;
|
|
405
440
|
};
|
|
@@ -500,31 +535,33 @@ export const useBacktest = (id: string | undefined) => {
|
|
|
500
535
|
|
|
501
536
|
setLoading(true);
|
|
502
537
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
if (cachedResult && !_.isEmpty(cachedResult)) {
|
|
506
|
-
setBacktest(id, cachedResult);
|
|
507
|
-
setLoading(false);
|
|
538
|
+
try {
|
|
539
|
+
const cachedResult = (await get(key)) as OrderLogData | null;
|
|
508
540
|
|
|
509
|
-
|
|
510
|
-
|
|
541
|
+
if (cachedResult && !_.isEmpty(cachedResult)) {
|
|
542
|
+
setBacktest(id, cachedResult);
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
511
545
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
546
|
+
let resolvedStrategy = strategyName;
|
|
547
|
+
if (!resolvedStrategy) {
|
|
548
|
+
const newTests = await loadBacktestFilesList();
|
|
549
|
+
setTestList(newTests);
|
|
550
|
+
resolvedStrategy = newTests.find((item) => item.value === id)?.data
|
|
551
|
+
?.strategyName as string | undefined;
|
|
552
|
+
}
|
|
519
553
|
|
|
520
|
-
|
|
554
|
+
const backtestData = await getOrderLog(id, resolvedStrategy);
|
|
521
555
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
556
|
+
if (backtestData && !_.isEmpty(backtestData)) {
|
|
557
|
+
setBacktest(id, backtestData);
|
|
558
|
+
await set(key, backtestData);
|
|
559
|
+
}
|
|
560
|
+
} catch {
|
|
561
|
+
// A missing or expired order-log artifact must not crash the dashboard.
|
|
562
|
+
} finally {
|
|
563
|
+
setLoading(false);
|
|
525
564
|
}
|
|
526
|
-
|
|
527
|
-
setLoading(false);
|
|
528
565
|
};
|
|
529
566
|
|
|
530
567
|
void updateBacktest();
|
|
@@ -537,6 +574,7 @@ export const useBacktest = (id: string | undefined) => {
|
|
|
537
574
|
};
|
|
538
575
|
|
|
539
576
|
export const resetTestsStoreForTests = () => {
|
|
577
|
+
testLoadRequests.clear();
|
|
540
578
|
useDataStore.setState({
|
|
541
579
|
backtests: new Map<string, OrderLogData | null>(),
|
|
542
580
|
});
|
package/src/app/store/tickers.ts
CHANGED
|
@@ -3,8 +3,8 @@ import { create } from 'zustand';
|
|
|
3
3
|
import { persist } from 'zustand/middleware';
|
|
4
4
|
import { get, set } from 'idb-keyval';
|
|
5
5
|
import _ from 'lodash';
|
|
6
|
-
import { scan } from '
|
|
7
|
-
import { Items } from '@tradejs/types';
|
|
6
|
+
import { scan } from '#actions/scanner';
|
|
7
|
+
import { Items, MarketUniverse } from '@tradejs/types';
|
|
8
8
|
|
|
9
9
|
const LOCAL_STORAGE_KEY = 'tickers';
|
|
10
10
|
const TICKERS_CACHE_TTL_MS = 10 * 60 * 1000;
|
|
@@ -78,12 +78,29 @@ const useScannerStore = create<TickersScannerState>((set) => ({
|
|
|
78
78
|
})),
|
|
79
79
|
}));
|
|
80
80
|
|
|
81
|
-
const
|
|
81
|
+
const getProviderUniverseKey = (provider: string, universe: MarketUniverse) =>
|
|
82
|
+
universe === 'crypto' ? provider : `${provider}:${universe}`;
|
|
83
|
+
const getTickersCacheKey = (providerUniverseKey: string) =>
|
|
84
|
+
`tickers:${providerUniverseKey}`;
|
|
82
85
|
|
|
83
86
|
const isFresh = (savedAt: number) =>
|
|
84
87
|
Date.now() - savedAt < TICKERS_CACHE_TTL_MS;
|
|
85
88
|
|
|
86
|
-
const
|
|
89
|
+
const reportTickerLoadError = (
|
|
90
|
+
provider: string,
|
|
91
|
+
universe: MarketUniverse,
|
|
92
|
+
error: unknown,
|
|
93
|
+
) => {
|
|
94
|
+
if (process.env.NODE_ENV !== 'test') {
|
|
95
|
+
console.warn(`Ticker loading failed for ${provider}:${universe}`, error);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const loadTickersForProvider = async (
|
|
100
|
+
provider: string,
|
|
101
|
+
universe: MarketUniverse,
|
|
102
|
+
) => {
|
|
103
|
+
const providerUniverseKey = getProviderUniverseKey(provider, universe);
|
|
87
104
|
const {
|
|
88
105
|
tickersByProvider,
|
|
89
106
|
loadedAtByProvider,
|
|
@@ -91,52 +108,72 @@ const loadTickersForProvider = async (provider: string) => {
|
|
|
91
108
|
setTickers,
|
|
92
109
|
setInFlight,
|
|
93
110
|
} = useScannerStore.getState();
|
|
94
|
-
const memoryTickers = tickersByProvider[
|
|
95
|
-
const loadedAt = loadedAtByProvider[
|
|
111
|
+
const memoryTickers = tickersByProvider[providerUniverseKey] ?? [];
|
|
112
|
+
const loadedAt = loadedAtByProvider[providerUniverseKey] ?? 0;
|
|
96
113
|
|
|
97
114
|
if (memoryTickers.length && isFresh(loadedAt)) {
|
|
98
115
|
return memoryTickers;
|
|
99
116
|
}
|
|
100
117
|
|
|
101
|
-
const inFlight = inFlightByProvider[
|
|
118
|
+
const inFlight = inFlightByProvider[providerUniverseKey];
|
|
102
119
|
if (inFlight) {
|
|
103
120
|
return inFlight;
|
|
104
121
|
}
|
|
105
122
|
|
|
106
123
|
const pending = (async () => {
|
|
107
124
|
const cached = (await get(
|
|
108
|
-
getTickersCacheKey(
|
|
125
|
+
getTickersCacheKey(providerUniverseKey),
|
|
109
126
|
)) as TickersCacheRecord | null;
|
|
110
127
|
|
|
111
128
|
if (cached?.items?.length && isFresh(cached.savedAt)) {
|
|
112
|
-
setTickers(
|
|
129
|
+
setTickers(providerUniverseKey, cached.items, cached.savedAt);
|
|
113
130
|
return cached.items;
|
|
114
131
|
}
|
|
115
132
|
|
|
116
|
-
|
|
133
|
+
let coins: Items;
|
|
134
|
+
try {
|
|
135
|
+
coins = await scan(provider, universe);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
if (cached?.items?.length) {
|
|
138
|
+
setTickers(providerUniverseKey, cached.items, cached.savedAt);
|
|
139
|
+
return cached.items;
|
|
140
|
+
}
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
117
143
|
const savedAt = Date.now();
|
|
118
|
-
setTickers(
|
|
119
|
-
await set(getTickersCacheKey(
|
|
144
|
+
setTickers(providerUniverseKey, coins, savedAt);
|
|
145
|
+
await set(getTickersCacheKey(providerUniverseKey), {
|
|
120
146
|
savedAt,
|
|
121
147
|
items: coins,
|
|
122
148
|
} satisfies TickersCacheRecord);
|
|
123
149
|
return coins;
|
|
124
|
-
})()
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
150
|
+
})()
|
|
151
|
+
.catch((error) => {
|
|
152
|
+
reportTickerLoadError(provider, universe, error);
|
|
153
|
+
throw error;
|
|
154
|
+
})
|
|
155
|
+
.finally(() => {
|
|
156
|
+
useScannerStore.getState().setInFlight(providerUniverseKey, undefined);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
setInFlight(providerUniverseKey, pending);
|
|
129
160
|
return pending;
|
|
130
161
|
};
|
|
131
162
|
|
|
132
163
|
export const useTickers = (
|
|
133
164
|
provider = 'bybit',
|
|
165
|
+
universeOrOptions: MarketUniverse | { enabled?: boolean } = 'crypto',
|
|
134
166
|
options: { enabled?: boolean } = {},
|
|
135
167
|
) => {
|
|
136
|
-
const
|
|
168
|
+
const universe =
|
|
169
|
+
typeof universeOrOptions === 'string' ? universeOrOptions : 'crypto';
|
|
170
|
+
const { enabled = true } =
|
|
171
|
+
typeof universeOrOptions === 'string' ? options : universeOrOptions;
|
|
137
172
|
const favorites = useFavoriteTickersStore((s) => s.favorites);
|
|
138
173
|
const tickers = useScannerStore(
|
|
139
|
-
(s) =>
|
|
174
|
+
(s) =>
|
|
175
|
+
s.tickersByProvider[getProviderUniverseKey(provider, universe)] ??
|
|
176
|
+
EMPTY_ITEMS,
|
|
140
177
|
);
|
|
141
178
|
const toggleFavorite = useFavoriteTickersStore((s) => s.toggleFavorite);
|
|
142
179
|
const checkIsFavorite = useCallback(
|
|
@@ -144,8 +181,8 @@ export const useTickers = (
|
|
|
144
181
|
[favorites],
|
|
145
182
|
);
|
|
146
183
|
const ensureLoaded = useCallback(
|
|
147
|
-
() => loadTickersForProvider(provider),
|
|
148
|
-
[provider],
|
|
184
|
+
() => loadTickersForProvider(provider, universe),
|
|
185
|
+
[provider, universe],
|
|
149
186
|
);
|
|
150
187
|
|
|
151
188
|
useEffect(() => {
|
|
@@ -153,7 +190,7 @@ export const useTickers = (
|
|
|
153
190
|
return;
|
|
154
191
|
}
|
|
155
192
|
|
|
156
|
-
void ensureLoaded();
|
|
193
|
+
void ensureLoaded().catch(() => undefined);
|
|
157
194
|
}, [enabled, ensureLoaded]);
|
|
158
195
|
|
|
159
196
|
const items = useMemo(() => {
|
package/src/proxy.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { encode, getToken } from 'next-auth/jwt';
|
|
|
3
3
|
import { consumeScreenshotSessionToken } from '@tradejs/infra/redis';
|
|
4
4
|
|
|
5
5
|
const SIGNIN_PATH = '/routes/signin';
|
|
6
|
+
const INSTALL_PATH = '/routes/install';
|
|
7
|
+
const INSTALL_API_PATH = '/api/install';
|
|
6
8
|
const SCREENSHOT_API_PREFIX = '/api/files/screenshot';
|
|
7
9
|
const SCREENSHOT_SESSION_QUERY_PARAM = 'screenshotToken';
|
|
8
10
|
const SESSION_COOKIE_NAME = 'authjs.session-token';
|
|
@@ -54,6 +56,8 @@ export const proxy = async (req: NextRequest) => {
|
|
|
54
56
|
pathname.startsWith('/_next') ||
|
|
55
57
|
pathname === '/favicon.ico' ||
|
|
56
58
|
pathname.startsWith(SIGNIN_PATH) ||
|
|
59
|
+
pathname.startsWith(INSTALL_PATH) ||
|
|
60
|
+
pathname === INSTALL_API_PATH ||
|
|
57
61
|
pathname.startsWith('/api/auth') ||
|
|
58
62
|
pathname.startsWith(SCREENSHOT_API_PREFIX) ||
|
|
59
63
|
(!pathname.startsWith('/api') && PUBLIC_FILE_RE.test(pathname))
|