@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.
Files changed (125) hide show
  1. package/README.md +16 -3
  2. package/bin/tradejs-app.mjs +146 -2
  3. package/next.config.mjs +0 -14
  4. package/package.json +35 -19
  5. package/src/app/actions/backtest.ts +54 -0
  6. package/src/app/actions/kline.ts +10 -5
  7. package/src/app/actions/scanner.ts +8 -3
  8. package/src/app/actions/strategies.ts +39 -0
  9. package/src/app/api/ai/route.ts +5 -4
  10. package/src/app/api/auth/[...nextauth]/route.ts +1 -1
  11. package/src/app/api/backtest/configs/route.ts +25 -0
  12. package/src/app/api/backtest/files/route.ts +5 -2
  13. package/src/app/api/backtest/order-log/[strategy]/[name]/route.ts +26 -2
  14. package/src/app/api/backtest/result/[strategy]/[name]/route.ts +27 -2
  15. package/src/app/api/backtest/runs/[jobId]/route.ts +124 -0
  16. package/src/app/api/backtest/runs/route.ts +45 -0
  17. package/src/app/api/backtest/test/[strategy]/[name]/route.ts +19 -2
  18. package/src/app/api/derivatives/summary/route.ts +8 -1
  19. package/src/app/api/install/route.ts +51 -0
  20. package/src/app/api/kline/[provider]/[universe]/[symbol]/[interval]/route.ts +1 -0
  21. package/src/app/api/kline/{[provider]/[symbol]/[interval]/route.ts → handler.ts} +61 -39
  22. package/src/app/api/scanner/[provider]/[universe]/route.ts +1 -0
  23. package/src/app/api/scanner/[provider]/route.ts +30 -12
  24. package/src/app/api/scanner/route.ts +3 -3
  25. package/src/app/api/signal/[symbol]/[signalId]/route.ts +1 -1
  26. package/src/app/api/strategies/ai/[cardId]/route.ts +22 -0
  27. package/src/app/api/strategies/ai/route.ts +139 -0
  28. package/src/app/api/strategies/replay/[cardId]/route.ts +18 -0
  29. package/src/app/api/strategies/replay/route.ts +44 -0
  30. package/src/app/api/strategies/runtime/route.ts +831 -0
  31. package/src/app/api/user/runtime-deployments/[deploymentId]/route.ts +25 -0
  32. package/src/app/api/user/runtime-deployments/route.ts +94 -0
  33. package/src/app/api/user/runtime-strategy-configs/route.ts +241 -0
  34. package/src/app/api/user/settings/route.ts +18 -1
  35. package/src/app/api/user/trading-accounts/[accountId]/route.ts +35 -0
  36. package/src/app/api/user/trading-accounts/route.ts +134 -0
  37. package/src/app/auth.ts +1 -19
  38. package/src/app/components/Backtest/CompareList/index.tsx +1 -1
  39. package/src/app/components/Backtest/ResultsPageClient.tsx +376 -0
  40. package/src/app/components/Backtest/TestCard/ActionsMenu/index.tsx +123 -0
  41. package/src/app/components/Backtest/TestCard/Chart/index.tsx +1 -1
  42. package/src/app/components/Backtest/TestCard/CompareButton/index.tsx +1 -1
  43. package/src/app/components/Backtest/TestCard/ConfigDrawer/index.tsx +28 -12
  44. package/src/app/components/Backtest/TestCard/DeleteButton/index.tsx +33 -17
  45. package/src/app/components/Backtest/TestCard/FavoriteIndicator/index.tsx +2 -2
  46. package/src/app/components/Backtest/TestCard/OpenDashboardButton/index.tsx +10 -1
  47. package/src/app/components/Backtest/TestCard/OrdersDrawer/index.tsx +128 -0
  48. package/src/app/components/Backtest/TestCard/Root/index.tsx +18 -4
  49. package/src/app/components/Backtest/TestCard/Stat/index.tsx +2 -0
  50. package/src/app/components/Backtest/TestCard/StatDrawer/index.tsx +66 -0
  51. package/src/app/components/Backtest/TestCard/index.ts +6 -2
  52. package/src/app/components/Backtest/TestList/index.tsx +4 -9
  53. package/src/app/components/Dashboard/AiDrawer/index.tsx +1 -1
  54. package/src/app/components/Dashboard/KlineChart/hooks/useBacktest.ts +1 -1
  55. package/src/app/components/Dashboard/KlineChart/hooks/useBbIndicator.ts +10 -5
  56. package/src/app/components/Dashboard/KlineChart/hooks/useBtcCorrelation.ts +1 -1
  57. package/src/app/components/Dashboard/KlineChart/hooks/useBtcIndicator.ts +4 -1
  58. package/src/app/components/Dashboard/KlineChart/hooks/useEmaIndicator.ts +3 -1
  59. package/src/app/components/Dashboard/KlineChart/hooks/usePluginIndicators.ts +1 -1
  60. package/src/app/components/Dashboard/KlineChart/hooks/useSetup.ts +1 -1
  61. package/src/app/components/Dashboard/KlineChart/hooks/useSignal.ts +1 -1
  62. package/src/app/components/Dashboard/KlineChart/hooks/useSpreadIndicator.ts +1 -1
  63. package/src/app/components/Dashboard/KlineChart/hooks/useTrendLine.ts +1 -1
  64. package/src/app/components/Dashboard/KlineChart/hooks/useWmaIndicator.ts +3 -1
  65. package/src/app/components/Dashboard/KlineChart/index.tsx +21 -7
  66. package/src/app/components/Dashboard/MainChart/index.tsx +3 -22
  67. package/src/app/components/Shared/AppShell.tsx +2 -2
  68. package/src/app/components/Shared/BulkSelection/index.tsx +140 -0
  69. package/src/app/components/Shared/BulkSelection/useBulkSelection.ts +97 -0
  70. package/src/app/components/Shared/BulkSelection/utils.ts +126 -0
  71. package/src/app/components/Shared/Filters/Backtest/index.tsx +51 -51
  72. package/src/app/components/Shared/Filters/FavoriteIndicator/index.tsx +5 -3
  73. package/src/app/components/Shared/Filters/Indicators/index.tsx +2 -2
  74. package/src/app/components/Shared/Filters/Interval/index.tsx +1 -1
  75. package/src/app/components/Shared/Filters/Provider/index.tsx +8 -2
  76. package/src/app/components/Shared/Filters/Symbol/index.tsx +4 -3
  77. package/src/app/components/Shared/Filters/Universe/index.tsx +36 -0
  78. package/src/app/components/Shared/Filters/index.ts +2 -0
  79. package/src/app/components/Shared/OrdersDrawer.tsx +629 -0
  80. package/src/app/components/Shared/Sidebar/AccountSettingsDrawer.tsx +55 -28
  81. package/src/app/components/Shared/Sidebar/TradingAccountsPanel.tsx +445 -0
  82. package/src/app/components/Shared/Sidebar/index.tsx +17 -3
  83. package/src/app/components/Strategies/AdvancedMetricsPanel.tsx +727 -0
  84. package/src/app/components/Strategies/RuntimeStrategyCard.tsx +2204 -0
  85. package/src/app/components/Strategies/RuntimeStrategyCardSkeleton.tsx +40 -0
  86. package/src/app/components/Strategies/RuntimeStrategyChart.tsx +121 -0
  87. package/src/app/components/Strategies/RuntimeStrategyConfigDrawer.tsx +458 -0
  88. package/src/app/components/Strategies/StrategySnapshotCard.tsx +2890 -0
  89. package/src/app/components/Strategies/StrategySnapshotChart.tsx +94 -0
  90. package/src/app/components/Strategies/StrategySnapshotList.tsx +82 -0
  91. package/src/app/components/UI/Segment/index.tsx +8 -1
  92. package/src/app/components/UI/Select/index.tsx +24 -13
  93. package/src/app/components/UI/SelectWithSearch/index.tsx +46 -5
  94. package/src/app/layout.tsx +1 -1
  95. package/src/app/lib/backtestJobs.ts +925 -0
  96. package/src/app/lib/cardPageLayout.ts +1 -0
  97. package/src/app/lib/connectorCreator.ts +28 -0
  98. package/src/app/lib/currentUser.ts +1 -1
  99. package/src/app/lib/errorMessage.ts +34 -0
  100. package/src/app/lib/installation.ts +83 -0
  101. package/src/app/lib/marketDefaults.ts +12 -0
  102. package/src/app/lib/marketRoutes.ts +58 -0
  103. package/src/app/lib/runtimeStrategies.ts +1376 -0
  104. package/src/app/lib/runtimeStrategyConfigForm.ts +86 -0
  105. package/src/app/provider.tsx +1 -1
  106. package/src/app/routes/backtest/page.tsx +1100 -302
  107. package/src/app/routes/dashboard/{[provider]/[symbol]/[interval]/page.tsx → Dashboard.tsx} +76 -35
  108. package/src/app/routes/dashboard/[provider]/[universe]/[symbol]/[interval]/page.tsx +1 -0
  109. package/src/app/routes/dashboard/page.tsx +15 -3
  110. package/src/app/routes/derivatives/page.tsx +1222 -165
  111. package/src/app/routes/install/page.tsx +175 -0
  112. package/src/app/routes/signin/page.tsx +7 -0
  113. package/src/app/routes/strategies/StrategiesPageClient.tsx +593 -0
  114. package/src/app/routes/strategies/[mode]/page.tsx +20 -0
  115. package/src/app/routes/strategies/page.tsx +7 -0
  116. package/src/app/store/ai.ts +1 -1
  117. package/src/app/store/data.ts +73 -17
  118. package/src/app/store/filters.ts +1 -0
  119. package/src/app/store/marketKlineStream.ts +87 -0
  120. package/src/app/store/tests.ts +83 -45
  121. package/src/app/store/tickers.ts +59 -22
  122. package/src/proxy.ts +4 -0
  123. package/tsconfig.json +17 -12
  124. package/src/app/components/Backtest/TestCard/OpenReportButton/index.tsx +0 -24
  125. package/src/app/routes/backtest/[test]/page.tsx +0 -33
@@ -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 { KlineChartData, Interval, Filters, Provider } from '@tradejs/types';
6
- import { kline } from '@actions/kline';
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 '@app/lib/klineWindow';
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 = (provider: Provider, symbol: string, interval: Interval) =>
34
- `${provider}_${symbol}_${interval}`;
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
- filterDataToWindow(storedData ?? [], dataRequest.start, dataRequest.end),
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,
@@ -11,6 +11,7 @@ interface FiltersState {
11
11
  const useStore = create<FiltersState>((set) => ({
12
12
  filters: {
13
13
  provider: 'bybit',
14
+ universe: 'crypto',
14
15
  symbol: 'BTCUSDT',
15
16
  interval: '15' as Interval,
16
17
  start: getTimestamp(DASHBOARD_PRELOAD_DAYS),
@@ -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
+ };
@@ -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 '@actions/backtest';
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
- const loadData = async () => {
368
- if (!_.isEmpty(testResult)) {
369
- return;
370
- }
380
+ if (hasTestResult) {
381
+ return;
382
+ }
371
383
 
384
+ const loadData = async () => {
372
385
  const key = `test-${testName}`;
373
386
 
374
- const cachedResult = (await get(key)) as TestResult | null;
387
+ try {
388
+ const cachedResult = (await get(key)) as TestResult | null;
375
389
 
376
- if (!_.isEmpty(cachedResult)) {
377
- setTest(cachedResult);
390
+ if (!_.isEmpty(cachedResult)) {
391
+ setTest(cachedResult);
378
392
 
379
- return;
380
- }
393
+ return;
394
+ }
381
395
 
382
- let resolvedStrategy = strategyName;
383
- if (!resolvedStrategy) {
384
- const newTests = await loadBacktestFilesList();
385
- setTestList(newTests);
386
- resolvedStrategy = newTests.find((item) => item.value === testName)
387
- ?.data?.strategyName as string | undefined;
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
- const test = await getBacktest(testName, resolvedStrategy);
404
+ const test = await getBacktest(testName, resolvedStrategy);
391
405
 
392
- if (!test) {
393
- return;
394
- }
406
+ if (!test) {
407
+ setTestUnavailable(testName);
408
+ return;
409
+ }
395
410
 
396
- setTest(test);
411
+ setTest(test);
397
412
 
398
- await set(key, test);
413
+ await set(key, test);
414
+ } catch {
415
+ setTestUnavailable(testName);
416
+ }
399
417
  };
400
418
 
401
- void loadData();
402
- }, [setTest, setTestList, strategyName, testName, testResult]);
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
- const cachedResult = (await get(key)) as OrderLogData | null;
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
- return;
510
- }
541
+ if (cachedResult && !_.isEmpty(cachedResult)) {
542
+ setBacktest(id, cachedResult);
543
+ return;
544
+ }
511
545
 
512
- let resolvedStrategy = strategyName;
513
- if (!resolvedStrategy) {
514
- const newTests = await loadBacktestFilesList();
515
- setTestList(newTests);
516
- resolvedStrategy = newTests.find((item) => item.value === id)?.data
517
- ?.strategyName as string | undefined;
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
- const backtestData = await getOrderLog(id, resolvedStrategy);
554
+ const backtestData = await getOrderLog(id, resolvedStrategy);
521
555
 
522
- if (backtestData && !_.isEmpty(backtestData)) {
523
- setBacktest(id, backtestData);
524
- await set(key, backtestData);
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
  });
@@ -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 '@actions/scanner';
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 getTickersCacheKey = (provider: string) => `tickers:${provider}`;
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 loadTickersForProvider = async (provider: string) => {
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[provider] ?? [];
95
- const loadedAt = loadedAtByProvider[provider] ?? 0;
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[provider];
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(provider),
125
+ getTickersCacheKey(providerUniverseKey),
109
126
  )) as TickersCacheRecord | null;
110
127
 
111
128
  if (cached?.items?.length && isFresh(cached.savedAt)) {
112
- setTickers(provider, cached.items, cached.savedAt);
129
+ setTickers(providerUniverseKey, cached.items, cached.savedAt);
113
130
  return cached.items;
114
131
  }
115
132
 
116
- const coins = await scan(provider);
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(provider, coins, savedAt);
119
- await set(getTickersCacheKey(provider), {
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
- })().finally(() => {
125
- useScannerStore.getState().setInFlight(provider, undefined);
126
- });
127
-
128
- setInFlight(provider, pending);
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 { enabled = true } = options;
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) => s.tickersByProvider[provider] ?? EMPTY_ITEMS,
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))