@tradejs/cli 2.0.19 → 2.0.21

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.
@@ -0,0 +1,2830 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/scripts/coreResearch.ts
31
+ var coreResearch_exports = {};
32
+ __export(coreResearch_exports, {
33
+ main: () => main
34
+ });
35
+ module.exports = __toCommonJS(coreResearch_exports);
36
+ var import_promises5 = __toESM(require("fs/promises"));
37
+ var import_node_path6 = __toESM(require("path"));
38
+ var import_chalk = __toESM(require("chalk"));
39
+
40
+ // src/lib/coreResearch/io.ts
41
+ var import_node_crypto = require("crypto");
42
+ var import_node_fs = require("fs");
43
+ var import_promises = __toESM(require("fs/promises"));
44
+ var import_node_path = __toESM(require("path"));
45
+
46
+ // src/lib/coreResearch/types.ts
47
+ var CORE_RESEARCH_SCHEMA = "tradejs-core-research/v1";
48
+ var CORE_RESEARCH_RESULT_SCHEMA = "tradejs-core-research-result/v1";
49
+ var CORE_RESEARCH_LEDGER_SCHEMA = "tradejs-core-research-ledger/v1";
50
+
51
+ // src/lib/coreResearch/io.ts
52
+ var RESEARCH_ID_RE = /^[a-z0-9][a-z0-9._-]{2,120}$/;
53
+ var SHA256_RE = /^[a-f0-9]{64}$/;
54
+ var canonicalJson = (value) => {
55
+ const normalize = (current) => {
56
+ if (Array.isArray(current)) return current.map(normalize);
57
+ if (current && typeof current === "object") {
58
+ return Object.fromEntries(
59
+ Object.entries(current).filter(([, entry]) => entry !== void 0).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0).map(([key, entry]) => [key, normalize(entry)])
60
+ );
61
+ }
62
+ return current;
63
+ };
64
+ return JSON.stringify(normalize(value));
65
+ };
66
+ var sha256Text = (value) => (0, import_node_crypto.createHash)("sha256").update(value).digest("hex");
67
+ var sha256Json = (value) => sha256Text(canonicalJson(value));
68
+ var sha256File = async (filePath) => {
69
+ const hash = (0, import_node_crypto.createHash)("sha256");
70
+ const input = (0, import_node_fs.createReadStream)(filePath);
71
+ for await (const chunk of input) hash.update(chunk);
72
+ return hash.digest("hex");
73
+ };
74
+ function assert(condition, message) {
75
+ if (!condition) throw new Error(`Invalid core research spec: ${message}`);
76
+ }
77
+ var assertFinite = (value, name) => {
78
+ assert(typeof value === "number" && Number.isFinite(value), name);
79
+ };
80
+ var validateCoreResearchSpec = (value) => {
81
+ assert(value && typeof value === "object", "root must be an object");
82
+ const spec = value;
83
+ assert(
84
+ spec.schema === CORE_RESEARCH_SCHEMA,
85
+ `schema must be ${CORE_RESEARCH_SCHEMA}`
86
+ );
87
+ assert(
88
+ RESEARCH_ID_RE.test(spec.researchId),
89
+ "researchId must be filesystem-safe"
90
+ );
91
+ assert(
92
+ ["screen", "isolated_long", "confirmation"].includes(spec.stage),
93
+ "stage must be screen, isolated_long, or confirmation"
94
+ );
95
+ assert(
96
+ spec.stage === "screen" || (spec.parentResearchIds?.length ?? 0) > 0,
97
+ "isolated_long and confirmation stages require parentResearchIds"
98
+ );
99
+ if (spec.parentResearchIds) {
100
+ assert(
101
+ spec.parentResearchIds.every((id) => RESEARCH_ID_RE.test(id)),
102
+ "parentResearchIds must contain filesystem-safe research IDs"
103
+ );
104
+ }
105
+ assert(Boolean(spec.strategy?.trim()), "strategy is required");
106
+ assert(
107
+ !Number.isNaN(Date.parse(spec.createdAt)),
108
+ "createdAt must be ISO-8601"
109
+ );
110
+ assert(
111
+ Boolean(spec.hypothesis?.family?.trim()),
112
+ "hypothesis.family is required"
113
+ );
114
+ assert(
115
+ Boolean(spec.hypothesis?.claim?.trim()),
116
+ "hypothesis.claim is required"
117
+ );
118
+ assert(
119
+ Boolean(spec.hypothesis?.mechanism?.trim()),
120
+ "hypothesis.mechanism is required"
121
+ );
122
+ assert(
123
+ ["ALL", "LONG", "SHORT"].includes(spec.hypothesis?.target),
124
+ "hypothesis.target must be ALL, LONG, or SHORT"
125
+ );
126
+ assert(
127
+ Array.isArray(spec.universe?.symbols) && spec.universe.symbols.length > 0,
128
+ "universe.symbols is required"
129
+ );
130
+ assert(
131
+ new Set(spec.universe.symbols).size === spec.universe.symbols.length,
132
+ "universe.symbols must be unique"
133
+ );
134
+ assert(
135
+ sha256Json(spec.universe.symbols) === spec.universe.sha256,
136
+ "universe.sha256 does not match the ordered symbol array"
137
+ );
138
+ assert(
139
+ SHA256_RE.test(spec.universe.sha256),
140
+ "universe.sha256 must be a lowercase SHA-256"
141
+ );
142
+ assertFinite(spec.window?.start, "window.start must be finite");
143
+ assertFinite(spec.window?.end, "window.end must be finite");
144
+ assert(spec.window.end > spec.window.start, "window.end must be after start");
145
+ assert(
146
+ Array.isArray(spec.window.terminalDays) && spec.window.terminalDays.every(
147
+ (days) => Number.isInteger(days) && days > 0
148
+ ),
149
+ "window.terminalDays must contain positive integers"
150
+ );
151
+ assert(
152
+ Number.isInteger(spec.window.folds) && spec.window.folds >= 2,
153
+ "window.folds must be >= 2"
154
+ );
155
+ assert(
156
+ Array.isArray(spec.variants) && spec.variants.length >= 2,
157
+ "at least two variants are required"
158
+ );
159
+ assert(
160
+ spec.variants.filter((variant) => variant.role === "control").length === 1,
161
+ "exactly one control is required"
162
+ );
163
+ assert(
164
+ spec.variants.some((variant) => variant.role === "candidate"),
165
+ "at least one candidate is required"
166
+ );
167
+ assert(
168
+ new Set(spec.variants.map((variant) => variant.id)).size === spec.variants.length,
169
+ "variant ids must be unique"
170
+ );
171
+ for (const variant of spec.variants) {
172
+ assert(
173
+ RESEARCH_ID_RE.test(variant.id),
174
+ `variant id ${variant.id} is invalid`
175
+ );
176
+ assert(
177
+ Boolean(variant.configName?.trim()),
178
+ `variant ${variant.id} configName is required`
179
+ );
180
+ assert(
181
+ variant.resolvedConfig && typeof variant.resolvedConfig === "object" && !Array.isArray(variant.resolvedConfig) && Object.keys(variant.resolvedConfig).length > 0,
182
+ `variant ${variant.id} resolvedConfig must be a non-empty object`
183
+ );
184
+ assert(
185
+ sha256Json(variant.resolvedConfig) === variant.configSha256,
186
+ `variant ${variant.id} configSha256 does not match resolvedConfig`
187
+ );
188
+ assert(
189
+ SHA256_RE.test(variant.configSha256),
190
+ `variant ${variant.id} configSha256 must be a lowercase SHA-256`
191
+ );
192
+ assert(
193
+ Array.isArray(variant.files),
194
+ `variant ${variant.id} files must be an array`
195
+ );
196
+ assert(
197
+ variant.files.length > 0 || Array.isArray(variant.command) && variant.command.length > 0,
198
+ `variant ${variant.id} needs files or a command`
199
+ );
200
+ if (variant.runId != null) {
201
+ assert(
202
+ Boolean(variant.runId.trim()),
203
+ `variant ${variant.id} runId is empty`
204
+ );
205
+ }
206
+ }
207
+ assert(
208
+ Number.isInteger(spec.selection?.minimumTrades) && spec.selection.minimumTrades >= 0,
209
+ "selection.minimumTrades must be non-negative"
210
+ );
211
+ assertFinite(
212
+ spec.selection?.minimumCadencePerDay,
213
+ "selection.minimumCadencePerDay must be finite"
214
+ );
215
+ assert(
216
+ spec.selection.minimumCadencePerDay >= 0,
217
+ "selection.minimumCadencePerDay must be non-negative"
218
+ );
219
+ const allowedMetrics = /* @__PURE__ */ new Set([
220
+ "pnl",
221
+ "pnlPerTrade",
222
+ "profitFactor",
223
+ "winRatePct",
224
+ "realizedMaxDrawdown",
225
+ "cadencePerDay"
226
+ ]);
227
+ const allowedComparisons = /* @__PURE__ */ new Set(["gt", "gte", "lt", "lte"]);
228
+ for (const [name, rules] of Object.entries({
229
+ targetRules: spec.selection.targetRules,
230
+ aggregateRules: spec.selection.aggregateRules,
231
+ nonTargetRules: spec.selection.nonTargetRules,
232
+ terminalRules: spec.selection.terminalRules ?? [],
233
+ costStressRules: spec.selection.costStressRules ?? []
234
+ })) {
235
+ assert(Array.isArray(rules), `selection.${name} must be an array`);
236
+ for (const rule of rules) {
237
+ assert(
238
+ allowedMetrics.has(rule.metric),
239
+ `selection.${name} contains an unsupported metric`
240
+ );
241
+ assert(
242
+ allowedComparisons.has(rule.comparison),
243
+ `selection.${name} contains an unsupported comparison`
244
+ );
245
+ if (rule.value != null) {
246
+ assertFinite(rule.value, `selection.${name} value must be finite`);
247
+ }
248
+ }
249
+ }
250
+ assertFinite(
251
+ spec.execution?.maxLossValue,
252
+ "execution.maxLossValue must be finite"
253
+ );
254
+ assert(
255
+ spec.execution.maxLossValue > 0,
256
+ "execution.maxLossValue must be positive"
257
+ );
258
+ assert(
259
+ Number.isInteger(spec.robustness?.bootstrapIterations) && spec.robustness.bootstrapIterations >= 100,
260
+ "robustness.bootstrapIterations must be >= 100"
261
+ );
262
+ assert(
263
+ spec.robustness.confidenceLevel > 0 && spec.robustness.confidenceLevel < 1,
264
+ "robustness.confidenceLevel must be in (0, 1)"
265
+ );
266
+ assert(
267
+ Number.isInteger(spec.robustness.clusterDays) && spec.robustness.clusterDays > 0,
268
+ "robustness.clusterDays must be positive"
269
+ );
270
+ assert(
271
+ Number.isInteger(spec.robustness.minimumFoldTrades) && spec.robustness.minimumFoldTrades >= 0,
272
+ "robustness.minimumFoldTrades must be a non-negative integer"
273
+ );
274
+ if (spec.selection.maximumPortfolioDrawdownRegressionPct != null) {
275
+ assert(
276
+ Number.isFinite(spec.selection.maximumPortfolioDrawdownRegressionPct) && spec.selection.maximumPortfolioDrawdownRegressionPct >= 0,
277
+ "selection.maximumPortfolioDrawdownRegressionPct must be non-negative"
278
+ );
279
+ }
280
+ if (spec.selection.maximumHolmPValue != null) {
281
+ assert(
282
+ spec.selection.maximumHolmPValue >= 0 && spec.selection.maximumHolmPValue <= 1,
283
+ "selection.maximumHolmPValue must be in [0, 1]"
284
+ );
285
+ }
286
+ if (spec.selection.minimumPositiveFoldPct != null) {
287
+ assert(
288
+ spec.selection.minimumPositiveFoldPct >= 0 && spec.selection.minimumPositiveFoldPct <= 100,
289
+ "selection.minimumPositiveFoldPct must be in [0, 100]"
290
+ );
291
+ }
292
+ assert(
293
+ Array.isArray(spec.robustness.costStressBps) && spec.robustness.costStressBps.every(
294
+ (entry) => Number.isFinite(entry) && entry >= 0
295
+ ),
296
+ "robustness.costStressBps must contain non-negative finite values"
297
+ );
298
+ assert(
299
+ Boolean(spec.artifacts?.rootDir?.trim()),
300
+ "artifacts.rootDir is required"
301
+ );
302
+ assert(
303
+ Boolean(spec.artifacts?.ledgerPath?.trim()),
304
+ "artifacts.ledgerPath is required"
305
+ );
306
+ return spec;
307
+ };
308
+ var loadCoreResearchSpec = async (specPath) => {
309
+ const resolvedPath = import_node_path.default.resolve(specPath);
310
+ const parsed = JSON.parse(await import_promises.default.readFile(resolvedPath, "utf8"));
311
+ const spec = validateCoreResearchSpec(parsed);
312
+ return { resolvedPath, spec, specSha256: sha256Json(spec) };
313
+ };
314
+ var resolveCoreResearchPaths = (spec) => {
315
+ const rootDir = import_node_path.default.resolve(spec.artifacts.rootDir);
316
+ const researchDir = import_node_path.default.join(rootDir, spec.researchId);
317
+ return {
318
+ rootDir,
319
+ researchDir,
320
+ ledgerPath: import_node_path.default.resolve(spec.artifacts.ledgerPath),
321
+ specPath: import_node_path.default.join(researchDir, "spec.json"),
322
+ manifestPath: import_node_path.default.join(researchDir, "manifest.json"),
323
+ resultPath: import_node_path.default.join(researchDir, "result.json"),
324
+ comparisonPath: import_node_path.default.join(researchDir, "comparison.json"),
325
+ matchesPath: import_node_path.default.join(researchDir, "matches.csv"),
326
+ tradesPath: import_node_path.default.join(researchDir, "trades.jsonl"),
327
+ reportPath: import_node_path.default.join(researchDir, "report.html"),
328
+ runLogDir: import_node_path.default.join(researchDir, "runs")
329
+ };
330
+ };
331
+ var writeJsonAtomic = async (filePath, value) => {
332
+ await import_promises.default.mkdir(import_node_path.default.dirname(filePath), { recursive: true });
333
+ const temporaryPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
334
+ await import_promises.default.writeFile(temporaryPath, `${canonicalJson(value)}
335
+ `, "utf8");
336
+ await import_promises.default.rename(temporaryPath, filePath);
337
+ };
338
+ var buildLedgerRecordHash = (record) => sha256Json(record);
339
+ var readCoreResearchLedger = async (ledgerPath) => {
340
+ let text = "";
341
+ try {
342
+ text = await import_promises.default.readFile(ledgerPath, "utf8");
343
+ } catch (error) {
344
+ if (error.code === "ENOENT") return [];
345
+ throw error;
346
+ }
347
+ return text.split("\n").filter(Boolean).map((line, index) => {
348
+ try {
349
+ return JSON.parse(line);
350
+ } catch (error) {
351
+ throw new Error(
352
+ `Invalid ledger JSON at line ${index + 1}: ${String(error)}`
353
+ );
354
+ }
355
+ });
356
+ };
357
+ var verifyCoreResearchLedger = (records) => {
358
+ let previousHash = null;
359
+ records.forEach((record, index) => {
360
+ if (record.schema !== CORE_RESEARCH_LEDGER_SCHEMA) {
361
+ throw new Error(`Ledger line ${index + 1} has unsupported schema`);
362
+ }
363
+ if (record.sequence !== index + 1) {
364
+ throw new Error(`Ledger line ${index + 1} has invalid sequence`);
365
+ }
366
+ if (record.previousHash !== previousHash) {
367
+ throw new Error(`Ledger line ${index + 1} breaks the hash chain`);
368
+ }
369
+ const { recordHash, ...payload } = record;
370
+ if (buildLedgerRecordHash(payload) !== recordHash) {
371
+ throw new Error(`Ledger line ${index + 1} has an invalid record hash`);
372
+ }
373
+ previousHash = recordHash;
374
+ });
375
+ return { records: records.length, head: previousHash };
376
+ };
377
+ var appendCoreResearchLedger = async (params) => {
378
+ const { ledgerPath } = params;
379
+ await import_promises.default.mkdir(import_node_path.default.dirname(ledgerPath), { recursive: true });
380
+ const lockPath = `${ledgerPath}.lock`;
381
+ let lock = null;
382
+ try {
383
+ lock = await import_promises.default.open(lockPath, "wx");
384
+ const records = await readCoreResearchLedger(ledgerPath);
385
+ const verified = verifyCoreResearchLedger(records);
386
+ const payload = {
387
+ schema: CORE_RESEARCH_LEDGER_SCHEMA,
388
+ sequence: records.length + 1,
389
+ recordedAt: (/* @__PURE__ */ new Date()).toISOString(),
390
+ researchId: params.researchId,
391
+ event: params.event,
392
+ specSha256: params.specSha256,
393
+ hypothesisFamily: params.hypothesisFamily,
394
+ hypothesesInRecord: params.hypothesesInRecord ?? 0,
395
+ artifactHashes: params.artifactHashes ?? {},
396
+ previousHash: verified.head
397
+ };
398
+ const record = {
399
+ ...payload,
400
+ recordHash: buildLedgerRecordHash(payload)
401
+ };
402
+ await import_promises.default.appendFile(ledgerPath, `${canonicalJson(record)}
403
+ `, "utf8");
404
+ return record;
405
+ } catch (error) {
406
+ if (error.code === "EEXIST") {
407
+ throw new Error(`Core research ledger is locked: ${ledgerPath}`);
408
+ }
409
+ throw error;
410
+ } finally {
411
+ await lock?.close();
412
+ await import_promises.default.rm(lockPath, { force: true });
413
+ }
414
+ };
415
+ var validateCoreResearchParentLineage = async (spec) => {
416
+ if (spec.stage === "screen") return;
417
+ const expectedParentStage = spec.stage === "isolated_long" ? "screen" : "isolated_long";
418
+ const rootDir = import_node_path.default.resolve(spec.artifacts.rootDir);
419
+ const passedParentCandidateConfigShas = /* @__PURE__ */ new Set();
420
+ for (const parentResearchId of spec.parentResearchIds ?? []) {
421
+ const parentDir = import_node_path.default.join(rootDir, parentResearchId);
422
+ const [parentSpec, parentManifest, parentResult] = await Promise.all([
423
+ import_promises.default.readFile(import_node_path.default.join(parentDir, "spec.json"), "utf8").then((text) => JSON.parse(text)),
424
+ import_promises.default.readFile(import_node_path.default.join(parentDir, "manifest.json"), "utf8").then((text) => JSON.parse(text)),
425
+ import_promises.default.readFile(import_node_path.default.join(parentDir, "result.json"), "utf8").then((text) => JSON.parse(text))
426
+ ]);
427
+ validateCoreResearchSpec(parentSpec);
428
+ if (parentManifest.status !== "completed") {
429
+ throw new Error(`Parent research ${parentResearchId} is not completed`);
430
+ }
431
+ if (parentSpec.stage !== expectedParentStage) {
432
+ throw new Error(
433
+ `${spec.stage} requires a ${expectedParentStage} parent, found ${parentSpec.stage}`
434
+ );
435
+ }
436
+ if (parentSpec.hypothesis.family !== spec.hypothesis.family) {
437
+ throw new Error(
438
+ `Parent research ${parentResearchId} belongs to a different hypothesis family`
439
+ );
440
+ }
441
+ if (!parentResult.comparisons.some(
442
+ (comparison) => comparison.selection.passed
443
+ )) {
444
+ throw new Error(
445
+ `Parent research ${parentResearchId} has no candidate that passed selection`
446
+ );
447
+ }
448
+ for (const comparison of parentResult.comparisons.filter(
449
+ (entry) => entry.selection.passed
450
+ )) {
451
+ const candidate = parentResult.variants.find(
452
+ (entry) => entry.variant.id === comparison.candidateId
453
+ );
454
+ if (candidate) {
455
+ passedParentCandidateConfigShas.add(candidate.variant.configSha256);
456
+ }
457
+ }
458
+ }
459
+ if (!spec.variants.some(
460
+ (variant) => variant.role === "candidate" && passedParentCandidateConfigShas.has(variant.configSha256)
461
+ )) {
462
+ throw new Error(
463
+ `${spec.stage} does not carry forward a passed parent candidate config SHA`
464
+ );
465
+ }
466
+ };
467
+ var prepareCoreResearch = async (spec) => {
468
+ validateCoreResearchSpec(spec);
469
+ await validateCoreResearchParentLineage(spec);
470
+ const specSha256 = sha256Json(spec);
471
+ const paths = resolveCoreResearchPaths(spec);
472
+ await import_promises.default.mkdir(paths.researchDir, { recursive: true });
473
+ let existing = null;
474
+ let existingManifest = null;
475
+ try {
476
+ existing = JSON.parse(
477
+ await import_promises.default.readFile(paths.specPath, "utf8")
478
+ );
479
+ } catch (error) {
480
+ if (error.code !== "ENOENT") throw error;
481
+ }
482
+ if (existing && sha256Json(existing) !== specSha256) {
483
+ throw new Error(
484
+ `Research ${spec.researchId} is immutable and already has a different spec`
485
+ );
486
+ }
487
+ if (existing) {
488
+ try {
489
+ existingManifest = JSON.parse(
490
+ await import_promises.default.readFile(paths.manifestPath, "utf8")
491
+ );
492
+ } catch (error) {
493
+ if (error.code !== "ENOENT") throw error;
494
+ }
495
+ if (existingManifest) {
496
+ return { paths, specSha256, manifest: existingManifest };
497
+ }
498
+ }
499
+ await writeJsonAtomic(paths.specPath, spec);
500
+ const manifest = {
501
+ schema: "tradejs-core-research-manifest/v1",
502
+ researchId: spec.researchId,
503
+ specSha256,
504
+ status: "prepared",
505
+ createdAt: spec.createdAt,
506
+ artifactHashes: {
507
+ "spec.json": await sha256File(paths.specPath)
508
+ }
509
+ };
510
+ await writeJsonAtomic(paths.manifestPath, manifest);
511
+ if (!existing) {
512
+ await appendCoreResearchLedger({
513
+ ledgerPath: paths.ledgerPath,
514
+ researchId: spec.researchId,
515
+ event: "prepared",
516
+ specSha256,
517
+ hypothesisFamily: spec.hypothesis.family,
518
+ hypothesesInRecord: spec.variants.filter(
519
+ (variant) => variant.role === "candidate"
520
+ ).length,
521
+ artifactHashes: manifest.artifactHashes
522
+ });
523
+ }
524
+ return { paths, specSha256, manifest };
525
+ };
526
+ var verifyCoreResearchArtifacts = async (spec) => {
527
+ const paths = resolveCoreResearchPaths(spec);
528
+ const manifest = JSON.parse(
529
+ await import_promises.default.readFile(paths.manifestPath, "utf8")
530
+ );
531
+ if (manifest.specSha256 !== sha256Json(spec)) {
532
+ throw new Error("Manifest spec hash does not match the supplied spec");
533
+ }
534
+ for (const [relativePath, expectedHash] of Object.entries(
535
+ manifest.artifactHashes
536
+ )) {
537
+ const actualHash = await sha256File(
538
+ import_node_path.default.join(paths.researchDir, relativePath)
539
+ );
540
+ if (actualHash !== expectedHash) {
541
+ throw new Error(`Artifact hash mismatch: ${relativePath}`);
542
+ }
543
+ }
544
+ const ledger = verifyCoreResearchLedger(
545
+ await readCoreResearchLedger(paths.ledgerPath)
546
+ );
547
+ return {
548
+ researchId: spec.researchId,
549
+ artifacts: Object.keys(manifest.artifactHashes).length,
550
+ ledger
551
+ };
552
+ };
553
+ var scanCoreResearchStages = async (rootDir) => {
554
+ const resolvedRoot = import_node_path.default.resolve(rootDir);
555
+ let entries = [];
556
+ try {
557
+ entries = await import_promises.default.readdir(resolvedRoot);
558
+ } catch (error) {
559
+ if (error.code === "ENOENT") {
560
+ return {
561
+ experiments: [],
562
+ familyByResearch: /* @__PURE__ */ new Map()
563
+ };
564
+ }
565
+ throw error;
566
+ }
567
+ const experiments = [];
568
+ const familyByResearch = /* @__PURE__ */ new Map();
569
+ for (const entry of entries.sort(
570
+ (left, right) => left < right ? -1 : left > right ? 1 : 0
571
+ )) {
572
+ const researchDir = import_node_path.default.join(resolvedRoot, entry);
573
+ const stats = await import_promises.default.stat(researchDir);
574
+ if (!stats.isDirectory()) continue;
575
+ let spec;
576
+ let manifest;
577
+ try {
578
+ [spec, manifest] = await Promise.all([
579
+ import_promises.default.readFile(import_node_path.default.join(researchDir, "spec.json"), "utf8").then((text) => JSON.parse(text)),
580
+ import_promises.default.readFile(import_node_path.default.join(researchDir, "manifest.json"), "utf8").then((text) => JSON.parse(text))
581
+ ]);
582
+ } catch (error) {
583
+ if (error.code === "ENOENT") continue;
584
+ throw error;
585
+ }
586
+ validateCoreResearchSpec(spec);
587
+ if (manifest.specSha256 !== sha256Json(spec)) {
588
+ throw new Error(`Stage index found a spec hash mismatch for ${entry}`);
589
+ }
590
+ const result = manifest.status === "completed" ? await import_promises.default.readFile(import_node_path.default.join(researchDir, "result.json"), "utf8").then((text) => JSON.parse(text)) : null;
591
+ familyByResearch.set(spec.researchId, spec.hypothesis.family);
592
+ experiments.push({
593
+ researchId: spec.researchId,
594
+ stage: spec.stage,
595
+ parentResearchIds: spec.parentResearchIds ?? [],
596
+ specSha256: manifest.specSha256,
597
+ manifestStatus: manifest.status,
598
+ passedCandidates: result?.comparisons.filter((comparison) => comparison.selection.passed).map((comparison) => comparison.candidateId) ?? [],
599
+ evidence: result?.evidence ?? null
600
+ });
601
+ }
602
+ for (const experiment of experiments) {
603
+ for (const parentId of experiment.parentResearchIds) {
604
+ const parentFamily = familyByResearch.get(parentId);
605
+ const family = familyByResearch.get(experiment.researchId);
606
+ if (!parentFamily) {
607
+ throw new Error(
608
+ `Stage ${experiment.researchId} references missing parent ${parentId}`
609
+ );
610
+ }
611
+ if (parentFamily !== family) {
612
+ throw new Error(
613
+ `Stage ${experiment.researchId} crosses hypothesis families`
614
+ );
615
+ }
616
+ }
617
+ }
618
+ return { experiments, familyByResearch };
619
+ };
620
+ var writeCoreResearchStageIndex = async (rootDir) => {
621
+ const resolvedRoot = import_node_path.default.resolve(rootDir);
622
+ const { experiments, familyByResearch } = await scanCoreResearchStages(resolvedRoot);
623
+ const grouped = /* @__PURE__ */ new Map();
624
+ for (const experiment of experiments) {
625
+ const family = familyByResearch.get(experiment.researchId);
626
+ if (!family) {
627
+ throw new Error(`Stage index lost family for ${experiment.researchId}`);
628
+ }
629
+ const bucket = grouped.get(family) ?? [];
630
+ bucket.push(experiment);
631
+ grouped.set(family, bucket);
632
+ }
633
+ const index = {
634
+ schema: "tradejs-core-research-stage-index/v1",
635
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
636
+ families: [...grouped.entries()].sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0).map(([family, experiments2]) => ({ family, experiments: experiments2 }))
637
+ };
638
+ await writeJsonAtomic(import_node_path.default.join(resolvedRoot, "index.json"), index);
639
+ return index;
640
+ };
641
+
642
+ // src/lib/coreResearch/orchestrator.ts
643
+ var import_promises4 = __toESM(require("fs/promises"));
644
+ var import_node_path5 = __toESM(require("path"));
645
+ var import_node_child_process = require("child_process");
646
+ var import_node_crypto5 = require("crypto");
647
+ var import_coreResearch = require("@tradejs/infra/coreResearch");
648
+
649
+ // src/lib/coreResearch/artifacts.ts
650
+ var import_node_fs2 = require("fs");
651
+ var import_node_events = require("events");
652
+ var import_promises2 = __toESM(require("fs/promises"));
653
+ var import_node_path2 = __toESM(require("path"));
654
+ var csv = (value) => {
655
+ const text = String(value ?? "");
656
+ return /[",\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
657
+ };
658
+ var writeLine = async (output, line) => {
659
+ if (!output.write(line)) await (0, import_node_events.once)(output, "drain");
660
+ };
661
+ var closeOutput = async (output) => {
662
+ output.end();
663
+ await (0, import_node_events.once)(output, "close");
664
+ };
665
+ var writeCoreResearchMatches = async (params) => {
666
+ await import_promises2.default.mkdir(import_node_path2.default.dirname(params.filePath), { recursive: true });
667
+ const output = (0, import_node_fs2.createWriteStream)(params.filePath, { encoding: "utf8" });
668
+ await writeLine(
669
+ output,
670
+ [
671
+ "controlId",
672
+ "candidateId",
673
+ "identity",
674
+ "symbol",
675
+ "direction",
676
+ "controlSignalTimestamp",
677
+ "candidateSignalTimestamp",
678
+ "controlPnl",
679
+ "candidatePnl",
680
+ "pnlDelta",
681
+ "exitReasonChanged"
682
+ ].map(csv).join(",") + "\n"
683
+ );
684
+ for (const comparison of params.comparisons) {
685
+ for (const pair of comparison.matchedPairs) {
686
+ await writeLine(
687
+ output,
688
+ [
689
+ comparison.controlId,
690
+ comparison.candidateId,
691
+ pair.identity,
692
+ pair.control.symbol,
693
+ pair.control.direction,
694
+ pair.control.signalTimestamp,
695
+ pair.candidate.signalTimestamp,
696
+ pair.control.netProfit,
697
+ pair.candidate.netProfit,
698
+ pair.pnlDelta,
699
+ pair.exitReasonChanged
700
+ ].map(csv).join(",") + "\n"
701
+ );
702
+ }
703
+ }
704
+ await closeOutput(output);
705
+ };
706
+ var writeCoreResearchTrades = async (params) => {
707
+ await import_promises2.default.mkdir(import_node_path2.default.dirname(params.filePath), { recursive: true });
708
+ const output = (0, import_node_fs2.createWriteStream)(params.filePath, { encoding: "utf8" });
709
+ for (const variant of params.variants) {
710
+ for (const trade of variant.trades) {
711
+ await writeLine(
712
+ output,
713
+ `${canonicalJson({ variantId: variant.variantId, ...trade })}
714
+ `
715
+ );
716
+ }
717
+ }
718
+ await closeOutput(output);
719
+ };
720
+
721
+ // src/lib/coreResearch/metrics.ts
722
+ var DAY_MS = 24 * 60 * 60 * 1e3;
723
+ var CORE_RESEARCH_COHORTS = [
724
+ "ALL",
725
+ "LONG",
726
+ "SHORT"
727
+ ];
728
+ var selectCohort = (trades, cohort) => cohort === "ALL" ? trades : trades.filter((trade) => trade.direction === cohort);
729
+ var quantile = (sorted, probability) => {
730
+ if (!sorted.length) return null;
731
+ const position = (sorted.length - 1) * probability;
732
+ const lower = Math.floor(position);
733
+ const upper = Math.ceil(position);
734
+ if (lower === upper) return sorted[lower];
735
+ const weight = position - lower;
736
+ return sorted[lower] * (1 - weight) + sorted[upper] * weight;
737
+ };
738
+ var compareChronologicalTrades = (left, right) => left.exitTimestamp - right.exitTimestamp || (left.signalId < right.signalId ? -1 : left.signalId > right.signalId ? 1 : 0);
739
+ var orderCoreResearchTrades = (trades) => {
740
+ for (let index = 1; index < trades.length; index += 1) {
741
+ if (compareChronologicalTrades(trades[index - 1], trades[index]) > 0) {
742
+ return [...trades].sort(compareChronologicalTrades);
743
+ }
744
+ }
745
+ return trades;
746
+ };
747
+ var summarizeCoreResearchTrades = (trades, periodDays) => {
748
+ const sorted = orderCoreResearchTrades(trades);
749
+ let pnl = 0;
750
+ let grossProfit = 0;
751
+ let grossLoss = 0;
752
+ let wins = 0;
753
+ let losses = 0;
754
+ let breakeven = 0;
755
+ let equity = 0;
756
+ let peak = 0;
757
+ let realizedMaxDrawdown = 0;
758
+ let consecutiveLosses = 0;
759
+ let maximumConsecutiveLosses = 0;
760
+ for (const trade of sorted) {
761
+ pnl += trade.netProfit;
762
+ if (trade.netProfit > 0) {
763
+ wins += 1;
764
+ grossProfit += trade.netProfit;
765
+ consecutiveLosses = 0;
766
+ } else {
767
+ losses += 1;
768
+ if (trade.netProfit === 0) breakeven += 1;
769
+ else {
770
+ grossLoss += Math.abs(trade.netProfit);
771
+ consecutiveLosses += 1;
772
+ maximumConsecutiveLosses = Math.max(
773
+ maximumConsecutiveLosses,
774
+ consecutiveLosses
775
+ );
776
+ }
777
+ }
778
+ equity += trade.netProfit;
779
+ peak = Math.max(peak, equity);
780
+ realizedMaxDrawdown = Math.max(realizedMaxDrawdown, peak - equity);
781
+ }
782
+ const count = sorted.length;
783
+ const pnlDistribution = sorted.map((trade) => trade.netProfit).sort((left, right) => left - right);
784
+ const holdingHours = sorted.map((trade) => (trade.exitTimestamp - trade.entryTimestamp) / 36e5).sort((left, right) => left - right);
785
+ const averageWin = wins > 0 ? grossProfit / wins : null;
786
+ const strictLosses = losses - breakeven;
787
+ const averageLoss = strictLosses > 0 ? grossLoss / strictLosses : null;
788
+ return {
789
+ trades: count,
790
+ wins,
791
+ losses,
792
+ breakeven,
793
+ pnl,
794
+ pnlPerTrade: count > 0 ? pnl / count : null,
795
+ grossProfit,
796
+ grossLoss,
797
+ profitFactor: grossLoss > 0 ? grossProfit / grossLoss : null,
798
+ profitFactorStatus: grossLoss > 0 ? "finite" : grossProfit > 0 ? "infinite_no_gross_loss" : "undefined",
799
+ winRatePct: count > 0 ? wins / count * 100 : null,
800
+ realizedMaxDrawdown,
801
+ cadencePerDay: periodDays > 0 ? count / periodDays : null,
802
+ averageWin,
803
+ averageLoss,
804
+ payoffRatio: averageWin != null && averageLoss != null && averageLoss > 0 ? averageWin / averageLoss : null,
805
+ medianPnl: quantile(pnlDistribution, 0.5),
806
+ pnlP05: quantile(pnlDistribution, 0.05),
807
+ pnlP95: quantile(pnlDistribution, 0.95),
808
+ medianHoldingHours: quantile(holdingHours, 0.5),
809
+ maximumConsecutiveLosses
810
+ };
811
+ };
812
+ var summarizeCoreResearchCohorts = (trades, periodDays) => Object.fromEntries(
813
+ CORE_RESEARCH_COHORTS.map((cohort) => [
814
+ cohort,
815
+ summarizeCoreResearchTrades(selectCohort(trades, cohort), periodDays)
816
+ ])
817
+ );
818
+ var summarizeCoreResearchWindow = (params) => {
819
+ const { trades, label, start, end } = params;
820
+ const periodDays = (end - start) / DAY_MS;
821
+ const selected = trades.filter(
822
+ (trade) => trade.exitTimestamp >= start && trade.exitTimestamp < end
823
+ );
824
+ return {
825
+ label,
826
+ start,
827
+ end,
828
+ periodDays,
829
+ cohorts: summarizeCoreResearchCohorts(selected, periodDays)
830
+ };
831
+ };
832
+ var buildTerminalWindows = (params) => params.terminalDays.map(
833
+ (days) => summarizeCoreResearchWindow({
834
+ trades: params.trades,
835
+ label: `${days}d`,
836
+ start: params.end - days * DAY_MS,
837
+ end: params.end
838
+ })
839
+ );
840
+ var buildEqualTimeFolds = (params) => {
841
+ const duration = (params.end - params.start) / params.folds;
842
+ return Array.from({ length: params.folds }, (_, index) => {
843
+ const start = params.start + index * duration;
844
+ const end = index === params.folds - 1 ? params.end : params.start + (index + 1) * duration;
845
+ return summarizeCoreResearchWindow({
846
+ trades: params.trades,
847
+ label: `fold-${index + 1}`,
848
+ start,
849
+ end
850
+ });
851
+ });
852
+ };
853
+ var buildMonthlyWindows = (params) => {
854
+ const windows = [];
855
+ const cursor = new Date(params.start);
856
+ cursor.setUTCDate(1);
857
+ cursor.setUTCHours(0, 0, 0, 0);
858
+ while (cursor.getTime() < params.end) {
859
+ const monthStart = Math.max(cursor.getTime(), params.start);
860
+ cursor.setUTCMonth(cursor.getUTCMonth() + 1);
861
+ const monthEnd = Math.min(cursor.getTime(), params.end);
862
+ windows.push(
863
+ summarizeCoreResearchWindow({
864
+ trades: params.trades,
865
+ label: new Date(monthStart).toISOString().slice(0, 7),
866
+ start: monthStart,
867
+ end: monthEnd
868
+ })
869
+ );
870
+ }
871
+ return windows;
872
+ };
873
+ var buildRegimeMetrics = (trades, periodDays) => {
874
+ const grouped = /* @__PURE__ */ new Map();
875
+ for (const trade of trades) {
876
+ const bucket = grouped.get(trade.regime.key) ?? [];
877
+ bucket.push(trade);
878
+ grouped.set(trade.regime.key, bucket);
879
+ }
880
+ return Object.fromEntries(
881
+ [...grouped.entries()].sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0).map(([key, bucket]) => [
882
+ key,
883
+ summarizeCoreResearchCohorts(bucket, periodDays)
884
+ ])
885
+ );
886
+ };
887
+ var buildCostStress = (params) => params.extraRoundTripBps.map((extraRoundTripBps) => {
888
+ const stressed = params.trades.map((trade) => ({
889
+ ...trade,
890
+ netProfit: trade.netProfit - Math.abs(trade.entryPrice * trade.qty) * extraRoundTripBps / 1e4
891
+ }));
892
+ return {
893
+ extraRoundTripBps,
894
+ cohorts: summarizeCoreResearchCohorts(stressed, params.periodDays)
895
+ };
896
+ });
897
+
898
+ // src/lib/coreResearch/rules.ts
899
+ var getCoreResearchMetricValue = (metrics, metric) => {
900
+ if (metric === "profitFactor" && metrics.profitFactorStatus === "infinite_no_gross_loss") {
901
+ return Number.POSITIVE_INFINITY;
902
+ }
903
+ const value = metrics[metric];
904
+ return typeof value === "number" && !Number.isNaN(value) ? value : null;
905
+ };
906
+ var compareNumbers = (actual, expected, comparison) => {
907
+ if (actual == null) return false;
908
+ if (comparison === "gt") return actual > expected;
909
+ if (comparison === "gte") return actual >= expected;
910
+ if (comparison === "lt") return actual < expected;
911
+ return actual <= expected;
912
+ };
913
+ var evaluateCoreResearchThresholdRule = (params) => {
914
+ const actual = getCoreResearchMetricValue(
915
+ params.candidate,
916
+ params.rule.metric
917
+ );
918
+ const control = getCoreResearchMetricValue(
919
+ params.control,
920
+ params.rule.metric
921
+ );
922
+ const offset = params.rule.value ?? 0;
923
+ const expected = params.rule.relativeToControl === false ? offset : control == null ? Number.NaN : control + offset;
924
+ return {
925
+ passed: !Number.isNaN(expected) && compareNumbers(actual, expected, params.rule.comparison),
926
+ actual,
927
+ control,
928
+ expected
929
+ };
930
+ };
931
+
932
+ // src/lib/coreResearch/statistics.ts
933
+ var import_node_crypto2 = require("crypto");
934
+ var createSeededRandom = (seedText) => {
935
+ const digest = (0, import_node_crypto2.createHash)("sha256").update(seedText).digest();
936
+ let state = digest.readUInt32LE(0) || 1;
937
+ return () => {
938
+ state ^= state << 13;
939
+ state ^= state >>> 17;
940
+ state ^= state << 5;
941
+ return (state >>> 0) / 4294967296;
942
+ };
943
+ };
944
+ var percentile = (sorted, probability) => {
945
+ if (!sorted.length) return Number.NaN;
946
+ const index = Math.min(
947
+ sorted.length - 1,
948
+ Math.max(0, Math.floor(probability * sorted.length))
949
+ );
950
+ return sorted[index];
951
+ };
952
+ var calendarClusterBootstrap = (params) => {
953
+ const clusterMs = params.clusterDays * DAY_MS;
954
+ const buckets = /* @__PURE__ */ new Map();
955
+ if (params.start != null && params.end != null && params.end > params.start) {
956
+ const first = Math.floor(params.start / clusterMs);
957
+ const last = Math.ceil(params.end / clusterMs);
958
+ for (let bucket = first; bucket < last; bucket += 1) {
959
+ buckets.set(bucket, { control: 0, candidate: 0 });
960
+ }
961
+ }
962
+ const add = (trades, field) => {
963
+ for (const trade of trades) {
964
+ const bucket = Math.floor(trade.exitTimestamp / clusterMs);
965
+ const current = buckets.get(bucket) ?? { control: 0, candidate: 0 };
966
+ current[field] += trade.netProfit;
967
+ buckets.set(bucket, current);
968
+ }
969
+ };
970
+ add(params.control, "control");
971
+ add(params.candidate, "candidate");
972
+ const deltas = [...buckets.entries()].sort(([left], [right]) => left - right).map(([, value]) => value.candidate - value.control);
973
+ if (!deltas.length) {
974
+ return {
975
+ method: "calendar-cluster-bootstrap",
976
+ clusterDays: params.clusterDays,
977
+ iterations: params.iterations,
978
+ confidenceLevel: params.confidenceLevel,
979
+ observedMeanPnlDelta: null,
980
+ confidenceInterval: null,
981
+ probabilityPositive: null,
982
+ oneSidedPValue: null,
983
+ holmAdjustedPValue: null
984
+ };
985
+ }
986
+ const random = createSeededRandom(params.seed);
987
+ const samples = [];
988
+ for (let iteration = 0; iteration < params.iterations; iteration += 1) {
989
+ let sum = 0;
990
+ for (let index = 0; index < deltas.length; index += 1) {
991
+ sum += deltas[Math.floor(random() * deltas.length)];
992
+ }
993
+ samples.push(sum / deltas.length);
994
+ }
995
+ samples.sort((left, right) => left - right);
996
+ const alpha = 1 - params.confidenceLevel;
997
+ const positive = samples.filter((value) => value > 0).length;
998
+ const probabilityPositive = positive / samples.length;
999
+ return {
1000
+ method: "calendar-cluster-bootstrap",
1001
+ clusterDays: params.clusterDays,
1002
+ iterations: params.iterations,
1003
+ confidenceLevel: params.confidenceLevel,
1004
+ observedMeanPnlDelta: deltas.reduce((sum, value) => sum + value, 0) / deltas.length,
1005
+ confidenceInterval: [
1006
+ percentile(samples, alpha / 2),
1007
+ percentile(samples, 1 - alpha / 2)
1008
+ ],
1009
+ probabilityPositive,
1010
+ oneSidedPValue: 1 - probabilityPositive,
1011
+ holmAdjustedPValue: null
1012
+ };
1013
+ };
1014
+ var applyHolmCorrection = (results, familyHypotheses = results.length) => {
1015
+ const ranked = results.map((result, index) => ({
1016
+ index,
1017
+ p: result.oneSidedPValue
1018
+ })).filter((entry) => entry.p != null).sort((left, right) => left.p - right.p);
1019
+ let previous = 0;
1020
+ ranked.forEach((entry, rank) => {
1021
+ const adjusted = Math.min(
1022
+ 1,
1023
+ Math.max(previous, entry.p * Math.max(1, familyHypotheses - rank))
1024
+ );
1025
+ results[entry.index].holmAdjustedPValue = adjusted;
1026
+ previous = adjusted;
1027
+ });
1028
+ };
1029
+ var inverseNormalCdf = (probability) => {
1030
+ const p = Math.min(1 - 1e-12, Math.max(1e-12, probability));
1031
+ const a = [
1032
+ -39.69683028665376,
1033
+ 220.9460984245205,
1034
+ -275.9285104469687,
1035
+ 138.357751867269,
1036
+ -30.66479806614716,
1037
+ 2.506628277459239
1038
+ ];
1039
+ const b = [
1040
+ -54.47609879822406,
1041
+ 161.5858368580409,
1042
+ -155.6989798598866,
1043
+ 66.80131188771972,
1044
+ -13.28068155288572
1045
+ ];
1046
+ const c = [
1047
+ -0.007784894002430293,
1048
+ -0.3223964580411365,
1049
+ -2.400758277161838,
1050
+ -2.549732539343734,
1051
+ 4.374664141464968,
1052
+ 2.938163982698783
1053
+ ];
1054
+ const d = [
1055
+ 0.007784695709041462,
1056
+ 0.3224671290700398,
1057
+ 2.445134137142996,
1058
+ 3.754408661907416
1059
+ ];
1060
+ if (p < 0.02425) {
1061
+ const q2 = Math.sqrt(-2 * Math.log(p));
1062
+ return (((((c[0] * q2 + c[1]) * q2 + c[2]) * q2 + c[3]) * q2 + c[4]) * q2 + c[5]) / ((((d[0] * q2 + d[1]) * q2 + d[2]) * q2 + d[3]) * q2 + 1);
1063
+ }
1064
+ if (p > 1 - 0.02425) return -inverseNormalCdf(1 - p);
1065
+ const q = p - 0.5;
1066
+ const r = q * q;
1067
+ return (((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q / (((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1);
1068
+ };
1069
+ var erf = (value) => {
1070
+ const sign = value < 0 ? -1 : 1;
1071
+ const x = Math.abs(value);
1072
+ const t = 1 / (1 + 0.3275911 * x);
1073
+ const y = 1 - ((((1.061405429 * t - 1.453152027) * t + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * Math.exp(-x * x);
1074
+ return sign * y;
1075
+ };
1076
+ var normalCdf = (value) => 0.5 * (1 + erf(value / Math.SQRT2));
1077
+ var deflatedSharpeDiagnostic = (returns, trials) => {
1078
+ const periods = returns.length;
1079
+ if (periods < 3) {
1080
+ return {
1081
+ periods,
1082
+ observedSharpe: null,
1083
+ expectedMaximumSharpe: null,
1084
+ probabilityAboveSelectionBias: null
1085
+ };
1086
+ }
1087
+ const mean = returns.reduce((sum, value) => sum + value, 0) / periods;
1088
+ const centered = returns.map((value) => value - mean);
1089
+ const variance = centered.reduce((sum, value) => sum + value * value, 0) / (periods - 1);
1090
+ const standardDeviation = Math.sqrt(variance);
1091
+ if (!(standardDeviation > 0)) {
1092
+ return {
1093
+ periods,
1094
+ observedSharpe: null,
1095
+ expectedMaximumSharpe: null,
1096
+ probabilityAboveSelectionBias: null
1097
+ };
1098
+ }
1099
+ const observedSharpe = mean / standardDeviation;
1100
+ const skew = centered.reduce((sum, value) => sum + (value / standardDeviation) ** 3, 0) / periods;
1101
+ const kurtosis = centered.reduce((sum, value) => sum + (value / standardDeviation) ** 4, 0) / periods;
1102
+ const expectedMaximumSharpe = inverseNormalCdf(
1103
+ (Math.max(1, trials) - 0.375) / (Math.max(1, trials) + 0.25)
1104
+ );
1105
+ const standardError = Math.sqrt(
1106
+ Math.max(
1107
+ 1e-12,
1108
+ (1 - skew * observedSharpe + (kurtosis - 1) / 4 * observedSharpe ** 2) / (periods - 1)
1109
+ )
1110
+ );
1111
+ return {
1112
+ periods,
1113
+ observedSharpe,
1114
+ expectedMaximumSharpe,
1115
+ probabilityAboveSelectionBias: normalCdf(
1116
+ (observedSharpe - expectedMaximumSharpe) / standardError
1117
+ )
1118
+ };
1119
+ };
1120
+ var combinations = (items, choose) => {
1121
+ const output = [];
1122
+ const visit = (start, selected) => {
1123
+ if (output.length >= 512) return;
1124
+ if (selected.length === choose) {
1125
+ output.push(selected);
1126
+ return;
1127
+ }
1128
+ for (let index = start; index < items.length; index += 1) {
1129
+ visit(index + 1, [...selected, items[index]]);
1130
+ }
1131
+ };
1132
+ visit(0, []);
1133
+ return output;
1134
+ };
1135
+ var probabilityOfBacktestOverfitting = (performanceByVariant) => {
1136
+ const blocks = Math.min(
1137
+ ...performanceByVariant.map((performance) => performance.length)
1138
+ );
1139
+ if (performanceByVariant.length < 2 || blocks < 4) {
1140
+ return { method: "CSCV", combinations: 0, probability: null };
1141
+ }
1142
+ if (performanceByVariant.every(
1143
+ (performance) => performance.slice(0, blocks).every((value, index) => value === performanceByVariant[0][index])
1144
+ )) {
1145
+ return { method: "CSCV", combinations: 0, probability: null };
1146
+ }
1147
+ const usableBlocks = blocks % 2 === 0 ? blocks : blocks - 1;
1148
+ const blockIndexes = Array.from(
1149
+ { length: usableBlocks },
1150
+ (_, index) => index
1151
+ );
1152
+ const splits = combinations(blockIndexes, usableBlocks / 2);
1153
+ let belowMedian = 0;
1154
+ for (const inSample of splits) {
1155
+ const inSet = new Set(inSample);
1156
+ const outSample = blockIndexes.filter((index) => !inSet.has(index));
1157
+ const inScores = performanceByVariant.map(
1158
+ (performance) => inSample.reduce((sum, index) => sum + performance[index], 0)
1159
+ );
1160
+ const selected = inScores.indexOf(Math.max(...inScores));
1161
+ const outScores = performanceByVariant.map(
1162
+ (performance) => outSample.reduce((sum, index) => sum + performance[index], 0)
1163
+ );
1164
+ const sorted = [...outScores].sort((left, right) => left - right);
1165
+ const rankAscending = sorted.indexOf(outScores[selected]);
1166
+ const omega = (rankAscending + 1) / (outScores.length + 1);
1167
+ if (Math.log(omega / (1 - omega)) < 0) belowMedian += 1;
1168
+ }
1169
+ return {
1170
+ method: "CSCV",
1171
+ combinations: splits.length,
1172
+ probability: splits.length ? belowMedian / splits.length : null
1173
+ };
1174
+ };
1175
+
1176
+ // src/lib/coreResearch/comparison.ts
1177
+ var selectCohort2 = (trades, cohort) => cohort === "ALL" ? trades : trades.filter((trade) => trade.direction === cohort);
1178
+ var groupBySetup = (trades) => {
1179
+ const grouped = /* @__PURE__ */ new Map();
1180
+ for (const trade of trades) {
1181
+ const bucket = grouped.get(trade.setupIdentity) ?? [];
1182
+ bucket.push(trade);
1183
+ grouped.set(trade.setupIdentity, bucket);
1184
+ }
1185
+ for (const bucket of grouped.values()) {
1186
+ bucket.sort(
1187
+ (left, right) => left.signalTimestamp - right.signalTimestamp || left.entryTimestamp - right.entryTimestamp || left.exitTimestamp - right.exitTimestamp
1188
+ );
1189
+ }
1190
+ return grouped;
1191
+ };
1192
+ var evaluateRules = (params) => {
1193
+ let passed = true;
1194
+ for (const rule of params.rules) {
1195
+ const evaluation = evaluateCoreResearchThresholdRule({
1196
+ control: params.control,
1197
+ candidate: params.candidate,
1198
+ rule
1199
+ });
1200
+ if (!evaluation.passed) {
1201
+ passed = false;
1202
+ params.failedRules.push({
1203
+ scope: params.scope,
1204
+ metric: rule.metric,
1205
+ expected: `${rule.comparison} ${rule.relativeToControl === false ? "" : "control "}${rule.value ?? 0}`.trim(),
1206
+ actual: evaluation.actual,
1207
+ control: evaluation.control
1208
+ });
1209
+ }
1210
+ }
1211
+ return passed;
1212
+ };
1213
+ var evaluateSelection = (params) => {
1214
+ const { spec, cohorts } = params;
1215
+ const target = spec.hypothesis.target;
1216
+ const nonTarget = target === "LONG" ? "SHORT" : target === "SHORT" ? "LONG" : null;
1217
+ const failedRules = [];
1218
+ const warnings = [];
1219
+ const targetMetrics = cohorts[target].candidate;
1220
+ let targetPassed = true;
1221
+ if (targetMetrics.trades < spec.selection.minimumTrades) {
1222
+ targetPassed = false;
1223
+ failedRules.push({
1224
+ scope: target,
1225
+ metric: "trades",
1226
+ expected: `>= ${spec.selection.minimumTrades}`,
1227
+ actual: targetMetrics.trades,
1228
+ control: cohorts[target].control.trades
1229
+ });
1230
+ }
1231
+ if (targetMetrics.cadencePerDay == null || targetMetrics.cadencePerDay < spec.selection.minimumCadencePerDay) {
1232
+ targetPassed = false;
1233
+ failedRules.push({
1234
+ scope: target,
1235
+ metric: "cadencePerDay",
1236
+ expected: `>= ${spec.selection.minimumCadencePerDay}`,
1237
+ actual: targetMetrics.cadencePerDay,
1238
+ control: cohorts[target].control.cadencePerDay
1239
+ });
1240
+ }
1241
+ targetPassed = evaluateRules({
1242
+ scope: target,
1243
+ control: cohorts[target].control,
1244
+ candidate: targetMetrics,
1245
+ rules: spec.selection.targetRules,
1246
+ failedRules
1247
+ }) && targetPassed;
1248
+ let aggregatePassed = evaluateRules({
1249
+ scope: "ALL",
1250
+ control: cohorts.ALL.control,
1251
+ candidate: cohorts.ALL.candidate,
1252
+ rules: spec.selection.aggregateRules,
1253
+ failedRules
1254
+ });
1255
+ const maxDdRegression = spec.selection.maximumPortfolioDrawdownRegressionPct;
1256
+ if (maxDdRegression != null) {
1257
+ const controlDd = cohorts.ALL.control.realizedMaxDrawdown;
1258
+ const allowed = controlDd * (1 + maxDdRegression / 100);
1259
+ if (cohorts.ALL.candidate.realizedMaxDrawdown > allowed) {
1260
+ aggregatePassed = false;
1261
+ failedRules.push({
1262
+ scope: "ALL",
1263
+ metric: "realizedMaxDrawdown",
1264
+ expected: `<= control + ${maxDdRegression}%`,
1265
+ actual: cohorts.ALL.candidate.realizedMaxDrawdown,
1266
+ control: controlDd
1267
+ });
1268
+ }
1269
+ }
1270
+ const nonTargetPassed = nonTarget ? evaluateRules({
1271
+ scope: nonTarget,
1272
+ control: cohorts[nonTarget].control,
1273
+ candidate: cohorts[nonTarget].candidate,
1274
+ rules: spec.selection.nonTargetRules,
1275
+ failedRules
1276
+ }) : true;
1277
+ if (target !== "ALL") {
1278
+ warnings.push(
1279
+ `Direction-targeted verdict uses ${target}; aggregate portfolio guardrails remain independent.`
1280
+ );
1281
+ }
1282
+ return {
1283
+ target,
1284
+ passed: targetPassed && aggregatePassed && nonTargetPassed,
1285
+ targetPassed,
1286
+ aggregatePassed,
1287
+ nonTargetPassed,
1288
+ failedRules,
1289
+ warnings
1290
+ };
1291
+ };
1292
+ var compareCoreResearchVariants = (params) => {
1293
+ const { spec, control, candidate } = params;
1294
+ const controlGrouped = groupBySetup(control.trades);
1295
+ const candidateGrouped = groupBySetup(candidate.trades);
1296
+ const setupKeys = /* @__PURE__ */ new Set([
1297
+ ...controlGrouped.keys(),
1298
+ ...candidateGrouped.keys()
1299
+ ]);
1300
+ const matchedPairs = [];
1301
+ const controlOnly = [];
1302
+ const candidateOnly = [];
1303
+ for (const setupIdentity of [...setupKeys].sort()) {
1304
+ const controlBucket = controlGrouped.get(setupIdentity) ?? [];
1305
+ const candidateBucket = candidateGrouped.get(setupIdentity) ?? [];
1306
+ const matched = Math.min(controlBucket.length, candidateBucket.length);
1307
+ for (let index = 0; index < matched; index += 1) {
1308
+ const controlTrade = controlBucket[index];
1309
+ const candidateTrade = candidateBucket[index];
1310
+ matchedPairs.push({
1311
+ identity: `${setupIdentity}#${index + 1}`,
1312
+ control: controlTrade,
1313
+ candidate: candidateTrade,
1314
+ pnlDelta: candidateTrade.netProfit - controlTrade.netProfit,
1315
+ exitReasonChanged: candidateTrade.exitReason !== controlTrade.exitReason,
1316
+ entryTimestampDeltaMs: candidateTrade.entryTimestamp - controlTrade.entryTimestamp,
1317
+ exitTimestampDeltaMs: candidateTrade.exitTimestamp - controlTrade.exitTimestamp
1318
+ });
1319
+ }
1320
+ controlOnly.push(...controlBucket.slice(matched));
1321
+ candidateOnly.push(...candidateBucket.slice(matched));
1322
+ }
1323
+ const periodDays = (spec.window.end - spec.window.start) / (24 * 60 * 60 * 1e3);
1324
+ const cohorts = Object.fromEntries(
1325
+ CORE_RESEARCH_COHORTS.map((cohort) => {
1326
+ const controlMetrics = summarizeCoreResearchTrades(
1327
+ selectCohort2(control.trades, cohort),
1328
+ periodDays
1329
+ );
1330
+ const candidateMetrics = summarizeCoreResearchTrades(
1331
+ selectCohort2(candidate.trades, cohort),
1332
+ periodDays
1333
+ );
1334
+ const matched = matchedPairs.filter(
1335
+ (pair) => cohort === "ALL" || pair.control.direction === cohort
1336
+ );
1337
+ return [
1338
+ cohort,
1339
+ {
1340
+ control: controlMetrics,
1341
+ candidate: candidateMetrics,
1342
+ delta: {
1343
+ pnl: candidateMetrics.pnl - controlMetrics.pnl,
1344
+ pnlPerTrade: candidateMetrics.pnlPerTrade == null || controlMetrics.pnlPerTrade == null ? null : candidateMetrics.pnlPerTrade - controlMetrics.pnlPerTrade,
1345
+ profitFactor: candidateMetrics.profitFactor == null || controlMetrics.profitFactor == null ? null : candidateMetrics.profitFactor - controlMetrics.profitFactor,
1346
+ winRatePct: candidateMetrics.winRatePct == null || controlMetrics.winRatePct == null ? null : candidateMetrics.winRatePct - controlMetrics.winRatePct,
1347
+ realizedMaxDrawdown: candidateMetrics.realizedMaxDrawdown - controlMetrics.realizedMaxDrawdown,
1348
+ cadencePerDay: candidateMetrics.cadencePerDay == null || controlMetrics.cadencePerDay == null ? null : candidateMetrics.cadencePerDay - controlMetrics.cadencePerDay
1349
+ },
1350
+ matchedPnlDelta: matched.reduce(
1351
+ (sum, pair) => sum + pair.pnlDelta,
1352
+ 0
1353
+ ),
1354
+ controlOnlyPnl: selectCohort2(controlOnly, cohort).reduce(
1355
+ (sum, trade) => sum + trade.netProfit,
1356
+ 0
1357
+ ),
1358
+ candidateOnlyPnl: selectCohort2(candidateOnly, cohort).reduce(
1359
+ (sum, trade) => sum + trade.netProfit,
1360
+ 0
1361
+ )
1362
+ }
1363
+ ];
1364
+ })
1365
+ );
1366
+ return {
1367
+ controlId: control.variant.id,
1368
+ candidateId: candidate.variant.id,
1369
+ matched: matchedPairs.length,
1370
+ controlOnly: controlOnly.length,
1371
+ candidateOnly: candidateOnly.length,
1372
+ matchedIdentityPctOfControl: control.trades.length > 0 ? matchedPairs.length / control.trades.length * 100 : null,
1373
+ matchedPairs,
1374
+ cohorts,
1375
+ bootstrap: calendarClusterBootstrap({
1376
+ control: control.trades,
1377
+ candidate: candidate.trades,
1378
+ clusterDays: spec.robustness.clusterDays,
1379
+ iterations: spec.robustness.bootstrapIterations,
1380
+ confidenceLevel: spec.robustness.confidenceLevel,
1381
+ seed: `${spec.researchId}:${candidate.variant.id}`,
1382
+ start: spec.window.start,
1383
+ end: spec.window.end
1384
+ }),
1385
+ selection: evaluateSelection({ spec, cohorts })
1386
+ };
1387
+ };
1388
+
1389
+ // src/lib/coreResearch/dataset.ts
1390
+ var import_node_crypto3 = require("crypto");
1391
+ var import_node_fs3 = require("fs");
1392
+ var import_node_path3 = __toESM(require("path"));
1393
+ var import_node_readline = __toESM(require("readline"));
1394
+ var asRecord = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : null;
1395
+ var finiteNumber = (value, field) => {
1396
+ const numeric = Number(value);
1397
+ if (!Number.isFinite(numeric)) throw new Error(`${field} must be finite`);
1398
+ return numeric;
1399
+ };
1400
+ var normalizeDirection = (value) => {
1401
+ const direction = String(value ?? "").toUpperCase();
1402
+ if (direction !== "LONG" && direction !== "SHORT") {
1403
+ throw new Error(`Unsupported trade direction: ${direction || "<empty>"}`);
1404
+ }
1405
+ return direction;
1406
+ };
1407
+ var findStrategySetupIdentity = (value, depth = 0) => {
1408
+ if (depth > 5) return null;
1409
+ const record = asRecord(value);
1410
+ if (!record) return null;
1411
+ for (const key of ["setupIdentity", "setupId", "patternId", "candidateId"]) {
1412
+ const candidate = record[key];
1413
+ if (typeof candidate === "string" && candidate.trim() || typeof candidate === "number" && Number.isFinite(candidate)) {
1414
+ return `${key}:${String(candidate)}`;
1415
+ }
1416
+ }
1417
+ for (const [key, nested] of Object.entries(record)) {
1418
+ if (/context|signal|setup|pattern|candidate|strategy/i.test(key) && nested != null) {
1419
+ const found = findStrategySetupIdentity(nested, depth + 1);
1420
+ if (found) return found;
1421
+ }
1422
+ }
1423
+ return null;
1424
+ };
1425
+ var classifyBreadth = (baseContext) => {
1426
+ const relative = asRecord(baseContext?.relative);
1427
+ const btcAlt = asRecord(relative?.btcAltRegime);
1428
+ const gateFeatures = asRecord(baseContext?.gateFeatures);
1429
+ const gateRelative = asRecord(gateFeatures?.relative);
1430
+ const regime = String(btcAlt?.regime ?? gateRelative?.btcAltRegime ?? "");
1431
+ if (regime === "risk_on" || regime === "alt_lead") return "risk_on";
1432
+ if (regime === "risk_off") return "risk_off";
1433
+ if (["mixed", "neutral", "btc_lead"].includes(regime))
1434
+ return "mixed";
1435
+ const breadth = asRecord(relative?.marketBreadth);
1436
+ const equalWeightedReturn = Number(
1437
+ breadth?.equalWeightedReturn ?? gateRelative?.marketBreadthReturn
1438
+ );
1439
+ const aboveMa50 = Number(breadth?.pctAboveMa50);
1440
+ if (Number.isFinite(equalWeightedReturn) || Number.isFinite(aboveMa50)) {
1441
+ if (equalWeightedReturn > 0 && (!Number.isFinite(aboveMa50) || aboveMa50 >= 50)) {
1442
+ return "risk_on";
1443
+ }
1444
+ if (equalWeightedReturn < 0 && (!Number.isFinite(aboveMa50) || aboveMa50 < 50)) {
1445
+ return "risk_off";
1446
+ }
1447
+ return "mixed";
1448
+ }
1449
+ return "unknown";
1450
+ };
1451
+ var classifyDerivatives = (baseContext) => {
1452
+ const derivatives = asRecord(baseContext?.derivatives);
1453
+ const targetContext = asRecord(derivatives?.targetContext);
1454
+ const summary = asRecord(targetContext?.summary) ?? asRecord(derivatives?.summary);
1455
+ const pressure = String(summary?.pressure ?? "");
1456
+ if (pressure === "crowded_long" || pressure === "crowded_short") {
1457
+ return "crowded";
1458
+ }
1459
+ if (pressure === "long_flush" || pressure === "short_flush") {
1460
+ return "supportive";
1461
+ }
1462
+ if (pressure === "neutral") return "neutral";
1463
+ return "unknown";
1464
+ };
1465
+ var resolveCoreResearchRegime = (row) => {
1466
+ const additional = asRecord(row.payload?.additionalIndicators);
1467
+ const baseContext = asRecord(additional?.baseContext);
1468
+ const regime = asRecord(baseContext?.regime);
1469
+ const trendContext = asRecord(regime?.trend);
1470
+ const volatilityContext = asRecord(regime?.volatility);
1471
+ const trendValue = String(trendContext?.bias ?? "unknown");
1472
+ const volatilityValue = String(volatilityContext?.state ?? "unknown");
1473
+ const trend = ["bull", "bear", "neutral"].includes(trendValue) ? trendValue : "unknown";
1474
+ const volatility = ["compressed", "normal", "expanded"].includes(
1475
+ volatilityValue
1476
+ ) ? volatilityValue : "unknown";
1477
+ const breadth = classifyBreadth(baseContext);
1478
+ const derivatives = classifyDerivatives(baseContext);
1479
+ return {
1480
+ trend,
1481
+ volatility,
1482
+ breadth,
1483
+ derivatives,
1484
+ key: [trend, volatility, breadth, derivatives].join("|")
1485
+ };
1486
+ };
1487
+ var normalizeCompletedTrade = (params) => {
1488
+ const { filePath, fileSha256, lineNumber, row } = params;
1489
+ const result = row.tradeResult;
1490
+ if (!result) return null;
1491
+ const signalTimestamp = finiteNumber(row.timestamp, "row.timestamp");
1492
+ const strategyIdentity = findStrategySetupIdentity(
1493
+ row.payload?.additionalIndicators
1494
+ );
1495
+ const explicitIdentity = row.research?.setupIdentity?.trim() || null;
1496
+ const direction = normalizeDirection(row.direction ?? result.direction);
1497
+ const signalId = String(row.signalId ?? result.signalId ?? "").trim();
1498
+ if (!signalId) throw new Error("signalId must be non-empty");
1499
+ const setupIdentity = explicitIdentity ? explicitIdentity : strategyIdentity ? `${row.strategyName}|${row.symbol}|${direction}|${strategyIdentity}` : `${row.strategyName}|${row.symbol}|${direction}|${signalTimestamp}`;
1500
+ return {
1501
+ sourceFile: filePath,
1502
+ sourceLine: lineNumber,
1503
+ sourceSha256: fileSha256,
1504
+ runId: row.backtestRunId ?? null,
1505
+ configId: row.configId?.trim() || "<missing-config-id>",
1506
+ signalId,
1507
+ setupIdentity,
1508
+ setupIdentitySource: explicitIdentity ? "research.setupIdentity" : strategyIdentity ? "strategy-context" : "signal-time-fallback",
1509
+ strategy: row.strategyName,
1510
+ symbol: row.symbol,
1511
+ direction,
1512
+ signalTimestamp,
1513
+ entryTimestamp: finiteNumber(result.entryTimestamp, "entryTimestamp"),
1514
+ exitTimestamp: finiteNumber(result.exitTimestamp, "exitTimestamp"),
1515
+ entryPrice: finiteNumber(result.entryPrice, "entryPrice"),
1516
+ exitPrice: result.exitPrice == null ? null : finiteNumber(result.exitPrice, "exitPrice"),
1517
+ qty: finiteNumber(result.qty, "qty"),
1518
+ netProfit: finiteNumber(result.netProfit, "netProfit"),
1519
+ grossProfit: finiteNumber(result.grossProfit, "grossProfit"),
1520
+ totalFee: finiteNumber(result.totalFee, "totalFee"),
1521
+ totalSlippageCost: finiteNumber(
1522
+ result.totalSlippageCost,
1523
+ "totalSlippageCost"
1524
+ ),
1525
+ exitReason: result.exitReason,
1526
+ regime: resolveCoreResearchRegime(row)
1527
+ };
1528
+ };
1529
+ var completedTradeIdentity = (trade) => [trade.runId ?? "", trade.configId, trade.signalId, trade.symbol].join("|");
1530
+ var compareText = (left, right) => left < right ? -1 : left > right ? 1 : 0;
1531
+ var compareTrades = (left, right) => left.exitTimestamp - right.exitTimestamp || compareText(left.symbol, right.symbol) || compareText(left.direction, right.direction) || compareText(left.signalId, right.signalId) || compareText(left.sourceFile, right.sourceFile) || left.sourceLine - right.sourceLine;
1532
+ var readCoreResearchVariant = async (variant) => {
1533
+ const trades = [];
1534
+ const sources = [];
1535
+ const seen = /* @__PURE__ */ new Map();
1536
+ let duplicateRowsDropped = 0;
1537
+ for (const inputPath of variant.files) {
1538
+ const filePath = import_node_path3.default.resolve(inputPath);
1539
+ const input = (0, import_node_fs3.createReadStream)(filePath);
1540
+ const hash = (0, import_node_crypto3.createHash)("sha256");
1541
+ input.on("data", (chunk) => hash.update(chunk));
1542
+ const lines = import_node_readline.default.createInterface({ input, crlfDelay: Infinity });
1543
+ const fileTrades = [];
1544
+ let lineNumber = 0;
1545
+ let rows = 0;
1546
+ let rowsForDifferentRun = 0;
1547
+ let rowsWithoutTradeResult = 0;
1548
+ let selectedTrades = 0;
1549
+ for await (const line of lines) {
1550
+ lineNumber += 1;
1551
+ if (!line.trim()) continue;
1552
+ rows += 1;
1553
+ let row;
1554
+ try {
1555
+ row = JSON.parse(line);
1556
+ } catch (error) {
1557
+ throw new Error(
1558
+ `${filePath}:${lineNumber} contains invalid JSON: ${String(error)}`
1559
+ );
1560
+ }
1561
+ if (variant.runId && row.backtestRunId !== variant.runId) {
1562
+ rowsForDifferentRun += 1;
1563
+ continue;
1564
+ }
1565
+ const trade = normalizeCompletedTrade({
1566
+ filePath,
1567
+ fileSha256: "",
1568
+ lineNumber,
1569
+ row
1570
+ });
1571
+ if (!trade) {
1572
+ rowsWithoutTradeResult += 1;
1573
+ continue;
1574
+ }
1575
+ selectedTrades += 1;
1576
+ const identity = completedTradeIdentity(trade);
1577
+ const existing = seen.get(identity);
1578
+ if (existing) {
1579
+ if (existing.netProfit !== trade.netProfit || existing.exitTimestamp !== trade.exitTimestamp || existing.direction !== trade.direction) {
1580
+ throw new Error(
1581
+ `Conflicting completed trades share identity ${identity}`
1582
+ );
1583
+ }
1584
+ duplicateRowsDropped += 1;
1585
+ continue;
1586
+ }
1587
+ seen.set(identity, trade);
1588
+ trades.push(trade);
1589
+ fileTrades.push(trade);
1590
+ }
1591
+ const fileSha256 = hash.digest("hex");
1592
+ for (const trade of fileTrades) trade.sourceSha256 = fileSha256;
1593
+ sources.push({
1594
+ path: filePath,
1595
+ sha256: fileSha256,
1596
+ rows,
1597
+ selectedTrades,
1598
+ rowsForDifferentRun,
1599
+ rowsWithoutTradeResult
1600
+ });
1601
+ }
1602
+ return {
1603
+ variant,
1604
+ files: sources,
1605
+ duplicateRowsDropped,
1606
+ trades: trades.sort(compareTrades)
1607
+ };
1608
+ };
1609
+
1610
+ // src/lib/coreResearch/report.ts
1611
+ var escapeHtml = (value) => String(value ?? "").replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#039;");
1612
+ var format = (value, digits = 2) => value == null || !Number.isFinite(value) ? "n/a" : value.toFixed(digits);
1613
+ var tone = (value) => value == null ? "" : value > 0 ? "positive" : value < 0 ? "negative" : "";
1614
+ var metricRows = (variant, window) => ["ALL", "LONG", "SHORT"].map((cohort) => {
1615
+ const metrics = window.cohorts[cohort];
1616
+ return `<tr><td>${escapeHtml(variant.variant.label)}</td><td>${escapeHtml(window.label)}</td><td>${cohort}</td><td>${metrics.trades}</td><td class="${tone(metrics.pnl)}">${format(metrics.pnl)}</td><td class="${tone(metrics.pnlPerTrade)}">${format(metrics.pnlPerTrade, 4)}</td><td>${format(metrics.profitFactor, 4)}</td><td>${format(metrics.winRatePct)}%</td><td>${format(metrics.realizedMaxDrawdown)}</td><td>${format(metrics.cadencePerDay, 3)}</td><td>${format(metrics.averageWin)}</td><td>${format(metrics.averageLoss)}</td><td>${format(metrics.payoffRatio, 3)}</td><td>${format(metrics.medianPnl)}</td><td>${format(metrics.pnlP05)}</td><td>${format(metrics.pnlP95)}</td><td>${format(metrics.medianHoldingHours, 2)}</td><td>${metrics.maximumConsecutiveLosses}</td></tr>`;
1617
+ }).join("");
1618
+ var equitySeries = (trades) => {
1619
+ let equity = 0;
1620
+ return orderCoreResearchTrades(trades).map((trade) => {
1621
+ equity += trade.netProfit;
1622
+ return { timestamp: trade.exitTimestamp, value: equity };
1623
+ });
1624
+ };
1625
+ var drawdownSeries = (trades) => {
1626
+ let equity = 0;
1627
+ let peak = 0;
1628
+ return orderCoreResearchTrades(trades).map((trade) => {
1629
+ equity += trade.netProfit;
1630
+ peak = Math.max(peak, equity);
1631
+ return { timestamp: trade.exitTimestamp, value: peak - equity };
1632
+ });
1633
+ };
1634
+ var MAX_CHART_POINTS = 1200;
1635
+ var downsampleCurve = (points) => {
1636
+ if (points.length <= MAX_CHART_POINTS) return points;
1637
+ const interior = points.slice(1, -1);
1638
+ const bucketCount = Math.max(1, Math.floor((MAX_CHART_POINTS - 2) / 2));
1639
+ const bucketSize = Math.ceil(interior.length / bucketCount);
1640
+ const selected = [points[0]];
1641
+ for (let start = 0; start < interior.length; start += bucketSize) {
1642
+ const bucket = interior.slice(start, start + bucketSize);
1643
+ if (!bucket.length) continue;
1644
+ const minimum = bucket.reduce(
1645
+ (best, point) => point.value < best.value ? point : best
1646
+ );
1647
+ const maximum = bucket.reduce(
1648
+ (best, point) => point.value > best.value ? point : best
1649
+ );
1650
+ const extrema = minimum === maximum ? [minimum] : [minimum, maximum];
1651
+ selected.push(
1652
+ ...extrema.sort((left, right) => left.timestamp - right.timestamp)
1653
+ );
1654
+ }
1655
+ selected.push(points.at(-1));
1656
+ return selected;
1657
+ };
1658
+ var buildCurveSvg = (tradesByVariant, cohort, curve) => {
1659
+ const colors = ["#78dce8", "#ff6188", "#a9dc76", "#ffd866", "#ab9df2"];
1660
+ const series = [...tradesByVariant.entries()].map(([id, trades], index) => ({
1661
+ id,
1662
+ color: colors[index % colors.length],
1663
+ points: downsampleCurve(
1664
+ (curve === "equity" ? equitySeries : drawdownSeries)(
1665
+ cohort === "ALL" ? trades : trades.filter((trade) => trade.direction === cohort)
1666
+ )
1667
+ )
1668
+ }));
1669
+ const points = series.flatMap((entry) => entry.points);
1670
+ if (!points.length) return "<p>No completed trades.</p>";
1671
+ const minTimestamp = Math.min(...points.map((point) => point.timestamp));
1672
+ const maxTimestamp = Math.max(...points.map((point) => point.timestamp));
1673
+ const minValue = Math.min(0, ...points.map((point) => point.value));
1674
+ const maxValue = Math.max(0, ...points.map((point) => point.value));
1675
+ const width = 960;
1676
+ const height = 300;
1677
+ const padding = 30;
1678
+ const x = (timestamp) => padding + (timestamp - minTimestamp) / Math.max(1, maxTimestamp - minTimestamp) * (width - 2 * padding);
1679
+ const y = (value) => height - padding - (value - minValue) / Math.max(1e-9, maxValue - minValue) * (height - 2 * padding);
1680
+ const zeroY = y(0);
1681
+ return `<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="${cohort} ${curve}"><line x1="${padding}" x2="${width - padding}" y1="${zeroY}" y2="${zeroY}" class="zero"/>${series.map(
1682
+ (entry) => `<polyline fill="none" stroke="${entry.color}" stroke-width="2" points="${entry.points.map((point) => `${x(point.timestamp).toFixed(1)},${y(point.value).toFixed(1)}`).join(" ")}"><title>${escapeHtml(entry.id)}</title></polyline>`
1683
+ ).join(
1684
+ ""
1685
+ )}</svg><div class="legend">${series.map((entry) => `<span><i style="background:${entry.color}"></i>${escapeHtml(entry.id)}</span>`).join("")}</div>`;
1686
+ };
1687
+ var comparisonCards = (comparisons) => comparisons.map(
1688
+ (comparison) => `<article class="card"><h3>${escapeHtml(comparison.candidateId)} vs ${escapeHtml(comparison.controlId)}</h3><p class="verdict ${comparison.selection.passed ? "positive" : "negative"}">${comparison.selection.passed ? "PASS selection" : "REJECT selection"}</p><dl><dt>Matched</dt><dd>${comparison.matched}</dd><dt>Control only</dt><dd>${comparison.controlOnly}</dd><dt>Candidate only</dt><dd>${comparison.candidateOnly}</dd><dt>Identity retained</dt><dd>${format(comparison.matchedIdentityPctOfControl)}%</dd><dt>Matched \u0394PnL</dt><dd>${format(comparison.cohorts.ALL.matchedPnlDelta)}</dd><dt>Removed control PnL</dt><dd>${format(comparison.cohorts.ALL.controlOnlyPnl)}</dd><dt>Added candidate PnL</dt><dd>${format(comparison.cohorts.ALL.candidateOnlyPnl)}</dd><dt>Aggregate \u0394PnL</dt><dd>${format(comparison.cohorts.ALL.delta.pnl)}</dd><dt>Aggregate \u0394DD</dt><dd>${format(comparison.cohorts.ALL.delta.realizedMaxDrawdown)}</dd><dt>Bootstrap P(\u0394&gt;0)</dt><dd>${format(comparison.bootstrap.probabilityPositive, 4)}</dd><dt>Holm p</dt><dd>${format(comparison.bootstrap.holmAdjustedPValue, 4)}</dd></dl><p>${comparison.selection.failedRules.length ? escapeHtml(comparison.selection.failedRules.map((rule) => `${rule.scope}.${rule.metric} ${rule.expected}`).join("; ")) : "All preregistered rules passed."}</p></article>`
1689
+ ).join("");
1690
+ var regimeRows = (variants) => {
1691
+ const keys = [
1692
+ ...new Set(variants.flatMap((variant) => Object.keys(variant.regimes)))
1693
+ ].sort();
1694
+ return keys.flatMap(
1695
+ (key) => variants.map((variant) => {
1696
+ const metrics = variant.regimes[key]?.ALL;
1697
+ return `<tr><td>${escapeHtml(key)}</td><td>${escapeHtml(variant.variant.label)}</td><td>${metrics?.trades ?? 0}</td><td class="${tone(metrics?.pnl ?? null)}">${format(metrics?.pnl ?? null)}</td><td>${format(metrics?.profitFactor ?? null, 3)}</td><td>${format(metrics?.winRatePct ?? null)}%</td></tr>`;
1698
+ })
1699
+ ).join("");
1700
+ };
1701
+ var traceRows = (variants) => variants.map(
1702
+ (variant) => `<tr><td>${escapeHtml(variant.variant.label)}</td><td>${escapeHtml(variant.reconciliation.status)}</td><td>${escapeHtml(JSON.stringify(variant.setupIdentitySources))}</td><td>${escapeHtml(JSON.stringify(variant.traceFunnel.events))}</td><td>${escapeHtml(JSON.stringify(variant.traceFunnel.skipCounts))}</td><td>${escapeHtml(variant.latestSignalTimeRegime?.key ?? "n/a")}</td></tr>`
1703
+ ).join("");
1704
+ var buildCoreResearchHtml = (params) => {
1705
+ const { spec, result, tradesByVariant } = params;
1706
+ const allRows = result.variants.flatMap(
1707
+ (variant) => [variant.full, ...variant.terminal].map(
1708
+ (window) => metricRows(variant, window)
1709
+ )
1710
+ ).join("");
1711
+ const robustnessRows = result.variants.flatMap(
1712
+ (variant) => [...variant.folds, ...variant.monthly].map(
1713
+ (window) => metricRows(variant, window)
1714
+ )
1715
+ ).join("");
1716
+ const costRows = result.variants.flatMap(
1717
+ (variant) => variant.costStress.map((stress) => {
1718
+ const metrics = stress.cohorts.ALL;
1719
+ return `<tr><td>${escapeHtml(variant.variant.label)}</td><td>${stress.extraRoundTripBps}</td><td>${metrics.trades}</td><td class="${tone(metrics.pnl)}">${format(metrics.pnl)}</td><td>${format(metrics.pnlPerTrade, 4)}</td><td>${format(metrics.profitFactor, 4)}</td><td>${format(metrics.realizedMaxDrawdown)}</td></tr>`;
1720
+ })
1721
+ ).join("");
1722
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(spec.researchId)}</title><style>:root{color-scheme:dark;--bg:#17171f;--panel:#22222d;--line:#3a3a49;--text:#f8f8f2;--muted:#a9a9b5;--good:#a9dc76;--bad:#ff6188}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace}main{max-width:1240px;margin:auto;padding:28px}h1{font-size:30px}h2{margin-top:36px}.muted{color:var(--muted)}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:14px}.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:16px}.positive{color:var(--good)}.negative{color:var(--bad)}dl{display:grid;grid-template-columns:1fr auto;gap:6px;margin:0}dt{color:var(--muted)}dd{margin:0}table{border-collapse:collapse;width:100%;font-size:12px;background:var(--panel)}th,td{padding:7px 9px;border:1px solid var(--line);text-align:right}th{text-align:center;position:sticky;top:0;background:#292936}td:nth-child(-n+3){text-align:left}.scroll{overflow:auto;max-height:600px}svg{width:100%;background:var(--panel);border:1px solid var(--line);border-radius:10px}.zero{stroke:#777;stroke-dasharray:4 4}.legend{display:flex;gap:16px;margin:7px}.legend i{display:inline-block;width:12px;height:3px;margin-right:6px;vertical-align:middle}.evidence{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px}.evidence span{padding:9px;border:1px solid var(--line);border-radius:6px}</style></head><body><main><header><p class="muted">${escapeHtml(result.schema)} \xB7 ${escapeHtml(result.generatedAt)}</p><h1>${escapeHtml(spec.researchId)}</h1><p>${escapeHtml(spec.hypothesis.claim)}</p><p class="muted">Mechanism: ${escapeHtml(spec.hypothesis.mechanism)} \xB7 stage=${spec.stage} \xB7 target=${spec.hypothesis.target} \xB7 spec=${result.specSha256}</p></header><h2>Selection</h2><section class="grid">${comparisonCards(result.comparisons)}</section><h2>Evidence matrix</h2><section class="evidence">${Object.entries(
1723
+ result.evidence
1724
+ ).map(
1725
+ ([key, value]) => `<span class="${value === "present" ? "positive" : "negative"}">${escapeHtml(key)}: ${value}</span>`
1726
+ ).join(
1727
+ ""
1728
+ )}</section><h2>ALL equity</h2>${buildCurveSvg(tradesByVariant, "ALL", "equity")}<h2>ALL realized drawdown</h2>${buildCurveSvg(tradesByVariant, "ALL", "drawdown")}<h2>LONG equity</h2>${buildCurveSvg(tradesByVariant, "LONG", "equity")}<h2>LONG realized drawdown</h2>${buildCurveSvg(tradesByVariant, "LONG", "drawdown")}<h2>SHORT equity</h2>${buildCurveSvg(tradesByVariant, "SHORT", "equity")}<h2>SHORT realized drawdown</h2>${buildCurveSvg(tradesByVariant, "SHORT", "drawdown")}<h2>Fixed cohort metrics</h2><p class="muted">Avg PnL/trade = cohort PnL / cohort N. ALL drawdown is aggregate portfolio; LONG/SHORT drawdowns are side-only.</p><div class="scroll"><table><thead><tr><th>Variant</th><th>Window</th><th>Cohort</th><th>N</th><th>PnL</th><th>Avg PnL/trade</th><th>PF</th><th>WR</th><th>Realized DD</th><th>Cad/day</th><th>Avg win</th><th>Avg loss</th><th>Payoff</th><th>Median</th><th>P05</th><th>P95</th><th>Median hold h</th><th>Max loss streak</th></tr></thead><tbody>${allRows}</tbody></table></div><h2>Fold and monthly robustness</h2><div class="scroll"><table><thead><tr><th>Variant</th><th>Window</th><th>Cohort</th><th>N</th><th>PnL</th><th>Avg PnL/trade</th><th>PF</th><th>WR</th><th>Realized DD</th><th>Cad/day</th><th>Avg win</th><th>Avg loss</th><th>Payoff</th><th>Median</th><th>P05</th><th>P95</th><th>Median hold h</th><th>Max loss streak</th></tr></thead><tbody>${robustnessRows}</tbody></table></div><h2>Cost stress</h2><div class="scroll"><table><thead><tr><th>Variant</th><th>Extra round-trip bps</th><th>N</th><th>PnL</th><th>Avg PnL/trade</th><th>PF</th><th>Realized DD</th></tr></thead><tbody>${costRows}</tbody></table></div><h2>Signal-time market regimes</h2><div class="scroll"><table><thead><tr><th>Trend | Volatility | Breadth | Derivatives</th><th>Variant</th><th>N</th><th>PnL</th><th>PF</th><th>WR</th></tr></thead><tbody>${regimeRows(result.variants)}</tbody></table></div><h2>Trace funnel and reconciliation</h2><div class="scroll"><table><thead><tr><th>Variant</th><th>Reconcile</th><th>Setup identity source</th><th>Events</th><th>Top skips</th><th>Latest signal-time regime</th></tr></thead><tbody>${traceRows(result.variants)}</tbody></table></div><h2>Multiple testing / overfitting</h2><pre>${escapeHtml(JSON.stringify({ multipleTesting: result.multipleTesting, overfittingDiagnostics: result.overfittingDiagnostics }, null, 2))}</pre><h2>Lineage</h2><pre>${escapeHtml(JSON.stringify({ stage: spec.stage, universeSha256: spec.universe.sha256, lineage: spec.lineage ?? null, artifactHashes: result.artifactHashes }, null, 2))}</pre></main></body></html>`;
1729
+ };
1730
+
1731
+ // src/lib/backtest/checkpoint.ts
1732
+ var import_node_crypto4 = require("crypto");
1733
+ var import_constants = require("@tradejs/core/constants");
1734
+ var import_redis = require("@tradejs/infra/redis");
1735
+
1736
+ // src/lib/runFormatting.ts
1737
+ var import_date_fns = require("date-fns");
1738
+ var ListIt = require("list-it");
1739
+
1740
+ // src/lib/backtest/checkpoint.ts
1741
+ var loadBacktestCheckpointResults = async ({
1742
+ runId,
1743
+ userName
1744
+ }) => (0, import_redis.getHashJsonValues)(
1745
+ import_redis.redisKeys.backtestRunResults(userName, runId)
1746
+ );
1747
+ var loadBacktestRunManifest = async ({
1748
+ runId,
1749
+ userName
1750
+ }) => await (0, import_redis.getData)(
1751
+ import_redis.redisKeys.backtestRun(userName, runId),
1752
+ null
1753
+ );
1754
+
1755
+ // src/lib/coreResearch/reconciliation.ts
1756
+ var finite = (value) => {
1757
+ const numeric = Number(value);
1758
+ return Number.isFinite(numeric) ? numeric : 0;
1759
+ };
1760
+ var reconcileCoreResearchVariant = async (params) => {
1761
+ const { variant, exportMetrics } = params;
1762
+ const exportSummary = {
1763
+ trades: exportMetrics.trades,
1764
+ wins: exportMetrics.wins,
1765
+ losses: exportMetrics.losses,
1766
+ pnl: exportMetrics.pnl
1767
+ };
1768
+ if (!variant.runId) {
1769
+ return {
1770
+ status: "not_requested",
1771
+ runId: null,
1772
+ manifestStatus: null,
1773
+ plannedTests: null,
1774
+ completedTests: null,
1775
+ redis: null,
1776
+ export: exportSummary,
1777
+ delta: null,
1778
+ pnlTolerance: null,
1779
+ reasons: ["variant.runId is not set"]
1780
+ };
1781
+ }
1782
+ const userName = params.userName ?? "root";
1783
+ const [manifest, completed] = await Promise.all([
1784
+ loadBacktestRunManifest({ runId: variant.runId, userName }),
1785
+ loadBacktestCheckpointResults({ runId: variant.runId, userName })
1786
+ ]);
1787
+ if (!manifest) {
1788
+ return {
1789
+ status: "unavailable",
1790
+ runId: variant.runId,
1791
+ manifestStatus: null,
1792
+ plannedTests: null,
1793
+ completedTests: null,
1794
+ redis: null,
1795
+ export: exportSummary,
1796
+ delta: null,
1797
+ pnlTolerance: null,
1798
+ reasons: ["backtest manifest is unavailable"]
1799
+ };
1800
+ }
1801
+ const redis = completed.reduce(
1802
+ (summary, envelope) => {
1803
+ const stat = envelope.result.stat;
1804
+ summary.trades += finite(stat.orders);
1805
+ summary.wins += finite(stat.wins);
1806
+ summary.losses += finite(stat.losses);
1807
+ summary.pnl += finite(stat.netProfit ?? stat.profit);
1808
+ return summary;
1809
+ },
1810
+ { trades: 0, wins: 0, losses: 0, pnl: 0 }
1811
+ );
1812
+ const delta = {
1813
+ trades: exportSummary.trades - redis.trades,
1814
+ wins: exportSummary.wins - redis.wins,
1815
+ losses: exportSummary.losses - redis.losses,
1816
+ pnl: exportSummary.pnl - redis.pnl
1817
+ };
1818
+ const pnlTolerance = completed.length * 5e-3 + 1e-9;
1819
+ const reasons = [];
1820
+ if (manifest.status !== "completed") {
1821
+ reasons.push(`manifest status is ${manifest.status}`);
1822
+ }
1823
+ if (manifest.window.start !== params.spec.window.start || manifest.window.end !== params.spec.window.end) {
1824
+ reasons.push("manifest window does not match the preregistered window");
1825
+ }
1826
+ if (manifest.config !== variant.configName) {
1827
+ reasons.push(
1828
+ `manifest config ${manifest.config} does not match ${variant.configName}`
1829
+ );
1830
+ }
1831
+ if (manifest.connectorName !== params.spec.execution.connector) {
1832
+ reasons.push(
1833
+ "manifest connector does not match the preregistered connector"
1834
+ );
1835
+ }
1836
+ if (String(manifest.interval) !== String(params.spec.execution.interval)) {
1837
+ reasons.push("manifest interval does not match the preregistered interval");
1838
+ }
1839
+ if (!manifest.flags.ai) {
1840
+ reasons.push("manifest did not enable raw AI dataset transport");
1841
+ }
1842
+ if (completed.length !== manifest.testSuite.length) {
1843
+ reasons.push(
1844
+ `checkpoint completion is ${completed.length}/${manifest.testSuite.length}`
1845
+ );
1846
+ }
1847
+ const manifestSymbols = [
1848
+ ...new Set(manifest.testSuite.map((test) => test.symbol))
1849
+ ];
1850
+ if (manifest.testSuite.length !== params.spec.universe.symbols.length || sha256Json(manifestSymbols) !== params.spec.universe.sha256) {
1851
+ reasons.push(
1852
+ "manifest test suite does not match the frozen ordered universe"
1853
+ );
1854
+ }
1855
+ if (manifest.testSuite.some(
1856
+ (test) => test.strategyName !== params.spec.strategy
1857
+ )) {
1858
+ reasons.push("manifest includes a different strategy");
1859
+ }
1860
+ const configIds = new Set(
1861
+ completed.map(
1862
+ (envelope) => envelope.result.test.configId?.trim() || "<missing-config-id>"
1863
+ )
1864
+ );
1865
+ if (configIds.size !== 1) {
1866
+ reasons.push(
1867
+ `variant run must be isolated to one configId, found ${configIds.size}`
1868
+ );
1869
+ }
1870
+ const resolvedConfigHashes = new Set(
1871
+ completed.map(
1872
+ (envelope) => sha256Json(envelope.result.test.strategyConfig ?? {})
1873
+ )
1874
+ );
1875
+ if (resolvedConfigHashes.size !== 1 || !resolvedConfigHashes.has(variant.configSha256)) {
1876
+ reasons.push(
1877
+ "executed resolved strategy config does not match the preregistered config SHA"
1878
+ );
1879
+ }
1880
+ if (delta.trades !== 0 || delta.wins !== 0 || delta.losses !== 0) {
1881
+ reasons.push("export N/W/L does not match Redis result.stat");
1882
+ }
1883
+ if (Math.abs(delta.pnl) > pnlTolerance) {
1884
+ reasons.push(
1885
+ `export PnL delta ${delta.pnl} exceeds tolerance ${pnlTolerance}`
1886
+ );
1887
+ }
1888
+ return {
1889
+ status: reasons.length ? "mismatch" : "match",
1890
+ runId: variant.runId,
1891
+ manifestStatus: manifest.status,
1892
+ plannedTests: manifest.testSuite.length,
1893
+ completedTests: completed.length,
1894
+ redis,
1895
+ export: exportSummary,
1896
+ delta,
1897
+ pnlTolerance,
1898
+ reasons
1899
+ };
1900
+ };
1901
+
1902
+ // src/lib/coreResearch/selection.ts
1903
+ var applyCoreResearchRobustnessGuardrails = (params) => {
1904
+ const { spec, analyses, comparisons } = params;
1905
+ const byVariantId = new Map(
1906
+ analyses.map((analysis) => [analysis.variant.id, analysis])
1907
+ );
1908
+ const maximumHolmPValue = spec.selection.maximumHolmPValue;
1909
+ for (const comparison of comparisons) {
1910
+ if (maximumHolmPValue != null) {
1911
+ const adjusted = comparison.bootstrap.holmAdjustedPValue;
1912
+ if (adjusted == null || adjusted > maximumHolmPValue) {
1913
+ comparison.selection.passed = false;
1914
+ comparison.selection.targetPassed = false;
1915
+ comparison.selection.warnings.push(
1916
+ `Holm-adjusted p-value ${adjusted ?? "n/a"} exceeds ${maximumHolmPValue}`
1917
+ );
1918
+ }
1919
+ }
1920
+ const controlAnalysis = byVariantId.get(comparison.controlId);
1921
+ const candidateAnalysis = byVariantId.get(comparison.candidateId);
1922
+ const required2 = [
1923
+ controlAnalysis?.reconciliation,
1924
+ candidateAnalysis?.reconciliation
1925
+ ].filter((value) => value?.runId);
1926
+ if (required2.some((value) => value?.status !== "match")) {
1927
+ comparison.selection.passed = false;
1928
+ comparison.selection.aggregatePassed = false;
1929
+ comparison.selection.warnings.push(
1930
+ "Run-scoped Redis/export reconciliation failed; selection is invalid."
1931
+ );
1932
+ }
1933
+ if (!candidateAnalysis || !controlAnalysis) continue;
1934
+ const target = spec.hypothesis.target;
1935
+ const underpoweredFolds = candidateAnalysis.folds.filter(
1936
+ (window) => window.cohorts[target].trades < spec.robustness.minimumFoldTrades
1937
+ );
1938
+ if (underpoweredFolds.length) {
1939
+ comparison.selection.passed = false;
1940
+ comparison.selection.targetPassed = false;
1941
+ comparison.selection.warnings.push(
1942
+ `${underpoweredFolds.length}/${candidateAnalysis.folds.length} target folds have fewer than ${spec.robustness.minimumFoldTrades} trades`
1943
+ );
1944
+ }
1945
+ const minimumPositiveFoldPct = spec.selection.minimumPositiveFoldPct;
1946
+ if (minimumPositiveFoldPct != null) {
1947
+ const positiveFoldPct = candidateAnalysis.folds.length ? candidateAnalysis.folds.filter(
1948
+ (window) => window.cohorts[target].pnl > 0
1949
+ ).length / candidateAnalysis.folds.length * 100 : 0;
1950
+ if (positiveFoldPct < minimumPositiveFoldPct) {
1951
+ comparison.selection.passed = false;
1952
+ comparison.selection.targetPassed = false;
1953
+ comparison.selection.warnings.push(
1954
+ `Positive target folds ${positiveFoldPct.toFixed(2)}% < ${minimumPositiveFoldPct}%`
1955
+ );
1956
+ }
1957
+ }
1958
+ for (const candidateWindow of candidateAnalysis.terminal) {
1959
+ const controlWindow = controlAnalysis.terminal.find(
1960
+ (window) => window.label === candidateWindow.label
1961
+ );
1962
+ if (!controlWindow) continue;
1963
+ const minimumTerminalTrades = Math.ceil(
1964
+ candidateWindow.periodDays * spec.selection.minimumCadencePerDay
1965
+ );
1966
+ if (candidateWindow.cohorts[target].trades < minimumTerminalTrades) {
1967
+ comparison.selection.passed = false;
1968
+ comparison.selection.targetPassed = false;
1969
+ comparison.selection.warnings.push(
1970
+ `${candidateWindow.label} ${target} has fewer than ${minimumTerminalTrades} trades`
1971
+ );
1972
+ }
1973
+ for (const rule of spec.selection.terminalRules ?? []) {
1974
+ if (!evaluateCoreResearchThresholdRule({
1975
+ control: controlWindow.cohorts[target],
1976
+ candidate: candidateWindow.cohorts[target],
1977
+ rule
1978
+ }).passed) {
1979
+ comparison.selection.passed = false;
1980
+ comparison.selection.targetPassed = false;
1981
+ comparison.selection.warnings.push(
1982
+ `${candidateWindow.label} ${target}.${rule.metric} failed`
1983
+ );
1984
+ }
1985
+ }
1986
+ }
1987
+ for (const candidateStress of candidateAnalysis.costStress) {
1988
+ const controlStress = controlAnalysis.costStress.find(
1989
+ (stress) => stress.extraRoundTripBps === candidateStress.extraRoundTripBps
1990
+ );
1991
+ if (!controlStress) continue;
1992
+ for (const rule of spec.selection.costStressRules ?? []) {
1993
+ if (!evaluateCoreResearchThresholdRule({
1994
+ control: controlStress.cohorts[target],
1995
+ candidate: candidateStress.cohorts[target],
1996
+ rule
1997
+ }).passed) {
1998
+ comparison.selection.passed = false;
1999
+ comparison.selection.targetPassed = false;
2000
+ comparison.selection.warnings.push(
2001
+ `cost+${candidateStress.extraRoundTripBps}bps ${target}.${rule.metric} failed`
2002
+ );
2003
+ }
2004
+ }
2005
+ }
2006
+ }
2007
+ };
2008
+
2009
+ // src/lib/coreResearch/trace.ts
2010
+ var import_promises3 = __toESM(require("fs/promises"));
2011
+ var import_node_path4 = __toESM(require("path"));
2012
+ var summarizeCoreResearchTrace = async (filePaths = []) => {
2013
+ const events = {};
2014
+ const skipCounts = {};
2015
+ for (const inputPath of filePaths) {
2016
+ const filePath = import_node_path4.default.resolve(inputPath);
2017
+ const text = await import_promises3.default.readFile(filePath, "utf8");
2018
+ for (const [index, line] of text.split("\n").entries()) {
2019
+ if (!line.trim()) continue;
2020
+ let event;
2021
+ try {
2022
+ event = JSON.parse(line);
2023
+ } catch (error) {
2024
+ throw new Error(
2025
+ `${filePath}:${index + 1} contains invalid trace JSON: ${String(error)}`
2026
+ );
2027
+ }
2028
+ events[event.event] = (events[event.event] ?? 0) + 1;
2029
+ if (event.event === "skip_summary") {
2030
+ for (const [reason, count] of Object.entries(event.skipCounts)) {
2031
+ skipCounts[reason] = (skipCounts[reason] ?? 0) + count;
2032
+ }
2033
+ }
2034
+ }
2035
+ }
2036
+ return {
2037
+ events: Object.fromEntries(Object.entries(events).sort()),
2038
+ skipCounts: Object.fromEntries(
2039
+ Object.entries(skipCounts).sort(
2040
+ ([leftKey, leftValue], [rightKey, rightValue]) => rightValue - leftValue || (leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0)
2041
+ )
2042
+ )
2043
+ };
2044
+ };
2045
+
2046
+ // src/lib/coreResearch/orchestrator.ts
2047
+ var summarizeSupplementalFiles = async (params) => {
2048
+ const loaded = await readCoreResearchVariant({
2049
+ ...params.variant,
2050
+ id: params.id,
2051
+ files: params.files,
2052
+ runId: void 0,
2053
+ command: void 0
2054
+ });
2055
+ return summarizeCoreResearchWindow({
2056
+ trades: loaded.trades,
2057
+ label: params.id,
2058
+ start: params.start,
2059
+ end: params.end
2060
+ });
2061
+ };
2062
+ var analyzeCoreResearchPrepared = async (spec, options = {}) => {
2063
+ const prepared = options.prepared ?? await prepareCoreResearch(spec);
2064
+ const { paths, specSha256 } = prepared;
2065
+ if (prepared.manifest.status === "completed") {
2066
+ throw new Error(
2067
+ `Research ${spec.researchId} is already completed and immutable; use verify or create a new researchId`
2068
+ );
2069
+ }
2070
+ await appendCoreResearchLedger({
2071
+ ledgerPath: paths.ledgerPath,
2072
+ researchId: spec.researchId,
2073
+ event: "analysis_started",
2074
+ specSha256,
2075
+ hypothesisFamily: spec.hypothesis.family,
2076
+ hypothesesInRecord: spec.variants.filter(
2077
+ (variant) => variant.role === "candidate"
2078
+ ).length
2079
+ });
2080
+ const analysisSpec = {
2081
+ ...spec,
2082
+ variants: options.variants ?? spec.variants
2083
+ };
2084
+ const loaded = await Promise.all(
2085
+ analysisSpec.variants.map(readCoreResearchVariant)
2086
+ );
2087
+ const windowed = loaded.map((entry) => ({
2088
+ ...entry,
2089
+ trades: entry.trades.filter(
2090
+ (trade) => trade.exitTimestamp >= spec.window.start && trade.exitTimestamp < spec.window.end
2091
+ )
2092
+ }));
2093
+ const periodDays = (spec.window.end - spec.window.start) / DAY_MS;
2094
+ const analyses = await Promise.all(
2095
+ windowed.map(async (entry) => ({
2096
+ variant: entry.variant,
2097
+ files: entry.files,
2098
+ duplicateRowsDropped: entry.duplicateRowsDropped,
2099
+ setupIdentitySources: entry.trades.reduce(
2100
+ (summary, trade) => {
2101
+ summary[trade.setupIdentitySource] += 1;
2102
+ return summary;
2103
+ },
2104
+ {
2105
+ "research.setupIdentity": 0,
2106
+ "strategy-context": 0,
2107
+ "signal-time-fallback": 0
2108
+ }
2109
+ ),
2110
+ full: summarizeCoreResearchWindow({
2111
+ trades: entry.trades,
2112
+ label: "full",
2113
+ start: spec.window.start,
2114
+ end: spec.window.end
2115
+ }),
2116
+ terminal: buildTerminalWindows({
2117
+ trades: entry.trades,
2118
+ end: spec.window.end,
2119
+ terminalDays: spec.window.terminalDays
2120
+ }),
2121
+ folds: buildEqualTimeFolds({
2122
+ trades: entry.trades,
2123
+ start: spec.window.start,
2124
+ end: spec.window.end,
2125
+ folds: spec.window.folds
2126
+ }),
2127
+ monthly: buildMonthlyWindows({
2128
+ trades: entry.trades,
2129
+ start: spec.window.start,
2130
+ end: spec.window.end
2131
+ }),
2132
+ regimes: buildRegimeMetrics(entry.trades, periodDays),
2133
+ costStress: buildCostStress({
2134
+ trades: entry.trades,
2135
+ periodDays,
2136
+ extraRoundTripBps: spec.robustness.costStressBps
2137
+ }),
2138
+ traceFunnel: await summarizeCoreResearchTrace(entry.variant.traceFiles),
2139
+ latestSignalTimeRegime: entry.trades.length ? {
2140
+ ...[...entry.trades].sort(
2141
+ (left, right) => right.signalTimestamp - left.signalTimestamp
2142
+ )[0].regime,
2143
+ timestamp: Math.max(
2144
+ ...entry.trades.map((trade) => trade.signalTimestamp)
2145
+ ),
2146
+ lagToWindowEndMs: spec.window.end - Math.max(...entry.trades.map((trade) => trade.signalTimestamp))
2147
+ } : null,
2148
+ reconciliation: await reconcileCoreResearchVariant({
2149
+ variant: entry.variant,
2150
+ spec,
2151
+ exportMetrics: summarizeCoreResearchWindow({
2152
+ trades: entry.trades,
2153
+ label: "full",
2154
+ start: spec.window.start,
2155
+ end: spec.window.end
2156
+ }).cohorts.ALL
2157
+ }),
2158
+ supplemental: {
2159
+ coldStart: Object.fromEntries(
2160
+ await Promise.all(
2161
+ Object.entries(entry.variant.coldStartFiles ?? {}).map(
2162
+ async ([label, files]) => [
2163
+ label,
2164
+ await summarizeSupplementalFiles({
2165
+ variant: entry.variant,
2166
+ files,
2167
+ id: `${entry.variant.id}-cold-${label}`,
2168
+ start: spec.window.start,
2169
+ end: spec.window.end
2170
+ })
2171
+ ]
2172
+ )
2173
+ )
2174
+ ),
2175
+ stress: Object.fromEntries(
2176
+ await Promise.all(
2177
+ Object.entries(entry.variant.stressFiles ?? {}).map(
2178
+ async ([label, files]) => [
2179
+ label,
2180
+ await summarizeSupplementalFiles({
2181
+ variant: entry.variant,
2182
+ files,
2183
+ id: `${entry.variant.id}-stress-${label}`,
2184
+ start: spec.window.start,
2185
+ end: spec.window.end
2186
+ })
2187
+ ]
2188
+ )
2189
+ )
2190
+ ),
2191
+ confirmation: entry.variant.confirmationFiles?.length ? await summarizeSupplementalFiles({
2192
+ variant: entry.variant,
2193
+ files: entry.variant.confirmationFiles,
2194
+ id: `${entry.variant.id}-confirmation`,
2195
+ start: spec.window.start,
2196
+ end: spec.window.end
2197
+ }) : null
2198
+ }
2199
+ }))
2200
+ );
2201
+ const control = windowed.find((entry) => entry.variant.role === "control");
2202
+ if (!control) throw new Error("Core research control variant was not loaded");
2203
+ const comparisons = windowed.filter((entry) => entry.variant.role === "candidate").map(
2204
+ (candidate) => compareCoreResearchVariants({ spec: analysisSpec, control, candidate })
2205
+ );
2206
+ const familyHypotheses = (await readCoreResearchLedger(paths.ledgerPath)).filter((record) => record.hypothesisFamily === spec.hypothesis.family).reduce(
2207
+ (sum, record) => record.event === "prepared" ? sum + record.hypothesesInRecord : sum,
2208
+ 0
2209
+ );
2210
+ applyHolmCorrection(
2211
+ comparisons.map((comparison) => comparison.bootstrap),
2212
+ Math.max(comparisons.length, familyHypotheses)
2213
+ );
2214
+ applyCoreResearchRobustnessGuardrails({ spec, analyses, comparisons });
2215
+ const evidence = {
2216
+ screen: spec.stage === "screen" ? "present" : "missing",
2217
+ isolatedLong: spec.stage === "isolated_long" ? "present" : "missing",
2218
+ terminals: spec.window.terminalDays.length ? "present" : "missing",
2219
+ folds: spec.window.folds >= 2 ? "present" : "missing",
2220
+ coldStart: analysisSpec.variants.every(
2221
+ (variant) => Object.keys(variant.coldStartFiles ?? {}).length > 0
2222
+ ) ? "present" : "missing",
2223
+ costStress: spec.robustness.costStressBps.length ? "present" : "missing",
2224
+ delayStress: analysisSpec.variants.every(
2225
+ (variant) => Object.keys(variant.stressFiles ?? {}).length > 0
2226
+ ) ? "present" : "missing",
2227
+ fastNonFast: analysisSpec.variants.every(
2228
+ (variant) => (variant.confirmationFiles?.length ?? 0) > 0
2229
+ ) ? "present" : "missing",
2230
+ runtimeParity: analysisSpec.variants.every(
2231
+ (variant) => (variant.runtimeParityFiles?.length ?? 0) > 0
2232
+ ) ? "present" : "missing"
2233
+ };
2234
+ const primarySourceHashes = Object.fromEntries(
2235
+ analyses.flatMap(
2236
+ (analysis) => analysis.files.map((file) => [
2237
+ import_node_path5.default.relative(paths.researchDir, file.path),
2238
+ file.sha256
2239
+ ])
2240
+ )
2241
+ );
2242
+ const supplementalSourcePaths = [
2243
+ ...new Set(
2244
+ analysisSpec.variants.flatMap((variant) => [
2245
+ ...variant.traceFiles ?? [],
2246
+ ...variant.confirmationFiles ?? [],
2247
+ ...variant.runtimeParityFiles ?? [],
2248
+ ...Object.values(variant.coldStartFiles ?? {}).flat(),
2249
+ ...Object.values(variant.stressFiles ?? {}).flat()
2250
+ ])
2251
+ )
2252
+ ];
2253
+ const supplementalSourceHashes = Object.fromEntries(
2254
+ await Promise.all(
2255
+ supplementalSourcePaths.map(async (filePath) => [
2256
+ import_node_path5.default.relative(paths.researchDir, import_node_path5.default.resolve(filePath)),
2257
+ await sha256File(import_node_path5.default.resolve(filePath))
2258
+ ])
2259
+ )
2260
+ );
2261
+ const sourceHashes = {
2262
+ ...primarySourceHashes,
2263
+ ...supplementalSourceHashes
2264
+ };
2265
+ const result = {
2266
+ schema: CORE_RESEARCH_RESULT_SCHEMA,
2267
+ researchId: spec.researchId,
2268
+ stage: spec.stage,
2269
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2270
+ specSha256,
2271
+ lineage: spec.lineage,
2272
+ semantics: {
2273
+ cohortOrder: ["ALL", "LONG", "SHORT"],
2274
+ pnlPerTrade: "cohort PnL / cohort completed positions",
2275
+ drawdown: {
2276
+ ALL: "time-ordered aggregate portfolio realized drawdown",
2277
+ LONG: "time-ordered LONG-only realized drawdown",
2278
+ SHORT: "time-ordered SHORT-only realized drawdown"
2279
+ },
2280
+ regimeCausality: "signal-time payload.additionalIndicators.baseContext only"
2281
+ },
2282
+ variants: analyses,
2283
+ comparisons,
2284
+ multipleTesting: {
2285
+ family: spec.hypothesis.family,
2286
+ hypotheses: Math.max(comparisons.length, familyHypotheses),
2287
+ method: "Holm"
2288
+ },
2289
+ evidence,
2290
+ overfittingDiagnostics: {
2291
+ deflatedSharpe: Object.fromEntries(
2292
+ analyses.map((analysis) => [
2293
+ analysis.variant.id,
2294
+ deflatedSharpeDiagnostic(
2295
+ analysis.monthly.map((window) => window.cohorts.ALL.pnl),
2296
+ Math.max(comparisons.length, familyHypotheses)
2297
+ )
2298
+ ])
2299
+ ),
2300
+ probabilityOfBacktestOverfitting: probabilityOfBacktestOverfitting(
2301
+ analyses.map(
2302
+ (analysis) => analysis.folds.map((window) => window.cohorts.ALL.pnl)
2303
+ )
2304
+ )
2305
+ },
2306
+ artifactHashes: sourceHashes
2307
+ };
2308
+ await writeJsonAtomic(paths.resultPath, result);
2309
+ await writeJsonAtomic(paths.comparisonPath, comparisons);
2310
+ await writeCoreResearchMatches({ filePath: paths.matchesPath, comparisons });
2311
+ await writeCoreResearchTrades({
2312
+ filePath: paths.tradesPath,
2313
+ variants: windowed.map((entry) => ({
2314
+ variantId: entry.variant.id,
2315
+ trades: entry.trades
2316
+ }))
2317
+ });
2318
+ const tradesByVariant = new Map(
2319
+ windowed.map((entry) => [entry.variant.id, entry.trades])
2320
+ );
2321
+ await import_promises4.default.writeFile(
2322
+ paths.reportPath,
2323
+ buildCoreResearchHtml({ spec, result, tradesByVariant }),
2324
+ "utf8"
2325
+ );
2326
+ const artifactHashes = {
2327
+ "spec.json": await sha256File(paths.specPath),
2328
+ "result.json": await sha256File(paths.resultPath),
2329
+ "comparison.json": await sha256File(paths.comparisonPath),
2330
+ "matches.csv": await sha256File(paths.matchesPath),
2331
+ "trades.jsonl": await sha256File(paths.tradesPath),
2332
+ "report.html": await sha256File(paths.reportPath)
2333
+ };
2334
+ const resolvedRunsPath = import_node_path5.default.join(paths.researchDir, "resolved-runs.json");
2335
+ try {
2336
+ artifactHashes["resolved-runs.json"] = await sha256File(resolvedRunsPath);
2337
+ } catch (error) {
2338
+ if (error.code !== "ENOENT") throw error;
2339
+ }
2340
+ try {
2341
+ for (const name of await import_promises4.default.readdir(paths.runLogDir)) {
2342
+ if (!name.endsWith(".log")) continue;
2343
+ artifactHashes[`runs/${name}`] = await sha256File(
2344
+ import_node_path5.default.join(paths.runLogDir, name)
2345
+ );
2346
+ }
2347
+ } catch (error) {
2348
+ if (error.code !== "ENOENT") throw error;
2349
+ }
2350
+ await writeJsonAtomic(paths.manifestPath, {
2351
+ schema: "tradejs-core-research-manifest/v1",
2352
+ researchId: spec.researchId,
2353
+ specSha256,
2354
+ status: "completed",
2355
+ createdAt: spec.createdAt,
2356
+ completedAt: result.generatedAt,
2357
+ selection: Object.fromEntries(
2358
+ comparisons.map((comparison) => [
2359
+ comparison.candidateId,
2360
+ comparison.selection
2361
+ ])
2362
+ ),
2363
+ artifactHashes
2364
+ });
2365
+ await appendCoreResearchLedger({
2366
+ ledgerPath: paths.ledgerPath,
2367
+ researchId: spec.researchId,
2368
+ event: "analysis_completed",
2369
+ specSha256,
2370
+ hypothesisFamily: spec.hypothesis.family,
2371
+ hypothesesInRecord: comparisons.length,
2372
+ artifactHashes
2373
+ });
2374
+ await writeCoreResearchStageIndex(paths.rootDir);
2375
+ return { result, paths, artifactHashes };
2376
+ };
2377
+ var analyzeCoreResearch = async (spec, options = {}) => {
2378
+ const prepared = options.prepared ?? await prepareCoreResearch(spec);
2379
+ try {
2380
+ return await analyzeCoreResearchPrepared(spec, { ...options, prepared });
2381
+ } catch (error) {
2382
+ if (prepared.manifest.status !== "completed") {
2383
+ try {
2384
+ await appendCoreResearchLedger({
2385
+ ledgerPath: prepared.paths.ledgerPath,
2386
+ researchId: spec.researchId,
2387
+ event: "analysis_failed",
2388
+ specSha256: prepared.specSha256,
2389
+ hypothesisFamily: spec.hypothesis.family,
2390
+ hypothesesInRecord: spec.variants.filter(
2391
+ (variant) => variant.role === "candidate"
2392
+ ).length
2393
+ });
2394
+ } catch {
2395
+ }
2396
+ }
2397
+ throw error;
2398
+ }
2399
+ };
2400
+ var createCoreResearchSpecTemplate = (params) => {
2401
+ const rootDir = "data/research/core";
2402
+ const projectRoot = process.cwd();
2403
+ const lineage = (() => {
2404
+ try {
2405
+ const gitSha = (0, import_node_child_process.execFileSync)("git", ["rev-parse", "HEAD"], {
2406
+ cwd: projectRoot,
2407
+ encoding: "utf8",
2408
+ stdio: ["ignore", "pipe", "ignore"]
2409
+ }).trim();
2410
+ const dirtyDiff = (0, import_node_child_process.execFileSync)(
2411
+ "git",
2412
+ ["diff", "--binary", "--no-ext-diff", "HEAD"],
2413
+ {
2414
+ cwd: projectRoot,
2415
+ encoding: "utf8",
2416
+ stdio: ["ignore", "pipe", "ignore"]
2417
+ }
2418
+ );
2419
+ return {
2420
+ gitSha,
2421
+ ...dirtyDiff ? {
2422
+ dirtyDiffSha256: (0, import_node_crypto5.createHash)("sha256").update(dirtyDiff).digest("hex")
2423
+ } : {}
2424
+ };
2425
+ } catch {
2426
+ return {};
2427
+ }
2428
+ })();
2429
+ return {
2430
+ schema: "tradejs-core-research/v1",
2431
+ researchId: params.researchId,
2432
+ stage: "screen",
2433
+ strategy: params.strategy,
2434
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
2435
+ hypothesis: {
2436
+ family: `${params.strategy}-hypothesis-family`,
2437
+ claim: "Candidate improves the preregistered metric versus control.",
2438
+ mechanism: "Describe the causal mechanism before running the experiment.",
2439
+ target: "ALL"
2440
+ },
2441
+ universe: { symbols: params.symbols, sha256: sha256Json(params.symbols) },
2442
+ window: {
2443
+ start: params.start,
2444
+ end: params.end,
2445
+ terminalDays: [365, 180, 90, 30],
2446
+ folds: 6
2447
+ },
2448
+ execution: {
2449
+ connector: "ByBit",
2450
+ interval: "15",
2451
+ maxLossValue: 10,
2452
+ feeRate: 1e-3,
2453
+ slippageBps: 10,
2454
+ entryDelayBars: 1
2455
+ },
2456
+ variants: [
2457
+ {
2458
+ id: "control",
2459
+ label: "Control",
2460
+ role: "control",
2461
+ configName: `${params.strategy}:control`,
2462
+ resolvedConfig: {},
2463
+ configSha256: sha256Json({}),
2464
+ files: ["data/ai/export/control.jsonl"]
2465
+ },
2466
+ {
2467
+ id: "candidate",
2468
+ label: "Candidate",
2469
+ role: "candidate",
2470
+ configName: `${params.strategy}:candidate`,
2471
+ resolvedConfig: {},
2472
+ configSha256: sha256Json({}),
2473
+ files: ["data/ai/export/candidate.jsonl"]
2474
+ }
2475
+ ],
2476
+ selection: {
2477
+ minimumTrades: 36,
2478
+ minimumCadencePerDay: 0.2,
2479
+ targetRules: [
2480
+ { metric: "pnl", comparison: "gt", relativeToControl: true },
2481
+ { metric: "pnlPerTrade", comparison: "gt", relativeToControl: true },
2482
+ { metric: "profitFactor", comparison: "gte", relativeToControl: true },
2483
+ { metric: "winRatePct", comparison: "gte", relativeToControl: true }
2484
+ ],
2485
+ aggregateRules: [
2486
+ { metric: "pnl", comparison: "gt", relativeToControl: true },
2487
+ {
2488
+ metric: "realizedMaxDrawdown",
2489
+ comparison: "lte",
2490
+ relativeToControl: true
2491
+ }
2492
+ ],
2493
+ nonTargetRules: [],
2494
+ costStressRules: [
2495
+ { metric: "pnl", comparison: "gt", relativeToControl: true },
2496
+ {
2497
+ metric: "realizedMaxDrawdown",
2498
+ comparison: "lte",
2499
+ relativeToControl: true
2500
+ }
2501
+ ],
2502
+ maximumPortfolioDrawdownRegressionPct: 0
2503
+ },
2504
+ robustness: {
2505
+ bootstrapIterations: 2e3,
2506
+ confidenceLevel: 0.95,
2507
+ clusterDays: 7,
2508
+ minimumFoldTrades: 20,
2509
+ costStressBps: [5, 10, 20]
2510
+ },
2511
+ artifacts: {
2512
+ rootDir,
2513
+ ledgerPath: `${rootDir}/trials.jsonl`
2514
+ },
2515
+ lineage,
2516
+ outputPath: params.outputPath
2517
+ };
2518
+ };
2519
+ var runCommand = async (params) => {
2520
+ if (!params.command.length) throw new Error("Cannot run an empty command");
2521
+ await import_promises4.default.mkdir(import_node_path5.default.dirname(params.logPath), { recursive: true });
2522
+ const chunks = [];
2523
+ const child = (0, import_node_child_process.spawn)(params.command[0], params.command.slice(1), {
2524
+ cwd: params.cwd,
2525
+ env: process.env,
2526
+ stdio: ["ignore", "pipe", "pipe"]
2527
+ });
2528
+ const capture = (chunk) => {
2529
+ const text = chunk.toString("utf8");
2530
+ chunks.push(text);
2531
+ process.stdout.write(text);
2532
+ text.split(/\r?\n/).filter(Boolean).forEach((line) => params.onLine?.(line));
2533
+ };
2534
+ child.stdout.on("data", capture);
2535
+ child.stderr.on("data", capture);
2536
+ const exitCode = await new Promise((resolve, reject) => {
2537
+ child.once("error", reject);
2538
+ child.once("close", (code) => resolve(code ?? 1));
2539
+ });
2540
+ const output = chunks.join("");
2541
+ await import_promises4.default.writeFile(params.logPath, output, "utf8");
2542
+ if (exitCode !== 0) {
2543
+ throw new Error(
2544
+ `Command failed with exit ${exitCode}: ${params.command.join(" ")}`
2545
+ );
2546
+ }
2547
+ return output;
2548
+ };
2549
+ var commandValue = (command, names) => {
2550
+ for (let index = 0; index < command.length; index += 1) {
2551
+ const token = command[index];
2552
+ if (names.includes(token)) return command[index + 1];
2553
+ for (const name of names) {
2554
+ if (token.startsWith(`${name}=`)) return token.slice(name.length + 1);
2555
+ }
2556
+ }
2557
+ return void 0;
2558
+ };
2559
+ var commandHas = (command, names) => command.some(
2560
+ (token) => names.includes(token) || names.some((name) => token.startsWith(`${name}=`))
2561
+ );
2562
+ var validateCoreResearchRunCommand = (params) => {
2563
+ const { spec, variant } = params;
2564
+ const command = variant.command ?? [];
2565
+ const failures = [];
2566
+ if (!commandHas(command, ["--ai", "-A"])) failures.push("--ai");
2567
+ if (!commandHas(command, ["--fast"])) failures.push("--fast");
2568
+ if (!commandHas(command, ["--cacheOnly", "-C"])) failures.push("--cacheOnly");
2569
+ if (commandHas(command, ["--continue", "-K", "--runId", "-R"])) {
2570
+ failures.push("fresh-run-only");
2571
+ }
2572
+ if (commandValue(command, ["--config", "-c"]) !== variant.configName) {
2573
+ failures.push(`config=${variant.configName}`);
2574
+ }
2575
+ if (Number(commandValue(command, ["--startTime"])) !== spec.window.start) {
2576
+ failures.push(`startTime=${spec.window.start}`);
2577
+ }
2578
+ if (Number(commandValue(command, ["--endTime"])) !== spec.window.end) {
2579
+ failures.push(`endTime=${spec.window.end}`);
2580
+ }
2581
+ const tickers = (commandValue(command, ["--tickers", "-t"]) ?? "").split(",").map((symbol) => symbol.trim()).filter(Boolean);
2582
+ if (sha256Json(tickers) !== spec.universe.sha256) {
2583
+ failures.push("frozen ordered ticker universe");
2584
+ }
2585
+ if (failures.length) {
2586
+ throw new Error(
2587
+ `Variant ${variant.id} run command failed preflight: ${failures.join(", ")}`
2588
+ );
2589
+ }
2590
+ };
2591
+ var runCoreResearch = async (params) => {
2592
+ const { spec } = params;
2593
+ const cwd = import_node_path5.default.resolve(params.cwd ?? process.cwd());
2594
+ const prepared = await prepareCoreResearch(spec);
2595
+ const resolvedVariants = [];
2596
+ for (const variant of spec.variants) {
2597
+ if (!variant.command?.length) {
2598
+ resolvedVariants.push(variant);
2599
+ continue;
2600
+ }
2601
+ validateCoreResearchRunCommand({ spec, variant });
2602
+ await appendCoreResearchLedger({
2603
+ ledgerPath: prepared.paths.ledgerPath,
2604
+ researchId: spec.researchId,
2605
+ event: "run_started",
2606
+ specSha256: prepared.specSha256,
2607
+ hypothesisFamily: spec.hypothesis.family,
2608
+ artifactHashes: { variant: sha256Json(variant) }
2609
+ });
2610
+ let runId = "";
2611
+ const backtestLogPath = import_node_path5.default.join(
2612
+ prepared.paths.runLogDir,
2613
+ `${variant.id}-backtest.log`
2614
+ );
2615
+ try {
2616
+ await runCommand({
2617
+ command: variant.command,
2618
+ cwd,
2619
+ logPath: backtestLogPath,
2620
+ onLine: (line) => {
2621
+ const match = line.match(
2622
+ /backtest run id:\s*([0-9]{12}-[a-f0-9]{8})/i
2623
+ );
2624
+ if (match) runId = match[1];
2625
+ }
2626
+ });
2627
+ if (!runId) {
2628
+ throw new Error(`Backtest for ${variant.id} did not report a run id`);
2629
+ }
2630
+ const exportLogPath = import_node_path5.default.join(
2631
+ prepared.paths.runLogDir,
2632
+ `${variant.id}-export.log`
2633
+ );
2634
+ const exportParts = [];
2635
+ const cliPath = import_node_path5.default.resolve(process.argv[1]);
2636
+ await runCommand({
2637
+ command: [
2638
+ process.execPath,
2639
+ cliPath,
2640
+ "ai-export",
2641
+ "--strategy",
2642
+ spec.strategy,
2643
+ "--runId",
2644
+ runId,
2645
+ "--partMonths",
2646
+ "0",
2647
+ "--keepChunks",
2648
+ "true"
2649
+ ],
2650
+ cwd,
2651
+ logPath: exportLogPath,
2652
+ onLine: (line) => {
2653
+ const match = line.match(/^part\d+:\s+(.+)$/);
2654
+ if (match) exportParts.push(import_node_path5.default.resolve(cwd, match[1].trim()));
2655
+ }
2656
+ });
2657
+ if (!exportParts.length) {
2658
+ throw new Error(
2659
+ `AI export for ${variant.id} did not report output files`
2660
+ );
2661
+ }
2662
+ const traceFiles = await (0, import_coreResearch.listCoreResearchTraceFiles)({
2663
+ strategyName: spec.strategy,
2664
+ runId,
2665
+ outDir: import_node_path5.default.resolve(cwd, "data/research/core/trace")
2666
+ });
2667
+ resolvedVariants.push({
2668
+ ...variant,
2669
+ runId,
2670
+ files: exportParts,
2671
+ traceFiles: traceFiles.length ? traceFiles : variant.traceFiles
2672
+ });
2673
+ await appendCoreResearchLedger({
2674
+ ledgerPath: prepared.paths.ledgerPath,
2675
+ researchId: spec.researchId,
2676
+ event: "run_completed",
2677
+ specSha256: prepared.specSha256,
2678
+ hypothesisFamily: spec.hypothesis.family,
2679
+ artifactHashes: {
2680
+ [`runs/${variant.id}-backtest.log`]: await sha256File(backtestLogPath),
2681
+ [`runs/${variant.id}-export.log`]: await sha256File(exportLogPath),
2682
+ ...Object.fromEntries(
2683
+ await Promise.all(
2684
+ exportParts.map(async (filePath) => [
2685
+ import_node_path5.default.basename(filePath),
2686
+ await sha256File(filePath)
2687
+ ])
2688
+ )
2689
+ )
2690
+ }
2691
+ });
2692
+ } catch (error) {
2693
+ await appendCoreResearchLedger({
2694
+ ledgerPath: prepared.paths.ledgerPath,
2695
+ researchId: spec.researchId,
2696
+ event: "run_failed",
2697
+ specSha256: prepared.specSha256,
2698
+ hypothesisFamily: spec.hypothesis.family
2699
+ });
2700
+ throw error;
2701
+ }
2702
+ }
2703
+ await writeJsonAtomic(
2704
+ import_node_path5.default.join(prepared.paths.researchDir, "resolved-runs.json"),
2705
+ {
2706
+ schema: "tradejs-core-research-resolved-runs/v1",
2707
+ researchId: spec.researchId,
2708
+ variants: resolvedVariants
2709
+ }
2710
+ );
2711
+ return analyzeCoreResearch(spec, {
2712
+ prepared,
2713
+ variants: resolvedVariants
2714
+ });
2715
+ };
2716
+
2717
+ // src/scripts/coreResearch.ts
2718
+ var valueAfter = (argv, name) => {
2719
+ const index = argv.indexOf(name);
2720
+ if (index < 0) return void 0;
2721
+ return argv[index + 1];
2722
+ };
2723
+ var required = (value, name) => {
2724
+ if (!value?.trim()) throw new Error(`${name} is required`);
2725
+ return value.trim();
2726
+ };
2727
+ var parseTimestamp = (value, name) => {
2728
+ const text = required(value, name);
2729
+ const numeric = /^\d+$/.test(text) ? Number(text) : Date.parse(text);
2730
+ if (!Number.isFinite(numeric)) throw new Error(`${name} is invalid: ${text}`);
2731
+ return numeric;
2732
+ };
2733
+ var readSymbols = async (argv) => {
2734
+ const symbolsFile = valueAfter(argv, "--symbolsFile");
2735
+ if (symbolsFile) {
2736
+ const parsed = JSON.parse(
2737
+ await import_promises5.default.readFile(import_node_path6.default.resolve(symbolsFile), "utf8")
2738
+ );
2739
+ if (!Array.isArray(parsed) || parsed.some((symbol) => typeof symbol !== "string")) {
2740
+ throw new Error("--symbolsFile must contain a JSON string array");
2741
+ }
2742
+ return parsed;
2743
+ }
2744
+ return required(valueAfter(argv, "--symbols"), "--symbols").split(",").map((symbol) => symbol.trim()).filter(Boolean);
2745
+ };
2746
+ var printUsage = () => {
2747
+ console.log(`Usage:
2748
+ yarn research:core init --out <spec.json> --researchId <id> --strategy <name> --start <ts> --end <ts> (--symbols <csv> | --symbolsFile <json>)
2749
+ yarn research:core prepare --spec <spec.json>
2750
+ yarn research:core analyze --spec <spec.json>
2751
+ yarn research:core run --spec <spec.json>
2752
+ yarn research:core verify --spec <spec.json>
2753
+ yarn research:core index --root <data/research/core>
2754
+
2755
+ The run command executes only variants with an explicit command[] in the preregistered spec. Variants with files[] are analyzed without starting a backtest.`);
2756
+ };
2757
+ var main = async () => {
2758
+ const argv = process.argv.slice(2);
2759
+ const command = argv[0];
2760
+ if (!command || !["init", "prepare", "analyze", "run", "verify", "index"].includes(command)) {
2761
+ printUsage();
2762
+ if (command) process.exitCode = 1;
2763
+ return;
2764
+ }
2765
+ if (command === "index") {
2766
+ const rootDir = import_node_path6.default.resolve(
2767
+ required(valueAfter(argv, "--root"), "--root")
2768
+ );
2769
+ const index = await writeCoreResearchStageIndex(rootDir);
2770
+ console.log(
2771
+ import_chalk.default.green(
2772
+ `Core research index: ${import_node_path6.default.join(rootDir, "index.json")} (${index.families.length} families)`
2773
+ )
2774
+ );
2775
+ return;
2776
+ }
2777
+ if (command === "init") {
2778
+ const outputPath = import_node_path6.default.resolve(
2779
+ required(valueAfter(argv, "--out"), "--out")
2780
+ );
2781
+ const spec = createCoreResearchSpecTemplate({
2782
+ researchId: required(valueAfter(argv, "--researchId"), "--researchId"),
2783
+ strategy: required(valueAfter(argv, "--strategy"), "--strategy"),
2784
+ symbols: await readSymbols(argv),
2785
+ start: parseTimestamp(valueAfter(argv, "--start"), "--start"),
2786
+ end: parseTimestamp(valueAfter(argv, "--end"), "--end"),
2787
+ outputPath
2788
+ });
2789
+ const { outputPath: _outputPath, ...persisted } = spec;
2790
+ await writeJsonAtomic(outputPath, persisted);
2791
+ console.log(import_chalk.default.green(`Core research spec created: ${outputPath}`));
2792
+ console.log(
2793
+ import_chalk.default.yellow(
2794
+ "Draft only: fill each variants[].resolvedConfig and configSha256 before prepare/analyze/run."
2795
+ )
2796
+ );
2797
+ return;
2798
+ }
2799
+ const specPath = required(valueAfter(argv, "--spec"), "--spec");
2800
+ const loaded = await loadCoreResearchSpec(specPath);
2801
+ if (command === "prepare") {
2802
+ const result = await prepareCoreResearch(loaded.spec);
2803
+ console.log(
2804
+ import_chalk.default.green(
2805
+ `Prepared ${loaded.spec.researchId}: ${result.paths.researchDir}`
2806
+ )
2807
+ );
2808
+ return;
2809
+ }
2810
+ if (command === "analyze") {
2811
+ const analyzed = await analyzeCoreResearch(loaded.spec);
2812
+ console.log(
2813
+ import_chalk.default.green(`Core research report: ${analyzed.paths.reportPath}`)
2814
+ );
2815
+ return;
2816
+ }
2817
+ if (command === "run") {
2818
+ const analyzed = await runCoreResearch({ spec: loaded.spec });
2819
+ console.log(
2820
+ import_chalk.default.green(`Core research report: ${analyzed.paths.reportPath}`)
2821
+ );
2822
+ return;
2823
+ }
2824
+ const verified = await verifyCoreResearchArtifacts(loaded.spec);
2825
+ console.log(import_chalk.default.green(JSON.stringify(verified, null, 2)));
2826
+ };
2827
+ // Annotate the CommonJS export names for ESM import in node:
2828
+ 0 && (module.exports = {
2829
+ main
2830
+ });