@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 +9 -1
- package/package.json +7 -7
- package/src/app/api/install/route.ts +51 -0
- package/src/app/auth.ts +1 -19
- package/src/app/components/Backtest/TestCard/Root/index.tsx +17 -3
- package/src/app/components/Dashboard/KlineChart/index.tsx +5 -1
- package/src/app/components/Shared/AppShell.tsx +1 -1
- package/src/app/components/Shared/Filters/Backtest/index.tsx +22 -7
- package/src/app/lib/backtestJobs.ts +155 -22
- package/src/app/lib/installation.ts +83 -0
- package/src/app/lib/marketDefaults.ts +1 -1
- package/src/app/routes/backtest/page.tsx +82 -31
- package/src/app/routes/dashboard/Dashboard.tsx +42 -21
- package/src/app/routes/install/page.tsx +175 -0
- package/src/app/routes/signin/page.tsx +7 -0
- package/src/app/store/tests.ts +82 -44
- package/src/proxy.ts +4 -0
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
|
-
|
|
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.
|
|
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.
|
|
55
|
-
"@tradejs/core": "^1.0.
|
|
56
|
-
"@tradejs/indicators": "^1.0.
|
|
57
|
-
"@tradejs/infra": "^1.0.
|
|
58
|
-
"@tradejs/node": "^1.0.
|
|
59
|
-
"@tradejs/types": "^1.0.
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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=
|
|
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
|
-
...
|
|
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 {
|
|
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 = '
|
|
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
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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, `$ ${
|
|
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
|
|
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(
|
|
797
|
+
const id = key.slice(jobsPrefix.length);
|
|
665
798
|
const live = getProcesses().get(processKey(userName, id));
|
|
666
|
-
return live?.record ?? (
|
|
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) =>
|
|
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 ?
|
|
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:
|
|
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(
|
|
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
|
+
};
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import 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('
|
|
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(
|
|
293
|
-
|
|
294
|
-
|
|
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 =
|
|
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
|
-
}, [
|
|
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] || '
|
|
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="
|
|
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
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
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
|
|
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:
|
|
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
|
-
<
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
<
|
|
135
|
-
<Filters.
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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('');
|
package/src/app/store/tests.ts
CHANGED
|
@@ -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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
}
|
|
380
|
+
if (hasTestResult) {
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
371
383
|
|
|
384
|
+
const loadData = async () => {
|
|
372
385
|
const key = `test-${testName}`;
|
|
373
386
|
|
|
374
|
-
|
|
387
|
+
try {
|
|
388
|
+
const cachedResult = (await get(key)) as TestResult | null;
|
|
375
389
|
|
|
376
|
-
|
|
377
|
-
|
|
390
|
+
if (!_.isEmpty(cachedResult)) {
|
|
391
|
+
setTest(cachedResult);
|
|
378
392
|
|
|
379
|
-
|
|
380
|
-
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
381
395
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
404
|
+
const test = await getBacktest(testName, resolvedStrategy);
|
|
391
405
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
406
|
+
if (!test) {
|
|
407
|
+
setTestUnavailable(testName);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
395
410
|
|
|
396
|
-
|
|
411
|
+
setTest(test);
|
|
397
412
|
|
|
398
|
-
|
|
413
|
+
await set(key, test);
|
|
414
|
+
} catch {
|
|
415
|
+
setTestUnavailable(testName);
|
|
416
|
+
}
|
|
399
417
|
};
|
|
400
418
|
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
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
|
-
|
|
510
|
-
|
|
541
|
+
if (cachedResult && !_.isEmpty(cachedResult)) {
|
|
542
|
+
setBacktest(id, cachedResult);
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
511
545
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
-
|
|
554
|
+
const backtestData = await getOrderLog(id, resolvedStrategy);
|
|
521
555
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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))
|