@tradejs/app 1.0.5 → 1.0.8

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 (37) hide show
  1. package/bin/tradejs-app.mjs +129 -20
  2. package/package.json +13 -12
  3. package/public/auth-bg.jpg +0 -0
  4. package/public/next.svg +1 -0
  5. package/public/og-image-source.svg +91 -0
  6. package/public/og-image.png +0 -0
  7. package/public/vercel.svg +1 -0
  8. package/src/app/api/ai/route.ts +84 -20
  9. package/src/app/api/backtest/files/route.ts +12 -1
  10. package/src/app/api/backtest/test/[strategy]/[name]/route.ts +18 -1
  11. package/src/app/api/kline/[provider]/[symbol]/[interval]/route.ts +357 -29
  12. package/src/app/api/scanner/[provider]/route.ts +7 -1
  13. package/src/app/api/scanner/route.ts +7 -1
  14. package/src/app/api/signal/[symbol]/[signalId]/route.ts +6 -0
  15. package/src/app/api/user/settings/route.ts +244 -0
  16. package/src/app/components/Dashboard/AiDrawer/index.tsx +38 -51
  17. package/src/app/components/Shared/Filters/Backtest/index.tsx +12 -5
  18. package/src/app/components/Shared/Filters/Root/index.tsx +12 -1
  19. package/src/app/components/Shared/Filters/Symbol/index.tsx +14 -19
  20. package/src/app/components/Shared/Filters/context.ts +2 -0
  21. package/src/app/components/Shared/Sidebar/AccountSettingsDrawer.tsx +948 -0
  22. package/src/app/components/Shared/Sidebar/index.tsx +13 -9
  23. package/src/app/components/UI/ColorMode/index.tsx +62 -15
  24. package/src/app/components/UI/Select/index.tsx +3 -0
  25. package/src/app/components/UI/SelectWithSearch/index.tsx +3 -0
  26. package/src/app/globals.css +11 -0
  27. package/src/app/layout.tsx +50 -11
  28. package/src/app/lib/currentUser.ts +27 -0
  29. package/src/app/lib/klineWindow.ts +17 -0
  30. package/src/app/routes/dashboard/[provider]/[symbol]/[interval]/page.tsx +10 -2
  31. package/src/app/routes/signin/page.tsx +11 -2
  32. package/src/app/store/ai.ts +174 -0
  33. package/src/app/store/data.ts +219 -88
  34. package/src/app/store/index.ts +1 -0
  35. package/src/app/store/tests.ts +96 -9
  36. package/src/app/store/tickers.ts +113 -17
  37. package/src/proxy.ts +23 -50
@@ -1,11 +1,11 @@
1
- import { useEffect, useRef, useState } from 'react';
1
+ import { useEffect, useMemo, useState } from 'react';
2
2
  import { create } from 'zustand';
3
- import _ from 'lodash';
4
3
  import { get, set } from 'idb-keyval';
5
4
  import { useSearchParams } from 'next/navigation';
6
5
  import { KlineChartData, Interval, Filters, Provider } from '@tradejs/types';
7
6
  import { kline } from '@actions/kline';
8
7
  import { isWrongData, mergeData } from '@tradejs/core/data';
8
+ import { normalizeEndToIntervalBoundary } from '@app/lib/klineWindow';
9
9
 
10
10
  interface DataState {
11
11
  data: Map<string, KlineChartData | null>;
@@ -17,8 +17,61 @@ interface DataState {
17
17
  ) => void;
18
18
  }
19
19
 
20
- const getKey = (filters: Pick<Filters, 'provider' | 'symbol' | 'interval'>) =>
21
- `${filters.provider || 'bybit'}_${filters.symbol}_${filters.interval}`;
20
+ interface DataRequest {
21
+ key: string;
22
+ provider: Provider;
23
+ symbol: string;
24
+ interval: Interval;
25
+ start: number;
26
+ end: number;
27
+ cacheBucketEnd: number;
28
+ cacheOnly: boolean;
29
+ }
30
+
31
+ const MIN_CACHED_CANDLES = 2;
32
+
33
+ const getKey = (provider: Provider, symbol: string, interval: Interval) =>
34
+ `${provider}_${symbol}_${interval}`;
35
+
36
+ const getProvider = (provider?: Provider): Provider => provider || 'bybit';
37
+
38
+ const toRequest = (filters: Filters, cacheOnly: boolean): DataRequest => {
39
+ const provider = getProvider(filters.provider);
40
+
41
+ return {
42
+ key: getKey(provider, filters.symbol, filters.interval),
43
+ provider,
44
+ symbol: filters.symbol,
45
+ interval: filters.interval,
46
+ start: filters.start,
47
+ end: filters.end,
48
+ cacheBucketEnd: normalizeEndToIntervalBoundary(
49
+ filters.end,
50
+ filters.interval,
51
+ ),
52
+ cacheOnly,
53
+ };
54
+ };
55
+
56
+ const getRequestKey = ({
57
+ key,
58
+ start,
59
+ cacheBucketEnd,
60
+ cacheOnly,
61
+ }: DataRequest) =>
62
+ `${key}_${start}_${cacheBucketEnd}_${cacheOnly ? 'cache' : 'live'}`;
63
+
64
+ const filterDataToWindow = (
65
+ data: KlineChartData,
66
+ start: number,
67
+ end: number,
68
+ ): KlineChartData =>
69
+ data.filter((candle) => candle.timestamp >= start && candle.timestamp <= end);
70
+
71
+ const hasContinuityData = (data: KlineChartData) =>
72
+ data.length > MIN_CACHED_CANDLES;
73
+
74
+ const inFlightRequests = new Map<string, Promise<KlineChartData>>();
22
75
 
23
76
  const useDataStore = create<DataState>((set) => ({
24
77
  data: new Map<string, KlineChartData | null>(),
@@ -26,7 +79,7 @@ const useDataStore = create<DataState>((set) => ({
26
79
  set(({ data }) => {
27
80
  const next = new Map(data);
28
81
 
29
- next.set(getKey({ provider, symbol, interval }), newData);
82
+ next.set(getKey(provider, symbol, interval), newData);
30
83
 
31
84
  return {
32
85
  data: next,
@@ -34,111 +87,189 @@ const useDataStore = create<DataState>((set) => ({
34
87
  }),
35
88
  }));
36
89
 
37
- export const useData = (filters: Filters) => {
38
- const key = getKey(filters);
39
- const prevKey = useRef(key);
40
- const retried = useRef(false);
41
- const [fulfilled, setFulfilled] = useState(false);
42
- const storedData = useDataStore((s) => s.data.get(key));
43
- const setData = useDataStore((s) => s.setData);
90
+ const loadCachedData = async (key: string) =>
91
+ ((await get(key)) as KlineChartData | null) ?? [];
44
92
 
45
- const searchParams = useSearchParams();
46
- const cacheOnly = Boolean(searchParams.get('cacheOnly')) ?? false;
93
+ const clearCachedData = async (key: string) => {
94
+ await set(key, []);
95
+ };
47
96
 
48
- useEffect(() => {
49
- if (key !== prevKey.current) {
50
- setFulfilled(false);
51
- prevKey.current = key;
52
- retried.current = false;
97
+ const getFetchStart = (start: number, data: KlineChartData) =>
98
+ Math.max(
99
+ start,
100
+ hasContinuityData(data) ? data[data.length - 2]?.timestamp || 0 : 0,
101
+ );
102
+
103
+ const requestKline = async (
104
+ request: Pick<
105
+ DataRequest,
106
+ 'provider' | 'symbol' | 'interval' | 'end' | 'cacheOnly'
107
+ > & { start: number },
108
+ ) =>
109
+ kline({
110
+ provider: request.provider,
111
+ symbol: request.symbol,
112
+ interval: request.interval,
113
+ start: request.start,
114
+ end: request.end,
115
+ cacheOnly: request.cacheOnly,
116
+ });
117
+
118
+ const loadCurrentData = async ({
119
+ key,
120
+ symbol,
121
+ interval,
122
+ }: Pick<DataRequest, 'key' | 'symbol' | 'interval'>) => {
123
+ let currentData = [
124
+ ...(useDataStore.getState().data.get(key) ?? []),
125
+ ] as KlineChartData;
126
+
127
+ if (currentData.length < MIN_CACHED_CANDLES) {
128
+ const cachedData = await loadCachedData(key);
129
+
130
+ if (hasContinuityData(cachedData)) {
131
+ currentData = [...cachedData];
53
132
  }
133
+ }
54
134
 
55
- const updateData = async () => {
56
- const { provider = 'bybit', symbol, interval, start, end } = filters;
57
- if (!symbol) {
58
- if (!fulfilled) {
59
- setFulfilled(true);
60
- }
61
- return;
62
- }
63
- let currentData = [...(storedData ?? [])];
135
+ if (hasContinuityData(currentData) && isWrongData(interval, currentData)) {
136
+ console.warn('Wrong kline continuity, drop cache', symbol, interval);
137
+ await clearCachedData(key);
138
+ return [];
139
+ }
64
140
 
65
- if (!currentData || currentData.length < 2) {
66
- const cachedResult = (await get(key)) as KlineChartData | null;
141
+ return currentData;
142
+ };
67
143
 
68
- if (cachedResult && cachedResult.length > 2) {
69
- currentData = [...cachedResult];
70
- }
71
- }
144
+ const mergeFreshData = async (
145
+ dataRequest: DataRequest,
146
+ currentData: KlineChartData,
147
+ ) => {
148
+ const incrementalData = await requestKline({
149
+ ...dataRequest,
150
+ start: getFetchStart(dataRequest.start, currentData),
151
+ });
72
152
 
73
- if (currentData?.length > 2 && isWrongData(interval, currentData)) {
74
- console.warn('Wrong kline continuity, drop cache', symbol, interval);
75
- currentData = [];
76
- set(key, []);
77
- }
153
+ const mergedData = mergeData(currentData, incrementalData);
78
154
 
79
- const normStart = Math.max(
80
- start,
81
- currentData?.length > 2
82
- ? currentData[currentData.length - 2]?.timestamp || 0
83
- : 0,
84
- );
85
-
86
- const newData = await kline({
87
- provider,
88
- symbol,
89
- interval,
90
- start: normStart,
91
- end,
92
- cacheOnly,
93
- });
94
-
95
- const finalData = mergeData(currentData, newData);
96
-
97
- if (
98
- !cacheOnly &&
99
- !retried.current &&
100
- finalData.length > 2 &&
101
- isWrongData(interval, finalData)
102
- ) {
103
- console.warn(
104
- 'Wrong kline continuity after merge, refetch full',
105
- symbol,
155
+ if (
156
+ dataRequest.cacheOnly ||
157
+ !hasContinuityData(mergedData) ||
158
+ !isWrongData(dataRequest.interval, mergedData)
159
+ ) {
160
+ return mergedData;
161
+ }
162
+
163
+ console.warn(
164
+ 'Wrong kline continuity after merge, refetch full',
165
+ dataRequest.symbol,
166
+ dataRequest.interval,
167
+ );
168
+ await clearCachedData(dataRequest.key);
169
+
170
+ const fullData = await requestKline({
171
+ ...dataRequest,
172
+ start: dataRequest.start,
173
+ });
174
+
175
+ return mergeData([], fullData);
176
+ };
177
+
178
+ const persistData = async (
179
+ {
180
+ provider,
181
+ symbol,
182
+ interval,
183
+ key,
184
+ }: Pick<DataRequest, 'provider' | 'symbol' | 'interval' | 'key'>,
185
+ data: KlineChartData,
186
+ ) => {
187
+ useDataStore.getState().setData(provider, symbol, interval, data);
188
+ await set(key, data);
189
+ };
190
+
191
+ const fetchAndStoreData = async (dataRequest: DataRequest) => {
192
+ const requestKey = getRequestKey(dataRequest);
193
+ const existingRequest = inFlightRequests.get(requestKey);
194
+
195
+ if (existingRequest) {
196
+ return existingRequest;
197
+ }
198
+
199
+ const pendingRequest = (async () => {
200
+ const currentData = await loadCurrentData(dataRequest);
201
+ const finalData = await mergeFreshData(dataRequest, currentData);
202
+ await persistData(dataRequest, finalData);
203
+
204
+ return finalData;
205
+ })().finally(() => {
206
+ inFlightRequests.delete(requestKey);
207
+ });
208
+
209
+ inFlightRequests.set(requestKey, pendingRequest);
210
+
211
+ return pendingRequest;
212
+ };
213
+
214
+ export const useData = (filters: Filters) => {
215
+ const searchParams = useSearchParams();
216
+ const cacheOnly = Boolean(searchParams.get('cacheOnly')) ?? false;
217
+ const { end, interval, provider, start, symbol } = filters;
218
+ const dataRequest = useMemo(
219
+ () =>
220
+ toRequest(
221
+ {
222
+ end,
106
223
  interval,
107
- );
108
- retried.current = true;
109
- set(key, []);
110
- const refetchData = await kline({
111
224
  provider,
112
- symbol,
113
- interval,
114
225
  start,
115
- end,
116
- cacheOnly,
117
- });
118
- const cleaned = mergeData([], refetchData);
119
- setData(provider as Provider, symbol, interval, cleaned);
120
- if (!fulfilled) {
121
- setFulfilled(true);
226
+ symbol,
227
+ } as Filters,
228
+ cacheOnly,
229
+ ),
230
+ [cacheOnly, end, interval, provider, start, symbol],
231
+ );
232
+ const requestKey = useMemo(() => getRequestKey(dataRequest), [dataRequest]);
233
+ const [fulfilledRequestKey, setFulfilledRequestKey] = useState<string | null>(
234
+ null,
235
+ );
236
+ const storedData = useDataStore((s) => s.data.get(dataRequest.key));
237
+ const windowedData = useMemo(
238
+ () =>
239
+ filterDataToWindow(storedData ?? [], dataRequest.start, dataRequest.end),
240
+ [dataRequest.end, dataRequest.start, storedData],
241
+ );
242
+
243
+ const fulfilled = fulfilledRequestKey === requestKey;
244
+
245
+ useEffect(() => {
246
+ let cancelled = false;
247
+
248
+ const updateData = async () => {
249
+ if (!dataRequest.symbol) {
250
+ if (!cancelled) {
251
+ setFulfilledRequestKey(requestKey);
122
252
  }
123
- set(key, cleaned);
124
253
  return;
125
254
  }
126
255
 
127
- setData(provider as Provider, symbol, interval, finalData);
256
+ await fetchAndStoreData(dataRequest);
128
257
 
129
- if (!fulfilled) {
130
- setFulfilled(true);
258
+ if (!cancelled) {
259
+ setFulfilledRequestKey(requestKey);
131
260
  }
132
-
133
- set(key, finalData);
134
261
  };
135
262
 
136
263
  void updateData();
137
- }, [cacheOnly, filters, fulfilled, key, setData, storedData]);
264
+
265
+ return () => {
266
+ cancelled = true;
267
+ };
268
+ }, [dataRequest, requestKey]);
138
269
 
139
270
  return {
140
- key,
141
- data: storedData ?? [],
271
+ key: dataRequest.key,
272
+ data: windowedData,
142
273
  fulfilled,
143
274
  };
144
275
  };
@@ -3,6 +3,7 @@ export { useTickers } from './tickers';
3
3
  export { useIndicators } from './indicators';
4
4
  export type { IndicatorRendererConfig } from './indicators';
5
5
  export { useData } from './data';
6
+ export { useAiChatStore } from './ai';
6
7
  export {
7
8
  useTest,
8
9
  useTestsCompare,
@@ -16,6 +16,8 @@ import { parseTestName } from '@tradejs/core/backtest';
16
16
 
17
17
  const COMPARE_LOCAL_STORAGE_KEY = 'compare';
18
18
  const FAVORITE_LOCAL_STORAGE_KEY = 'favorite';
19
+ const BACKTEST_FILES_CACHE_KEY = 'backtest-files';
20
+ const BACKTEST_FILES_CACHE_TTL_MS = 5 * 60 * 1000;
19
21
 
20
22
  const COLORS = [
21
23
  'purple',
@@ -28,6 +30,14 @@ const COLORS = [
28
30
  'green',
29
31
  ];
30
32
 
33
+ type BacktestFilesCacheRecord = {
34
+ savedAt: number;
35
+ items: Items;
36
+ };
37
+
38
+ const isFresh = (savedAt: number) =>
39
+ Date.now() - savedAt < BACKTEST_FILES_CACHE_TTL_MS;
40
+
31
41
  interface BacktestState {
32
42
  backtests: Map<string, OrderLogData | null>;
33
43
  setBacktest: (id: string, backtest: OrderLogData) => void;
@@ -100,15 +110,26 @@ const useFavoriteTetstsStore = create<FavotiteTestsState>()(
100
110
 
101
111
  interface TestListState {
102
112
  tests: Items;
103
- setTest: (tests: Items) => void;
113
+ loadedAt: number;
114
+ inFlight?: Promise<Items>;
115
+ setTest: (tests: Items, loadedAt?: number) => void;
116
+ setInFlight: (request?: Promise<Items>) => void;
104
117
  removeTest: (testName: string) => void;
105
118
  }
106
119
 
107
120
  const useTestListStore = create<TestListState>((set) => ({
108
121
  tests: [],
109
- setTest: (tests) =>
122
+ loadedAt: 0,
123
+ inFlight: undefined,
124
+ setTest: (tests, loadedAt = Date.now()) =>
110
125
  set(() => ({
111
126
  tests,
127
+ loadedAt,
128
+ inFlight: undefined,
129
+ })),
130
+ setInFlight: (request) =>
131
+ set(() => ({
132
+ inFlight: request,
112
133
  })),
113
134
  removeTest: (testName) =>
114
135
  set(({ tests }) => ({
@@ -184,6 +205,44 @@ const useTestsStore = create<TestsState>((set) => ({
184
205
  }),
185
206
  }));
186
207
 
208
+ const loadBacktestFilesList = async () => {
209
+ const { tests, loadedAt, inFlight, setInFlight, setTest } =
210
+ useTestListStore.getState();
211
+
212
+ if (loadedAt > 0 && isFresh(loadedAt)) {
213
+ return tests;
214
+ }
215
+
216
+ if (inFlight) {
217
+ return inFlight;
218
+ }
219
+
220
+ const pending = (async () => {
221
+ const cached = (await get(
222
+ BACKTEST_FILES_CACHE_KEY,
223
+ )) as BacktestFilesCacheRecord | null;
224
+
225
+ if (cached?.savedAt && isFresh(cached.savedAt)) {
226
+ setTest(cached.items, cached.savedAt);
227
+ return cached.items;
228
+ }
229
+
230
+ const newTests = await getBacktestFiles();
231
+ const savedAt = Date.now();
232
+ setTest(newTests, savedAt);
233
+ await set(BACKTEST_FILES_CACHE_KEY, {
234
+ savedAt,
235
+ items: newTests,
236
+ } satisfies BacktestFilesCacheRecord);
237
+ return newTests;
238
+ })().finally(() => {
239
+ useTestListStore.getState().setInFlight(undefined);
240
+ });
241
+
242
+ setInFlight(pending);
243
+ return pending;
244
+ };
245
+
187
246
  export const useFavoriteTests = () => {
188
247
  const favotites = useFavoriteTetstsStore((s) => s.tests);
189
248
  const toggleFavorite = useFavoriteTetstsStore((s) => s.toggleFavorite);
@@ -215,14 +274,15 @@ export const useFavoriteTests = () => {
215
274
 
216
275
  interface TestListProps {
217
276
  symbol?: string;
277
+ enabled?: boolean;
218
278
  }
219
279
 
220
280
  export const useTestList = (filters: TestListProps = {}) => {
281
+ const { enabled = true } = filters;
221
282
  const [loadding, setLoading] = useState(false);
222
283
  const [fulFilled, setFulfilled] = useState(false);
223
284
  const [error, setError] = useState<unknown>(null);
224
285
  const tests = useTestListStore((s) => s.tests);
225
- const setTest = useTestListStore((s) => s.setTest);
226
286
  const { favoriteItems } = useFavoriteTests();
227
287
 
228
288
  const testStrategyMap = new Map(
@@ -266,22 +326,24 @@ export const useTestList = (filters: TestListProps = {}) => {
266
326
  const noData = _.isEmpty(testItems);
267
327
 
268
328
  useEffect(() => {
329
+ if (!enabled) {
330
+ return;
331
+ }
332
+
269
333
  const loadData = async () => {
270
334
  try {
271
335
  setLoading(true);
272
336
  await delay();
273
- const newTests = await getBacktestFiles();
337
+ await loadBacktestFilesList();
274
338
  setLoading(false);
275
339
  setFulfilled(true);
276
-
277
- setTest(newTests);
278
340
  } catch (err) {
279
341
  setError(err);
280
342
  }
281
343
  };
282
344
 
283
345
  void loadData();
284
- }, [setTest]);
346
+ }, [enabled]);
285
347
 
286
348
  return {
287
349
  loadding,
@@ -289,6 +351,7 @@ export const useTestList = (filters: TestListProps = {}) => {
289
351
  error,
290
352
  noData,
291
353
  tests: testItems,
354
+ ensureLoaded: loadBacktestFilesList,
292
355
  };
293
356
  };
294
357
 
@@ -318,7 +381,7 @@ export const useTest = (testName: string) => {
318
381
 
319
382
  let resolvedStrategy = strategyName;
320
383
  if (!resolvedStrategy) {
321
- const newTests = await getBacktestFiles();
384
+ const newTests = await loadBacktestFilesList();
322
385
  setTestList(newTests);
323
386
  resolvedStrategy = newTests.find((item) => item.value === testName)
324
387
  ?.data?.strategyName as string | undefined;
@@ -400,6 +463,16 @@ export const useBacktestMutations = () => {
400
463
  removeFavorite(testName);
401
464
  removeFromCompare(testName);
402
465
 
466
+ const cache = (await get(
467
+ BACKTEST_FILES_CACHE_KEY,
468
+ )) as BacktestFilesCacheRecord | null;
469
+ if (cache?.items) {
470
+ await set(BACKTEST_FILES_CACHE_KEY, {
471
+ ...cache,
472
+ items: cache.items.filter((item) => item.value !== testName),
473
+ } satisfies BacktestFilesCacheRecord);
474
+ }
475
+
403
476
  await Promise.all([del(`test-${testName}`), del(`backtest-${testName}`)]);
404
477
  };
405
478
 
@@ -438,7 +511,7 @@ export const useBacktest = (id: string | undefined) => {
438
511
 
439
512
  let resolvedStrategy = strategyName;
440
513
  if (!resolvedStrategy) {
441
- const newTests = await getBacktestFiles();
514
+ const newTests = await loadBacktestFilesList();
442
515
  setTestList(newTests);
443
516
  resolvedStrategy = newTests.find((item) => item.value === id)?.data
444
517
  ?.strategyName as string | undefined;
@@ -462,3 +535,17 @@ export const useBacktest = (id: string | undefined) => {
462
535
  loading,
463
536
  };
464
537
  };
538
+
539
+ export const resetTestsStoreForTests = () => {
540
+ useDataStore.setState({
541
+ backtests: new Map<string, OrderLogData | null>(),
542
+ });
543
+ useTestListStore.setState({
544
+ tests: [],
545
+ loadedAt: 0,
546
+ inFlight: undefined,
547
+ });
548
+ useTestsStore.setState({
549
+ tests: new Map<string, TestResult | null>(),
550
+ });
551
+ };