@tradejs/app 2.0.21 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. package/package.json +8 -8
  2. package/src/app/actions/backtest.ts +1 -1
  3. package/src/app/actions/strategies.ts +1 -1
  4. package/src/app/api/ai/route.ts +13 -6
  5. package/src/app/api/user/settings/route.ts +48 -28
  6. package/src/app/components/Shared/OrdersDrawer.tsx +4 -2
  7. package/src/app/components/Shared/Sidebar/AccountSettingsDrawer.tsx +3 -3
  8. package/src/app/components/Strategies/RuntimeStrategyCard.presenter.ts +536 -0
  9. package/src/app/components/Strategies/RuntimeStrategyCard.tsx +16 -1207
  10. package/src/app/components/Strategies/RuntimeStrategyConfigDrawer.tsx +1 -1
  11. package/src/app/components/Strategies/RuntimeStrategyStatsDrawer.tsx +677 -0
  12. package/src/app/components/Strategies/StrategySnapshotCard.details.presenter.ts +38 -0
  13. package/src/app/components/Strategies/StrategySnapshotCard.diagnostics.presenter.ts +263 -0
  14. package/src/app/components/Strategies/StrategySnapshotCard.orders.presenter.ts +680 -0
  15. package/src/app/components/Strategies/StrategySnapshotCard.presenter.ts +119 -0
  16. package/src/app/components/Strategies/StrategySnapshotCard.ranking.presenter.ts +76 -0
  17. package/src/app/components/Strategies/StrategySnapshotCard.tsx +13 -1793
  18. package/src/app/components/Strategies/StrategySnapshotCardDetailsDrawer.tsx +682 -0
  19. package/src/app/lib/runtimeStrategies.ts +7 -80
  20. package/src/app/lib/runtimeStrategyContracts.ts +85 -0
  21. package/src/app/routes/backtest/BacktestJobItem.tsx +275 -0
  22. package/src/app/routes/backtest/BacktestRunForm.tsx +502 -0
  23. package/src/app/routes/backtest/page.tsx +16 -1099
  24. package/src/app/routes/backtest/useBacktestRunsController.ts +441 -0
  25. package/src/app/routes/strategies/StrategiesPageClient.tsx +1 -1
@@ -0,0 +1,441 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
4
+ import {
5
+ controlBacktestRun,
6
+ deleteBacktestRun,
7
+ getBacktestRunConfigs,
8
+ getBacktestRuns,
9
+ startBacktestRun,
10
+ } from '#actions/backtest';
11
+ import type {
12
+ BacktestConfigSummary,
13
+ BacktestJobRecord,
14
+ } from '#app/lib/backtestJobContracts';
15
+ import { reachYandexMetrikaGoal } from '#app/lib/yandexMetrika';
16
+ import { useTickers } from '#store';
17
+ import { toaster } from '#ui';
18
+
19
+ export type PeriodMode = 'days' | 'range';
20
+ export type JobAction = 'pause' | 'stop' | 'resume' | 'cancel' | 'heartbeat';
21
+
22
+ export interface BacktestRunForm {
23
+ selectedStrategy: string;
24
+ selectedConfigId: string;
25
+ periodMode: PeriodMode;
26
+ days: string;
27
+ startDate: string;
28
+ endDate: string;
29
+ ai: boolean;
30
+ fast: boolean;
31
+ interval: string;
32
+ connector: string;
33
+ selectedTickers: string[];
34
+ tickersLimit: string;
35
+ testsLimit: string;
36
+ parallel: string;
37
+ }
38
+
39
+ interface BacktestRunsState {
40
+ configs: BacktestConfigSummary[];
41
+ jobs: BacktestJobRecord[];
42
+ loadingConfigs: boolean;
43
+ loadingJobs: boolean;
44
+ starting: boolean;
45
+ busyAction: string;
46
+ onboardingMode: boolean;
47
+ form: BacktestRunForm;
48
+ }
49
+
50
+ type BacktestRunsAction =
51
+ | { type: 'patch'; patch: Partial<BacktestRunsState> }
52
+ | { type: 'form'; patch: Partial<BacktestRunForm> }
53
+ | { type: 'jobs'; jobs: BacktestJobRecord[] }
54
+ | { type: 'mergeJob'; job: BacktestJobRecord }
55
+ | { type: 'mergeJobs'; jobs: BacktestJobRecord[] }
56
+ | { type: 'removeJob'; jobId: string }
57
+ | { type: 'onboarding' };
58
+
59
+ const DAY_MS = 24 * 60 * 60 * 1000;
60
+ const FIRST_BACKTEST_REPORTED_KEY = 'tradejs:analytics:first-backtest-reported';
61
+ const FIRST_BACKTEST_PENDING_JOB_KEY =
62
+ 'tradejs:analytics:first-backtest-pending-job';
63
+
64
+ const toInputDate = (date: Date) => date.toISOString().slice(0, 10);
65
+
66
+ const initialState = (): BacktestRunsState => ({
67
+ configs: [],
68
+ jobs: [],
69
+ loadingConfigs: false,
70
+ loadingJobs: false,
71
+ starting: false,
72
+ busyAction: '',
73
+ onboardingMode: false,
74
+ form: {
75
+ selectedStrategy: '',
76
+ selectedConfigId: '',
77
+ periodMode: 'days',
78
+ days: '30',
79
+ startDate: toInputDate(new Date(Date.now() - 30 * DAY_MS)),
80
+ endDate: toInputDate(new Date()),
81
+ ai: false,
82
+ fast: false,
83
+ interval: '15',
84
+ connector: 'binance',
85
+ selectedTickers: [],
86
+ tickersLimit: '',
87
+ testsLimit: '',
88
+ parallel: '',
89
+ },
90
+ });
91
+
92
+ const mergeJob = (
93
+ jobs: BacktestJobRecord[],
94
+ updated: BacktestJobRecord,
95
+ ): BacktestJobRecord[] => {
96
+ const existingIndex = jobs.findIndex((job) => job.id === updated.id);
97
+ if (existingIndex === -1) return [updated, ...jobs];
98
+ const nextJobs = [...jobs];
99
+ nextJobs[existingIndex] = updated;
100
+ return nextJobs;
101
+ };
102
+
103
+ const reducer = (
104
+ state: BacktestRunsState,
105
+ action: BacktestRunsAction,
106
+ ): BacktestRunsState => {
107
+ switch (action.type) {
108
+ case 'patch':
109
+ return { ...state, ...action.patch };
110
+ case 'form':
111
+ return { ...state, form: { ...state.form, ...action.patch } };
112
+ case 'jobs':
113
+ return { ...state, jobs: action.jobs };
114
+ case 'mergeJob':
115
+ return { ...state, jobs: mergeJob(state.jobs, action.job) };
116
+ case 'mergeJobs':
117
+ return {
118
+ ...state,
119
+ jobs: action.jobs.reduce(mergeJob, state.jobs),
120
+ };
121
+ case 'removeJob':
122
+ return {
123
+ ...state,
124
+ jobs: state.jobs.filter((job) => job.id !== action.jobId),
125
+ };
126
+ case 'onboarding':
127
+ return {
128
+ ...state,
129
+ onboardingMode: true,
130
+ form: {
131
+ ...state.form,
132
+ days: '45',
133
+ interval: '15',
134
+ connector: 'binance',
135
+ selectedTickers: ['BTCUSDT'],
136
+ testsLimit: '1',
137
+ parallel: '1',
138
+ },
139
+ };
140
+ }
141
+ };
142
+
143
+ const dateToStartMs = (value: string) => {
144
+ const timestamp = Date.parse(`${value}T00:00:00.000Z`);
145
+ return Number.isFinite(timestamp) ? timestamp : null;
146
+ };
147
+
148
+ const dateToEndMs = (value: string) => {
149
+ const timestamp = Date.parse(`${value}T23:59:59.999Z`);
150
+ return Number.isFinite(timestamp) ? timestamp : null;
151
+ };
152
+
153
+ const getJobTitle = (job: BacktestJobRecord) =>
154
+ `${job.request.strategyName} / ${job.request.configId}`;
155
+
156
+ const buildStrategyItems = (configs: BacktestConfigSummary[]) =>
157
+ [...new Set(configs.map((config) => config.strategyName))]
158
+ .sort((left, right) => left.localeCompare(right))
159
+ .map((strategyName) => ({ label: strategyName, value: strategyName }));
160
+
161
+ const buildConfigItems = (
162
+ configs: BacktestConfigSummary[],
163
+ selectedStrategy: string,
164
+ ) =>
165
+ configs
166
+ .filter((config) => config.strategyName === selectedStrategy)
167
+ .map((config) => ({
168
+ label: config.id,
169
+ value: config.id,
170
+ description: `${config.combinationCount} combos / ${config.paramCount} params`,
171
+ }));
172
+
173
+ export const useBacktestRunsController = () => {
174
+ const [state, dispatch] = useReducer(reducer, undefined, initialState);
175
+ const jobsRequestRef = useRef<Promise<void> | null>(null);
176
+ const jobsErrorNotifiedRef = useRef(false);
177
+ const jobsRef = useRef<BacktestJobRecord[]>([]);
178
+ const { connector, selectedStrategy, selectedConfigId } = state.form;
179
+ const { tickers: tickerItems, ensureLoaded: ensureTickersLoaded } =
180
+ useTickers(connector, { enabled: false });
181
+
182
+ const strategyItems = useMemo(
183
+ () => buildStrategyItems(state.configs),
184
+ [state.configs],
185
+ );
186
+ const configItems = useMemo(
187
+ () => buildConfigItems(state.configs, selectedStrategy),
188
+ [selectedStrategy, state.configs],
189
+ );
190
+ const selectedConfig = useMemo(
191
+ () => state.configs.find((config) => config.id === selectedConfigId),
192
+ [selectedConfigId, state.configs],
193
+ );
194
+
195
+ const updateForm = useCallback((patch: Partial<BacktestRunForm>) => {
196
+ dispatch({ type: 'form', patch });
197
+ }, []);
198
+
199
+ const setSelectedTickers = useCallback(
200
+ (next: string[] | ((current: string[]) => string[])) => {
201
+ const selectedTickers =
202
+ typeof next === 'function' ? next(state.form.selectedTickers) : next;
203
+ dispatch({ type: 'form', patch: { selectedTickers } });
204
+ },
205
+ [state.form.selectedTickers],
206
+ );
207
+
208
+ const loadConfigs = useCallback(async () => {
209
+ dispatch({ type: 'patch', patch: { loadingConfigs: true } });
210
+ try {
211
+ dispatch({
212
+ type: 'patch',
213
+ patch: { configs: await getBacktestRunConfigs() },
214
+ });
215
+ } catch (error) {
216
+ toaster.error({
217
+ title: 'Failed to load backtest configs',
218
+ description: (error as Error)?.message || 'Request failed.',
219
+ });
220
+ } finally {
221
+ dispatch({ type: 'patch', patch: { loadingConfigs: false } });
222
+ }
223
+ }, []);
224
+
225
+ const loadJobs = useCallback((background = false) => {
226
+ if (jobsRequestRef.current) return jobsRequestRef.current;
227
+ const request = (async () => {
228
+ if (!background)
229
+ dispatch({ type: 'patch', patch: { loadingJobs: true } });
230
+ try {
231
+ dispatch({ type: 'jobs', jobs: await getBacktestRuns() });
232
+ jobsErrorNotifiedRef.current = false;
233
+ } catch (error) {
234
+ if (!background && !jobsErrorNotifiedRef.current) {
235
+ jobsErrorNotifiedRef.current = true;
236
+ toaster.error({
237
+ title: 'Failed to load backtest jobs',
238
+ description: (error as Error)?.message || 'Request failed.',
239
+ });
240
+ }
241
+ } finally {
242
+ if (!background)
243
+ dispatch({ type: 'patch', patch: { loadingJobs: false } });
244
+ jobsRequestRef.current = null;
245
+ }
246
+ })();
247
+ jobsRequestRef.current = request;
248
+ return request;
249
+ }, []);
250
+
251
+ const refresh = useCallback(async () => {
252
+ await Promise.all([loadConfigs(), loadJobs()]);
253
+ }, [loadConfigs, loadJobs]);
254
+
255
+ useEffect(() => {
256
+ void refresh();
257
+ }, [refresh]);
258
+
259
+ useEffect(() => {
260
+ if (new URLSearchParams(window.location.search).get('onboarding') === '1') {
261
+ dispatch({ type: 'onboarding' });
262
+ }
263
+ }, []);
264
+
265
+ useEffect(() => {
266
+ const next = strategyItems[0]?.value || '';
267
+ if (!strategyItems.some((item) => item.value === selectedStrategy)) {
268
+ updateForm({ selectedStrategy: next });
269
+ }
270
+ }, [selectedStrategy, strategyItems, updateForm]);
271
+
272
+ useEffect(() => {
273
+ const next = configItems[0]?.value || '';
274
+ if (!configItems.some((item) => item.value === selectedConfigId)) {
275
+ updateForm({ selectedConfigId: next });
276
+ }
277
+ }, [configItems, selectedConfigId, updateForm]);
278
+
279
+ useEffect(() => {
280
+ const timer = window.setInterval(() => void loadJobs(true), 3_000);
281
+ return () => window.clearInterval(timer);
282
+ }, [loadJobs]);
283
+
284
+ useEffect(() => {
285
+ jobsRef.current = state.jobs;
286
+ }, [state.jobs]);
287
+
288
+ useEffect(() => {
289
+ if (window.localStorage.getItem(FIRST_BACKTEST_REPORTED_KEY) === '1')
290
+ return;
291
+ const pendingJobId = window.localStorage.getItem(
292
+ FIRST_BACKTEST_PENDING_JOB_KEY,
293
+ );
294
+ if (!pendingJobId) return;
295
+ const pendingJob = state.jobs.find((job) => job.id === pendingJobId);
296
+ if (
297
+ pendingJob?.status !== 'completed' ||
298
+ !reachYandexMetrikaGoal('first_backtest')
299
+ )
300
+ return;
301
+ window.localStorage.setItem(FIRST_BACKTEST_REPORTED_KEY, '1');
302
+ window.localStorage.removeItem(FIRST_BACKTEST_PENDING_JOB_KEY);
303
+ }, [state.jobs]);
304
+
305
+ useEffect(() => {
306
+ const timer = window.setInterval(() => {
307
+ const runningJobs = jobsRef.current.filter(
308
+ (job) => job.status === 'running',
309
+ );
310
+ if (!runningJobs.length) return;
311
+ void Promise.all(
312
+ runningJobs.map((job) => controlBacktestRun(job.id, 'heartbeat')),
313
+ )
314
+ .then((jobs) => dispatch({ type: 'mergeJobs', jobs }))
315
+ .catch(() => undefined);
316
+ }, 5_000);
317
+ return () => window.clearInterval(timer);
318
+ }, []);
319
+
320
+ const start = useCallback(async () => {
321
+ const form = state.form;
322
+ if (!form.selectedStrategy || !form.selectedConfigId) {
323
+ toaster.error({
324
+ title: 'Select strategy and config',
325
+ description: 'Backtest config is required before launch.',
326
+ });
327
+ return;
328
+ }
329
+
330
+ const payload: Record<string, unknown> = {
331
+ strategyName: form.selectedStrategy,
332
+ configId: form.selectedConfigId,
333
+ periodMode: form.periodMode,
334
+ ai: form.ai,
335
+ fast: form.fast,
336
+ interval: form.interval,
337
+ connector: form.connector,
338
+ };
339
+ if (form.periodMode === 'range') {
340
+ const startTime = dateToStartMs(form.startDate);
341
+ const endTime = dateToEndMs(form.endDate);
342
+ if (!startTime || !endTime || startTime >= endTime) {
343
+ toaster.error({
344
+ title: 'Invalid date range',
345
+ description: 'Start date must be earlier than end date.',
346
+ });
347
+ return;
348
+ }
349
+ payload.startTime = startTime;
350
+ payload.endTime = endTime;
351
+ } else {
352
+ const days = Number(form.days);
353
+ if (!Number.isFinite(days) || days <= 0) {
354
+ toaster.error({
355
+ title: 'Invalid days value',
356
+ description: 'Days must be greater than zero.',
357
+ });
358
+ return;
359
+ }
360
+ payload.days = days;
361
+ }
362
+ if (form.selectedTickers.length)
363
+ payload.tickers = form.selectedTickers.join(',');
364
+ for (const [field, value] of [
365
+ ['tickersLimit', form.tickersLimit],
366
+ ['testsLimit', form.testsLimit],
367
+ ['parallel', form.parallel],
368
+ ] as const) {
369
+ const parsed = Number(value);
370
+ if (value.trim() && Number.isFinite(parsed) && parsed > 0)
371
+ payload[field] = Math.trunc(parsed);
372
+ }
373
+
374
+ dispatch({ type: 'patch', patch: { starting: true } });
375
+ try {
376
+ const job = await startBacktestRun(payload);
377
+ dispatch({ type: 'mergeJob', job });
378
+ if (window.localStorage.getItem(FIRST_BACKTEST_REPORTED_KEY) !== '1') {
379
+ window.localStorage.setItem(FIRST_BACKTEST_PENDING_JOB_KEY, job.id);
380
+ }
381
+ toaster.success({
382
+ title: 'Backtest started',
383
+ description: getJobTitle(job),
384
+ });
385
+ } catch (error) {
386
+ toaster.error({
387
+ title: 'Backtest start failed',
388
+ description: (error as Error)?.message || 'Request failed.',
389
+ });
390
+ } finally {
391
+ dispatch({ type: 'patch', patch: { starting: false } });
392
+ }
393
+ }, [state.form]);
394
+
395
+ const control = useCallback(async (jobId: string, action: JobAction) => {
396
+ dispatch({ type: 'patch', patch: { busyAction: `${jobId}:${action}` } });
397
+ try {
398
+ dispatch({
399
+ type: 'mergeJob',
400
+ job: await controlBacktestRun(jobId, action),
401
+ });
402
+ } catch (error) {
403
+ toaster.error({
404
+ title: 'Backtest action failed',
405
+ description: (error as Error)?.message || 'Request failed.',
406
+ });
407
+ } finally {
408
+ dispatch({ type: 'patch', patch: { busyAction: '' } });
409
+ }
410
+ }, []);
411
+
412
+ const remove = useCallback(async (jobId: string) => {
413
+ dispatch({ type: 'patch', patch: { busyAction: `${jobId}:delete` } });
414
+ try {
415
+ if (await deleteBacktestRun(jobId))
416
+ dispatch({ type: 'removeJob', jobId });
417
+ } catch (error) {
418
+ toaster.error({
419
+ title: 'Backtest job delete failed',
420
+ description: (error as Error)?.message || 'Request failed.',
421
+ });
422
+ } finally {
423
+ dispatch({ type: 'patch', patch: { busyAction: '' } });
424
+ }
425
+ }, []);
426
+
427
+ return {
428
+ state,
429
+ strategyItems,
430
+ configItems,
431
+ selectedConfig,
432
+ tickerItems,
433
+ ensureTickersLoaded,
434
+ updateForm,
435
+ setSelectedTickers,
436
+ start,
437
+ control,
438
+ remove,
439
+ refresh,
440
+ };
441
+ };
@@ -20,7 +20,7 @@ import {
20
20
  BulkDeleteToolbar,
21
21
  useBulkSelection,
22
22
  } from '#components/Shared/BulkSelection';
23
- import type { RuntimeStrategiesResponse } from '#app/lib/runtimeStrategies';
23
+ import type { RuntimeStrategiesResponse } from '#app/lib/runtimeStrategyContracts';
24
24
  import { CARD_PAGE_CONTENT_MAX_WIDTH } from '#app/lib/cardPageLayout';
25
25
  import { EmptyState, Segment, Select, toaster } from '#ui';
26
26