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.
Files changed (40) hide show
  1. package/README.md +19 -3
  2. package/dist/index.js +36 -5
  3. package/dist/skill-bundle/.codex/skills/ai-train-local-research/SKILL.md +596 -0
  4. package/dist/skill-bundle/.codex/skills/ai-train-local-research/references/gate-ablation.md +324 -0
  5. package/dist/skill-bundle/.codex/skills/ai-train-local-research/references/reporting.md +227 -0
  6. package/dist/skill-bundle/.codex/skills/ai-train-local-research/scripts/ai-gate-ablation.mjs +5082 -0
  7. package/dist/skill-bundle/.codex/skills/ai-train-local-research/scripts/ai-gate-ablation.test.mjs +1170 -0
  8. package/dist/skill-bundle/.codex/skills/backtest-config-redis/SKILL.md +17 -0
  9. package/dist/skill-bundle/.codex/skills/backtest-config-redis/scripts/get_backtest_config.sh +21 -0
  10. package/dist/skill-bundle/.codex/skills/runtime-parity-mismatch-analysis/SKILL.md +146 -0
  11. package/dist/skill-bundle/.codex/skills/save-strategy-config-from-backtest/SKILL.md +58 -0
  12. package/dist/skill-bundle/.codex/skills/save-strategy-config-from-backtest/agents/openai.yaml +4 -0
  13. package/dist/skill-bundle/.codex/skills/strategy-backtest-research/SKILL.md +334 -0
  14. package/dist/skill-bundle/.codex/skills/strategy-backtest-research/references/research-notes.md +247 -0
  15. package/dist/skill-bundle/.codex/skills/strategy-backtest-research/scripts/backtest-run-metrics.mjs +647 -0
  16. package/dist/skill-bundle/.codex/skills/strategy-backtest-research/scripts/backtest-run-metrics.test.mjs +321 -0
  17. package/dist/skill-bundle/.codex/skills/strategy-backtest-research/scripts/fast-ai-export-metrics.mjs +744 -0
  18. package/dist/skill-bundle/.codex/skills/strategy-backtest-research/scripts/fast-ai-export-metrics.test.mjs +553 -0
  19. package/dist/skill-bundle/.codex/skills/strategy-backtest-research/scripts/research-notes-check.mjs +125 -0
  20. package/dist/skill-bundle/.codex/skills/strategy-improvement-research/SKILL.md +18 -1
  21. package/dist/skill-bundle/.codex/skills/strategy-release/SKILL.md +22 -0
  22. package/dist/skill-bundle/.codex/skills/strategy-release/agents/openai.yaml +4 -0
  23. package/dist/skill-bundle/.codex/skills/strategy-release/references/diagnose-live.md +126 -0
  24. package/dist/skill-bundle/.codex/skills/strategy-release/references/direction-policy.md +141 -0
  25. package/dist/skill-bundle/.codex/skills/strategy-release/references/directional-parameter-split.md +93 -0
  26. package/dist/skill-bundle/.codex/skills/strategy-release/references/evidence-limitations.md +76 -0
  27. package/dist/skill-bundle/.codex/skills/strategy-release/references/evidence-retention.md +157 -0
  28. package/dist/skill-bundle/.codex/skills/strategy-release/references/historical-hypothesis-audit.md +163 -0
  29. package/dist/skill-bundle/.codex/skills/strategy-release/references/professional-research-loop.md +198 -0
  30. package/dist/skill-bundle/.codex/skills/strategy-release/references/release-workflow.md +755 -0
  31. package/dist/skill-bundle/.codex/skills/strategy-release/references/research-objective.md +255 -0
  32. package/dist/skill-bundle/.codex/skills/strategy-release/references/verdict-contract.md +200 -0
  33. package/dist/skill-bundle/.codex/skills/strategy-release/scripts/direction-policy-checkpoint.mjs +137 -0
  34. package/dist/skill-bundle/.codex/skills/strategy-release/scripts/direction-policy-checkpoint.test.mjs +85 -0
  35. package/dist/skill-bundle/.codex/skills/strategy-release/scripts/directional-parameter-checkpoint.mjs +149 -0
  36. package/dist/skill-bundle/.codex/skills/strategy-release/scripts/directional-parameter-checkpoint.test.mjs +120 -0
  37. package/dist/skill-bundle/.codex/skills/strategy-release/scripts/release-progress-checkpoint.mjs +621 -0
  38. package/dist/skill-bundle/.codex/skills/strategy-release/scripts/release-progress-checkpoint.test.mjs +349 -0
  39. package/dist/skill-bundle/.codex/tradejs-skill-bundle.json +44 -3
  40. package/package.json +1 -1
@@ -0,0 +1,647 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ import { calculateAdvancedTradeMetrics } from '@tradejs/core/backtest';
8
+ import {
9
+ closeRedisConnection,
10
+ getData,
11
+ getHashJsonValues,
12
+ redisKeys,
13
+ } from '@tradejs/infra/redis';
14
+
15
+ const DAY_MS = 24 * 60 * 60 * 1000;
16
+ const DEFAULT_PERIODS = [365, 180, 90, 30, 7];
17
+ const ARTIFACT_READ_CONCURRENCY = 8;
18
+ const WORST_SYMBOL_DRAWDOWN_WARNING =
19
+ 'worstSymbolMaxDrawdownPct is the maximum stat.maxDrawdown across individual results/symbols; it is not portfolio MaxDD.';
20
+
21
+ const resolveProjectRoot = () =>
22
+ path.resolve(String(process.env.PROJECT_CWD || process.cwd()));
23
+
24
+ const readCachedOrderLog = async ({ orderLogId, userName }) => {
25
+ const filePath = path.join(
26
+ resolveProjectRoot(),
27
+ 'data',
28
+ 'backtests',
29
+ 'cache',
30
+ encodeURIComponent(userName),
31
+ 'orders',
32
+ `${encodeURIComponent(orderLogId)}.json`,
33
+ );
34
+ try {
35
+ return JSON.parse(await fs.readFile(filePath, 'utf8'));
36
+ } catch (error) {
37
+ if (error?.code === 'ENOENT') return null;
38
+ throw error;
39
+ }
40
+ };
41
+
42
+ const toFiniteNumber = (value, fallback = 0) => {
43
+ const numeric = Number(value);
44
+ return Number.isFinite(numeric) ? numeric : fallback;
45
+ };
46
+
47
+ const normalizeExitReason = (type) => {
48
+ const normalized = String(type ?? '').toUpperCase();
49
+ if (normalized.startsWith('TAKE_PROFIT')) return 'take_profit';
50
+ if (normalized.startsWith('STOP_LOSS')) return 'stop_loss';
51
+ return 'exit';
52
+ };
53
+
54
+ const isOpenOrder = (order) =>
55
+ String(order?.type ?? '')
56
+ .toUpperCase()
57
+ .startsWith('OPEN_');
58
+
59
+ const isExitOrder = (order) => {
60
+ const type = String(order?.type ?? '').toUpperCase();
61
+ return (
62
+ type.startsWith('TAKE_PROFIT') ||
63
+ type.startsWith('STOP_LOSS') ||
64
+ type.startsWith('CLOSE_') ||
65
+ type.startsWith('EXIT_') ||
66
+ type.startsWith('LIQUIDATION')
67
+ );
68
+ };
69
+
70
+ const isTerminalExitOrder = (order) => {
71
+ const type = String(order?.type ?? '').toUpperCase();
72
+ return (
73
+ type.startsWith('STOP_LOSS') ||
74
+ type.startsWith('CLOSE_') ||
75
+ type.startsWith('EXIT_') ||
76
+ type.startsWith('LIQUIDATION')
77
+ );
78
+ };
79
+
80
+ export const reconstructTrades = (orderLogs) => {
81
+ const trades = [];
82
+ const increaseEvents = [];
83
+ let incompleteCycles = 0;
84
+
85
+ for (const orders of orderLogs) {
86
+ let cycle = null;
87
+ const sorted = [...orders].sort(
88
+ (a, b) => toFiniteNumber(a.timestamp) - toFiniteNumber(b.timestamp),
89
+ );
90
+
91
+ for (const order of sorted) {
92
+ const profit = toFiniteNumber(order.profit);
93
+
94
+ if (isOpenOrder(order)) {
95
+ if (order.positionIntent === 'increase') {
96
+ if (!cycle) continue;
97
+ cycle.pnl += profit;
98
+ cycle.increases += 1;
99
+ const increaseQty = toFiniteNumber(order.qty, Number.NaN);
100
+ cycle.remainingQty =
101
+ cycle.remainingQty != null &&
102
+ Number.isFinite(increaseQty) &&
103
+ increaseQty > 0
104
+ ? cycle.remainingQty + increaseQty
105
+ : null;
106
+ increaseEvents.push({
107
+ timestamp: toFiniteNumber(order.timestamp),
108
+ symbol: cycle.symbol,
109
+ level: cycle.increases + 1,
110
+ });
111
+ continue;
112
+ }
113
+
114
+ if (cycle) incompleteCycles += 1;
115
+ cycle = {
116
+ id: String(order.orderId ?? `${order.symbol}:${order.timestamp}`),
117
+ timestamp: toFiniteNumber(order.timestamp),
118
+ pnl: profit,
119
+ symbol: order.symbol ?? null,
120
+ direction: order.direction ?? null,
121
+ increases: 0,
122
+ remainingQty: (() => {
123
+ const qty = toFiniteNumber(order.qty, Number.NaN);
124
+ return Number.isFinite(qty) && qty > 0 ? qty : null;
125
+ })(),
126
+ };
127
+ continue;
128
+ }
129
+
130
+ if (!cycle) continue;
131
+ cycle.pnl += profit;
132
+ if (!isExitOrder(order)) continue;
133
+
134
+ const exitQty = toFiniteNumber(order.qty, Number.NaN);
135
+ if (
136
+ cycle.remainingQty != null &&
137
+ Number.isFinite(exitQty) &&
138
+ exitQty > 0
139
+ ) {
140
+ cycle.remainingQty = Math.max(0, cycle.remainingQty - exitQty);
141
+ } else {
142
+ cycle.remainingQty = null;
143
+ }
144
+ const positionClosed =
145
+ isTerminalExitOrder(order) ||
146
+ cycle.remainingQty == null ||
147
+ cycle.remainingQty <= 1e-10;
148
+ if (!positionClosed) continue;
149
+
150
+ trades.push({
151
+ id: cycle.id,
152
+ timestamp: toFiniteNumber(order.timestamp),
153
+ pnl: cycle.pnl,
154
+ symbol: cycle.symbol,
155
+ direction: cycle.direction,
156
+ exitReason: normalizeExitReason(order.type),
157
+ increases: cycle.increases,
158
+ });
159
+ cycle = null;
160
+ }
161
+
162
+ if (cycle) incompleteCycles += 1;
163
+ }
164
+
165
+ return {
166
+ trades: trades.sort((a, b) => a.timestamp - b.timestamp),
167
+ increaseEvents: increaseEvents.sort((a, b) => a.timestamp - b.timestamp),
168
+ incompleteCycles,
169
+ };
170
+ };
171
+
172
+ const getLosingMonths = (trades) => {
173
+ const monthly = new Map();
174
+ for (const trade of trades) {
175
+ const date = new Date(trade.timestamp);
176
+ const key = `${date.getUTCFullYear()}-${String(
177
+ date.getUTCMonth() + 1,
178
+ ).padStart(2, '0')}`;
179
+ monthly.set(key, (monthly.get(key) ?? 0) + trade.pnl);
180
+ }
181
+
182
+ return [...monthly.entries()]
183
+ .filter(([, pnl]) => pnl < 0)
184
+ .sort(([a], [b]) => a.localeCompare(b))
185
+ .map(([month, pnl]) => ({ month, pnl }));
186
+ };
187
+
188
+ export const summarizeTradeWindow = ({
189
+ trades,
190
+ increaseEvents,
191
+ startTimestamp,
192
+ endTimestamp,
193
+ }) => {
194
+ const selectedTrades = trades.filter(
195
+ (trade) =>
196
+ trade.timestamp >= startTimestamp && trade.timestamp <= endTimestamp,
197
+ );
198
+ const selectedIncreases = increaseEvents.filter(
199
+ (event) =>
200
+ event.timestamp >= startTimestamp && event.timestamp <= endTimestamp,
201
+ );
202
+ const metrics = calculateAdvancedTradeMetrics({
203
+ trades: selectedTrades,
204
+ startTimestamp,
205
+ endTimestamp,
206
+ });
207
+ const levelCounts = Object.fromEntries(
208
+ [2, 3, 4].map((level) => [
209
+ level,
210
+ selectedIncreases.filter((event) => event.level === level).length,
211
+ ]),
212
+ );
213
+
214
+ return {
215
+ ...metrics,
216
+ increases: {
217
+ total: selectedIncreases.length,
218
+ levels: levelCounts,
219
+ tradesWithIncrease: selectedTrades.filter((trade) => trade.increases > 0)
220
+ .length,
221
+ },
222
+ losingMonthValues: getLosingMonths(selectedTrades),
223
+ };
224
+ };
225
+
226
+ const mapWithConcurrency = async (items, concurrency, mapper) => {
227
+ const results = new Array(items.length);
228
+ let nextIndex = 0;
229
+ const workers = Array.from(
230
+ { length: Math.min(concurrency, items.length) },
231
+ async () => {
232
+ while (nextIndex < items.length) {
233
+ const index = nextIndex;
234
+ nextIndex += 1;
235
+ results[index] = await mapper(items[index], index);
236
+ }
237
+ },
238
+ );
239
+ await Promise.all(workers);
240
+ return results;
241
+ };
242
+
243
+ export const summarizeResultStats = ({
244
+ results,
245
+ startTimestamp,
246
+ endTimestamp,
247
+ projectedUniverse = null,
248
+ }) => {
249
+ const resultCount = results.length;
250
+ const windowDays = (endTimestamp - startTimestamp) / DAY_MS;
251
+ let netProfit = 0;
252
+ let orders = 0;
253
+ let wins = 0;
254
+ let losses = 0;
255
+ let worstSymbolMaxDrawdownPct = null;
256
+
257
+ for (const result of results) {
258
+ const stat = result?.stat ?? {};
259
+ netProfit += toFiniteNumber(stat.netProfit ?? stat.profit);
260
+ orders += toFiniteNumber(stat.orders);
261
+ wins += toFiniteNumber(stat.wins);
262
+ losses += toFiniteNumber(stat.losses);
263
+
264
+ const maxDrawdown = toFiniteNumber(stat.maxDrawdown, Number.NaN);
265
+ if (Number.isFinite(maxDrawdown)) {
266
+ worstSymbolMaxDrawdownPct = Math.max(
267
+ worstSymbolMaxDrawdownPct ?? Number.NEGATIVE_INFINITY,
268
+ maxDrawdown,
269
+ );
270
+ }
271
+ }
272
+
273
+ const closedOutcomes = wins + losses;
274
+ const observedCadenceTradesPerDay =
275
+ windowDays > 0 ? orders / windowDays : null;
276
+ const projectedCadence =
277
+ projectedUniverse != null &&
278
+ resultCount > 0 &&
279
+ observedCadenceTradesPerDay != null
280
+ ? {
281
+ label: `projected cadence for ${projectedUniverse} results`,
282
+ projectedUniverse,
283
+ actualResultCount: resultCount,
284
+ scaleFactor: projectedUniverse / resultCount,
285
+ tradesPerDay:
286
+ observedCadenceTradesPerDay * (projectedUniverse / resultCount),
287
+ }
288
+ : null;
289
+
290
+ return {
291
+ source: 'redis-result-stat',
292
+ authoritativeAggregate: true,
293
+ resultCount,
294
+ startTimestamp,
295
+ endTimestamp,
296
+ windowDays,
297
+ netProfit,
298
+ orders,
299
+ wins,
300
+ losses,
301
+ winRatePct: closedOutcomes > 0 ? (wins / closedOutcomes) * 100 : null,
302
+ pnlPerTrade: orders > 0 ? netProfit / orders : null,
303
+ observedCadenceTradesPerDay,
304
+ projectedCadence,
305
+ worstSymbolMaxDrawdownPct,
306
+ warnings: [WORST_SYMBOL_DRAWDOWN_WARNING],
307
+ };
308
+ };
309
+
310
+ const normalizeConfigId = (test) => {
311
+ const configId = String(test?.configId ?? '').trim();
312
+ return configId || '<missing-config-id>';
313
+ };
314
+
315
+ const uniqueSymbolCount = (tests) =>
316
+ new Set(
317
+ tests.map((test) => String(test?.symbol ?? '').trim()).filter(Boolean),
318
+ ).size;
319
+
320
+ export const buildConfigStatSummaries = ({
321
+ results,
322
+ manifest,
323
+ startTimestamp,
324
+ endTimestamp,
325
+ projectedUniverse = null,
326
+ }) => {
327
+ const plannedTests = Array.isArray(manifest?.testSuite)
328
+ ? manifest.testSuite
329
+ : [];
330
+ const resultsByConfig = new Map();
331
+ const plannedByConfig = new Map();
332
+
333
+ for (const test of plannedTests) {
334
+ const configId = normalizeConfigId(test);
335
+ const bucket = plannedByConfig.get(configId) ?? [];
336
+ bucket.push(test);
337
+ plannedByConfig.set(configId, bucket);
338
+ }
339
+ for (const result of results) {
340
+ const configId = normalizeConfigId(result?.test);
341
+ const bucket = resultsByConfig.get(configId) ?? [];
342
+ bucket.push(result);
343
+ resultsByConfig.set(configId, bucket);
344
+ }
345
+
346
+ const configIds = [
347
+ ...new Set([...plannedByConfig.keys(), ...resultsByConfig.keys()]),
348
+ ].sort((left, right) => left.localeCompare(right));
349
+ const manifestStatus = String(manifest?.status ?? 'missing');
350
+ const statSummariesByConfig = Object.fromEntries(
351
+ configIds.map((configId) => {
352
+ const configResults = resultsByConfig.get(configId) ?? [];
353
+ const configPlannedTests = plannedByConfig.get(configId) ?? [];
354
+ const planned = configPlannedTests.length;
355
+ const completed = configResults.length;
356
+ const missing = Math.max(0, planned - completed);
357
+ const extra = Math.max(0, completed - planned);
358
+ const authoritativeAggregate =
359
+ planned > 0 && completed === planned && manifestStatus === 'completed';
360
+ const completionWarning = authoritativeAggregate
361
+ ? null
362
+ : `Config ${configId} is not an authoritative complete aggregate: manifest status=${manifestStatus}, completed=${completed}, planned=${planned}.`;
363
+ const errorPersistenceWarning =
364
+ 'Worker error counts are not persisted in the backtest run manifest; inspect the terminal/report log for actual worker errors.';
365
+ const summary = summarizeResultStats({
366
+ results: configResults,
367
+ startTimestamp,
368
+ endTimestamp,
369
+ projectedUniverse,
370
+ });
371
+
372
+ return [
373
+ configId,
374
+ {
375
+ ...summary,
376
+ configId,
377
+ authoritativeAggregate,
378
+ completion: {
379
+ status: authoritativeAggregate ? 'complete' : 'partial',
380
+ manifestStatus,
381
+ planned,
382
+ completed,
383
+ missing,
384
+ extra,
385
+ plannedSymbols: uniqueSymbolCount(configPlannedTests),
386
+ completedSymbols: uniqueSymbolCount(
387
+ configResults.map((result) => result.test),
388
+ ),
389
+ errors: null,
390
+ errorStatus: 'not_persisted',
391
+ },
392
+ warnings: [
393
+ ...summary.warnings,
394
+ errorPersistenceWarning,
395
+ ...(completionWarning ? [completionWarning] : []),
396
+ ],
397
+ },
398
+ ];
399
+ }),
400
+ );
401
+ const multipleConfigsWarning =
402
+ configIds.length > 1
403
+ ? `Run contains multiple configId buckets (${configIds.join(', ')}); top-level statSummary is null and config metrics are reported separately.`
404
+ : null;
405
+
406
+ return {
407
+ configIds,
408
+ statSummary:
409
+ configIds.length === 1 ? statSummariesByConfig[configIds[0]] : null,
410
+ statSummariesByConfig,
411
+ warnings: multipleConfigsWarning ? [multipleConfigsWarning] : [],
412
+ };
413
+ };
414
+
415
+ const parseArgs = (argv) => {
416
+ const parsed = {
417
+ runId: null,
418
+ userName: 'root',
419
+ periods: DEFAULT_PERIODS,
420
+ projectedUniverse: null,
421
+ json: false,
422
+ };
423
+
424
+ for (let index = 0; index < argv.length; index += 1) {
425
+ const arg = argv[index];
426
+ if (arg === '--run') parsed.runId = argv[++index] ?? null;
427
+ else if (arg === '--user') parsed.userName = argv[++index] ?? 'root';
428
+ else if (arg === '--periods') {
429
+ parsed.periods = String(argv[++index] ?? '')
430
+ .split(',')
431
+ .map((value) => Number.parseInt(value.trim(), 10))
432
+ .filter((value) => Number.isFinite(value) && value > 0);
433
+ } else if (arg === '--projected-universe') {
434
+ const projectedUniverse = Number(argv[++index] ?? '');
435
+ if (!Number.isInteger(projectedUniverse) || projectedUniverse <= 0) {
436
+ throw new Error('--projected-universe must be a positive integer');
437
+ }
438
+ parsed.projectedUniverse = projectedUniverse;
439
+ } else if (arg === '--json') parsed.json = true;
440
+ }
441
+
442
+ if (!parsed.runId) {
443
+ throw new Error('Usage: backtest-run-metrics.mjs --run <run-id>');
444
+ }
445
+ return parsed;
446
+ };
447
+
448
+ const formatNumber = (value, digits = 2) =>
449
+ value == null || !Number.isFinite(value) ? 'n/a' : value.toFixed(digits);
450
+
451
+ const formatSummaryTable = (report) => {
452
+ const rows = [
453
+ '| Period | Trades | WR | PF | PnL | MaxDD | Strict loss | Loss streak | Losing months | Trades/day | L2/L3/L4 |',
454
+ '| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |',
455
+ ];
456
+
457
+ for (const period of report.periods) {
458
+ const { core, risk, distribution, increases } = period.metrics;
459
+ rows.push(
460
+ `| ${period.label} | ${core.trades} | ${formatNumber(core.winRate)}% | ${formatNumber(core.profitFactor, 3)} | ${formatNumber(core.totalPnl)} | ${formatNumber(risk.maxDrawdown)} | ${formatNumber(distribution.largestLoss)} | ${risk.maxLossStreak} | ${risk.losingMonthsCount} | ${formatNumber(core.tradesPerDay)} | ${increases.levels[2]}/${increases.levels[3]}/${increases.levels[4]} |`,
461
+ );
462
+ }
463
+
464
+ return rows.join('\n');
465
+ };
466
+
467
+ const formatStatSummary = (statSummary) => {
468
+ const projected = statSummary.projectedCadence
469
+ ? `${formatNumber(statSummary.projectedCadence.tradesPerDay)} trades/day (${statSummary.projectedCadence.label}; scale=${formatNumber(statSummary.projectedCadence.scaleFactor, 4)})`
470
+ : 'not requested';
471
+
472
+ return [
473
+ `Redis result.stat aggregate for config ${statSummary.configId ?? '<unknown>'} (${statSummary.authoritativeAggregate ? 'authoritative' : 'partial'}, including --fast runs):`,
474
+ ...(statSummary.completion
475
+ ? [
476
+ `completion: ${statSummary.completion.completed}/${statSummary.completion.planned} tests; missing=${statSummary.completion.missing}; symbols=${statSummary.completion.completedSymbols}/${statSummary.completion.plannedSymbols}; manifest=${statSummary.completion.manifestStatus}; errors=${statSummary.completion.errorStatus}`,
477
+ ]
478
+ : []),
479
+ `results/window: ${statSummary.resultCount}/${formatNumber(statSummary.windowDays, 2)}d`,
480
+ `PnL/N/W/L/WR: ${formatNumber(statSummary.netProfit)}/${statSummary.orders}/${statSummary.wins}/${statSummary.losses}/${formatNumber(statSummary.winRatePct)}%`,
481
+ `PnL/trade: ${formatNumber(statSummary.pnlPerTrade, 4)}`,
482
+ `observed cadence: ${formatNumber(statSummary.observedCadenceTradesPerDay)} trades/day`,
483
+ `projected cadence: ${projected}`,
484
+ `worst symbol MaxDD: ${formatNumber(statSummary.worstSymbolMaxDrawdownPct)}% (not portfolio MaxDD)`,
485
+ ].join('\n');
486
+ };
487
+
488
+ export const buildRunReport = async ({
489
+ runId,
490
+ userName = 'root',
491
+ periods = DEFAULT_PERIODS,
492
+ projectedUniverse = null,
493
+ }) => {
494
+ const [manifest, envelopes] = await Promise.all([
495
+ getData(redisKeys.backtestRun(userName, runId), null),
496
+ getHashJsonValues(redisKeys.backtestRunResults(userName, runId)),
497
+ ]);
498
+ const results = envelopes
499
+ .map((entry) => entry?.result ?? entry)
500
+ .filter((entry) => entry?.test && entry?.stat);
501
+
502
+ if (!results.length) {
503
+ throw new Error(`No backtest results found for run ${runId}`);
504
+ }
505
+
506
+ const artifactAnalyses = await mapWithConcurrency(
507
+ results,
508
+ ARTIFACT_READ_CONCURRENCY,
509
+ async (result) => {
510
+ if (!result.orderLogId) return null;
511
+ const orderLog = await readCachedOrderLog({
512
+ userName,
513
+ orderLogId: result.orderLogId,
514
+ });
515
+ return orderLog ? reconstructTrades([orderLog]) : null;
516
+ },
517
+ );
518
+
519
+ const availableArtifacts = artifactAnalyses.filter(Boolean);
520
+ const reconstructed = {
521
+ trades: availableArtifacts
522
+ .flatMap((artifact) => artifact.trades)
523
+ .sort((a, b) => a.timestamp - b.timestamp),
524
+ increaseEvents: availableArtifacts
525
+ .flatMap((artifact) => artifact.increaseEvents)
526
+ .sort((a, b) => a.timestamp - b.timestamp),
527
+ incompleteCycles: availableArtifacts.reduce(
528
+ (total, artifact) => total + artifact.incompleteCycles,
529
+ 0,
530
+ ),
531
+ };
532
+ const startTimestamps = results
533
+ .map((result) => toFiniteNumber(result.test.options?.start, Number.NaN))
534
+ .filter(Number.isFinite);
535
+ const endTimestamps = results
536
+ .map((result) => toFiniteNumber(result.test.options?.end, Number.NaN))
537
+ .filter(Number.isFinite);
538
+ if (
539
+ startTimestamps.length !== results.length ||
540
+ endTimestamps.length !== results.length
541
+ ) {
542
+ throw new Error(`Run ${runId} contains invalid backtest time bounds`);
543
+ }
544
+ const startTimestamp = Math.min(...startTimestamps);
545
+ const endTimestamp = Math.max(...endTimestamps);
546
+ const fullDays = (endTimestamp - startTimestamp) / DAY_MS;
547
+ const configStats = buildConfigStatSummaries({
548
+ results,
549
+ manifest,
550
+ startTimestamp,
551
+ endTimestamp,
552
+ projectedUniverse,
553
+ });
554
+ const periodSpecs = [
555
+ { label: `full (${formatNumber(fullDays, 0)}d)`, days: null },
556
+ ...periods
557
+ .filter((days) => days < fullDays - 0.5)
558
+ .map((days) => ({ label: `${days}d`, days })),
559
+ ];
560
+ const artifactMetricsAvailable =
561
+ configStats.configIds.length === 1 &&
562
+ availableArtifacts.length === results.length;
563
+ const artifactMetricsWarning = artifactMetricsAvailable
564
+ ? null
565
+ : configStats.configIds.length > 1
566
+ ? `Artifact-derived periods are disabled for grid runs with multiple configId buckets (${configStats.configIds.join(', ')}) to avoid aggregating configs.`
567
+ : `Artifact-derived periods are incomplete (${availableArtifacts.length}/${results.length} order logs); for --fast --ai runs use fast-ai-export-metrics.mjs instead.`;
568
+
569
+ return {
570
+ runId,
571
+ userName,
572
+ manifestStatus: manifest?.status ?? null,
573
+ results: results.length,
574
+ statSummary: configStats.statSummary,
575
+ statSummariesByConfig: configStats.statSummariesByConfig,
576
+ statSummaryWarnings: configStats.warnings,
577
+ artifacts: availableArtifacts.length,
578
+ missingArtifacts: results.length - availableArtifacts.length,
579
+ incompleteCycles: reconstructed.incompleteCycles,
580
+ trades: reconstructed.trades.length,
581
+ increases: reconstructed.increaseEvents.length,
582
+ periods: (artifactMetricsAvailable ? periodSpecs : []).map(
583
+ ({ label, days }) => {
584
+ const periodStart =
585
+ days == null
586
+ ? startTimestamp
587
+ : Math.max(startTimestamp, endTimestamp - days * DAY_MS);
588
+ return {
589
+ label,
590
+ startTimestamp: periodStart,
591
+ endTimestamp,
592
+ metrics: summarizeTradeWindow({
593
+ ...reconstructed,
594
+ startTimestamp: periodStart,
595
+ endTimestamp,
596
+ }),
597
+ };
598
+ },
599
+ ),
600
+ artifactMetricsAvailable,
601
+ artifactMetricsWarning,
602
+ };
603
+ };
604
+
605
+ const main = async () => {
606
+ const flags = parseArgs(process.argv.slice(2));
607
+ try {
608
+ const report = await buildRunReport(flags);
609
+ if (flags.json) {
610
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
611
+ return;
612
+ }
613
+
614
+ process.stdout.write(
615
+ [
616
+ `run: ${report.runId}`,
617
+ ...(report.statSummary
618
+ ? [formatStatSummary(report.statSummary)]
619
+ : Object.values(report.statSummariesByConfig).map(formatStatSummary)),
620
+ ...report.statSummaryWarnings,
621
+ '',
622
+ `results/artifacts: ${report.results}/${report.artifacts} (missing=${report.missingArtifacts}, incomplete=${report.incompleteCycles})`,
623
+ `trades/increases: ${report.trades}/${report.increases}`,
624
+ ...(report.artifactMetricsWarning
625
+ ? [report.artifactMetricsWarning]
626
+ : []),
627
+ '',
628
+ formatSummaryTable(report),
629
+ '',
630
+ ].join('\n'),
631
+ );
632
+ } finally {
633
+ await closeRedisConnection();
634
+ }
635
+ };
636
+
637
+ const isMain =
638
+ process.argv[1] &&
639
+ path.resolve(process.argv[1]) ===
640
+ path.resolve(fileURLToPath(import.meta.url));
641
+
642
+ if (isMain) {
643
+ main().catch((error) => {
644
+ console.error(error instanceof Error ? error.message : error);
645
+ process.exitCode = 1;
646
+ });
647
+ }