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,553 @@
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import test from 'node:test';
6
+
7
+ import {
8
+ aggregateRedisResultStatsByConfig,
9
+ buildExportReport,
10
+ buildRedisReconciliation,
11
+ compareCompletedTrades,
12
+ CORE_COHORT_ORDER,
13
+ DEFAULT_PERIODS,
14
+ formatReport,
15
+ parseArgs,
16
+ readExportFiles,
17
+ summarizeTerminalWindow,
18
+ } from './fast-ai-export-metrics.mjs';
19
+
20
+ const DAY_MS = 24 * 60 * 60 * 1000;
21
+
22
+ const trade = ({
23
+ configId = 'config-a',
24
+ direction = 'LONG',
25
+ exitTimestamp,
26
+ netProfit,
27
+ signalId,
28
+ symbol = 'BTCUSDT',
29
+ }) => ({
30
+ configId,
31
+ direction,
32
+ exitTimestamp,
33
+ netProfit,
34
+ signalId,
35
+ sourceFile: 'fixture.jsonl',
36
+ sourceLine: 1,
37
+ symbol,
38
+ });
39
+
40
+ test('uses deterministic exit/signal/symbol ordering for portfolio MaxDD', () => {
41
+ const endTimestamp = Date.UTC(2026, 7, 12);
42
+ const trades = [
43
+ trade({
44
+ exitTimestamp: endTimestamp - DAY_MS,
45
+ netProfit: -4,
46
+ signalId: 'b',
47
+ symbol: 'ETHUSDT',
48
+ }),
49
+ trade({
50
+ exitTimestamp: endTimestamp - DAY_MS,
51
+ netProfit: 5,
52
+ signalId: 'a',
53
+ symbol: 'BTCUSDT',
54
+ }),
55
+ trade({
56
+ direction: 'SHORT',
57
+ exitTimestamp: endTimestamp - 1,
58
+ netProfit: -3,
59
+ signalId: 'c',
60
+ }),
61
+ ];
62
+
63
+ assert.deepEqual(
64
+ [...trades].sort(compareCompletedTrades).map((item) => item.signalId),
65
+ ['a', 'b', 'c'],
66
+ );
67
+ const window = summarizeTerminalWindow({
68
+ trades,
69
+ endTimestamp,
70
+ periodDays: 30,
71
+ });
72
+
73
+ assert.equal(window.metrics.completedTrades, 3);
74
+ assert.equal(window.metrics.wins, 1);
75
+ assert.equal(window.metrics.losses, 2);
76
+ assert.ok(Math.abs(window.metrics.winRatePct - 100 / 3) < 1e-12);
77
+ assert.equal(window.metrics.profitFactor, 5 / 7);
78
+ assert.equal(window.metrics.pnl, -2);
79
+ assert.equal(window.metrics.pnlPerTrade, -2 / 3);
80
+ assert.equal(window.metrics.portfolioMaxDrawdown, 7);
81
+ assert.equal(window.metrics.observedCadenceTradesPerDay, 0.1);
82
+ assert.equal(window.directions.LONG.completedTrades, 2);
83
+ assert.equal(window.directions.SHORT.completedTrades, 1);
84
+ });
85
+
86
+ test('uses aggregate PnL/N and direction-filtered side-only drawdown', () => {
87
+ const endTimestamp = Date.UTC(2026, 7, 12);
88
+ const trades = [
89
+ trade({
90
+ direction: 'LONG',
91
+ exitTimestamp: endTimestamp - 5,
92
+ netProfit: 10,
93
+ signalId: 'long-win',
94
+ }),
95
+ trade({
96
+ direction: 'SHORT',
97
+ exitTimestamp: endTimestamp - 4,
98
+ netProfit: 100,
99
+ signalId: 'short-win',
100
+ }),
101
+ trade({
102
+ direction: 'LONG',
103
+ exitTimestamp: endTimestamp - 3,
104
+ netProfit: -6,
105
+ signalId: 'long-loss',
106
+ }),
107
+ trade({
108
+ direction: 'SHORT',
109
+ exitTimestamp: endTimestamp - 2,
110
+ netProfit: -30,
111
+ signalId: 'short-loss-1',
112
+ }),
113
+ trade({
114
+ direction: 'SHORT',
115
+ exitTimestamp: endTimestamp - 1,
116
+ netProfit: -10,
117
+ signalId: 'short-loss-2',
118
+ }),
119
+ ];
120
+
121
+ const window = summarizeTerminalWindow({
122
+ trades,
123
+ endTimestamp,
124
+ periodDays: 30,
125
+ });
126
+ const unweightedAverageOfSideMeans =
127
+ (window.directions.LONG.pnlPerTrade + window.directions.SHORT.pnlPerTrade) /
128
+ 2;
129
+
130
+ assert.equal(window.metrics.completedTrades, 5);
131
+ assert.equal(window.metrics.pnl, 64);
132
+ assert.equal(window.metrics.pnlPerTrade, 64 / 5);
133
+ assert.equal(window.directions.LONG.pnlPerTrade, 4 / 2);
134
+ assert.equal(window.directions.SHORT.pnlPerTrade, 60 / 3);
135
+ assert.notEqual(window.metrics.pnlPerTrade, unweightedAverageOfSideMeans);
136
+ assert.equal(window.metrics.portfolioMaxDrawdown, 46);
137
+ assert.equal(window.directions.LONG.portfolioMaxDrawdown, 6);
138
+ assert.equal(window.directions.SHORT.portfolioMaxDrawdown, 40);
139
+ });
140
+
141
+ test('human report fixes cohort order and labels PnL/trade and drawdown scope', () => {
142
+ const endTimestamp = Date.UTC(2026, 7, 12);
143
+ const window = summarizeTerminalWindow({
144
+ trades: [
145
+ trade({
146
+ direction: 'SHORT',
147
+ exitTimestamp: endTimestamp - 2,
148
+ netProfit: 2,
149
+ signalId: 'short',
150
+ }),
151
+ trade({
152
+ direction: 'LONG',
153
+ exitTimestamp: endTimestamp - 1,
154
+ netProfit: -1,
155
+ signalId: 'long',
156
+ }),
157
+ ],
158
+ endTimestamp,
159
+ periodDays: 30,
160
+ });
161
+ const humanReport = formatReport({
162
+ source: {
163
+ kind: 'fixture',
164
+ pnlField: 'tradeResult.netProfit',
165
+ timestampField: 'tradeResult.exitTimestamp',
166
+ },
167
+ anchor: {
168
+ endIso: new Date(endTimestamp).toISOString(),
169
+ source: 'fixture',
170
+ runId: null,
171
+ },
172
+ scan: {
173
+ selectedCompletedTrades: 2,
174
+ rowsForDifferentRun: 0,
175
+ rowsWithoutTradeResult: 0,
176
+ duplicateRowsDropped: 0,
177
+ rowsAfterAnchor: 0,
178
+ },
179
+ configAggregationWarning: null,
180
+ configReports: {
181
+ 'config-a': {
182
+ configId: 'config-a',
183
+ windows: [window],
184
+ reconciliation: { status: 'unavailable', reason: 'fixture' },
185
+ },
186
+ },
187
+ });
188
+
189
+ assert.deepEqual(CORE_COHORT_ORDER, ['ALL', 'LONG', 'SHORT']);
190
+ assert.match(humanReport, /Avg PnL\/trade \(cohort PnL\/N\)/);
191
+ assert.match(humanReport, /ALL is aggregate PnL \/ aggregate N/);
192
+ assert.match(humanReport, /aggregate portfolio equity curve/);
193
+ assert.match(humanReport, /side-only time-ordered equity curves/);
194
+ const allIndex = humanReport.indexOf('| config-a | 30d | ALL |');
195
+ const longIndex = humanReport.indexOf('| config-a | 30d | LONG |');
196
+ const shortIndex = humanReport.indexOf('| config-a | 30d | SHORT |');
197
+ assert.ok(allIndex >= 0 && allIndex < longIndex && longIndex < shortIndex);
198
+ assert.match(
199
+ humanReport,
200
+ /\| config-a \| 30d \| ALL \|.*\| aggregate portfolio \|/,
201
+ );
202
+ assert.match(
203
+ humanReport,
204
+ /\| config-a \| 30d \| LONG \|.*\| side-only LONG \|/,
205
+ );
206
+ assert.match(
207
+ humanReport,
208
+ /\| config-a \| 30d \| SHORT \|.*\| side-only SHORT \|/,
209
+ );
210
+ });
211
+
212
+ test('anchors terminal windows strictly to the supplied end and exact days', () => {
213
+ const endTimestamp = Date.UTC(2026, 7, 12);
214
+ const window = summarizeTerminalWindow({
215
+ trades: [
216
+ trade({
217
+ exitTimestamp: endTimestamp - 30 * DAY_MS,
218
+ netProfit: 1,
219
+ signalId: 'at-start',
220
+ }),
221
+ trade({
222
+ exitTimestamp: endTimestamp,
223
+ netProfit: 2,
224
+ signalId: 'at-end',
225
+ }),
226
+ trade({
227
+ exitTimestamp: endTimestamp - 1,
228
+ netProfit: 2,
229
+ signalId: 'before-end',
230
+ }),
231
+ trade({
232
+ exitTimestamp: endTimestamp - 30 * DAY_MS - 1,
233
+ netProfit: 100,
234
+ signalId: 'before-start',
235
+ }),
236
+ trade({
237
+ exitTimestamp: endTimestamp + 1,
238
+ netProfit: 100,
239
+ signalId: 'after-end',
240
+ }),
241
+ ],
242
+ endTimestamp,
243
+ periodDays: 30,
244
+ coverageStartTimestamp: endTimestamp - 1100 * DAY_MS,
245
+ });
246
+
247
+ assert.equal(window.startTimestamp, endTimestamp - 30 * DAY_MS);
248
+ assert.equal(window.endTimestamp, endTimestamp);
249
+ assert.equal(window.metrics.completedTrades, 2);
250
+ assert.equal(window.metrics.pnl, 3);
251
+ assert.equal(window.metrics.observedCadenceTradesPerDay, 2 / 30);
252
+ assert.equal(window.coverage, 'complete_within_run_manifest');
253
+ });
254
+
255
+ test('streams JSONL, filters the requested run, and drops duplicate trades', async () => {
256
+ const temporaryDirectory = await fs.mkdtemp(
257
+ path.join(os.tmpdir(), 'tradejs-fast-export-metrics-'),
258
+ );
259
+ const filePath = path.join(temporaryDirectory, 'export.jsonl');
260
+ const completedRow = {
261
+ backtestRunId: 'run-a',
262
+ direction: 'LONG',
263
+ signalId: 'signal-1',
264
+ symbol: 'BTCUSDT',
265
+ tradeResult: { exitTimestamp: 100, netProfit: 3 },
266
+ };
267
+
268
+ try {
269
+ await fs.writeFile(
270
+ filePath,
271
+ [
272
+ JSON.stringify(completedRow),
273
+ JSON.stringify(completedRow),
274
+ JSON.stringify({ ...completedRow, backtestRunId: 'run-b' }),
275
+ JSON.stringify({ backtestRunId: 'run-a', signalId: 'open' }),
276
+ '',
277
+ ].join('\n'),
278
+ );
279
+
280
+ const result = await readExportFiles({
281
+ filePaths: [filePath],
282
+ runId: 'run-a',
283
+ });
284
+
285
+ assert.equal(result.scan.rowsRead, 4);
286
+ assert.equal(result.scan.rowsForDifferentRun, 1);
287
+ assert.equal(result.scan.rowsWithoutTradeResult, 1);
288
+ assert.equal(result.scan.selectedCompletedTradesBeforeDedup, 2);
289
+ assert.equal(result.scan.duplicateRowsDropped, 1);
290
+ assert.equal(result.scan.selectedCompletedTrades, 1);
291
+ assert.equal(result.trades[0].netProfit, 3);
292
+ assert.match(result.scan.files[0].sha256, /^[a-f0-9]{64}$/);
293
+ } finally {
294
+ await fs.rm(temporaryDirectory, { recursive: true, force: true });
295
+ }
296
+ });
297
+
298
+ test('rejects conflicting outcomes for the same run/config/signal/symbol identity', async () => {
299
+ const temporaryDirectory = await fs.mkdtemp(
300
+ path.join(os.tmpdir(), 'tradejs-fast-export-conflict-'),
301
+ );
302
+ const filePath = path.join(temporaryDirectory, 'export.jsonl');
303
+ const baseRow = {
304
+ backtestRunId: 'run-a',
305
+ configId: 'config-a',
306
+ direction: 'LONG',
307
+ signalId: 'signal-1',
308
+ symbol: 'BTCUSDT',
309
+ };
310
+
311
+ try {
312
+ await fs.writeFile(
313
+ filePath,
314
+ [
315
+ JSON.stringify({
316
+ ...baseRow,
317
+ tradeResult: { exitTimestamp: 100, netProfit: 3 },
318
+ }),
319
+ JSON.stringify({
320
+ ...baseRow,
321
+ tradeResult: { exitTimestamp: 200, netProfit: 4 },
322
+ }),
323
+ ].join('\n'),
324
+ );
325
+
326
+ await assert.rejects(
327
+ readExportFiles({ filePaths: [filePath], runId: 'run-a' }),
328
+ /Conflicting completed-trade rows share identity/,
329
+ );
330
+ } finally {
331
+ await fs.rm(temporaryDirectory, { recursive: true, force: true });
332
+ }
333
+ });
334
+
335
+ test('reconciles export trade metrics with Redis stat aggregates', () => {
336
+ const reconciliation = buildRedisReconciliation({
337
+ redisAggregate: {
338
+ resultCount: 2,
339
+ completedTrades: 3,
340
+ wins: 1,
341
+ losses: 2,
342
+ pnl: 1.004,
343
+ },
344
+ exportMetrics: {
345
+ completedTrades: 3,
346
+ wins: 1,
347
+ losses: 2,
348
+ pnl: 1,
349
+ },
350
+ });
351
+
352
+ assert.equal(reconciliation.status, 'match');
353
+ assert.ok(Math.abs(reconciliation.delta.pnl + 0.004) < 1e-12);
354
+ assert.equal(reconciliation.matches.pnl, true);
355
+ assert.match(reconciliation.semantics, /PF and aggregate portfolio MaxDD/);
356
+ });
357
+
358
+ test('aggregates Redis result stats independently by result configId', () => {
359
+ const summaries = aggregateRedisResultStatsByConfig([
360
+ {
361
+ result: {
362
+ test: { configId: 'config-a' },
363
+ stat: { orders: 2, wins: 1, losses: 1, netProfit: 3 },
364
+ },
365
+ },
366
+ {
367
+ result: {
368
+ test: { configId: 'config-b' },
369
+ stat: { orders: 5, wins: 2, losses: 3, netProfit: -7 },
370
+ },
371
+ },
372
+ {
373
+ result: {
374
+ test: { configId: 'config-a' },
375
+ stat: { orders: 1, wins: 1, losses: 0, profit: 2 },
376
+ },
377
+ },
378
+ ]);
379
+
380
+ assert.deepEqual(summaries, {
381
+ 'config-a': {
382
+ resultCount: 2,
383
+ completedTrades: 3,
384
+ wins: 2,
385
+ losses: 1,
386
+ pnl: 5,
387
+ },
388
+ 'config-b': {
389
+ resultCount: 1,
390
+ completedTrades: 5,
391
+ wins: 2,
392
+ losses: 3,
393
+ pnl: -7,
394
+ },
395
+ });
396
+ });
397
+
398
+ test('builds all default windows from the Redis manifest end, not export max', async () => {
399
+ const temporaryDirectory = await fs.mkdtemp(
400
+ path.join(os.tmpdir(), 'tradejs-fast-export-report-'),
401
+ );
402
+ const filePath = path.join(temporaryDirectory, 'export.jsonl');
403
+ const endTimestamp = Date.UTC(2026, 7, 12);
404
+
405
+ try {
406
+ await fs.writeFile(
407
+ filePath,
408
+ `${JSON.stringify({
409
+ backtestRunId: 'run-a',
410
+ direction: 'SHORT',
411
+ signalId: 'future',
412
+ symbol: 'ETHUSDT',
413
+ tradeResult: { exitTimestamp: endTimestamp + DAY_MS, netProfit: 10 },
414
+ })}\n`,
415
+ );
416
+ const report = await buildExportReport({
417
+ filePaths: [filePath],
418
+ periods: DEFAULT_PERIODS,
419
+ runId: 'run-a',
420
+ runContextLoader: async () => ({
421
+ manifest: {
422
+ runId: 'run-a',
423
+ startTimestamp: endTimestamp - 1100 * DAY_MS,
424
+ endTimestamp,
425
+ },
426
+ redisAggregate: {
427
+ resultCount: 1,
428
+ completedTrades: 0,
429
+ wins: 0,
430
+ losses: 0,
431
+ pnl: 0,
432
+ },
433
+ }),
434
+ });
435
+
436
+ assert.deepEqual(
437
+ report.windows.map((window) => window.periodDays),
438
+ DEFAULT_PERIODS,
439
+ );
440
+ assert.equal(report.anchor.endTimestamp, endTimestamp);
441
+ assert.equal(report.scan.rowsAfterAnchor, 1);
442
+ assert.equal(report.windows[0].metrics.completedTrades, 0);
443
+ assert.equal(report.source.gateDecisionsUsed, false);
444
+ assert.equal(report.reconciliation.status, 'match');
445
+ assert.deepEqual(report.semantics.cohortOrder, CORE_COHORT_ORDER);
446
+ assert.match(report.semantics.pnlPerTrade, /never an unweighted average/);
447
+ assert.equal(
448
+ report.metricSchema.realizedMaxDrawdown.jsonField,
449
+ 'portfolioMaxDrawdown',
450
+ );
451
+ assert.match(report.metricSchema.compatibility, /schemaVersion 1/);
452
+ } finally {
453
+ await fs.rm(temporaryDirectory, { recursive: true, force: true });
454
+ }
455
+ });
456
+
457
+ test('keeps export windows and Redis reconciliation separate for grid configs', async () => {
458
+ const temporaryDirectory = await fs.mkdtemp(
459
+ path.join(os.tmpdir(), 'tradejs-fast-export-grid-report-'),
460
+ );
461
+ const filePath = path.join(temporaryDirectory, 'export.jsonl');
462
+ const endTimestamp = Date.UTC(2026, 7, 12);
463
+
464
+ try {
465
+ await fs.writeFile(
466
+ filePath,
467
+ [
468
+ {
469
+ backtestRunId: 'run-grid',
470
+ configId: 'config-a',
471
+ direction: 'LONG',
472
+ signalId: 'same-signal',
473
+ symbol: 'BTCUSDT',
474
+ tradeResult: { exitTimestamp: endTimestamp - 1, netProfit: 4 },
475
+ },
476
+ {
477
+ backtestRunId: 'run-grid',
478
+ configId: 'config-b',
479
+ direction: 'SHORT',
480
+ signalId: 'same-signal',
481
+ symbol: 'BTCUSDT',
482
+ tradeResult: { exitTimestamp: endTimestamp - 1, netProfit: -9 },
483
+ },
484
+ ]
485
+ .map(JSON.stringify)
486
+ .join('\n'),
487
+ );
488
+ const report = await buildExportReport({
489
+ filePaths: [filePath],
490
+ periods: [30],
491
+ runId: 'run-grid',
492
+ runContextLoader: async () => ({
493
+ manifest: {
494
+ runId: 'run-grid',
495
+ startTimestamp: endTimestamp - 30 * DAY_MS,
496
+ endTimestamp,
497
+ configIds: ['config-a', 'config-b'],
498
+ },
499
+ redisAggregatesByConfig: {
500
+ 'config-a': {
501
+ resultCount: 1,
502
+ completedTrades: 1,
503
+ wins: 1,
504
+ losses: 0,
505
+ pnl: 4,
506
+ },
507
+ 'config-b': {
508
+ resultCount: 1,
509
+ completedTrades: 1,
510
+ wins: 0,
511
+ losses: 1,
512
+ pnl: -9,
513
+ },
514
+ },
515
+ }),
516
+ });
517
+
518
+ assert.deepEqual(report.configIds, ['config-a', 'config-b']);
519
+ assert.equal(report.scan.selectedCompletedTrades, 2);
520
+ assert.equal(report.scan.duplicateRowsDropped, 0);
521
+ assert.deepEqual(report.windows, []);
522
+ assert.equal(report.runWindowMetrics, null);
523
+ assert.equal(report.reconciliation, null);
524
+ assert.equal(report.configReports['config-a'].windows[0].metrics.pnl, 4);
525
+ assert.equal(report.configReports['config-b'].windows[0].metrics.pnl, -9);
526
+ assert.equal(
527
+ report.configReports['config-a'].reconciliation.status,
528
+ 'match',
529
+ );
530
+ assert.equal(
531
+ report.configReports['config-b'].reconciliation.status,
532
+ 'match',
533
+ );
534
+ assert.match(report.configAggregationWarning, /Multiple configId buckets/);
535
+ } finally {
536
+ await fs.rm(temporaryDirectory, { recursive: true, force: true });
537
+ }
538
+ });
539
+
540
+ test('parses repeated files and the requested default metric periods', () => {
541
+ const flags = parseArgs([
542
+ '--file',
543
+ 'part1.jsonl',
544
+ '--file',
545
+ 'part2.jsonl',
546
+ '--end',
547
+ '2026-08-12T00:00:00.000Z',
548
+ ]);
549
+
550
+ assert.deepEqual(flags.filePaths, ['part1.jsonl', 'part2.jsonl']);
551
+ assert.deepEqual(flags.periods, DEFAULT_PERIODS);
552
+ assert.equal(flags.endTimestamp, Date.UTC(2026, 7, 12));
553
+ });
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+
6
+ const projectRoot = path.resolve(
7
+ String(process.env.PROJECT_CWD || process.cwd()),
8
+ );
9
+ const notesRoot = path.join(projectRoot, 'notes');
10
+ const filenamePattern = /^\d{4}-\d{2}-\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*\.md$/;
11
+ const requiredFrontmatter = [
12
+ 'schema',
13
+ 'strategy',
14
+ 'date',
15
+ 'kind',
16
+ 'status',
17
+ 'reproduction',
18
+ ];
19
+ const completeSections = [
20
+ '## Research question',
21
+ '## Decision',
22
+ '## Reproduction manifest',
23
+ '## Resolved configuration',
24
+ '## Metrics snapshot (machine-readable)',
25
+ '## Reported metrics',
26
+ '## Findings',
27
+ '## Artifact inventory',
28
+ '## Limitations and next step',
29
+ ];
30
+
31
+ const parseFrontmatter = (content) => {
32
+ const match = content.match(/^---\n([\s\S]*?)\n---\n/);
33
+ if (!match) return null;
34
+
35
+ return Object.fromEntries(
36
+ match[1]
37
+ .split('\n')
38
+ .map((line) => line.match(/^([a-z0-9_]+):\s*(.*)$/))
39
+ .filter(Boolean)
40
+ .map((entry) => {
41
+ const value = entry[2].trim();
42
+ return [
43
+ entry[1],
44
+ value.startsWith('"') ? JSON.parse(value) : value,
45
+ ];
46
+ }),
47
+ );
48
+ };
49
+
50
+ const errors = [];
51
+ let records = 0;
52
+ const rootEntries = await fs.readdir(notesRoot, { withFileTypes: true });
53
+
54
+ for (const entry of rootEntries) {
55
+ if (!entry.isDirectory()) {
56
+ errors.push(`notes/${entry.name}: files are not allowed directly in notes/`);
57
+ continue;
58
+ }
59
+
60
+ const directoryPath = path.join(notesRoot, entry.name);
61
+ const noteEntries = await fs.readdir(directoryPath, { withFileTypes: true });
62
+ for (const noteEntry of noteEntries) {
63
+ const relativePath = path.join('notes', entry.name, noteEntry.name);
64
+ if (!noteEntry.isFile() || !noteEntry.name.endsWith('.md')) {
65
+ errors.push(`${relativePath}: only Markdown research files are allowed`);
66
+ continue;
67
+ }
68
+
69
+ records += 1;
70
+ if (!filenamePattern.test(noteEntry.name)) {
71
+ errors.push(`${relativePath}: expected YYYY-MM-DD-<kebab-slug>.md`);
72
+ }
73
+
74
+ const content = await fs.readFile(path.join(directoryPath, noteEntry.name), 'utf8');
75
+ const frontmatter = parseFrontmatter(content);
76
+ if (!frontmatter) {
77
+ errors.push(`${relativePath}: missing YAML frontmatter`);
78
+ continue;
79
+ }
80
+
81
+ for (const key of requiredFrontmatter) {
82
+ if (!frontmatter[key]) errors.push(`${relativePath}: missing ${key}`);
83
+ }
84
+ if (frontmatter.schema !== 'tradejs-research/v1') {
85
+ errors.push(`${relativePath}: unsupported schema ${frontmatter.schema}`);
86
+ }
87
+ if (frontmatter.strategy !== entry.name) {
88
+ errors.push(
89
+ `${relativePath}: strategy ${frontmatter.strategy} must match directory ${entry.name}`,
90
+ );
91
+ }
92
+ if (frontmatter.date !== noteEntry.name.slice(0, 10)) {
93
+ errors.push(`${relativePath}: date must match the filename`);
94
+ }
95
+
96
+ let previousIndex = -1;
97
+ for (const section of completeSections) {
98
+ const sectionIndex = content.indexOf(section);
99
+ if (sectionIndex === -1) {
100
+ errors.push(`${relativePath}: missing ${section}`);
101
+ } else if (sectionIndex < previousIndex) {
102
+ errors.push(`${relativePath}: ${section} is out of order`);
103
+ }
104
+ previousIndex = Math.max(previousIndex, sectionIndex);
105
+ }
106
+
107
+ if (frontmatter.reproduction === 'complete') {
108
+ const snapshotStart = content.indexOf(
109
+ '## Metrics snapshot (machine-readable)',
110
+ );
111
+ const reportedStart = content.indexOf('## Reported metrics');
112
+ const snapshot = content.slice(snapshotStart, reportedStart);
113
+ if (!/```json\n[\s\S]+?\n```/.test(snapshot)) {
114
+ errors.push(`${relativePath}: complete record needs a JSON metric snapshot`);
115
+ }
116
+ }
117
+ }
118
+ }
119
+
120
+ if (errors.length) {
121
+ process.stderr.write(`${errors.join('\n')}\n`);
122
+ process.exitCode = 1;
123
+ } else {
124
+ process.stdout.write(`Validated ${records} research note records.\n`);
125
+ }