@tradejs/app 1.0.9 → 1.0.10
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 +7 -2
- package/bin/tradejs-app.mjs +146 -2
- package/next.config.mjs +0 -14
- package/package.json +35 -19
- package/src/app/actions/backtest.ts +54 -0
- package/src/app/actions/kline.ts +10 -5
- package/src/app/actions/scanner.ts +8 -3
- package/src/app/actions/strategies.ts +39 -0
- package/src/app/api/ai/route.ts +5 -4
- package/src/app/api/auth/[...nextauth]/route.ts +1 -1
- package/src/app/api/backtest/configs/route.ts +25 -0
- package/src/app/api/backtest/files/route.ts +5 -2
- package/src/app/api/backtest/order-log/[strategy]/[name]/route.ts +26 -2
- package/src/app/api/backtest/result/[strategy]/[name]/route.ts +27 -2
- package/src/app/api/backtest/runs/[jobId]/route.ts +124 -0
- package/src/app/api/backtest/runs/route.ts +45 -0
- package/src/app/api/backtest/test/[strategy]/[name]/route.ts +19 -2
- package/src/app/api/derivatives/summary/route.ts +8 -1
- package/src/app/api/kline/[provider]/[universe]/[symbol]/[interval]/route.ts +1 -0
- package/src/app/api/kline/{[provider]/[symbol]/[interval]/route.ts → handler.ts} +61 -39
- package/src/app/api/scanner/[provider]/[universe]/route.ts +1 -0
- package/src/app/api/scanner/[provider]/route.ts +30 -12
- package/src/app/api/scanner/route.ts +3 -3
- package/src/app/api/signal/[symbol]/[signalId]/route.ts +1 -1
- package/src/app/api/strategies/ai/[cardId]/route.ts +22 -0
- package/src/app/api/strategies/ai/route.ts +139 -0
- package/src/app/api/strategies/replay/[cardId]/route.ts +18 -0
- package/src/app/api/strategies/replay/route.ts +44 -0
- package/src/app/api/strategies/runtime/route.ts +831 -0
- package/src/app/api/user/runtime-deployments/[deploymentId]/route.ts +25 -0
- package/src/app/api/user/runtime-deployments/route.ts +94 -0
- package/src/app/api/user/runtime-strategy-configs/route.ts +241 -0
- package/src/app/api/user/settings/route.ts +18 -1
- package/src/app/api/user/trading-accounts/[accountId]/route.ts +35 -0
- package/src/app/api/user/trading-accounts/route.ts +134 -0
- package/src/app/components/Backtest/CompareList/index.tsx +1 -1
- package/src/app/components/Backtest/ResultsPageClient.tsx +376 -0
- package/src/app/components/Backtest/TestCard/ActionsMenu/index.tsx +123 -0
- package/src/app/components/Backtest/TestCard/Chart/index.tsx +1 -1
- package/src/app/components/Backtest/TestCard/CompareButton/index.tsx +1 -1
- package/src/app/components/Backtest/TestCard/ConfigDrawer/index.tsx +28 -12
- package/src/app/components/Backtest/TestCard/DeleteButton/index.tsx +33 -17
- package/src/app/components/Backtest/TestCard/FavoriteIndicator/index.tsx +2 -2
- package/src/app/components/Backtest/TestCard/OpenDashboardButton/index.tsx +10 -1
- package/src/app/components/Backtest/TestCard/OrdersDrawer/index.tsx +128 -0
- package/src/app/components/Backtest/TestCard/Root/index.tsx +1 -1
- package/src/app/components/Backtest/TestCard/Stat/index.tsx +2 -0
- package/src/app/components/Backtest/TestCard/StatDrawer/index.tsx +66 -0
- package/src/app/components/Backtest/TestCard/index.ts +6 -2
- package/src/app/components/Backtest/TestList/index.tsx +4 -9
- package/src/app/components/Dashboard/AiDrawer/index.tsx +1 -1
- package/src/app/components/Dashboard/KlineChart/hooks/useBacktest.ts +1 -1
- package/src/app/components/Dashboard/KlineChart/hooks/useBbIndicator.ts +10 -5
- package/src/app/components/Dashboard/KlineChart/hooks/useBtcCorrelation.ts +1 -1
- package/src/app/components/Dashboard/KlineChart/hooks/useBtcIndicator.ts +4 -1
- package/src/app/components/Dashboard/KlineChart/hooks/useEmaIndicator.ts +3 -1
- package/src/app/components/Dashboard/KlineChart/hooks/usePluginIndicators.ts +1 -1
- package/src/app/components/Dashboard/KlineChart/hooks/useSetup.ts +1 -1
- package/src/app/components/Dashboard/KlineChart/hooks/useSignal.ts +1 -1
- package/src/app/components/Dashboard/KlineChart/hooks/useSpreadIndicator.ts +1 -1
- package/src/app/components/Dashboard/KlineChart/hooks/useTrendLine.ts +1 -1
- package/src/app/components/Dashboard/KlineChart/hooks/useWmaIndicator.ts +3 -1
- package/src/app/components/Dashboard/KlineChart/index.tsx +16 -6
- package/src/app/components/Dashboard/MainChart/index.tsx +3 -22
- package/src/app/components/Shared/AppShell.tsx +1 -1
- package/src/app/components/Shared/BulkSelection/index.tsx +140 -0
- package/src/app/components/Shared/BulkSelection/useBulkSelection.ts +97 -0
- package/src/app/components/Shared/BulkSelection/utils.ts +126 -0
- package/src/app/components/Shared/Filters/Backtest/index.tsx +33 -48
- package/src/app/components/Shared/Filters/FavoriteIndicator/index.tsx +5 -3
- package/src/app/components/Shared/Filters/Indicators/index.tsx +2 -2
- package/src/app/components/Shared/Filters/Interval/index.tsx +1 -1
- package/src/app/components/Shared/Filters/Provider/index.tsx +8 -2
- package/src/app/components/Shared/Filters/Symbol/index.tsx +4 -3
- package/src/app/components/Shared/Filters/Universe/index.tsx +36 -0
- package/src/app/components/Shared/Filters/index.ts +2 -0
- package/src/app/components/Shared/OrdersDrawer.tsx +629 -0
- package/src/app/components/Shared/Sidebar/AccountSettingsDrawer.tsx +55 -28
- package/src/app/components/Shared/Sidebar/TradingAccountsPanel.tsx +445 -0
- package/src/app/components/Shared/Sidebar/index.tsx +17 -3
- package/src/app/components/Strategies/AdvancedMetricsPanel.tsx +727 -0
- package/src/app/components/Strategies/RuntimeStrategyCard.tsx +2204 -0
- package/src/app/components/Strategies/RuntimeStrategyCardSkeleton.tsx +40 -0
- package/src/app/components/Strategies/RuntimeStrategyChart.tsx +121 -0
- package/src/app/components/Strategies/RuntimeStrategyConfigDrawer.tsx +458 -0
- package/src/app/components/Strategies/StrategySnapshotCard.tsx +2890 -0
- package/src/app/components/Strategies/StrategySnapshotChart.tsx +94 -0
- package/src/app/components/Strategies/StrategySnapshotList.tsx +82 -0
- package/src/app/components/UI/Segment/index.tsx +8 -1
- package/src/app/components/UI/Select/index.tsx +24 -13
- package/src/app/components/UI/SelectWithSearch/index.tsx +46 -5
- package/src/app/layout.tsx +1 -1
- package/src/app/lib/backtestJobs.ts +792 -0
- package/src/app/lib/cardPageLayout.ts +1 -0
- package/src/app/lib/connectorCreator.ts +28 -0
- package/src/app/lib/currentUser.ts +1 -1
- package/src/app/lib/errorMessage.ts +34 -0
- package/src/app/lib/marketDefaults.ts +12 -0
- package/src/app/lib/marketRoutes.ts +58 -0
- package/src/app/lib/runtimeStrategies.ts +1376 -0
- package/src/app/lib/runtimeStrategyConfigForm.ts +86 -0
- package/src/app/provider.tsx +1 -1
- package/src/app/routes/backtest/page.tsx +1051 -304
- package/src/app/routes/dashboard/{[provider]/[symbol]/[interval]/page.tsx → Dashboard.tsx} +35 -15
- package/src/app/routes/dashboard/[provider]/[universe]/[symbol]/[interval]/page.tsx +1 -0
- package/src/app/routes/dashboard/page.tsx +15 -3
- package/src/app/routes/derivatives/page.tsx +1222 -165
- package/src/app/routes/strategies/StrategiesPageClient.tsx +593 -0
- package/src/app/routes/strategies/[mode]/page.tsx +20 -0
- package/src/app/routes/strategies/page.tsx +7 -0
- package/src/app/store/ai.ts +1 -1
- package/src/app/store/data.ts +73 -17
- package/src/app/store/filters.ts +1 -0
- package/src/app/store/marketKlineStream.ts +87 -0
- package/src/app/store/tests.ts +1 -1
- package/src/app/store/tickers.ts +59 -22
- package/tsconfig.json +17 -12
- package/src/app/components/Backtest/TestCard/OpenReportButton/index.tsx +0 -24
- package/src/app/routes/backtest/[test]/page.tsx +0 -33
|
@@ -0,0 +1,792 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from 'child_process';
|
|
2
|
+
import { randomUUID } from 'crypto';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { TTL_1M } from '@tradejs/core/constants';
|
|
5
|
+
import { delKey, getData, getKeys, setData } from '@tradejs/infra/redis';
|
|
6
|
+
import { logger } from '@tradejs/infra/logger';
|
|
7
|
+
import type { StrategyConfigGrid } from '@tradejs/types';
|
|
8
|
+
|
|
9
|
+
const HEARTBEAT_TIMEOUT_MS = 20_000;
|
|
10
|
+
const SWEEP_INTERVAL_MS = 5_000;
|
|
11
|
+
const MAX_LOG_LINES = 220;
|
|
12
|
+
const DEFAULT_INTERVAL = '15';
|
|
13
|
+
const DEFAULT_CONNECTOR = 'bybit';
|
|
14
|
+
|
|
15
|
+
export type BacktestJobStatus =
|
|
16
|
+
| 'running'
|
|
17
|
+
| 'pausing'
|
|
18
|
+
| 'paused'
|
|
19
|
+
| 'completed'
|
|
20
|
+
| 'failed'
|
|
21
|
+
| 'cancelled';
|
|
22
|
+
|
|
23
|
+
export type BacktestPeriodMode = 'days' | 'range';
|
|
24
|
+
|
|
25
|
+
export interface BacktestJobRequest {
|
|
26
|
+
strategyName: string;
|
|
27
|
+
configId: string;
|
|
28
|
+
periodMode: BacktestPeriodMode;
|
|
29
|
+
days?: number;
|
|
30
|
+
startTime?: number;
|
|
31
|
+
endTime?: number;
|
|
32
|
+
ai: boolean;
|
|
33
|
+
fast: boolean;
|
|
34
|
+
interval: string;
|
|
35
|
+
connector: string;
|
|
36
|
+
tickers?: string;
|
|
37
|
+
tickersLimit?: number;
|
|
38
|
+
testsLimit?: number;
|
|
39
|
+
parallel?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface BacktestJobProgress {
|
|
43
|
+
completed: number;
|
|
44
|
+
total: number | null;
|
|
45
|
+
percent: number;
|
|
46
|
+
averageProfit: number | null;
|
|
47
|
+
winRate: number | null;
|
|
48
|
+
successTests: number | null;
|
|
49
|
+
errorTests: number | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface BacktestJobRecord {
|
|
53
|
+
id: string;
|
|
54
|
+
userName: string;
|
|
55
|
+
status: BacktestJobStatus;
|
|
56
|
+
request: BacktestJobRequest;
|
|
57
|
+
command: string;
|
|
58
|
+
args: string[];
|
|
59
|
+
createdAt: string;
|
|
60
|
+
updatedAt: string;
|
|
61
|
+
startedAt?: string;
|
|
62
|
+
finishedAt?: string;
|
|
63
|
+
pausedAt?: string;
|
|
64
|
+
cancelledAt?: string;
|
|
65
|
+
lastHeartbeatAt?: string;
|
|
66
|
+
pid?: number;
|
|
67
|
+
exitCode?: number | null;
|
|
68
|
+
signal?: NodeJS.Signals | null;
|
|
69
|
+
runCount: number;
|
|
70
|
+
progress: BacktestJobProgress;
|
|
71
|
+
logs: string[];
|
|
72
|
+
error?: string;
|
|
73
|
+
pauseReason?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface BacktestConfigSummary {
|
|
77
|
+
id: string;
|
|
78
|
+
strategyName: string;
|
|
79
|
+
paramCount: number;
|
|
80
|
+
combinationCount: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
type BacktestProcessHandle = {
|
|
84
|
+
child: ChildProcess;
|
|
85
|
+
record: BacktestJobRecord;
|
|
86
|
+
offset: number;
|
|
87
|
+
pauseRequested: boolean;
|
|
88
|
+
cancelRequested: boolean;
|
|
89
|
+
killTimer?: ReturnType<typeof setTimeout>;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
declare global {
|
|
93
|
+
var __tradejsBacktestProcesses__:
|
|
94
|
+
| Map<string, BacktestProcessHandle>
|
|
95
|
+
| undefined;
|
|
96
|
+
var __tradejsBacktestSweepTimer__: ReturnType<typeof setInterval> | undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const getProcesses = () => {
|
|
100
|
+
global.__tradejsBacktestProcesses__ ??= new Map();
|
|
101
|
+
return global.__tradejsBacktestProcesses__;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const processKey = (userName: string, jobId: string) => `${userName}:${jobId}`;
|
|
105
|
+
|
|
106
|
+
const getJobsPrefix = (userName: string) => `users:${userName}:backtests:runs:`;
|
|
107
|
+
|
|
108
|
+
const getJobKey = (userName: string, jobId: string) =>
|
|
109
|
+
`${getJobsPrefix(userName)}${jobId}`;
|
|
110
|
+
|
|
111
|
+
const getBacktestConfigsPrefix = (userName: string) =>
|
|
112
|
+
`users:${userName}:backtests:configs:`;
|
|
113
|
+
|
|
114
|
+
const nowIso = () => new Date().toISOString();
|
|
115
|
+
|
|
116
|
+
const projectRoot =
|
|
117
|
+
String(process.env.PROJECT_CWD || process.cwd()).trim() || process.cwd();
|
|
118
|
+
|
|
119
|
+
const yarnCommand = process.platform === 'win32' ? 'yarn.cmd' : 'yarn';
|
|
120
|
+
|
|
121
|
+
const emptyProgress = (): BacktestJobProgress => ({
|
|
122
|
+
completed: 0,
|
|
123
|
+
total: null,
|
|
124
|
+
percent: 0,
|
|
125
|
+
averageProfit: null,
|
|
126
|
+
winRate: null,
|
|
127
|
+
successTests: null,
|
|
128
|
+
errorTests: null,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const toFiniteNumber = (value: unknown): number | null => {
|
|
132
|
+
const parsed = Number(value);
|
|
133
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const toPositiveNumber = (value: unknown): number | undefined => {
|
|
137
|
+
const parsed = toFiniteNumber(value);
|
|
138
|
+
return parsed != null && parsed > 0 ? parsed : undefined;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const toPositiveInteger = (value: unknown): number | undefined => {
|
|
142
|
+
const parsed = toPositiveNumber(value);
|
|
143
|
+
return parsed == null ? undefined : Math.trunc(parsed);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const normalizeText = (value: unknown) =>
|
|
147
|
+
typeof value === 'string' ? value.trim() : '';
|
|
148
|
+
|
|
149
|
+
const stripAnsi = (value: string) =>
|
|
150
|
+
value.replace(
|
|
151
|
+
/[\u001b\u009b][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[a-zA-Z\d]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g,
|
|
152
|
+
'',
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
|
156
|
+
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
157
|
+
|
|
158
|
+
const isStrategyConfigGrid = (value: unknown): value is StrategyConfigGrid =>
|
|
159
|
+
isPlainObject(value) &&
|
|
160
|
+
Object.values(value).every((item) => Array.isArray(item));
|
|
161
|
+
|
|
162
|
+
const estimateCombinationCount = (grid: StrategyConfigGrid) =>
|
|
163
|
+
Object.values(grid).reduce((total, values) => total * values.length, 1);
|
|
164
|
+
|
|
165
|
+
const formatError = (error: unknown) =>
|
|
166
|
+
error instanceof Error ? error.message : String(error);
|
|
167
|
+
|
|
168
|
+
const appendLog = (record: BacktestJobRecord, rawLine: string) => {
|
|
169
|
+
const line = stripAnsi(rawLine).trim();
|
|
170
|
+
if (!line) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
record.logs = [...record.logs, line].slice(-MAX_LOG_LINES);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const recalculatePercent = (progress: BacktestJobProgress) => {
|
|
178
|
+
if (!progress.total || progress.total <= 0) {
|
|
179
|
+
progress.percent = 0;
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
progress.percent = Math.max(
|
|
184
|
+
0,
|
|
185
|
+
Math.min(100, (progress.completed / progress.total) * 100),
|
|
186
|
+
);
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
export const parseBacktestProgressLine = (line: string, offset = 0) => {
|
|
190
|
+
const text = stripAnsi(line);
|
|
191
|
+
const progressMatch = text.match(
|
|
192
|
+
/(\d+)\/(\d+).*?\bavg\s+(-?\d+(?:\.\d+)?)\$\s+win\s+(-?\d+(?:\.\d+)?)%/i,
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
if (progressMatch) {
|
|
196
|
+
const completed = Number(progressMatch[1]);
|
|
197
|
+
const total = Number(progressMatch[2]);
|
|
198
|
+
return {
|
|
199
|
+
completed: offset + completed,
|
|
200
|
+
total: offset + total,
|
|
201
|
+
averageProfit: Number(progressMatch[3]),
|
|
202
|
+
winRate: Number(progressMatch[4]),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const testsMatch = text.match(/\btests:\s*(\d+)\b/i);
|
|
207
|
+
if (testsMatch) {
|
|
208
|
+
return {
|
|
209
|
+
total: offset + Number(testsMatch[1]),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const successMatch = text.match(/\bSUCCESS TESTS:\s*(\d+)\b/i);
|
|
214
|
+
if (successMatch) {
|
|
215
|
+
return {
|
|
216
|
+
successTests: offset + Number(successMatch[1]),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const errorMatch = text.match(/\bERRORS:\s*(\d+)\b/i);
|
|
221
|
+
if (errorMatch) {
|
|
222
|
+
return {
|
|
223
|
+
errorTests: Number(errorMatch[1]),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return null;
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const applyProgressLine = (
|
|
231
|
+
record: BacktestJobRecord,
|
|
232
|
+
line: string,
|
|
233
|
+
offset: number,
|
|
234
|
+
) => {
|
|
235
|
+
const parsed = parseBacktestProgressLine(line, offset);
|
|
236
|
+
if (!parsed) {
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
record.progress = {
|
|
241
|
+
...record.progress,
|
|
242
|
+
...parsed,
|
|
243
|
+
};
|
|
244
|
+
recalculatePercent(record.progress);
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const applyOutputChunk = (
|
|
248
|
+
record: BacktestJobRecord,
|
|
249
|
+
chunk: Buffer,
|
|
250
|
+
offset: number,
|
|
251
|
+
) => {
|
|
252
|
+
const text = chunk.toString('utf8');
|
|
253
|
+
const lines = text.split(/\r|\n/);
|
|
254
|
+
for (const line of lines) {
|
|
255
|
+
appendLog(record, line);
|
|
256
|
+
applyProgressLine(record, line, offset);
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const saveJob = async (record: BacktestJobRecord) => {
|
|
261
|
+
record.updatedAt = nowIso();
|
|
262
|
+
await setData(getJobKey(record.userName, record.id), record, {
|
|
263
|
+
expire: TTL_1M,
|
|
264
|
+
});
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const loadJob = async (userName: string, jobId: string) =>
|
|
268
|
+
(await getData(getJobKey(userName, jobId), null)) as BacktestJobRecord | null;
|
|
269
|
+
|
|
270
|
+
const getLiveRecord = async (userName: string, jobId: string) => {
|
|
271
|
+
const handle = getProcesses().get(processKey(userName, jobId));
|
|
272
|
+
if (handle) {
|
|
273
|
+
return handle.record;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return loadJob(userName, jobId);
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
export const normalizeBacktestJobRequest = (
|
|
280
|
+
payload: unknown,
|
|
281
|
+
): BacktestJobRequest => {
|
|
282
|
+
if (!isPlainObject(payload)) {
|
|
283
|
+
throw new Error('Invalid backtest request');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const configId = normalizeText(payload.configId);
|
|
287
|
+
if (!configId) {
|
|
288
|
+
throw new Error('Backtest config is required');
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const derivedStrategyName = configId.split(':')[0] || configId;
|
|
292
|
+
const strategyName =
|
|
293
|
+
normalizeText(payload.strategyName) || derivedStrategyName;
|
|
294
|
+
const periodMode: BacktestPeriodMode =
|
|
295
|
+
payload.periodMode === 'range' ? 'range' : 'days';
|
|
296
|
+
const interval = normalizeText(payload.interval) || DEFAULT_INTERVAL;
|
|
297
|
+
const connector = normalizeText(payload.connector) || DEFAULT_CONNECTOR;
|
|
298
|
+
|
|
299
|
+
const request: BacktestJobRequest = {
|
|
300
|
+
strategyName,
|
|
301
|
+
configId,
|
|
302
|
+
periodMode,
|
|
303
|
+
ai: payload.ai === true,
|
|
304
|
+
fast: payload.fast === true,
|
|
305
|
+
interval,
|
|
306
|
+
connector,
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
if (periodMode === 'range') {
|
|
310
|
+
const startTime = toPositiveInteger(payload.startTime);
|
|
311
|
+
const endTime = toPositiveInteger(payload.endTime);
|
|
312
|
+
if (!startTime || !endTime || startTime >= endTime) {
|
|
313
|
+
throw new Error('Valid start and end timestamps are required');
|
|
314
|
+
}
|
|
315
|
+
request.startTime = startTime;
|
|
316
|
+
request.endTime = endTime;
|
|
317
|
+
} else {
|
|
318
|
+
request.days = toPositiveNumber(payload.days) ?? 30;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const tickers = normalizeText(payload.tickers);
|
|
322
|
+
if (tickers) {
|
|
323
|
+
request.tickers = tickers;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const tickersLimit = toPositiveInteger(payload.tickersLimit);
|
|
327
|
+
if (tickersLimit) {
|
|
328
|
+
request.tickersLimit = tickersLimit;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const testsLimit = toPositiveInteger(payload.testsLimit);
|
|
332
|
+
if (testsLimit) {
|
|
333
|
+
request.testsLimit = testsLimit;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const parallel = toPositiveInteger(payload.parallel);
|
|
337
|
+
if (parallel) {
|
|
338
|
+
request.parallel = parallel;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return request;
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
export const buildBacktestCommandArgs = ({
|
|
345
|
+
request,
|
|
346
|
+
userName,
|
|
347
|
+
skip = 0,
|
|
348
|
+
}: {
|
|
349
|
+
request: BacktestJobRequest;
|
|
350
|
+
userName: string;
|
|
351
|
+
skip?: number;
|
|
352
|
+
}) => {
|
|
353
|
+
const args = [
|
|
354
|
+
'backtest',
|
|
355
|
+
'--config',
|
|
356
|
+
request.configId,
|
|
357
|
+
'--user',
|
|
358
|
+
userName,
|
|
359
|
+
'--timeframe',
|
|
360
|
+
request.interval,
|
|
361
|
+
'--connector',
|
|
362
|
+
request.connector,
|
|
363
|
+
'--progressStep',
|
|
364
|
+
'1',
|
|
365
|
+
];
|
|
366
|
+
|
|
367
|
+
if (request.periodMode === 'range') {
|
|
368
|
+
args.push(
|
|
369
|
+
'--startTime',
|
|
370
|
+
String(request.startTime),
|
|
371
|
+
'--endTime',
|
|
372
|
+
String(request.endTime),
|
|
373
|
+
);
|
|
374
|
+
} else if (request.days) {
|
|
375
|
+
args.push('--days', String(request.days));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (request.ai) {
|
|
379
|
+
args.push('--ai');
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (request.fast) {
|
|
383
|
+
args.push('--fast');
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (request.tickers) {
|
|
387
|
+
args.push('--tickers', request.tickers);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (request.tickersLimit) {
|
|
391
|
+
args.push('--tickersLimit', String(request.tickersLimit));
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (request.parallel) {
|
|
395
|
+
args.push('--parallel', String(request.parallel));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (skip > 0) {
|
|
399
|
+
args.push('--skip', String(skip));
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (request.testsLimit) {
|
|
403
|
+
args.push('--tests', String(Math.max(0, request.testsLimit - skip)));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return args;
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
const shouldSkipLaunch = (record: BacktestJobRecord, skip: number) =>
|
|
410
|
+
Boolean(record.request.testsLimit && skip >= record.request.testsLimit);
|
|
411
|
+
|
|
412
|
+
const finalizeWithoutLaunch = async (record: BacktestJobRecord) => {
|
|
413
|
+
record.status = 'completed';
|
|
414
|
+
record.finishedAt = nowIso();
|
|
415
|
+
record.progress.total = record.progress.completed;
|
|
416
|
+
record.progress.percent = 100;
|
|
417
|
+
appendLog(record, 'No remaining tests to run.');
|
|
418
|
+
await saveJob(record);
|
|
419
|
+
return record;
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
const launchBacktestProcess = async (
|
|
423
|
+
record: BacktestJobRecord,
|
|
424
|
+
skip: number,
|
|
425
|
+
) => {
|
|
426
|
+
ensureSweepTimer();
|
|
427
|
+
|
|
428
|
+
if (shouldSkipLaunch(record, skip)) {
|
|
429
|
+
return finalizeWithoutLaunch(record);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const key = processKey(record.userName, record.id);
|
|
433
|
+
if (getProcesses().has(key)) {
|
|
434
|
+
throw new Error('Backtest job is already running');
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const args = buildBacktestCommandArgs({
|
|
438
|
+
request: record.request,
|
|
439
|
+
userName: record.userName,
|
|
440
|
+
skip,
|
|
441
|
+
});
|
|
442
|
+
const child = spawn(yarnCommand, args, {
|
|
443
|
+
cwd: projectRoot,
|
|
444
|
+
env: {
|
|
445
|
+
...process.env,
|
|
446
|
+
PROJECT_CWD: projectRoot,
|
|
447
|
+
DOTENV_CONFIG_PATH:
|
|
448
|
+
process.env.DOTENV_CONFIG_PATH || path.join(projectRoot, '.env'),
|
|
449
|
+
},
|
|
450
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
451
|
+
});
|
|
452
|
+
const startedAt = nowIso();
|
|
453
|
+
|
|
454
|
+
record.status = 'running';
|
|
455
|
+
record.command = yarnCommand;
|
|
456
|
+
record.args = args;
|
|
457
|
+
record.pid = child.pid;
|
|
458
|
+
record.exitCode = undefined;
|
|
459
|
+
record.signal = undefined;
|
|
460
|
+
record.error = undefined;
|
|
461
|
+
record.pauseReason = undefined;
|
|
462
|
+
record.startedAt ??= startedAt;
|
|
463
|
+
record.lastHeartbeatAt = startedAt;
|
|
464
|
+
record.runCount += 1;
|
|
465
|
+
appendLog(record, `$ ${yarnCommand} ${args.join(' ')}`);
|
|
466
|
+
await saveJob(record);
|
|
467
|
+
|
|
468
|
+
const handle: BacktestProcessHandle = {
|
|
469
|
+
child,
|
|
470
|
+
record,
|
|
471
|
+
offset: skip,
|
|
472
|
+
pauseRequested: false,
|
|
473
|
+
cancelRequested: false,
|
|
474
|
+
};
|
|
475
|
+
getProcesses().set(key, handle);
|
|
476
|
+
|
|
477
|
+
child.stdout?.on('data', (chunk: Buffer) => {
|
|
478
|
+
applyOutputChunk(record, chunk, handle.offset);
|
|
479
|
+
void saveJob(record);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
child.stderr?.on('data', (chunk: Buffer) => {
|
|
483
|
+
applyOutputChunk(record, chunk, handle.offset);
|
|
484
|
+
void saveJob(record);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
child.on('error', (error) => {
|
|
488
|
+
record.status = 'failed';
|
|
489
|
+
record.error = formatError(error);
|
|
490
|
+
record.finishedAt = nowIso();
|
|
491
|
+
appendLog(record, `Process error: ${record.error}`);
|
|
492
|
+
getProcesses().delete(key);
|
|
493
|
+
void saveJob(record);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
child.on('exit', (code, signal) => {
|
|
497
|
+
if (handle.killTimer) {
|
|
498
|
+
clearTimeout(handle.killTimer);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
record.exitCode = code;
|
|
502
|
+
record.signal = signal;
|
|
503
|
+
record.pid = undefined;
|
|
504
|
+
getProcesses().delete(key);
|
|
505
|
+
|
|
506
|
+
if (handle.cancelRequested) {
|
|
507
|
+
record.status = 'cancelled';
|
|
508
|
+
record.cancelledAt = nowIso();
|
|
509
|
+
appendLog(record, 'Backtest cancelled.');
|
|
510
|
+
} else if (handle.pauseRequested) {
|
|
511
|
+
record.status = 'paused';
|
|
512
|
+
record.pausedAt = nowIso();
|
|
513
|
+
appendLog(record, 'Backtest paused.');
|
|
514
|
+
} else if (code === 0) {
|
|
515
|
+
record.status = 'completed';
|
|
516
|
+
record.finishedAt = nowIso();
|
|
517
|
+
if (record.progress.total != null) {
|
|
518
|
+
record.progress.completed = record.progress.total;
|
|
519
|
+
record.progress.percent = 100;
|
|
520
|
+
}
|
|
521
|
+
appendLog(record, 'Backtest completed.');
|
|
522
|
+
} else {
|
|
523
|
+
record.status = 'failed';
|
|
524
|
+
record.finishedAt = nowIso();
|
|
525
|
+
record.error = `Backtest process exited with code ${code ?? 'null'}${signal ? ` (${signal})` : ''}`;
|
|
526
|
+
appendLog(record, record.error);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
void saveJob(record);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
return record;
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
const requestProcessStop = (
|
|
536
|
+
handle: BacktestProcessHandle,
|
|
537
|
+
{
|
|
538
|
+
cancel,
|
|
539
|
+
reason,
|
|
540
|
+
}: {
|
|
541
|
+
cancel: boolean;
|
|
542
|
+
reason: string;
|
|
543
|
+
},
|
|
544
|
+
) => {
|
|
545
|
+
const { child, record } = handle;
|
|
546
|
+
if (cancel) {
|
|
547
|
+
handle.cancelRequested = true;
|
|
548
|
+
record.status = 'cancelled';
|
|
549
|
+
record.cancelledAt = nowIso();
|
|
550
|
+
} else {
|
|
551
|
+
handle.pauseRequested = true;
|
|
552
|
+
record.status = 'pausing';
|
|
553
|
+
record.pauseReason = reason;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
appendLog(record, cancel ? 'Cancelling backtest...' : 'Pausing backtest...');
|
|
557
|
+
|
|
558
|
+
if (!child.killed) {
|
|
559
|
+
child.kill('SIGTERM');
|
|
560
|
+
handle.killTimer = setTimeout(() => {
|
|
561
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
562
|
+
child.kill('SIGKILL');
|
|
563
|
+
}
|
|
564
|
+
}, 8_000);
|
|
565
|
+
}
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
const pauseRunningHandle = async (
|
|
569
|
+
handle: BacktestProcessHandle,
|
|
570
|
+
reason: string,
|
|
571
|
+
) => {
|
|
572
|
+
requestProcessStop(handle, { cancel: false, reason });
|
|
573
|
+
await saveJob(handle.record);
|
|
574
|
+
return handle.record;
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
const isHeartbeatStale = (record: BacktestJobRecord, now = Date.now()) => {
|
|
578
|
+
const heartbeatAt = Date.parse(record.lastHeartbeatAt || record.updatedAt);
|
|
579
|
+
return (
|
|
580
|
+
!Number.isFinite(heartbeatAt) || now - heartbeatAt > HEARTBEAT_TIMEOUT_MS
|
|
581
|
+
);
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
const sweepRunningHandles = async () => {
|
|
585
|
+
const now = Date.now();
|
|
586
|
+
for (const handle of getProcesses().values()) {
|
|
587
|
+
if (
|
|
588
|
+
handle.record.status === 'running' &&
|
|
589
|
+
isHeartbeatStale(handle.record, now)
|
|
590
|
+
) {
|
|
591
|
+
await pauseRunningHandle(handle, 'heartbeat_lost');
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
const ensureSweepTimer = () => {
|
|
597
|
+
if (global.__tradejsBacktestSweepTimer__) {
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
global.__tradejsBacktestSweepTimer__ = setInterval(() => {
|
|
602
|
+
void sweepRunningHandles().catch((error) => {
|
|
603
|
+
logger.warn('backtest job sweep failed: %s', formatError(error));
|
|
604
|
+
});
|
|
605
|
+
}, SWEEP_INTERVAL_MS);
|
|
606
|
+
global.__tradejsBacktestSweepTimer__.unref?.();
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
const reconcileDetachedRunningJob = async (record: BacktestJobRecord) => {
|
|
610
|
+
if (
|
|
611
|
+
(record.status === 'running' || record.status === 'pausing') &&
|
|
612
|
+
!getProcesses().has(processKey(record.userName, record.id))
|
|
613
|
+
) {
|
|
614
|
+
record.status = 'paused';
|
|
615
|
+
record.pausedAt = nowIso();
|
|
616
|
+
record.pauseReason = 'detached_process';
|
|
617
|
+
record.pid = undefined;
|
|
618
|
+
appendLog(record, 'Backtest process is no longer attached; paused.');
|
|
619
|
+
await saveJob(record);
|
|
620
|
+
}
|
|
621
|
+
return record;
|
|
622
|
+
};
|
|
623
|
+
|
|
624
|
+
export const listBacktestConfigs = async (
|
|
625
|
+
userName: string,
|
|
626
|
+
): Promise<BacktestConfigSummary[]> => {
|
|
627
|
+
const prefix = getBacktestConfigsPrefix(userName);
|
|
628
|
+
const keys = await getKeys(prefix);
|
|
629
|
+
const configs = await Promise.all(
|
|
630
|
+
keys.map(async (key): Promise<BacktestConfigSummary | null> => {
|
|
631
|
+
const id = key.slice(prefix.length);
|
|
632
|
+
if (!id) {
|
|
633
|
+
return null;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const grid = await getData(key, null);
|
|
637
|
+
if (!isStrategyConfigGrid(grid)) {
|
|
638
|
+
return null;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
return {
|
|
642
|
+
id,
|
|
643
|
+
strategyName: id.split(':')[0] || id,
|
|
644
|
+
paramCount: Object.keys(grid).length,
|
|
645
|
+
combinationCount: estimateCombinationCount(grid),
|
|
646
|
+
};
|
|
647
|
+
}),
|
|
648
|
+
);
|
|
649
|
+
|
|
650
|
+
return configs
|
|
651
|
+
.filter((config): config is BacktestConfigSummary => Boolean(config))
|
|
652
|
+
.sort((left, right) => left.id.localeCompare(right.id));
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
export const listBacktestJobs = async (
|
|
656
|
+
userName: string,
|
|
657
|
+
): Promise<BacktestJobRecord[]> => {
|
|
658
|
+
ensureSweepTimer();
|
|
659
|
+
await sweepRunningHandles();
|
|
660
|
+
|
|
661
|
+
const keys = await getKeys(getJobsPrefix(userName));
|
|
662
|
+
const records = await Promise.all(
|
|
663
|
+
keys.map(async (key) => {
|
|
664
|
+
const id = key.slice(getJobsPrefix(userName).length);
|
|
665
|
+
const live = getProcesses().get(processKey(userName, id));
|
|
666
|
+
return live?.record ?? ((await getData(key, null)) as BacktestJobRecord);
|
|
667
|
+
}),
|
|
668
|
+
);
|
|
669
|
+
|
|
670
|
+
const reconciled = await Promise.all(
|
|
671
|
+
records
|
|
672
|
+
.filter(Boolean)
|
|
673
|
+
.map((record) => reconcileDetachedRunningJob(record)),
|
|
674
|
+
);
|
|
675
|
+
|
|
676
|
+
return reconciled.sort(
|
|
677
|
+
(left, right) =>
|
|
678
|
+
Date.parse(right.createdAt || '') - Date.parse(left.createdAt || ''),
|
|
679
|
+
);
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
export const getBacktestJob = async (userName: string, jobId: string) => {
|
|
683
|
+
ensureSweepTimer();
|
|
684
|
+
const record = await getLiveRecord(userName, jobId);
|
|
685
|
+
return record ? reconcileDetachedRunningJob(record) : null;
|
|
686
|
+
};
|
|
687
|
+
|
|
688
|
+
export const startBacktestJob = async (userName: string, payload: unknown) => {
|
|
689
|
+
const request = normalizeBacktestJobRequest(payload);
|
|
690
|
+
const createdAt = nowIso();
|
|
691
|
+
const record: BacktestJobRecord = {
|
|
692
|
+
id: randomUUID(),
|
|
693
|
+
userName,
|
|
694
|
+
status: 'paused',
|
|
695
|
+
request,
|
|
696
|
+
command: yarnCommand,
|
|
697
|
+
args: [],
|
|
698
|
+
createdAt,
|
|
699
|
+
updatedAt: createdAt,
|
|
700
|
+
lastHeartbeatAt: createdAt,
|
|
701
|
+
runCount: 0,
|
|
702
|
+
progress: emptyProgress(),
|
|
703
|
+
logs: [],
|
|
704
|
+
};
|
|
705
|
+
await saveJob(record);
|
|
706
|
+
return launchBacktestProcess(record, 0);
|
|
707
|
+
};
|
|
708
|
+
|
|
709
|
+
export const heartbeatBacktestJob = async (userName: string, jobId: string) => {
|
|
710
|
+
const record = await getBacktestJob(userName, jobId);
|
|
711
|
+
if (!record) {
|
|
712
|
+
return null;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
if (record.status === 'running') {
|
|
716
|
+
record.lastHeartbeatAt = nowIso();
|
|
717
|
+
await saveJob(record);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
return record;
|
|
721
|
+
};
|
|
722
|
+
|
|
723
|
+
export const pauseBacktestJob = async (
|
|
724
|
+
userName: string,
|
|
725
|
+
jobId: string,
|
|
726
|
+
reason = 'manual_pause',
|
|
727
|
+
) => {
|
|
728
|
+
const key = processKey(userName, jobId);
|
|
729
|
+
const handle = getProcesses().get(key);
|
|
730
|
+
if (handle) {
|
|
731
|
+
return pauseRunningHandle(handle, reason);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const record = await loadJob(userName, jobId);
|
|
735
|
+
if (!record) {
|
|
736
|
+
return null;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
if (record.status === 'running' || record.status === 'pausing') {
|
|
740
|
+
record.status = 'paused';
|
|
741
|
+
record.pausedAt = nowIso();
|
|
742
|
+
record.pauseReason = reason;
|
|
743
|
+
record.pid = undefined;
|
|
744
|
+
appendLog(record, 'Backtest paused.');
|
|
745
|
+
await saveJob(record);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
return record;
|
|
749
|
+
};
|
|
750
|
+
|
|
751
|
+
export const resumeBacktestJob = async (userName: string, jobId: string) => {
|
|
752
|
+
const record = await getBacktestJob(userName, jobId);
|
|
753
|
+
if (!record) {
|
|
754
|
+
return null;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
if (record.status !== 'paused') {
|
|
758
|
+
return record;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
return launchBacktestProcess(record, record.progress.completed);
|
|
762
|
+
};
|
|
763
|
+
|
|
764
|
+
export const cancelBacktestJob = async (userName: string, jobId: string) => {
|
|
765
|
+
const key = processKey(userName, jobId);
|
|
766
|
+
const handle = getProcesses().get(key);
|
|
767
|
+
if (handle) {
|
|
768
|
+
requestProcessStop(handle, { cancel: true, reason: 'cancelled' });
|
|
769
|
+
await saveJob(handle.record);
|
|
770
|
+
return handle.record;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const record = await loadJob(userName, jobId);
|
|
774
|
+
if (!record) {
|
|
775
|
+
return null;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
if (record.status !== 'completed') {
|
|
779
|
+
record.status = 'cancelled';
|
|
780
|
+
record.cancelledAt = nowIso();
|
|
781
|
+
record.pid = undefined;
|
|
782
|
+
appendLog(record, 'Backtest cancelled.');
|
|
783
|
+
await saveJob(record);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
return record;
|
|
787
|
+
};
|
|
788
|
+
|
|
789
|
+
export const deleteBacktestJob = async (userName: string, jobId: string) => {
|
|
790
|
+
await cancelBacktestJob(userName, jobId);
|
|
791
|
+
return delKey(getJobKey(userName, jobId));
|
|
792
|
+
};
|