@tradejs/app 1.0.6 → 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.
@@ -1,5 +1,4 @@
1
1
  import type { Metadata } from 'next';
2
- import { ClientOnly } from '@chakra-ui/react';
3
2
  import { AppShell } from '@shared/AppShell';
4
3
  import Provider from './provider';
5
4
  import './globals.css';
@@ -55,13 +54,16 @@ export default function RootLayout({
55
54
  children: React.ReactNode;
56
55
  }>) {
57
56
  return (
58
- <html lang="en" suppressHydrationWarning>
59
- <body>
60
- <ClientOnly>
61
- <Provider>
62
- <AppShell>{children}</AppShell>
63
- </Provider>
64
- </ClientOnly>
57
+ <html
58
+ lang="en"
59
+ className="dark"
60
+ style={{ colorScheme: 'dark' }}
61
+ suppressHydrationWarning
62
+ >
63
+ <body suppressHydrationWarning>
64
+ <Provider>
65
+ <AppShell>{children}</AppShell>
66
+ </Provider>
65
67
  </body>
66
68
  </html>
67
69
  );
@@ -0,0 +1,17 @@
1
+ import { intervalToMs } from '@tradejs/core/data';
2
+ import { Interval } from '@tradejs/types';
3
+
4
+ export const normalizeEndToIntervalBoundary = (
5
+ end: number,
6
+ interval: Interval,
7
+ ): number => {
8
+ const stepMs = intervalToMs(interval);
9
+ if (!Number.isFinite(end) || stepMs <= 0) {
10
+ return end;
11
+ }
12
+
13
+ return Math.floor(end / stepMs) * stepMs;
14
+ };
15
+
16
+ export const getCurrentIntervalBoundary = (interval: Interval) =>
17
+ normalizeEndToIntervalBoundary(Date.now(), interval);
@@ -11,8 +11,14 @@ import { Interval, OnChangeFilters, Provider } from '@tradejs/types';
11
11
  const Dashboard = () => {
12
12
  const searchParams = useSearchParams();
13
13
  const { filters, setFilters } = useFilters();
14
- const { tickers } = useTickers(filters.provider || 'bybit');
15
- const { tests } = useTestList({ symbol: filters.symbol });
14
+ const { tickers, ensureLoaded: ensureTickersLoaded } = useTickers(
15
+ filters.provider || 'bybit',
16
+ { enabled: false },
17
+ );
18
+ const { tests, ensureLoaded: ensureBacktestsLoaded } = useTestList({
19
+ symbol: filters.symbol,
20
+ enabled: false,
21
+ });
16
22
  const hasBacktestId = searchParams.has('backtestId');
17
23
  const hasBacktestStrategy = searchParams.has('backtestStrategy');
18
24
  const backtestId = searchParams.get('backtestId');
@@ -103,6 +109,8 @@ const Dashboard = () => {
103
109
  tickers={tickers}
104
110
  backtestFiles={tests}
105
111
  onChangeFilters={onChangeFilters}
112
+ ensureTickersLoaded={ensureTickersLoaded}
113
+ ensureBacktestsLoaded={ensureBacktestsLoaded}
106
114
  >
107
115
  <Flex mb={2} gap={4} alignItems="center" flexDirection="row">
108
116
  <Filters.SelectProvider />
@@ -0,0 +1,174 @@
1
+ import { create } from 'zustand';
2
+ import { getHistory, sendMessage } from '@actions/ai';
3
+ import { AIChatHistory, AIChatMessage, Filters } from '@tradejs/types';
4
+
5
+ type AiChatEntry = {
6
+ loading: boolean;
7
+ sending: boolean;
8
+ loaded: boolean;
9
+ error: string | null;
10
+ messages: AIChatHistory;
11
+ };
12
+
13
+ interface AiChatState {
14
+ chats: Record<string, AiChatEntry>;
15
+ getChat: (symbol: string) => AiChatEntry;
16
+ loadHistory: (symbol: string) => Promise<void>;
17
+ sendPrompt: (filters: Filters, input: string) => Promise<void>;
18
+ sendQuickCommand: (filters: Filters, command: string) => Promise<void>;
19
+ }
20
+
21
+ const EMPTY_CHAT: AiChatEntry = {
22
+ loading: false,
23
+ sending: false,
24
+ loaded: false,
25
+ error: null,
26
+ messages: [],
27
+ };
28
+
29
+ const normalizeSymbolKey = (symbol: string) => symbol.trim().toUpperCase();
30
+
31
+ const getQuickMessage = (command: string): AIChatMessage | null => {
32
+ if (command === '/line') {
33
+ return {
34
+ from: 'user',
35
+ text: 'Какие наклонные линии можно построить на данном графике',
36
+ command,
37
+ };
38
+ }
39
+
40
+ return null;
41
+ };
42
+
43
+ const updateChat = (
44
+ chats: Record<string, AiChatEntry>,
45
+ symbol: string,
46
+ patch: Partial<AiChatEntry>,
47
+ ) => ({
48
+ ...chats,
49
+ [symbol]: {
50
+ ...(chats[symbol] ?? EMPTY_CHAT),
51
+ ...patch,
52
+ },
53
+ });
54
+
55
+ export const useAiChatStore = create<AiChatState>((set, get) => ({
56
+ chats: {},
57
+
58
+ getChat: (symbol) => get().chats[normalizeSymbolKey(symbol)] ?? EMPTY_CHAT,
59
+
60
+ loadHistory: async (symbol) => {
61
+ const symbolKey = normalizeSymbolKey(symbol);
62
+ if (!symbolKey) {
63
+ return;
64
+ }
65
+
66
+ const existing = get().chats[symbolKey];
67
+ if (existing?.loading) {
68
+ return;
69
+ }
70
+
71
+ set((state) => ({
72
+ chats: updateChat(state.chats, symbolKey, {
73
+ loading: true,
74
+ error: null,
75
+ }),
76
+ }));
77
+
78
+ try {
79
+ const history = await getHistory(symbol);
80
+ set((state) => ({
81
+ chats: updateChat(state.chats, symbolKey, {
82
+ loading: false,
83
+ loaded: true,
84
+ messages: history,
85
+ }),
86
+ }));
87
+ } catch (error) {
88
+ set((state) => ({
89
+ chats: updateChat(state.chats, symbolKey, {
90
+ loading: false,
91
+ error: error instanceof Error ? error.message : 'Failed to load chat',
92
+ }),
93
+ }));
94
+ }
95
+ },
96
+
97
+ sendPrompt: async (filters, input) => {
98
+ const trimmed = input.trim();
99
+ if (!trimmed) {
100
+ return;
101
+ }
102
+
103
+ const message: AIChatMessage = {
104
+ from: 'user',
105
+ text: trimmed,
106
+ command: 'prompt',
107
+ };
108
+
109
+ const symbolKey = normalizeSymbolKey(filters.symbol);
110
+
111
+ set((state) => ({
112
+ chats: updateChat(state.chats, symbolKey, {
113
+ sending: true,
114
+ error: null,
115
+ loaded: true,
116
+ messages: [...(state.chats[symbolKey]?.messages ?? []), message],
117
+ }),
118
+ }));
119
+
120
+ try {
121
+ const response = await sendMessage({ message, filters });
122
+ set((state) => ({
123
+ chats: updateChat(state.chats, symbolKey, {
124
+ sending: false,
125
+ messages: [...(state.chats[symbolKey]?.messages ?? []), response],
126
+ }),
127
+ }));
128
+ } catch (error) {
129
+ set((state) => ({
130
+ chats: updateChat(state.chats, symbolKey, {
131
+ sending: false,
132
+ error:
133
+ error instanceof Error ? error.message : 'Failed to send message',
134
+ }),
135
+ }));
136
+ }
137
+ },
138
+
139
+ sendQuickCommand: async (filters, command) => {
140
+ const message = getQuickMessage(command);
141
+ if (!message) {
142
+ return;
143
+ }
144
+
145
+ const symbolKey = normalizeSymbolKey(filters.symbol);
146
+
147
+ set((state) => ({
148
+ chats: updateChat(state.chats, symbolKey, {
149
+ sending: true,
150
+ error: null,
151
+ loaded: true,
152
+ messages: [...(state.chats[symbolKey]?.messages ?? []), message],
153
+ }),
154
+ }));
155
+
156
+ try {
157
+ const response = await sendMessage({ message, filters });
158
+ set((state) => ({
159
+ chats: updateChat(state.chats, symbolKey, {
160
+ sending: false,
161
+ messages: [...(state.chats[symbolKey]?.messages ?? []), response],
162
+ }),
163
+ }));
164
+ } catch (error) {
165
+ set((state) => ({
166
+ chats: updateChat(state.chats, symbolKey, {
167
+ sending: false,
168
+ error:
169
+ error instanceof Error ? error.message : 'Failed to send message',
170
+ }),
171
+ }));
172
+ }
173
+ },
174
+ }));
@@ -5,6 +5,7 @@ import { useSearchParams } from 'next/navigation';
5
5
  import { KlineChartData, Interval, Filters, Provider } from '@tradejs/types';
6
6
  import { kline } from '@actions/kline';
7
7
  import { isWrongData, mergeData } from '@tradejs/core/data';
8
+ import { normalizeEndToIntervalBoundary } from '@app/lib/klineWindow';
8
9
 
9
10
  interface DataState {
10
11
  data: Map<string, KlineChartData | null>;
@@ -23,6 +24,7 @@ interface DataRequest {
23
24
  interval: Interval;
24
25
  start: number;
25
26
  end: number;
27
+ cacheBucketEnd: number;
26
28
  cacheOnly: boolean;
27
29
  }
28
30
 
@@ -43,12 +45,28 @@ const toRequest = (filters: Filters, cacheOnly: boolean): DataRequest => {
43
45
  interval: filters.interval,
44
46
  start: filters.start,
45
47
  end: filters.end,
48
+ cacheBucketEnd: normalizeEndToIntervalBoundary(
49
+ filters.end,
50
+ filters.interval,
51
+ ),
46
52
  cacheOnly,
47
53
  };
48
54
  };
49
55
 
50
- const getRequestKey = ({ key, start, end, cacheOnly }: DataRequest) =>
51
- `${key}_${start}_${end}_${cacheOnly ? 'cache' : 'live'}`;
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);
52
70
 
53
71
  const hasContinuityData = (data: KlineChartData) =>
54
72
  data.length > MIN_CACHED_CANDLES;
@@ -211,10 +229,18 @@ export const useData = (filters: Filters) => {
211
229
  ),
212
230
  [cacheOnly, end, interval, provider, start, symbol],
213
231
  );
214
- const [fulfilledKey, setFulfilledKey] = useState<string | null>(null);
232
+ const requestKey = useMemo(() => getRequestKey(dataRequest), [dataRequest]);
233
+ const [fulfilledRequestKey, setFulfilledRequestKey] = useState<string | null>(
234
+ null,
235
+ );
215
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
+ );
216
242
 
217
- const fulfilled = fulfilledKey === dataRequest.key;
243
+ const fulfilled = fulfilledRequestKey === requestKey;
218
244
 
219
245
  useEffect(() => {
220
246
  let cancelled = false;
@@ -222,7 +248,7 @@ export const useData = (filters: Filters) => {
222
248
  const updateData = async () => {
223
249
  if (!dataRequest.symbol) {
224
250
  if (!cancelled) {
225
- setFulfilledKey(dataRequest.key);
251
+ setFulfilledRequestKey(requestKey);
226
252
  }
227
253
  return;
228
254
  }
@@ -230,7 +256,7 @@ export const useData = (filters: Filters) => {
230
256
  await fetchAndStoreData(dataRequest);
231
257
 
232
258
  if (!cancelled) {
233
- setFulfilledKey(dataRequest.key);
259
+ setFulfilledRequestKey(requestKey);
234
260
  }
235
261
  };
236
262
 
@@ -239,11 +265,11 @@ export const useData = (filters: Filters) => {
239
265
  return () => {
240
266
  cancelled = true;
241
267
  };
242
- }, [dataRequest]);
268
+ }, [dataRequest, requestKey]);
243
269
 
244
270
  return {
245
271
  key: dataRequest.key,
246
- data: storedData ?? [],
272
+ data: windowedData,
247
273
  fulfilled,
248
274
  };
249
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
+ };