@tradejs/app 1.0.10 → 1.0.12

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 CHANGED
@@ -2,7 +2,15 @@
2
2
 
3
3
  Publishable Next.js UI package for the TradeJS open-source framework, with backtests, charts, and signal flows.
4
4
 
5
- Typical external usage:
5
+ Recommended external usage:
6
+
7
+ ```bash
8
+ npx create-tradejs
9
+ ```
10
+
11
+ The generator starts local infrastructure and opens the install page. Choose
12
+ the local `root` password there; TradeJS then opens the dashboard with a
13
+ **Create backtest** action. For manual installation into an existing project:
6
14
 
7
15
  ```bash
8
16
  npm install @tradejs/app @tradejs/core @tradejs/node @tradejs/types @tradejs/base @tradejs/cli
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tradejs/app",
3
- "version": "1.0.10",
3
+ "version": "1.0.12",
4
4
  "description": "Installable Next.js UI for the TradeJS open-source framework: dashboards, backtests, charts, and runtime data.",
5
5
  "keywords": [
6
6
  "tradejs",
@@ -51,12 +51,12 @@
51
51
  "@emotion/react": "^11.14.0",
52
52
  "@langchain/core": "^1.1.42",
53
53
  "@langchain/openai": "^1.4.5",
54
- "@tradejs/connectors": "^1.0.10",
55
- "@tradejs/core": "^1.0.10",
56
- "@tradejs/indicators": "^1.0.10",
57
- "@tradejs/infra": "^1.0.10",
58
- "@tradejs/node": "^1.0.10",
59
- "@tradejs/types": "^1.0.10",
54
+ "@tradejs/connectors": "^1.0.12",
55
+ "@tradejs/core": "^1.0.12",
56
+ "@tradejs/indicators": "^1.0.12",
57
+ "@tradejs/infra": "^1.0.12",
58
+ "@tradejs/node": "^1.0.12",
59
+ "@tradejs/types": "^1.0.12",
60
60
  "@types/bcryptjs": "2.4.6",
61
61
  "@types/lodash": "4.14.202",
62
62
  "@types/node": "24.13.3",
@@ -0,0 +1,51 @@
1
+ import { NextResponse } from 'next/server';
2
+ import {
3
+ initializeInstallation,
4
+ isInstallationRequired,
5
+ } from '#app/lib/installation';
6
+
7
+ export const dynamic = 'force-dynamic';
8
+
9
+ export const GET = async () => {
10
+ const required = await isInstallationRequired();
11
+ return NextResponse.json({ required });
12
+ };
13
+
14
+ export const POST = async (request: Request) => {
15
+ const body = (await request.json().catch(() => null)) as {
16
+ password?: unknown;
17
+ confirmPassword?: unknown;
18
+ } | null;
19
+ const password = typeof body?.password === 'string' ? body.password : '';
20
+ const confirmPassword =
21
+ typeof body?.confirmPassword === 'string' ? body.confirmPassword : '';
22
+
23
+ if (password.length < 8) {
24
+ return NextResponse.json(
25
+ { error: 'Password must contain at least 8 characters' },
26
+ { status: 400 },
27
+ );
28
+ }
29
+ if (password.length > 256) {
30
+ return NextResponse.json(
31
+ { error: 'Password is too long' },
32
+ { status: 400 },
33
+ );
34
+ }
35
+ if (password !== confirmPassword) {
36
+ return NextResponse.json(
37
+ { error: 'Passwords do not match' },
38
+ { status: 400 },
39
+ );
40
+ }
41
+
42
+ const initialized = await initializeInstallation(password);
43
+ if (!initialized) {
44
+ return NextResponse.json(
45
+ { error: 'TradeJS is already installed' },
46
+ { status: 409 },
47
+ );
48
+ }
49
+
50
+ return NextResponse.json({ userName: 'root' }, { status: 201 });
51
+ };
package/src/app/auth.ts CHANGED
@@ -2,25 +2,7 @@ import NextAuth from 'next-auth';
2
2
  import Credentials from 'next-auth/providers/credentials';
3
3
  import bcrypt from 'bcryptjs';
4
4
  import { getData, redisKeys } from '@tradejs/infra/redis';
5
-
6
- const getPasswordHash = (user: unknown): string | null => {
7
- if (!user) return null;
8
- if (typeof user === 'string') return user;
9
- if (typeof user !== 'object') return null;
10
-
11
- const record = user as Record<string, unknown>;
12
- const direct = record.passwordHash ?? record.password;
13
- if (typeof direct === 'string') return direct;
14
-
15
- const nested = record.password as Record<string, unknown> | undefined;
16
- const nestedHash = nested?.hash;
17
- if (typeof nestedHash === 'string') return nestedHash;
18
-
19
- const alt = record.hash;
20
- if (typeof alt === 'string') return alt;
21
-
22
- return null;
23
- };
5
+ import { getPasswordHash } from '#app/lib/installation';
24
6
 
25
7
  export const { handlers, auth, signIn, signOut } = NextAuth({
26
8
  trustHost: true,
@@ -1,8 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { PropsWithChildren } from 'react';
4
- import _ from 'lodash';
5
- import { Box } from '@chakra-ui/react';
4
+ import { Box, Text } from '@chakra-ui/react';
6
5
  import { TestResultContext } from '../context';
7
6
  import { TestCardSkeleton } from '../Skeleton';
8
7
  import { useTest, useFavoriteTests } from '#store';
@@ -20,7 +19,22 @@ export const TestCardRoot = ({
20
19
  const testResult = useTest(testName);
21
20
  const { checkIsFavorite } = useFavoriteTests();
22
21
 
23
- if (_.isEmpty(testResult)) {
22
+ if (testResult === null) {
23
+ return (
24
+ <Box
25
+ p={4}
26
+ mb={4}
27
+ borderRadius="md"
28
+ borderWidth="1px"
29
+ borderColor="gray.700"
30
+ color="gray.400"
31
+ >
32
+ <Text>Backtest data is no longer available.</Text>
33
+ </Box>
34
+ );
35
+ }
36
+
37
+ if (!testResult) {
24
38
  return <TestCardSkeleton />;
25
39
  }
26
40
 
@@ -172,7 +172,11 @@ export const KlineChart = ({
172
172
 
173
173
  return (
174
174
  <>
175
- <div id={id} />
175
+ <div
176
+ id={id}
177
+ data-testid="market-chart"
178
+ data-chart-ready={fulfilled && !_.isEmpty(data) ? 'true' : 'false'}
179
+ />
176
180
  {!fulfilled && <OverlaySpinner />}
177
181
  </>
178
182
  );
@@ -4,7 +4,7 @@ import { Box } from '@chakra-ui/react';
4
4
  import { usePathname } from 'next/navigation';
5
5
  import { Sidebar } from '#shared/Sidebar';
6
6
 
7
- const AUTH_ROUTES = ['/routes/signin'];
7
+ const AUTH_ROUTES = ['/routes/signin', '/routes/install'];
8
8
 
9
9
  const isAuthRoute = (pathname: string) =>
10
10
  AUTH_ROUTES.some((route) => pathname.startsWith(route));
@@ -16,6 +16,9 @@ export const SelectBacktest = () => {
16
16
 
17
17
  const strategyItems = useMemo(() => {
18
18
  const names = new Set<string>();
19
+ if (filters.backtestStrategy) {
20
+ names.add(filters.backtestStrategy);
21
+ }
19
22
  for (const test of tests) {
20
23
  const strategyName = test.data?.strategyName;
21
24
  if (typeof strategyName === 'string' && strategyName) {
@@ -29,9 +32,11 @@ export const SelectBacktest = () => {
29
32
  label: strategyName,
30
33
  value: strategyName,
31
34
  }));
32
- }, [tests]);
35
+ }, [filters.backtestStrategy, tests]);
33
36
 
34
- const [selectedStrategy, setSelectedStrategy] = useState<string>('');
37
+ const [selectedStrategy, setSelectedStrategy] = useState<string>(
38
+ filters.backtestStrategy || '',
39
+ );
35
40
  const selectedTestStrategy = useMemo(() => {
36
41
  if (!filters.backtestId) return null;
37
42
 
@@ -45,7 +50,7 @@ export const SelectBacktest = () => {
45
50
 
46
51
  useEffect(() => {
47
52
  if (_.isEmpty(strategyItems)) {
48
- setSelectedStrategy('');
53
+ setSelectedStrategy(filters.backtestStrategy || '');
49
54
  return;
50
55
  }
51
56
 
@@ -112,12 +117,23 @@ export const SelectBacktest = () => {
112
117
  const strategyTests = tests.filter(
113
118
  (test) => test.data?.strategyName === selectedStrategy,
114
119
  );
115
- const hasStrategyItems = !_.isEmpty(strategyItems);
120
+ const backtestItems = [...strategyTests];
121
+ if (
122
+ filters.backtestId &&
123
+ selectedStrategy &&
124
+ !backtestItems.some((test) => test.value === filters.backtestId)
125
+ ) {
126
+ backtestItems.unshift({
127
+ label: filters.backtestId,
128
+ value: filters.backtestId,
129
+ data: { strategyName: selectedStrategy },
130
+ });
131
+ }
116
132
 
117
133
  return (
118
134
  <>
119
135
  <Select
120
- placeholder={hasStrategyItems ? 'Strategy' : 'No strategies'}
136
+ placeholder="Strategy"
121
137
  emptyState="No strategies for this symbol"
122
138
  defaultValue={[selectedStrategy]}
123
139
  value={[selectedStrategy]}
@@ -128,7 +144,6 @@ export const SelectBacktest = () => {
128
144
  }
129
145
  }}
130
146
  items={strategyItems}
131
- disabled={!hasStrategyItems}
132
147
  width="220px"
133
148
  />
134
149
  <Select
@@ -146,7 +161,7 @@ export const SelectBacktest = () => {
146
161
  label: 'Not selected',
147
162
  value: '',
148
163
  },
149
- ...strategyTests,
164
+ ...backtestItems,
150
165
  ]}
151
166
  disabled={!selectedStrategy}
152
167
  width="240px"
@@ -1,8 +1,15 @@
1
1
  import { spawn, type ChildProcess } from 'child_process';
2
2
  import { randomUUID } from 'crypto';
3
+ import { existsSync } from 'fs';
3
4
  import path from 'path';
4
5
  import { TTL_1M } from '@tradejs/core/constants';
5
- import { delKey, getData, getKeys, setData } from '@tradejs/infra/redis';
6
+ import {
7
+ delKey,
8
+ getData,
9
+ getKeys,
10
+ redisKeys,
11
+ setData,
12
+ } from '@tradejs/infra/redis';
6
13
  import { logger } from '@tradejs/infra/logger';
7
14
  import type { StrategyConfigGrid } from '@tradejs/types';
8
15
 
@@ -10,7 +17,7 @@ const HEARTBEAT_TIMEOUT_MS = 20_000;
10
17
  const SWEEP_INTERVAL_MS = 5_000;
11
18
  const MAX_LOG_LINES = 220;
12
19
  const DEFAULT_INTERVAL = '15';
13
- const DEFAULT_CONNECTOR = 'bybit';
20
+ const DEFAULT_CONNECTOR = 'binance';
14
21
 
15
22
  export type BacktestJobStatus =
16
23
  | 'running'
@@ -103,11 +110,6 @@ const getProcesses = () => {
103
110
 
104
111
  const processKey = (userName: string, jobId: string) => `${userName}:${jobId}`;
105
112
 
106
- const getJobsPrefix = (userName: string) => `users:${userName}:backtests:runs:`;
107
-
108
- const getJobKey = (userName: string, jobId: string) =>
109
- `${getJobsPrefix(userName)}${jobId}`;
110
-
111
113
  const getBacktestConfigsPrefix = (userName: string) =>
112
114
  `users:${userName}:backtests:configs:`;
113
115
 
@@ -116,7 +118,13 @@ const nowIso = () => new Date().toISOString();
116
118
  const projectRoot =
117
119
  String(process.env.PROJECT_CWD || process.cwd()).trim() || process.cwd();
118
120
 
119
- const yarnCommand = process.platform === 'win32' ? 'yarn.cmd' : 'yarn';
121
+ const localCliCommand = path.join(
122
+ projectRoot,
123
+ 'node_modules',
124
+ '.bin',
125
+ process.platform === 'win32' ? 'tradejs.cmd' : 'tradejs',
126
+ );
127
+ const cliCommand = existsSync(localCliCommand) ? localCliCommand : 'tradejs';
120
128
 
121
129
  const emptyProgress = (): BacktestJobProgress => ({
122
130
  completed: 0,
@@ -155,6 +163,93 @@ const stripAnsi = (value: string) =>
155
163
  const isPlainObject = (value: unknown): value is Record<string, unknown> =>
156
164
  Boolean(value) && typeof value === 'object' && !Array.isArray(value);
157
165
 
166
+ const BACKTEST_JOB_STATUSES = new Set<BacktestJobStatus>([
167
+ 'running',
168
+ 'pausing',
169
+ 'paused',
170
+ 'completed',
171
+ 'failed',
172
+ 'cancelled',
173
+ ]);
174
+
175
+ const isFiniteNumber = (value: unknown): value is number =>
176
+ typeof value === 'number' && Number.isFinite(value);
177
+
178
+ const isNullableFiniteNumber = (value: unknown) =>
179
+ value === null || isFiniteNumber(value);
180
+
181
+ const isOptionalFiniteNumber = (value: unknown) =>
182
+ value === undefined || isFiniteNumber(value);
183
+
184
+ const isOptionalString = (value: unknown) =>
185
+ value === undefined || typeof value === 'string';
186
+
187
+ const isBacktestJobRequest = (value: unknown): value is BacktestJobRequest => {
188
+ if (!isPlainObject(value)) {
189
+ return false;
190
+ }
191
+
192
+ const periodIsValid =
193
+ value.periodMode === 'days'
194
+ ? isFiniteNumber(value.days)
195
+ : value.periodMode === 'range' &&
196
+ isFiniteNumber(value.startTime) &&
197
+ isFiniteNumber(value.endTime);
198
+
199
+ return (
200
+ normalizeText(value.strategyName).length > 0 &&
201
+ normalizeText(value.configId).length > 0 &&
202
+ periodIsValid &&
203
+ typeof value.ai === 'boolean' &&
204
+ typeof value.fast === 'boolean' &&
205
+ normalizeText(value.interval).length > 0 &&
206
+ normalizeText(value.connector).length > 0 &&
207
+ isOptionalString(value.tickers) &&
208
+ isOptionalFiniteNumber(value.tickersLimit) &&
209
+ isOptionalFiniteNumber(value.testsLimit) &&
210
+ isOptionalFiniteNumber(value.parallel)
211
+ );
212
+ };
213
+
214
+ const isBacktestJobProgress = (value: unknown): value is BacktestJobProgress =>
215
+ isPlainObject(value) &&
216
+ isFiniteNumber(value.completed) &&
217
+ isNullableFiniteNumber(value.total) &&
218
+ isFiniteNumber(value.percent) &&
219
+ isNullableFiniteNumber(value.averageProfit) &&
220
+ isNullableFiniteNumber(value.winRate) &&
221
+ isNullableFiniteNumber(value.successTests) &&
222
+ isNullableFiniteNumber(value.errorTests);
223
+
224
+ export const isBacktestJobRecord = (
225
+ value: unknown,
226
+ ): value is BacktestJobRecord =>
227
+ isPlainObject(value) &&
228
+ normalizeText(value.id).length > 0 &&
229
+ normalizeText(value.userName).length > 0 &&
230
+ typeof value.status === 'string' &&
231
+ BACKTEST_JOB_STATUSES.has(value.status as BacktestJobStatus) &&
232
+ isBacktestJobRequest(value.request) &&
233
+ typeof value.command === 'string' &&
234
+ Array.isArray(value.args) &&
235
+ value.args.every((item) => typeof item === 'string') &&
236
+ normalizeText(value.createdAt).length > 0 &&
237
+ normalizeText(value.updatedAt).length > 0 &&
238
+ isOptionalString(value.startedAt) &&
239
+ isOptionalString(value.finishedAt) &&
240
+ isOptionalString(value.pausedAt) &&
241
+ isOptionalString(value.cancelledAt) &&
242
+ isOptionalString(value.lastHeartbeatAt) &&
243
+ isOptionalFiniteNumber(value.pid) &&
244
+ (value.exitCode === null || isOptionalFiniteNumber(value.exitCode)) &&
245
+ (value.signal === null || isOptionalString(value.signal)) &&
246
+ isFiniteNumber(value.runCount) &&
247
+ isBacktestJobProgress(value.progress) &&
248
+ Array.isArray(value.logs) &&
249
+ value.logs.every((item) => typeof item === 'string') &&
250
+ isOptionalString(value.error) &&
251
+ isOptionalString(value.pauseReason);
252
+
158
253
  const isStrategyConfigGrid = (value: unknown): value is StrategyConfigGrid =>
159
254
  isPlainObject(value) &&
160
255
  Object.values(value).every((item) => Array.isArray(item));
@@ -259,13 +354,28 @@ const applyOutputChunk = (
259
354
 
260
355
  const saveJob = async (record: BacktestJobRecord) => {
261
356
  record.updatedAt = nowIso();
262
- await setData(getJobKey(record.userName, record.id), record, {
357
+ await setData(redisKeys.backtestJob(record.userName, record.id), record, {
263
358
  expire: TTL_1M,
264
359
  });
265
360
  };
266
361
 
267
- const loadJob = async (userName: string, jobId: string) =>
268
- (await getData(getJobKey(userName, jobId), null)) as BacktestJobRecord | null;
362
+ const loadJob = async (userName: string, jobId: string) => {
363
+ const stored = await getData(redisKeys.backtestJob(userName, jobId), null);
364
+ if (stored == null) {
365
+ return null;
366
+ }
367
+
368
+ if (
369
+ !isBacktestJobRecord(stored) ||
370
+ stored.userName !== userName ||
371
+ stored.id !== jobId
372
+ ) {
373
+ logger.warn('ignored invalid backtest job record: %s', jobId);
374
+ return null;
375
+ }
376
+
377
+ return stored;
378
+ };
269
379
 
270
380
  const getLiveRecord = async (userName: string, jobId: string) => {
271
381
  const handle = getProcesses().get(processKey(userName, jobId));
@@ -439,7 +549,7 @@ const launchBacktestProcess = async (
439
549
  userName: record.userName,
440
550
  skip,
441
551
  });
442
- const child = spawn(yarnCommand, args, {
552
+ const child = spawn(cliCommand, args, {
443
553
  cwd: projectRoot,
444
554
  env: {
445
555
  ...process.env,
@@ -452,7 +562,7 @@ const launchBacktestProcess = async (
452
562
  const startedAt = nowIso();
453
563
 
454
564
  record.status = 'running';
455
- record.command = yarnCommand;
565
+ record.command = cliCommand;
456
566
  record.args = args;
457
567
  record.pid = child.pid;
458
568
  record.exitCode = undefined;
@@ -462,7 +572,7 @@ const launchBacktestProcess = async (
462
572
  record.startedAt ??= startedAt;
463
573
  record.lastHeartbeatAt = startedAt;
464
574
  record.runCount += 1;
465
- appendLog(record, `$ ${yarnCommand} ${args.join(' ')}`);
575
+ appendLog(record, `$ ${cliCommand} ${args.join(' ')}`);
466
576
  await saveJob(record);
467
577
 
468
578
  const handle: BacktestProcessHandle = {
@@ -621,6 +731,28 @@ const reconcileDetachedRunningJob = async (record: BacktestJobRecord) => {
621
731
  return record;
622
732
  };
623
733
 
734
+ const reconcileEmptyCompletedJob = async (record: BacktestJobRecord) => {
735
+ if (record.status !== 'completed') {
736
+ return record;
737
+ }
738
+
739
+ const noTestsMessage = record.logs.find((line) =>
740
+ /^(No tests selected|No backtest tests selected)\b/.test(line),
741
+ );
742
+ if (!noTestsMessage) {
743
+ return record;
744
+ }
745
+
746
+ record.status = 'failed';
747
+ record.error = noTestsMessage;
748
+ appendLog(record, 'Backtest failed because no tests were generated.');
749
+ await saveJob(record);
750
+ return record;
751
+ };
752
+
753
+ const reconcileStoredJob = async (record: BacktestJobRecord) =>
754
+ reconcileEmptyCompletedJob(await reconcileDetachedRunningJob(record));
755
+
624
756
  export const listBacktestConfigs = async (
625
757
  userName: string,
626
758
  ): Promise<BacktestConfigSummary[]> => {
@@ -658,19 +790,20 @@ export const listBacktestJobs = async (
658
790
  ensureSweepTimer();
659
791
  await sweepRunningHandles();
660
792
 
661
- const keys = await getKeys(getJobsPrefix(userName));
793
+ const jobsPrefix = redisKeys.backtestJobs(userName);
794
+ const keys = await getKeys(jobsPrefix);
662
795
  const records = await Promise.all(
663
796
  keys.map(async (key) => {
664
- const id = key.slice(getJobsPrefix(userName).length);
797
+ const id = key.slice(jobsPrefix.length);
665
798
  const live = getProcesses().get(processKey(userName, id));
666
- return live?.record ?? ((await getData(key, null)) as BacktestJobRecord);
799
+ return live?.record ?? loadJob(userName, id);
667
800
  }),
668
801
  );
669
802
 
670
803
  const reconciled = await Promise.all(
671
804
  records
672
- .filter(Boolean)
673
- .map((record) => reconcileDetachedRunningJob(record)),
805
+ .filter((record): record is BacktestJobRecord => Boolean(record))
806
+ .map((record) => reconcileStoredJob(record)),
674
807
  );
675
808
 
676
809
  return reconciled.sort(
@@ -682,7 +815,7 @@ export const listBacktestJobs = async (
682
815
  export const getBacktestJob = async (userName: string, jobId: string) => {
683
816
  ensureSweepTimer();
684
817
  const record = await getLiveRecord(userName, jobId);
685
- return record ? reconcileDetachedRunningJob(record) : null;
818
+ return record ? reconcileStoredJob(record) : null;
686
819
  };
687
820
 
688
821
  export const startBacktestJob = async (userName: string, payload: unknown) => {
@@ -693,7 +826,7 @@ export const startBacktestJob = async (userName: string, payload: unknown) => {
693
826
  userName,
694
827
  status: 'paused',
695
828
  request,
696
- command: yarnCommand,
829
+ command: cliCommand,
697
830
  args: [],
698
831
  createdAt,
699
832
  updatedAt: createdAt,
@@ -788,5 +921,5 @@ export const cancelBacktestJob = async (userName: string, jobId: string) => {
788
921
 
789
922
  export const deleteBacktestJob = async (userName: string, jobId: string) => {
790
923
  await cancelBacktestJob(userName, jobId);
791
- return delKey(getJobKey(userName, jobId));
924
+ return delKey(redisKeys.backtestJob(userName, jobId));
792
925
  };
@@ -0,0 +1,83 @@
1
+ import bcrypt from 'bcryptjs';
2
+ import { getData, redisKeys, setData } from '@tradejs/infra/redis';
3
+ import type { StrategyConfigGrid } from '@tradejs/types';
4
+
5
+ export const INSTALL_USER_NAME = 'root';
6
+ export const FIRST_BACKTEST_CONFIG_ID = 'MaStrategy:base';
7
+
8
+ export const FIRST_BACKTEST_CONFIG: StrategyConfigGrid = {
9
+ INTERVAL: ['15'],
10
+ MAX_LOSS_VALUE: [10],
11
+ MA_FAST: [21],
12
+ MA_SLOW: [55],
13
+ LONG: [
14
+ {
15
+ enable: true,
16
+ direction: 'LONG',
17
+ TP: 2,
18
+ SL: 1,
19
+ minRiskRatio: 1.2,
20
+ },
21
+ ],
22
+ SHORT: [
23
+ {
24
+ enable: true,
25
+ direction: 'SHORT',
26
+ TP: 2,
27
+ SL: 1,
28
+ minRiskRatio: 1.2,
29
+ },
30
+ ],
31
+ };
32
+
33
+ export const getPasswordHash = (user: unknown): string | null => {
34
+ if (!user) return null;
35
+ if (typeof user === 'string') return user;
36
+ if (typeof user !== 'object') return null;
37
+
38
+ const record = user as Record<string, unknown>;
39
+ const direct = record.passwordHash ?? record.password;
40
+ if (typeof direct === 'string') return direct;
41
+
42
+ const nested = record.password as Record<string, unknown> | undefined;
43
+ const nestedHash = nested?.hash;
44
+ if (typeof nestedHash === 'string') return nestedHash;
45
+
46
+ const alt = record.hash;
47
+ return typeof alt === 'string' ? alt : null;
48
+ };
49
+
50
+ export const isInstallationRequired = async () => {
51
+ const user = await getData(redisKeys.user(INSTALL_USER_NAME), null);
52
+ return !getPasswordHash(user);
53
+ };
54
+
55
+ export const initializeInstallation = async (password: string) => {
56
+ const existing = (await getData(
57
+ redisKeys.user(INSTALL_USER_NAME),
58
+ null,
59
+ )) as Record<string, unknown> | null;
60
+
61
+ if (getPasswordHash(existing)) {
62
+ return false;
63
+ }
64
+
65
+ const passwordHash = await bcrypt.hash(password, 10);
66
+ await setData(
67
+ redisKeys.user(INSTALL_USER_NAME),
68
+ {
69
+ ...(existing ?? {}),
70
+ passwordHash,
71
+ userName: INSTALL_USER_NAME,
72
+ updatedAt: new Date().toISOString(),
73
+ },
74
+ { expire: 0 },
75
+ );
76
+ await setData(
77
+ redisKeys.backtestConfig(INSTALL_USER_NAME, FIRST_BACKTEST_CONFIG_ID),
78
+ FIRST_BACKTEST_CONFIG,
79
+ { expire: 0 },
80
+ );
81
+
82
+ return true;
83
+ };
@@ -8,5 +8,5 @@ export const getDefaultMarketSymbol = (
8
8
  return 'AAPLUSDT';
9
9
  }
10
10
 
11
- return provider === 'coinbase' ? 'BTC-USD' : 'BTCUSDT';
11
+ return 'BTCUSDT';
12
12
  };
@@ -1,6 +1,12 @@
1
1
  'use client';
2
2
 
3
- import React, { useCallback, useEffect, useMemo, useState } from 'react';
3
+ import React, {
4
+ useCallback,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ } from 'react';
4
10
  import {
5
11
  Badge,
6
12
  Box,
@@ -247,6 +253,7 @@ const BacktestRunPage = () => {
247
253
  const [loadingJobs, setLoadingJobs] = useState(false);
248
254
  const [starting, setStarting] = useState(false);
249
255
  const [busyAction, setBusyAction] = useState('');
256
+ const [onboardingMode, setOnboardingMode] = useState(false);
250
257
  const [selectedStrategy, setSelectedStrategy] = useState('');
251
258
  const [selectedConfigId, setSelectedConfigId] = useState('');
252
259
  const [periodMode, setPeriodMode] = useState<PeriodMode>('days');
@@ -258,11 +265,14 @@ const BacktestRunPage = () => {
258
265
  const [ai, setAi] = useState(false);
259
266
  const [fast, setFast] = useState(false);
260
267
  const [interval, setIntervalValue] = useState('15');
261
- const [connector, setConnector] = useState('bybit');
268
+ const [connector, setConnector] = useState('binance');
262
269
  const [selectedTickers, setSelectedTickers] = useState<string[]>([]);
263
270
  const [tickersLimit, setTickersLimit] = useState('');
264
271
  const [testsLimit, setTestsLimit] = useState('');
265
272
  const [parallel, setParallel] = useState('');
273
+ const jobsRequestRef = useRef<Promise<void> | null>(null);
274
+ const jobsErrorNotifiedRef = useRef(false);
275
+ const jobsRef = useRef<BacktestJobRecord[]>([]);
266
276
  const { tickers: tickerItems, ensureLoaded: ensureTickersLoaded } =
267
277
  useTickers(connector, {
268
278
  enabled: false,
@@ -289,19 +299,37 @@ const BacktestRunPage = () => {
289
299
  }
290
300
  }, []);
291
301
 
292
- const loadJobs = useCallback(async () => {
293
- setLoadingJobs(true);
294
- try {
295
- const nextJobs = await getBacktestRuns();
296
- setJobs(nextJobs);
297
- } catch (error) {
298
- toaster.error({
299
- title: 'Failed to load backtest jobs',
300
- description: (error as Error)?.message || 'Request failed.',
301
- });
302
- } finally {
303
- setLoadingJobs(false);
302
+ const loadJobs = useCallback((background = false) => {
303
+ if (jobsRequestRef.current) {
304
+ return jobsRequestRef.current;
304
305
  }
306
+
307
+ const request = (async () => {
308
+ if (!background) {
309
+ setLoadingJobs(true);
310
+ }
311
+ try {
312
+ const nextJobs = await getBacktestRuns();
313
+ setJobs(nextJobs);
314
+ jobsErrorNotifiedRef.current = false;
315
+ } catch (error) {
316
+ if (!background && !jobsErrorNotifiedRef.current) {
317
+ jobsErrorNotifiedRef.current = true;
318
+ toaster.error({
319
+ title: 'Failed to load backtest jobs',
320
+ description: (error as Error)?.message || 'Request failed.',
321
+ });
322
+ }
323
+ } finally {
324
+ if (!background) {
325
+ setLoadingJobs(false);
326
+ }
327
+ jobsRequestRef.current = null;
328
+ }
329
+ })();
330
+
331
+ jobsRequestRef.current = request;
332
+ return request;
305
333
  }, []);
306
334
 
307
335
  useEffect(() => {
@@ -309,6 +337,21 @@ const BacktestRunPage = () => {
309
337
  void loadJobs();
310
338
  }, [loadConfigs, loadJobs]);
311
339
 
340
+ useEffect(() => {
341
+ const params = new URLSearchParams(window.location.search);
342
+ if (params.get('onboarding') !== '1') {
343
+ return;
344
+ }
345
+
346
+ setOnboardingMode(true);
347
+ setDays('45');
348
+ setIntervalValue('15');
349
+ setConnector('binance');
350
+ setSelectedTickers(['BTCUSDT']);
351
+ setTestsLimit('1');
352
+ setParallel('1');
353
+ }, []);
354
+
312
355
  useEffect(() => {
313
356
  if (!strategyItems.length) {
314
357
  setSelectedStrategy('');
@@ -333,15 +376,21 @@ const BacktestRunPage = () => {
333
376
 
334
377
  useEffect(() => {
335
378
  const timer = window.setInterval(() => {
336
- void loadJobs();
379
+ void loadJobs(true);
337
380
  }, 3_000);
338
381
 
339
382
  return () => window.clearInterval(timer);
340
383
  }, [loadJobs]);
341
384
 
385
+ useEffect(() => {
386
+ jobsRef.current = jobs;
387
+ }, [jobs]);
388
+
342
389
  useEffect(() => {
343
390
  const timer = window.setInterval(() => {
344
- const runningJobs = jobs.filter((job) => job.status === 'running');
391
+ const runningJobs = jobsRef.current.filter(
392
+ (job) => job.status === 'running',
393
+ );
345
394
  if (!runningJobs.length) {
346
395
  return;
347
396
  }
@@ -358,7 +407,7 @@ const BacktestRunPage = () => {
358
407
  }, 5_000);
359
408
 
360
409
  return () => window.clearInterval(timer);
361
- }, [jobs]);
410
+ }, []);
362
411
 
363
412
  const selectedConfig = useMemo(
364
413
  () => configs.find((config) => config.id === selectedConfigId),
@@ -542,6 +591,9 @@ const BacktestRunPage = () => {
542
591
  <Text fontWeight="700" flexShrink={0}>
543
592
  New run
544
593
  </Text>
594
+ {onboardingMode ? (
595
+ <Badge colorPalette="teal">First backtest preset</Badge>
596
+ ) : null}
545
597
  {selectedConfig ? (
546
598
  <Flex gap={2} wrap="wrap">
547
599
  <Badge colorPalette="teal">
@@ -727,7 +779,7 @@ const BacktestRunPage = () => {
727
779
  value={[connector]}
728
780
  defaultValue={[connector]}
729
781
  onChange={(value) => {
730
- setConnector(value[0] || 'bybit');
782
+ setConnector(value[0] || 'binance');
731
783
  setSelectedTickers([]);
732
784
  }}
733
785
  items={CONNECTOR_ITEMS}
@@ -1020,7 +1072,7 @@ const BacktestJobItem = ({
1020
1072
  <Button
1021
1073
  type="button"
1022
1074
  size="xs"
1023
- variant="outline"
1075
+ variant="ghost"
1024
1076
  colorPalette="red"
1025
1077
  loading={busyAction === `${job.id}:cancel`}
1026
1078
  onClick={() => onAction(job.id, 'cancel')}
@@ -1030,18 +1082,17 @@ const BacktestJobItem = ({
1030
1082
  </Button>
1031
1083
  ) : null}
1032
1084
 
1033
- {canDelete ? (
1034
- <Button
1035
- type="button"
1036
- size="xs"
1037
- variant="ghost"
1038
- colorPalette="red"
1039
- loading={busyAction === `${job.id}:delete`}
1040
- onClick={() => onDelete(job.id)}
1041
- >
1042
- <FiTrash2 />
1043
- </Button>
1044
- ) : null}
1085
+ <Button
1086
+ type="button"
1087
+ size="xs"
1088
+ variant="ghost"
1089
+ colorPalette="red"
1090
+ disabled={!canDelete}
1091
+ loading={busyAction === `${job.id}:delete`}
1092
+ onClick={() => onDelete(job.id)}
1093
+ >
1094
+ <FiTrash2 />
1095
+ </Button>
1045
1096
  </Flex>
1046
1097
  </Flex>
1047
1098
 
@@ -2,7 +2,8 @@
2
2
 
3
3
  import { useCallback, useEffect } from 'react';
4
4
  import { useSearchParams } from 'next/navigation';
5
- import { Box, Flex, ClientOnly } from '@chakra-ui/react';
5
+ import Link from 'next/link';
6
+ import { Box, Button, Flex, ClientOnly } from '@chakra-ui/react';
6
7
  import { useFilters, useTickers, useTestList } from '#store';
7
8
  import { Filters } from '#shared/Filters';
8
9
  import { MainChart } from '#app/components/Dashboard/MainChart';
@@ -27,7 +28,7 @@ const DashboardRoute = () => {
27
28
  );
28
29
  const { tests, ensureLoaded: ensureBacktestsLoaded } = useTestList({
29
30
  symbol: filters.symbol,
30
- enabled: false,
31
+ enabled: Boolean(filters.backtestId || filters.backtestStrategy),
31
32
  });
32
33
  const hasBacktestId = searchParams.has('backtestId');
33
34
  const hasBacktestStrategy = searchParams.has('backtestStrategy');
@@ -123,26 +124,46 @@ const DashboardRoute = () => {
123
124
  alignItems="flex-start"
124
125
  >
125
126
  {!isScreenshotMode && (
126
- <Filters.Root
127
- filters={filters}
128
- tickers={tickers}
129
- backtestFiles={tests}
130
- onChangeFilters={onChangeFilters}
131
- ensureTickersLoaded={ensureTickersLoaded}
132
- ensureBacktestsLoaded={ensureBacktestsLoaded}
127
+ <Box
128
+ w="full"
129
+ display="grid"
130
+ gridTemplateColumns={{ base: '1fr', lg: 'minmax(0, 1fr) auto' }}
131
+ columnGap={4}
132
+ alignItems="start"
133
133
  >
134
- <Flex mb={2} gap={4} alignItems="center" flexDirection="row">
135
- <Filters.SelectProvider />
136
- {Filters.SelectUniverse ? <Filters.SelectUniverse /> : null}
137
- <Filters.SelectSymbol />
138
- <Filters.FavoriteIndicator />
139
- <Filters.SelectInterval />
140
- <Filters.SelectIndicator />
141
- </Flex>
142
- <Flex mb={4} gap={4} flexDirection="row">
143
- <Filters.SelectBacktest />
144
- </Flex>
145
- </Filters.Root>
134
+ <Box minW={0}>
135
+ <Filters.Root
136
+ filters={filters}
137
+ tickers={tickers}
138
+ backtestFiles={tests}
139
+ onChangeFilters={onChangeFilters}
140
+ ensureTickersLoaded={ensureTickersLoaded}
141
+ ensureBacktestsLoaded={ensureBacktestsLoaded}
142
+ >
143
+ <Flex mb={2} gap={4} alignItems="center" flexDirection="row">
144
+ <Filters.SelectProvider />
145
+ {Filters.SelectUniverse ? <Filters.SelectUniverse /> : null}
146
+ <Filters.SelectSymbol />
147
+ <Filters.FavoriteIndicator />
148
+ <Filters.SelectInterval />
149
+ <Filters.SelectIndicator />
150
+ </Flex>
151
+ <Flex mb={4} gap={4} flexDirection="row">
152
+ <Filters.SelectBacktest />
153
+ </Flex>
154
+ </Filters.Root>
155
+ </Box>
156
+ <Button
157
+ asChild
158
+ bg="#20c5bd"
159
+ color="gray.950"
160
+ _hover={{ bg: '#42d8d0' }}
161
+ justifySelf={{ base: 'start', lg: 'end' }}
162
+ mb={4}
163
+ >
164
+ <Link href="/routes/backtest">Create backtest</Link>
165
+ </Button>
166
+ </Box>
146
167
  )}
147
168
  <Box position="relative" flex="1" w="full">
148
169
  <MainChart screenshotMode={isScreenshotMode} />
@@ -0,0 +1,175 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import { useRouter } from 'next/navigation';
5
+ import Image from 'next/image';
6
+ import { signIn } from 'next-auth/react';
7
+ import { Box, Button, Field, Flex, Input, Stack, Text } from '@chakra-ui/react';
8
+
9
+ const FIRST_DASHBOARD_PATH = '/routes/dashboard/coinbase/crypto/BTCUSDT/15';
10
+
11
+ const Install = () => {
12
+ const router = useRouter();
13
+ const [password, setPassword] = useState('');
14
+ const [confirmPassword, setConfirmPassword] = useState('');
15
+ const [error, setError] = useState('');
16
+ const [isLoading, setIsLoading] = useState(true);
17
+ const [isSubmitting, setIsSubmitting] = useState(false);
18
+
19
+ useEffect(() => {
20
+ void fetch('/api/install')
21
+ .then(async (response) => {
22
+ if (!response.ok) throw new Error('Unable to check installation');
23
+ return (await response.json()) as { required?: boolean };
24
+ })
25
+ .then(({ required }) => {
26
+ if (!required) {
27
+ router.replace('/routes/signin');
28
+ return;
29
+ }
30
+ setIsLoading(false);
31
+ })
32
+ .catch(() => {
33
+ setError('Unable to connect to the local TradeJS infrastructure');
34
+ setIsLoading(false);
35
+ });
36
+ }, [router]);
37
+
38
+ const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
39
+ event.preventDefault();
40
+ setError('');
41
+
42
+ if (password.length < 8) {
43
+ setError('Password must contain at least 8 characters');
44
+ return;
45
+ }
46
+ if (password !== confirmPassword) {
47
+ setError('Passwords do not match');
48
+ return;
49
+ }
50
+
51
+ setIsSubmitting(true);
52
+ const response = await fetch('/api/install', {
53
+ method: 'POST',
54
+ headers: { 'content-type': 'application/json' },
55
+ body: JSON.stringify({ password, confirmPassword }),
56
+ });
57
+ const payload = (await response.json().catch(() => null)) as {
58
+ error?: string;
59
+ } | null;
60
+
61
+ if (!response.ok) {
62
+ setError(payload?.error || 'Unable to install TradeJS');
63
+ setIsSubmitting(false);
64
+ return;
65
+ }
66
+
67
+ const result = await signIn('credentials', {
68
+ redirect: false,
69
+ username: 'root',
70
+ password,
71
+ callbackUrl: FIRST_DASHBOARD_PATH,
72
+ });
73
+
74
+ if (!result || result.error) {
75
+ router.replace('/routes/signin');
76
+ return;
77
+ }
78
+
79
+ router.replace(FIRST_DASHBOARD_PATH);
80
+ };
81
+
82
+ return (
83
+ <Flex minH="100vh" direction={{ base: 'column', lg: 'row' }} bg="gray.950">
84
+ <Flex
85
+ w={{ base: 'full', lg: '40%' }}
86
+ px={{ base: 6, md: 10, lg: 12 }}
87
+ py={{ base: 10, lg: 12 }}
88
+ align="center"
89
+ justify="center"
90
+ minH={{ base: '100vh', lg: 'auto' }}
91
+ >
92
+ <form onSubmit={handleSubmit} style={{ width: '100%' }}>
93
+ <Box w="full" maxW="420px" mx="auto">
94
+ <Stack gap="6">
95
+ <Stack gap="3">
96
+ <Text fontSize="sm" opacity={0.7} letterSpacing="0.2em">
97
+ INSTALL TRADEJS
98
+ </Text>
99
+ <Text as="h1" fontSize="3xl" fontWeight="700" color="white">
100
+ Create your local password
101
+ </Text>
102
+ <Text color="gray.400">
103
+ This password protects the local root account. It is stored
104
+ only in your TradeJS infrastructure.
105
+ </Text>
106
+ </Stack>
107
+
108
+ <Stack gap="4">
109
+ <Field.Root>
110
+ <Field.Label>Password</Field.Label>
111
+ <Input
112
+ aria-label="Password"
113
+ value={password}
114
+ onChange={(event) => setPassword(event.target.value)}
115
+ type="password"
116
+ autoComplete="new-password"
117
+ disabled={isLoading}
118
+ />
119
+ </Field.Root>
120
+ <Field.Root>
121
+ <Field.Label>Confirm password</Field.Label>
122
+ <Input
123
+ aria-label="Confirm password"
124
+ value={confirmPassword}
125
+ onChange={(event) => setConfirmPassword(event.target.value)}
126
+ type="password"
127
+ autoComplete="new-password"
128
+ disabled={isLoading}
129
+ />
130
+ </Field.Root>
131
+ </Stack>
132
+
133
+ {error ? (
134
+ <Text role="alert" fontSize="sm" color="red.300">
135
+ {error}
136
+ </Text>
137
+ ) : null}
138
+
139
+ <Button
140
+ type="submit"
141
+ loading={isSubmitting || isLoading}
142
+ disabled={isLoading || !password || !confirmPassword}
143
+ bg="#20c5bd"
144
+ color="gray.950"
145
+ _hover={{ bg: '#42d8d0' }}
146
+ >
147
+ Install and open dashboard
148
+ </Button>
149
+ </Stack>
150
+ </Box>
151
+ </form>
152
+ </Flex>
153
+
154
+ <Box
155
+ display={{ base: 'none', lg: 'block' }}
156
+ w={{ lg: '60%' }}
157
+ minH="100vh"
158
+ bg="gray.900"
159
+ position="relative"
160
+ overflow="hidden"
161
+ >
162
+ <Image
163
+ src="/auth-bg.jpg"
164
+ alt="Market chart background"
165
+ fill
166
+ priority
167
+ sizes="(min-width: 1024px) 60vw, 0vw"
168
+ style={{ objectFit: 'cover', objectPosition: 'center' }}
169
+ />
170
+ </Box>
171
+ </Flex>
172
+ );
173
+ };
174
+
175
+ export default Install;
@@ -26,6 +26,13 @@ const SigninContent = () => {
26
26
  }
27
27
  }, [status, router, callbackUrl]);
28
28
 
29
+ useEffect(() => {
30
+ const suggestedUsername = searchParams.get('username')?.trim();
31
+ if (suggestedUsername) {
32
+ setUsername((current) => current || suggestedUsername);
33
+ }
34
+ }, [searchParams]);
35
+
29
36
  const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
30
37
  event.preventDefault();
31
38
  setError('');
@@ -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
  });
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))