create-tradejs 3.1.22 → 3.1.23
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 +19 -3
- package/dist/index.js +36 -5
- package/dist/skill-bundle/.codex/skills/ai-train-local-research/SKILL.md +596 -0
- package/dist/skill-bundle/.codex/skills/ai-train-local-research/references/gate-ablation.md +324 -0
- package/dist/skill-bundle/.codex/skills/ai-train-local-research/references/reporting.md +227 -0
- package/dist/skill-bundle/.codex/skills/ai-train-local-research/scripts/ai-gate-ablation.mjs +5082 -0
- package/dist/skill-bundle/.codex/skills/ai-train-local-research/scripts/ai-gate-ablation.test.mjs +1170 -0
- package/dist/skill-bundle/.codex/skills/backtest-config-redis/SKILL.md +17 -0
- package/dist/skill-bundle/.codex/skills/backtest-config-redis/scripts/get_backtest_config.sh +21 -0
- package/dist/skill-bundle/.codex/skills/runtime-parity-mismatch-analysis/SKILL.md +146 -0
- package/dist/skill-bundle/.codex/skills/save-strategy-config-from-backtest/SKILL.md +58 -0
- package/dist/skill-bundle/.codex/skills/save-strategy-config-from-backtest/agents/openai.yaml +4 -0
- package/dist/skill-bundle/.codex/skills/strategy-backtest-research/SKILL.md +334 -0
- package/dist/skill-bundle/.codex/skills/strategy-backtest-research/references/research-notes.md +247 -0
- package/dist/skill-bundle/.codex/skills/strategy-backtest-research/scripts/backtest-run-metrics.mjs +647 -0
- package/dist/skill-bundle/.codex/skills/strategy-backtest-research/scripts/backtest-run-metrics.test.mjs +321 -0
- package/dist/skill-bundle/.codex/skills/strategy-backtest-research/scripts/fast-ai-export-metrics.mjs +744 -0
- package/dist/skill-bundle/.codex/skills/strategy-backtest-research/scripts/fast-ai-export-metrics.test.mjs +553 -0
- package/dist/skill-bundle/.codex/skills/strategy-backtest-research/scripts/research-notes-check.mjs +125 -0
- package/dist/skill-bundle/.codex/skills/strategy-improvement-research/SKILL.md +18 -1
- package/dist/skill-bundle/.codex/skills/strategy-release/SKILL.md +22 -0
- package/dist/skill-bundle/.codex/skills/strategy-release/agents/openai.yaml +4 -0
- package/dist/skill-bundle/.codex/skills/strategy-release/references/diagnose-live.md +126 -0
- package/dist/skill-bundle/.codex/skills/strategy-release/references/direction-policy.md +141 -0
- package/dist/skill-bundle/.codex/skills/strategy-release/references/directional-parameter-split.md +93 -0
- package/dist/skill-bundle/.codex/skills/strategy-release/references/evidence-limitations.md +76 -0
- package/dist/skill-bundle/.codex/skills/strategy-release/references/evidence-retention.md +157 -0
- package/dist/skill-bundle/.codex/skills/strategy-release/references/historical-hypothesis-audit.md +163 -0
- package/dist/skill-bundle/.codex/skills/strategy-release/references/professional-research-loop.md +198 -0
- package/dist/skill-bundle/.codex/skills/strategy-release/references/release-workflow.md +755 -0
- package/dist/skill-bundle/.codex/skills/strategy-release/references/research-objective.md +255 -0
- package/dist/skill-bundle/.codex/skills/strategy-release/references/verdict-contract.md +200 -0
- package/dist/skill-bundle/.codex/skills/strategy-release/scripts/direction-policy-checkpoint.mjs +137 -0
- package/dist/skill-bundle/.codex/skills/strategy-release/scripts/direction-policy-checkpoint.test.mjs +85 -0
- package/dist/skill-bundle/.codex/skills/strategy-release/scripts/directional-parameter-checkpoint.mjs +149 -0
- package/dist/skill-bundle/.codex/skills/strategy-release/scripts/directional-parameter-checkpoint.test.mjs +120 -0
- package/dist/skill-bundle/.codex/skills/strategy-release/scripts/release-progress-checkpoint.mjs +621 -0
- package/dist/skill-bundle/.codex/skills/strategy-release/scripts/release-progress-checkpoint.test.mjs +349 -0
- package/dist/skill-bundle/.codex/tradejs-skill-bundle.json +44 -3
- package/package.json +1 -1
|
@@ -0,0 +1,744 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { createReadStream } from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import readline from 'node:readline';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
10
|
+
export const DEFAULT_PERIODS = [1100, 365, 180, 90, 30];
|
|
11
|
+
export const CORE_COHORT_ORDER = ['ALL', 'LONG', 'SHORT'];
|
|
12
|
+
const PNL_EPSILON = 1e-9;
|
|
13
|
+
|
|
14
|
+
const toFiniteNumber = (value) => {
|
|
15
|
+
const numeric = Number(value);
|
|
16
|
+
return Number.isFinite(numeric) ? numeric : null;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const normalizeDirection = (value) => {
|
|
20
|
+
const direction = String(value ?? '')
|
|
21
|
+
.trim()
|
|
22
|
+
.toUpperCase();
|
|
23
|
+
return direction === 'LONG' || direction === 'SHORT' ? direction : 'UNKNOWN';
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const normalizeConfigId = (value) => {
|
|
27
|
+
const configId = String(value ?? '').trim();
|
|
28
|
+
return configId || '<missing-config-id>';
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const compareText = (left, right) => (left < right ? -1 : left > right ? 1 : 0);
|
|
32
|
+
|
|
33
|
+
export const compareCompletedTrades = (left, right) =>
|
|
34
|
+
left.exitTimestamp - right.exitTimestamp ||
|
|
35
|
+
compareText(left.configId, right.configId) ||
|
|
36
|
+
compareText(left.signalId, right.signalId) ||
|
|
37
|
+
compareText(left.symbol, right.symbol) ||
|
|
38
|
+
compareText(left.direction, right.direction) ||
|
|
39
|
+
compareText(left.sourceFile, right.sourceFile) ||
|
|
40
|
+
left.sourceLine - right.sourceLine;
|
|
41
|
+
|
|
42
|
+
const normalizeCompletedTrade = ({ filePath, lineNumber, row }) => {
|
|
43
|
+
if (row?.tradeResult == null) return null;
|
|
44
|
+
|
|
45
|
+
const netProfit = toFiniteNumber(row.tradeResult.netProfit);
|
|
46
|
+
const exitTimestamp = toFiniteNumber(row.tradeResult.exitTimestamp);
|
|
47
|
+
if (netProfit == null || exitTimestamp == null) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`${filePath}:${lineNumber} has tradeResult without finite netProfit/exitTimestamp`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
backtestRunId:
|
|
55
|
+
typeof row.backtestRunId === 'string' ? row.backtestRunId : null,
|
|
56
|
+
configId: normalizeConfigId(row.configId),
|
|
57
|
+
direction: normalizeDirection(row.direction ?? row.tradeResult.direction),
|
|
58
|
+
exitTimestamp,
|
|
59
|
+
netProfit,
|
|
60
|
+
signalId: String(row.signalId ?? row.tradeResult.signalId ?? ''),
|
|
61
|
+
sourceFile: filePath,
|
|
62
|
+
sourceLine: lineNumber,
|
|
63
|
+
symbol: String(row.symbol ?? ''),
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const tradeIdentity = (trade) =>
|
|
68
|
+
[
|
|
69
|
+
trade.backtestRunId ?? '',
|
|
70
|
+
trade.configId,
|
|
71
|
+
trade.signalId,
|
|
72
|
+
trade.symbol,
|
|
73
|
+
].join(':');
|
|
74
|
+
|
|
75
|
+
const readOneExportFile = async ({ filePath, runId }) => {
|
|
76
|
+
const resolvedPath = path.resolve(filePath);
|
|
77
|
+
const input = createReadStream(resolvedPath, { encoding: 'utf8' });
|
|
78
|
+
const sha256 = createHash('sha256');
|
|
79
|
+
input.on('data', (chunk) => sha256.update(chunk));
|
|
80
|
+
const lines = readline.createInterface({ input, crlfDelay: Infinity });
|
|
81
|
+
const trades = [];
|
|
82
|
+
let rowsRead = 0;
|
|
83
|
+
let blankLines = 0;
|
|
84
|
+
let rowsWithoutTradeResult = 0;
|
|
85
|
+
let rowsForDifferentRun = 0;
|
|
86
|
+
|
|
87
|
+
for await (const line of lines) {
|
|
88
|
+
const lineNumber = rowsRead + blankLines + 1;
|
|
89
|
+
if (!line.trim()) {
|
|
90
|
+
blankLines += 1;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
rowsRead += 1;
|
|
95
|
+
let row;
|
|
96
|
+
try {
|
|
97
|
+
row = JSON.parse(line);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`${resolvedPath}:${lineNumber} contains invalid JSON: ${error.message}`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const trade = normalizeCompletedTrade({
|
|
105
|
+
filePath: resolvedPath,
|
|
106
|
+
lineNumber,
|
|
107
|
+
row,
|
|
108
|
+
});
|
|
109
|
+
if (!trade) {
|
|
110
|
+
rowsWithoutTradeResult += 1;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (runId && trade.backtestRunId !== runId) {
|
|
114
|
+
rowsForDifferentRun += 1;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
trades.push(trade);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
file: resolvedPath,
|
|
122
|
+
sha256: sha256.digest('hex'),
|
|
123
|
+
rowsRead,
|
|
124
|
+
blankLines,
|
|
125
|
+
rowsWithoutTradeResult,
|
|
126
|
+
rowsForDifferentRun,
|
|
127
|
+
selectedCompletedTrades: trades.length,
|
|
128
|
+
trades,
|
|
129
|
+
};
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export const readExportFiles = async ({ filePaths, runId = null }) => {
|
|
133
|
+
const fileReports = [];
|
|
134
|
+
const trades = [];
|
|
135
|
+
const identities = new Map();
|
|
136
|
+
let duplicateRowsDropped = 0;
|
|
137
|
+
|
|
138
|
+
for (const filePath of filePaths) {
|
|
139
|
+
const fileReport = await readOneExportFile({ filePath, runId });
|
|
140
|
+
const { trades: fileTrades, ...source } = fileReport;
|
|
141
|
+
fileReports.push(source);
|
|
142
|
+
|
|
143
|
+
for (const trade of fileTrades) {
|
|
144
|
+
const identity = tradeIdentity(trade);
|
|
145
|
+
const duplicate = identities.get(identity);
|
|
146
|
+
if (duplicate) {
|
|
147
|
+
if (
|
|
148
|
+
duplicate.direction !== trade.direction ||
|
|
149
|
+
duplicate.exitTimestamp !== trade.exitTimestamp ||
|
|
150
|
+
duplicate.netProfit !== trade.netProfit
|
|
151
|
+
) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
`Conflicting completed-trade rows share identity ${identity}`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
duplicateRowsDropped += 1;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
identities.set(identity, trade);
|
|
160
|
+
trades.push(trade);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
trades: trades.sort(compareCompletedTrades),
|
|
166
|
+
scan: {
|
|
167
|
+
files: fileReports,
|
|
168
|
+
rowsRead: fileReports.reduce((sum, file) => sum + file.rowsRead, 0),
|
|
169
|
+
rowsWithoutTradeResult: fileReports.reduce(
|
|
170
|
+
(sum, file) => sum + file.rowsWithoutTradeResult,
|
|
171
|
+
0,
|
|
172
|
+
),
|
|
173
|
+
rowsForDifferentRun: fileReports.reduce(
|
|
174
|
+
(sum, file) => sum + file.rowsForDifferentRun,
|
|
175
|
+
0,
|
|
176
|
+
),
|
|
177
|
+
selectedCompletedTradesBeforeDedup: fileReports.reduce(
|
|
178
|
+
(sum, file) => sum + file.selectedCompletedTrades,
|
|
179
|
+
0,
|
|
180
|
+
),
|
|
181
|
+
duplicateRowsDropped,
|
|
182
|
+
selectedCompletedTrades: trades.length,
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const summarizeSelectedTrades = ({ trades, periodDays }) => {
|
|
188
|
+
const sorted = [...trades].sort(compareCompletedTrades);
|
|
189
|
+
let wins = 0;
|
|
190
|
+
let losses = 0;
|
|
191
|
+
let breakeven = 0;
|
|
192
|
+
let grossProfit = 0;
|
|
193
|
+
let grossLoss = 0;
|
|
194
|
+
let pnl = 0;
|
|
195
|
+
let equity = 0;
|
|
196
|
+
let peak = 0;
|
|
197
|
+
let portfolioMaxDrawdown = 0;
|
|
198
|
+
|
|
199
|
+
for (const trade of sorted) {
|
|
200
|
+
const tradePnl = trade.netProfit;
|
|
201
|
+
pnl += tradePnl;
|
|
202
|
+
if (tradePnl > 0) {
|
|
203
|
+
wins += 1;
|
|
204
|
+
grossProfit += tradePnl;
|
|
205
|
+
} else {
|
|
206
|
+
losses += 1;
|
|
207
|
+
if (tradePnl === 0) breakeven += 1;
|
|
208
|
+
else grossLoss += Math.abs(tradePnl);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
equity += tradePnl;
|
|
212
|
+
peak = Math.max(peak, equity);
|
|
213
|
+
portfolioMaxDrawdown = Math.max(portfolioMaxDrawdown, peak - equity);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const completedTrades = sorted.length;
|
|
217
|
+
const profitFactorStatus =
|
|
218
|
+
grossLoss > 0
|
|
219
|
+
? 'finite'
|
|
220
|
+
: grossProfit > 0
|
|
221
|
+
? 'infinite_no_gross_loss'
|
|
222
|
+
: 'undefined_no_gross_profit_or_loss';
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
completedTrades,
|
|
226
|
+
wins,
|
|
227
|
+
losses,
|
|
228
|
+
breakeven,
|
|
229
|
+
winRatePct: completedTrades > 0 ? (wins / completedTrades) * 100 : null,
|
|
230
|
+
grossProfit,
|
|
231
|
+
grossLoss,
|
|
232
|
+
profitFactor: grossLoss > 0 ? grossProfit / grossLoss : null,
|
|
233
|
+
profitFactorStatus,
|
|
234
|
+
pnl,
|
|
235
|
+
pnlPerTrade: completedTrades > 0 ? pnl / completedTrades : null,
|
|
236
|
+
portfolioMaxDrawdown,
|
|
237
|
+
observedCadenceTradesPerDay:
|
|
238
|
+
periodDays > 0 ? completedTrades / periodDays : null,
|
|
239
|
+
};
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
export const summarizeTerminalWindow = ({
|
|
243
|
+
trades,
|
|
244
|
+
endTimestamp,
|
|
245
|
+
periodDays,
|
|
246
|
+
coverageStartTimestamp = null,
|
|
247
|
+
}) => {
|
|
248
|
+
const startTimestamp = endTimestamp - periodDays * DAY_MS;
|
|
249
|
+
const selected = trades.filter(
|
|
250
|
+
(trade) =>
|
|
251
|
+
trade.exitTimestamp >= startTimestamp &&
|
|
252
|
+
trade.exitTimestamp < endTimestamp,
|
|
253
|
+
);
|
|
254
|
+
const byDirection = Object.fromEntries(
|
|
255
|
+
['LONG', 'SHORT'].map((direction) => [
|
|
256
|
+
direction,
|
|
257
|
+
summarizeSelectedTrades({
|
|
258
|
+
trades: selected.filter((trade) => trade.direction === direction),
|
|
259
|
+
periodDays,
|
|
260
|
+
}),
|
|
261
|
+
]),
|
|
262
|
+
);
|
|
263
|
+
const unknownDirection = selected.filter(
|
|
264
|
+
(trade) => trade.direction === 'UNKNOWN',
|
|
265
|
+
);
|
|
266
|
+
if (unknownDirection.length) {
|
|
267
|
+
byDirection.UNKNOWN = summarizeSelectedTrades({
|
|
268
|
+
trades: unknownDirection,
|
|
269
|
+
periodDays,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
label: `${periodDays}d`,
|
|
275
|
+
periodDays,
|
|
276
|
+
startTimestamp,
|
|
277
|
+
startIso: new Date(startTimestamp).toISOString(),
|
|
278
|
+
endTimestamp,
|
|
279
|
+
endIso: new Date(endTimestamp).toISOString(),
|
|
280
|
+
interval: '[start, end)',
|
|
281
|
+
coverage:
|
|
282
|
+
coverageStartTimestamp == null
|
|
283
|
+
? 'unknown_without_run_manifest_start'
|
|
284
|
+
: startTimestamp >= coverageStartTimestamp
|
|
285
|
+
? 'complete_within_run_manifest'
|
|
286
|
+
: 'partial_before_run_manifest_start',
|
|
287
|
+
metrics: summarizeSelectedTrades({ trades: selected, periodDays }),
|
|
288
|
+
directions: byDirection,
|
|
289
|
+
};
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const aggregateRedisResults = (results) => {
|
|
293
|
+
const aggregate = {
|
|
294
|
+
resultCount: results.length,
|
|
295
|
+
completedTrades: 0,
|
|
296
|
+
wins: 0,
|
|
297
|
+
losses: 0,
|
|
298
|
+
pnl: 0,
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
for (const result of results) {
|
|
302
|
+
const stat = result.stat;
|
|
303
|
+
aggregate.completedTrades += toFiniteNumber(stat.orders) ?? 0;
|
|
304
|
+
aggregate.wins += toFiniteNumber(stat.wins) ?? 0;
|
|
305
|
+
aggregate.losses += toFiniteNumber(stat.losses) ?? 0;
|
|
306
|
+
aggregate.pnl += toFiniteNumber(stat.netProfit ?? stat.profit) ?? 0;
|
|
307
|
+
}
|
|
308
|
+
return aggregate;
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
export const aggregateRedisResultStatsByConfig = (envelopes) => {
|
|
312
|
+
const results = envelopes
|
|
313
|
+
.map((entry) => entry?.result ?? entry)
|
|
314
|
+
.filter((result) => result?.test && result?.stat);
|
|
315
|
+
const grouped = new Map();
|
|
316
|
+
for (const result of results) {
|
|
317
|
+
const configId = normalizeConfigId(result.test.configId);
|
|
318
|
+
const bucket = grouped.get(configId) ?? [];
|
|
319
|
+
bucket.push(result);
|
|
320
|
+
grouped.set(configId, bucket);
|
|
321
|
+
}
|
|
322
|
+
return Object.fromEntries(
|
|
323
|
+
[...grouped.entries()]
|
|
324
|
+
.sort(([left], [right]) => compareText(left, right))
|
|
325
|
+
.map(([configId, configResults]) => [
|
|
326
|
+
configId,
|
|
327
|
+
aggregateRedisResults(configResults),
|
|
328
|
+
]),
|
|
329
|
+
);
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
export const buildRedisReconciliation = ({ redisAggregate, exportMetrics }) => {
|
|
333
|
+
if (!redisAggregate || redisAggregate.resultCount === 0) {
|
|
334
|
+
return {
|
|
335
|
+
source: 'redis-result-stat',
|
|
336
|
+
status: 'unavailable',
|
|
337
|
+
reason: 'No Redis checkpoint result.stat rows were found for the run.',
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const pnlTolerance = redisAggregate.resultCount * 0.005 + PNL_EPSILON;
|
|
342
|
+
const delta = {
|
|
343
|
+
completedTrades:
|
|
344
|
+
exportMetrics.completedTrades - redisAggregate.completedTrades,
|
|
345
|
+
wins: exportMetrics.wins - redisAggregate.wins,
|
|
346
|
+
losses: exportMetrics.losses - redisAggregate.losses,
|
|
347
|
+
pnl: exportMetrics.pnl - redisAggregate.pnl,
|
|
348
|
+
};
|
|
349
|
+
const matches = {
|
|
350
|
+
completedTrades: delta.completedTrades === 0,
|
|
351
|
+
wins: delta.wins === 0,
|
|
352
|
+
losses: delta.losses === 0,
|
|
353
|
+
pnl: Math.abs(delta.pnl) <= pnlTolerance,
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
source: 'redis-result-stat',
|
|
358
|
+
status: Object.values(matches).every(Boolean) ? 'match' : 'mismatch',
|
|
359
|
+
semantics:
|
|
360
|
+
'Redis supplies aggregate N/W/L/PnL only; PF and aggregate portfolio MaxDD remain export-derived.',
|
|
361
|
+
pnlTolerance,
|
|
362
|
+
redis: redisAggregate,
|
|
363
|
+
export: {
|
|
364
|
+
completedTrades: exportMetrics.completedTrades,
|
|
365
|
+
wins: exportMetrics.wins,
|
|
366
|
+
losses: exportMetrics.losses,
|
|
367
|
+
pnl: exportMetrics.pnl,
|
|
368
|
+
},
|
|
369
|
+
delta,
|
|
370
|
+
matches,
|
|
371
|
+
};
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
export const buildConfigExportReports = ({
|
|
375
|
+
configIds,
|
|
376
|
+
coverageStartTimestamp,
|
|
377
|
+
endTimestamp,
|
|
378
|
+
periods,
|
|
379
|
+
redisAggregatesByConfig = {},
|
|
380
|
+
fallbackRedisAggregate = null,
|
|
381
|
+
runStartTimestamp = null,
|
|
382
|
+
trades,
|
|
383
|
+
}) =>
|
|
384
|
+
Object.fromEntries(
|
|
385
|
+
configIds.map((configId) => {
|
|
386
|
+
const configTrades = trades.filter(
|
|
387
|
+
(trade) => trade.configId === configId,
|
|
388
|
+
);
|
|
389
|
+
const windows = periods.map((periodDays) =>
|
|
390
|
+
summarizeTerminalWindow({
|
|
391
|
+
trades: configTrades,
|
|
392
|
+
endTimestamp,
|
|
393
|
+
periodDays,
|
|
394
|
+
coverageStartTimestamp,
|
|
395
|
+
}),
|
|
396
|
+
);
|
|
397
|
+
const runWindowMetrics =
|
|
398
|
+
runStartTimestamp == null
|
|
399
|
+
? null
|
|
400
|
+
: summarizeSelectedTrades({
|
|
401
|
+
trades: configTrades.filter(
|
|
402
|
+
(trade) =>
|
|
403
|
+
trade.exitTimestamp >= runStartTimestamp &&
|
|
404
|
+
trade.exitTimestamp < endTimestamp,
|
|
405
|
+
),
|
|
406
|
+
periodDays: (endTimestamp - runStartTimestamp) / DAY_MS,
|
|
407
|
+
});
|
|
408
|
+
const redisAggregate =
|
|
409
|
+
redisAggregatesByConfig[configId] ??
|
|
410
|
+
(configIds.length === 1 ? fallbackRedisAggregate : null);
|
|
411
|
+
|
|
412
|
+
return [
|
|
413
|
+
configId,
|
|
414
|
+
{
|
|
415
|
+
configId,
|
|
416
|
+
completedTradesInFiles: configTrades.length,
|
|
417
|
+
windows,
|
|
418
|
+
runWindowMetrics,
|
|
419
|
+
reconciliation:
|
|
420
|
+
runWindowMetrics == null
|
|
421
|
+
? {
|
|
422
|
+
source: 'redis-result-stat',
|
|
423
|
+
status: 'not_requested',
|
|
424
|
+
reason:
|
|
425
|
+
'Pass --run to reconcile export N/W/L/PnL with Redis.',
|
|
426
|
+
}
|
|
427
|
+
: buildRedisReconciliation({
|
|
428
|
+
redisAggregate,
|
|
429
|
+
exportMetrics: runWindowMetrics,
|
|
430
|
+
}),
|
|
431
|
+
},
|
|
432
|
+
];
|
|
433
|
+
}),
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
const loadRunContextFromRedis = async ({ runId, userName }) => {
|
|
437
|
+
const redis = await import('@tradejs/infra/redis');
|
|
438
|
+
try {
|
|
439
|
+
const [manifest, envelopes] = await Promise.all([
|
|
440
|
+
redis.getData(redis.redisKeys.backtestRun(userName, runId), null),
|
|
441
|
+
redis.getHashJsonValues(
|
|
442
|
+
redis.redisKeys.backtestRunResults(userName, runId),
|
|
443
|
+
),
|
|
444
|
+
]);
|
|
445
|
+
if (!manifest) {
|
|
446
|
+
throw new Error(`No Redis backtest manifest found for run ${runId}`);
|
|
447
|
+
}
|
|
448
|
+
const startTimestamp = toFiniteNumber(manifest.window?.start);
|
|
449
|
+
const endTimestamp = toFiniteNumber(manifest.window?.end);
|
|
450
|
+
if (startTimestamp == null || endTimestamp == null) {
|
|
451
|
+
throw new Error(`Redis manifest for run ${runId} has invalid window`);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return {
|
|
455
|
+
manifest: {
|
|
456
|
+
config: manifest.config ?? null,
|
|
457
|
+
connectorName: manifest.connectorName ?? null,
|
|
458
|
+
flags: manifest.flags ?? null,
|
|
459
|
+
interval: manifest.interval ?? null,
|
|
460
|
+
runId,
|
|
461
|
+
startTimestamp,
|
|
462
|
+
endTimestamp,
|
|
463
|
+
status: manifest.status ?? null,
|
|
464
|
+
configIds: [
|
|
465
|
+
...new Set(
|
|
466
|
+
(Array.isArray(manifest.testSuite) ? manifest.testSuite : []).map(
|
|
467
|
+
(test) => normalizeConfigId(test?.configId),
|
|
468
|
+
),
|
|
469
|
+
),
|
|
470
|
+
].sort(compareText),
|
|
471
|
+
userName,
|
|
472
|
+
},
|
|
473
|
+
redisAggregatesByConfig: aggregateRedisResultStatsByConfig(envelopes),
|
|
474
|
+
};
|
|
475
|
+
} finally {
|
|
476
|
+
await redis.closeRedisConnection();
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
export const parseEndTimestamp = (value) => {
|
|
481
|
+
if (value == null || String(value).trim() === '') return null;
|
|
482
|
+
const text = String(value).trim();
|
|
483
|
+
const numeric = /^\d+$/.test(text) ? Number(text) : Number.NaN;
|
|
484
|
+
const timestamp = Number.isFinite(numeric) ? numeric : Date.parse(text);
|
|
485
|
+
if (
|
|
486
|
+
!Number.isFinite(timestamp) ||
|
|
487
|
+
Number.isNaN(new Date(timestamp).getTime())
|
|
488
|
+
) {
|
|
489
|
+
throw new Error(`Invalid --end value: ${value}`);
|
|
490
|
+
}
|
|
491
|
+
return timestamp;
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
const parsePeriods = (value) => {
|
|
495
|
+
const periods = String(value ?? '')
|
|
496
|
+
.split(',')
|
|
497
|
+
.map((part) => Number(part.trim()));
|
|
498
|
+
if (
|
|
499
|
+
periods.length === 0 ||
|
|
500
|
+
periods.some((period) => !Number.isInteger(period) || period <= 0)
|
|
501
|
+
) {
|
|
502
|
+
throw new Error(
|
|
503
|
+
'--periods must be a comma-separated list of positive days',
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
return [...new Set(periods)];
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
export const parseArgs = (argv) => {
|
|
510
|
+
const flags = {
|
|
511
|
+
endTimestamp: null,
|
|
512
|
+
filePaths: [],
|
|
513
|
+
json: false,
|
|
514
|
+
periods: DEFAULT_PERIODS,
|
|
515
|
+
runId: null,
|
|
516
|
+
userName: 'root',
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
520
|
+
const arg = argv[index];
|
|
521
|
+
if (arg === '--file') {
|
|
522
|
+
const filePath = argv[++index];
|
|
523
|
+
if (!filePath) throw new Error('--file requires a JSONL path');
|
|
524
|
+
flags.filePaths.push(filePath);
|
|
525
|
+
} else if (arg === '--end') {
|
|
526
|
+
flags.endTimestamp = parseEndTimestamp(argv[++index]);
|
|
527
|
+
} else if (arg === '--run') {
|
|
528
|
+
flags.runId = argv[++index] ?? null;
|
|
529
|
+
} else if (arg === '--user') {
|
|
530
|
+
flags.userName = argv[++index] ?? 'root';
|
|
531
|
+
} else if (arg === '--periods') {
|
|
532
|
+
flags.periods = parsePeriods(argv[++index]);
|
|
533
|
+
} else if (arg === '--json') {
|
|
534
|
+
flags.json = true;
|
|
535
|
+
} else {
|
|
536
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if (!flags.filePaths.length || (!flags.runId && flags.endTimestamp == null)) {
|
|
541
|
+
throw new Error(
|
|
542
|
+
'Usage: fast-ai-export-metrics.mjs --file <export.jsonl> [--file <part.jsonl>] (--end <epoch-ms|ISO> | --run <run-id>) [--user root] [--periods 1100,365,180,90,30] [--json]',
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
return flags;
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
export const buildExportReport = async ({
|
|
549
|
+
endTimestamp: explicitEndTimestamp = null,
|
|
550
|
+
filePaths,
|
|
551
|
+
periods = DEFAULT_PERIODS,
|
|
552
|
+
runId = null,
|
|
553
|
+
userName = 'root',
|
|
554
|
+
runContextLoader = loadRunContextFromRedis,
|
|
555
|
+
}) => {
|
|
556
|
+
const runContext = runId ? await runContextLoader({ runId, userName }) : null;
|
|
557
|
+
const manifestEndTimestamp = runContext?.manifest.endTimestamp ?? null;
|
|
558
|
+
if (
|
|
559
|
+
explicitEndTimestamp != null &&
|
|
560
|
+
manifestEndTimestamp != null &&
|
|
561
|
+
explicitEndTimestamp !== manifestEndTimestamp
|
|
562
|
+
) {
|
|
563
|
+
throw new Error(
|
|
564
|
+
`--end (${explicitEndTimestamp}) does not match Redis run manifest end (${manifestEndTimestamp})`,
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
const endTimestamp = manifestEndTimestamp ?? explicitEndTimestamp;
|
|
568
|
+
if (endTimestamp == null) {
|
|
569
|
+
throw new Error('An explicit --end or a Redis --run manifest is required');
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const { trades, scan } = await readExportFiles({ filePaths, runId });
|
|
573
|
+
const coverageStartTimestamp = runContext?.manifest.startTimestamp ?? null;
|
|
574
|
+
const configIds = [
|
|
575
|
+
...new Set([
|
|
576
|
+
...trades.map((trade) => trade.configId),
|
|
577
|
+
...(runContext?.manifest.configIds ?? []),
|
|
578
|
+
...Object.keys(runContext?.redisAggregatesByConfig ?? {}),
|
|
579
|
+
]),
|
|
580
|
+
].sort(compareText);
|
|
581
|
+
const configReports = buildConfigExportReports({
|
|
582
|
+
configIds,
|
|
583
|
+
coverageStartTimestamp,
|
|
584
|
+
endTimestamp,
|
|
585
|
+
periods,
|
|
586
|
+
redisAggregatesByConfig: runContext?.redisAggregatesByConfig ?? {},
|
|
587
|
+
fallbackRedisAggregate: runContext?.redisAggregate ?? null,
|
|
588
|
+
runStartTimestamp: runContext?.manifest.startTimestamp ?? null,
|
|
589
|
+
trades,
|
|
590
|
+
});
|
|
591
|
+
const singleConfigReport =
|
|
592
|
+
configIds.length === 1 ? configReports[configIds[0]] : null;
|
|
593
|
+
const rowsAfterAnchor = trades.filter(
|
|
594
|
+
(trade) => trade.exitTimestamp >= endTimestamp,
|
|
595
|
+
).length;
|
|
596
|
+
const { files: fileReports, ...scanSummary } = scan;
|
|
597
|
+
|
|
598
|
+
return {
|
|
599
|
+
schemaVersion: 1,
|
|
600
|
+
reportType: 'fast-ai-export-terminal-core-metrics',
|
|
601
|
+
source: {
|
|
602
|
+
kind: 'ai-export-jsonl-completed-trades',
|
|
603
|
+
pnlField: 'tradeResult.netProfit',
|
|
604
|
+
timestampField: 'tradeResult.exitTimestamp',
|
|
605
|
+
gateDecisionsUsed: false,
|
|
606
|
+
runFilterApplied: Boolean(runId),
|
|
607
|
+
files: fileReports,
|
|
608
|
+
},
|
|
609
|
+
semantics: {
|
|
610
|
+
cohortOrder: CORE_COHORT_ORDER,
|
|
611
|
+
deterministicSort:
|
|
612
|
+
'tradeResult.exitTimestamp, then signalId, symbol, direction, source file, source line',
|
|
613
|
+
terminalWindow:
|
|
614
|
+
'[manifestEnd - periodDays * 24h, manifestEnd), using UTC epoch milliseconds',
|
|
615
|
+
cadence: 'completed trades / exact requested calendar days',
|
|
616
|
+
deduplication:
|
|
617
|
+
'exact duplicate run/config/signal/symbol identities are counted once; conflicting direction, exit timestamp, or outcome fails the report',
|
|
618
|
+
loss: 'tradeResult.netProfit <= 0 (zero is a loss, matching Redis stat)',
|
|
619
|
+
pnlPerTrade:
|
|
620
|
+
'cohort total PnL / cohort completed trades; ALL uses aggregate PnL / aggregate N and is never an unweighted average of LONG/SHORT means',
|
|
621
|
+
portfolioMaxDrawdown:
|
|
622
|
+
'backward-compatible JSON field: metrics.portfolioMaxDrawdown is ALL aggregate portfolio realized MaxDD; directions.<side>.portfolioMaxDrawdown is side-only realized MaxDD after filtering to that direction; both use the chronological completed-trade net-PnL equity curve',
|
|
623
|
+
profitFactor: 'gross positive PnL / absolute gross negative PnL',
|
|
624
|
+
},
|
|
625
|
+
metricSchema: {
|
|
626
|
+
compatibility:
|
|
627
|
+
'schemaVersion 1 field names are retained; scope is clarified additively',
|
|
628
|
+
pnlPerTrade: {
|
|
629
|
+
jsonField: 'pnlPerTrade',
|
|
630
|
+
humanLabel: 'Avg PnL/trade (cohort PnL/N)',
|
|
631
|
+
aggregateFormula: 'ALL.pnl / ALL.completedTrades',
|
|
632
|
+
aggregateIsUnweightedAverageOfSideMeans: false,
|
|
633
|
+
},
|
|
634
|
+
realizedMaxDrawdown: {
|
|
635
|
+
jsonField: 'portfolioMaxDrawdown',
|
|
636
|
+
allScope: 'aggregate portfolio',
|
|
637
|
+
longScope: 'side-only LONG',
|
|
638
|
+
shortScope: 'side-only SHORT',
|
|
639
|
+
},
|
|
640
|
+
},
|
|
641
|
+
anchor: {
|
|
642
|
+
source: runContext ? 'redis-backtest-run-manifest' : 'explicit-end',
|
|
643
|
+
runId,
|
|
644
|
+
endTimestamp,
|
|
645
|
+
endIso: new Date(endTimestamp).toISOString(),
|
|
646
|
+
manifest: runContext?.manifest ?? null,
|
|
647
|
+
},
|
|
648
|
+
scan: {
|
|
649
|
+
...scanSummary,
|
|
650
|
+
rowsAfterAnchor,
|
|
651
|
+
},
|
|
652
|
+
configIds,
|
|
653
|
+
configReports,
|
|
654
|
+
configAggregationWarning:
|
|
655
|
+
configIds.length > 1
|
|
656
|
+
? `Multiple configId buckets found (${configIds.join(', ')}); top-level windows, runWindowMetrics, and reconciliation are null. Use configReports.`
|
|
657
|
+
: null,
|
|
658
|
+
windows: singleConfigReport?.windows ?? [],
|
|
659
|
+
runWindowMetrics: singleConfigReport?.runWindowMetrics ?? null,
|
|
660
|
+
reconciliation: singleConfigReport?.reconciliation ?? null,
|
|
661
|
+
};
|
|
662
|
+
};
|
|
663
|
+
|
|
664
|
+
const formatNumber = (value, digits = 2) =>
|
|
665
|
+
value == null || !Number.isFinite(value) ? 'n/a' : value.toFixed(digits);
|
|
666
|
+
|
|
667
|
+
const formatProfitFactor = (metrics) => {
|
|
668
|
+
if (metrics.profitFactor != null)
|
|
669
|
+
return formatNumber(metrics.profitFactor, 3);
|
|
670
|
+
return metrics.profitFactorStatus === 'infinite_no_gross_loss' ? '∞' : 'n/a';
|
|
671
|
+
};
|
|
672
|
+
|
|
673
|
+
const formatPercent = (value) =>
|
|
674
|
+
value == null || !Number.isFinite(value) ? 'n/a' : `${formatNumber(value)}%`;
|
|
675
|
+
|
|
676
|
+
const reportCohorts = (window) => {
|
|
677
|
+
const required = [
|
|
678
|
+
['ALL', window.metrics, 'aggregate portfolio'],
|
|
679
|
+
['LONG', window.directions.LONG, 'side-only LONG'],
|
|
680
|
+
['SHORT', window.directions.SHORT, 'side-only SHORT'],
|
|
681
|
+
];
|
|
682
|
+
return window.directions.UNKNOWN
|
|
683
|
+
? [
|
|
684
|
+
...required,
|
|
685
|
+
['UNKNOWN', window.directions.UNKNOWN, 'direction-filtered diagnostic'],
|
|
686
|
+
]
|
|
687
|
+
: required;
|
|
688
|
+
};
|
|
689
|
+
|
|
690
|
+
export const formatReport = (report) => {
|
|
691
|
+
const rows = [
|
|
692
|
+
'| Config | Period | Cohort | N | W | L | WR | PF | PnL | Avg PnL/trade (cohort PnL/N) | Realized MaxDD | MaxDD scope | Cadence/day | Coverage |',
|
|
693
|
+
'| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | ---: | --- |',
|
|
694
|
+
];
|
|
695
|
+
for (const configReport of Object.values(report.configReports)) {
|
|
696
|
+
for (const window of configReport.windows) {
|
|
697
|
+
for (const [cohort, metrics, drawdownScope] of reportCohorts(window)) {
|
|
698
|
+
rows.push(
|
|
699
|
+
`| ${configReport.configId} | ${window.label} | ${cohort} | ${metrics.completedTrades} | ${metrics.wins} | ${metrics.losses} | ${formatPercent(metrics.winRatePct)} | ${formatProfitFactor(metrics)} | ${formatNumber(metrics.pnl)} | ${formatNumber(metrics.pnlPerTrade, 4)} | ${formatNumber(metrics.portfolioMaxDrawdown)} | ${drawdownScope} | ${formatNumber(metrics.observedCadenceTradesPerDay, 4)} | ${window.coverage} |`,
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const reconciliationLines = Object.values(report.configReports).map(
|
|
706
|
+
({ configId, reconciliation }) =>
|
|
707
|
+
`Redis reconciliation [${configId}]: ${reconciliation.status}${reconciliation.status === 'match' || reconciliation.status === 'mismatch' ? `; ΔN=${reconciliation.delta.completedTrades}, ΔW=${reconciliation.delta.wins}, ΔL=${reconciliation.delta.losses}, ΔPnL=${formatNumber(reconciliation.delta.pnl, 4)}` : `; ${reconciliation.reason}`}`,
|
|
708
|
+
);
|
|
709
|
+
return [
|
|
710
|
+
`source: ${report.source.kind} (${report.source.pnlField}, ${report.source.timestampField}; gate decisions used: no)`,
|
|
711
|
+
`anchor: ${report.anchor.endIso} (${report.anchor.source}${report.anchor.runId ? `, run=${report.anchor.runId}` : ''})`,
|
|
712
|
+
`rows: ${report.scan.selectedCompletedTrades} completed; filtered other run=${report.scan.rowsForDifferentRun}; missing tradeResult=${report.scan.rowsWithoutTradeResult}; duplicates dropped=${report.scan.duplicateRowsDropped}; after anchor=${report.scan.rowsAfterAnchor}`,
|
|
713
|
+
`cohort order: ${CORE_COHORT_ORDER.join(' -> ')}`,
|
|
714
|
+
'Avg PnL/trade: cohort total PnL / cohort N; ALL is aggregate PnL / aggregate N, never the unweighted average of LONG/SHORT means.',
|
|
715
|
+
'Realized MaxDD: ALL uses the aggregate portfolio equity curve; LONG/SHORT use side-only time-ordered equity curves after direction filtering.',
|
|
716
|
+
...(report.configAggregationWarning
|
|
717
|
+
? [report.configAggregationWarning]
|
|
718
|
+
: []),
|
|
719
|
+
...reconciliationLines,
|
|
720
|
+
'',
|
|
721
|
+
rows.join('\n'),
|
|
722
|
+
'',
|
|
723
|
+
].join('\n');
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
const main = async () => {
|
|
727
|
+
const flags = parseArgs(process.argv.slice(2));
|
|
728
|
+
const report = await buildExportReport(flags);
|
|
729
|
+
process.stdout.write(
|
|
730
|
+
flags.json ? `${JSON.stringify(report, null, 2)}\n` : formatReport(report),
|
|
731
|
+
);
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
const isMain =
|
|
735
|
+
process.argv[1] &&
|
|
736
|
+
path.resolve(process.argv[1]) ===
|
|
737
|
+
path.resolve(fileURLToPath(import.meta.url));
|
|
738
|
+
|
|
739
|
+
if (isMain) {
|
|
740
|
+
main().catch((error) => {
|
|
741
|
+
console.error(error instanceof Error ? error.message : error);
|
|
742
|
+
process.exitCode = 1;
|
|
743
|
+
});
|
|
744
|
+
}
|