@tradejs/cli 2.0.19 → 2.0.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +8787 -3214
- package/dist/lib/aiExport.js +14 -1
- package/dist/lib/aiPocketSearch.js +499 -67
- package/dist/lib/aiPocketSearchCli.js +113 -0
- package/dist/lib/aiTrainCharts.js +14 -1
- package/dist/lib/aiTrainMetrics.js +4 -1
- package/dist/lib/aiTrainResearch.js +88 -54
- package/dist/lib/coreResearch.js +2738 -0
- package/dist/lib/executionCalibration.js +6 -3
- package/dist/lib/runtimeLineage.js +81 -48
- package/dist/lib/runtimeScorecard.js +153 -10
- package/dist/lib/strategyGateFingerprint.js +80 -0
- package/dist/lib/strategyRelease.js +1365 -0
- package/dist/scripts/aiExport.js +16 -3
- package/dist/scripts/aiPocketSearch.js +1069 -163
- package/dist/scripts/aiTrain.js +91 -65
- package/dist/scripts/backtest.js +10 -1
- package/dist/scripts/coreResearch.js +2830 -0
- package/dist/scripts/executionCalibration.js +6 -3
- package/dist/scripts/replay.js +106 -73
- package/dist/scripts/replayRunner.js +106 -73
- package/dist/scripts/replayRuntimeEvidence.js +27 -14
- package/dist/scripts/runtimeEvidence.js +16 -8
- package/dist/scripts/runtimeScorecard.js +883 -29
- package/dist/scripts/signals.js +133 -89
- package/dist/scripts/signalsDaemon.js +133 -89
- package/dist/scripts/strategyRelease.js +1469 -0
- package/package.json +11 -9
|
@@ -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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
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>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
|
+
});
|