create-tradejs 3.1.24 → 3.1.25
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/skill-bundle/.codex/skills/ai-train-local-research/references/gate-ablation.md +9 -0
- package/dist/skill-bundle/.codex/skills/ai-train-local-research/scripts/ai-gate-ablation.mjs +98 -1
- package/dist/skill-bundle/.codex/skills/ai-train-local-research/scripts/ai-gate-ablation.test.mjs +46 -0
- package/dist/skill-bundle/.codex/skills/strategy-backtest-research/SKILL.md +5 -4
- package/dist/skill-bundle/.codex/skills/strategy-forward-start/SKILL.md +10 -4
- package/dist/skill-bundle/.codex/skills/strategy-improvement-research/SKILL.md +47 -11
- package/dist/skill-bundle/.codex/skills/strategy-improvement-research/references/final-composition-board.md +188 -0
- package/dist/skill-bundle/.codex/skills/strategy-improvement-research/scripts/build-final-composition-spec.mjs +240 -0
- package/dist/skill-bundle/.codex/skills/strategy-improvement-research/scripts/build-final-composition-spec.test.mjs +159 -0
- package/dist/skill-bundle/.codex/skills/strategy-improvement-research/scripts/final-composition-board.mjs +825 -0
- package/dist/skill-bundle/.codex/skills/strategy-improvement-research/scripts/final-composition-board.test.mjs +223 -0
- package/dist/skill-bundle/.codex/skills/strategy-improvement-research/scripts/freeze-gate-variants.mjs +103 -0
- package/dist/skill-bundle/.codex/skills/strategy-improvement-research/scripts/freeze-gate-variants.test.mjs +52 -0
- package/dist/skill-bundle/.codex/tradejs-skill-bundle.json +14 -7
- package/package.json +1 -1
|
@@ -0,0 +1,825 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { createReadStream } from 'node:fs';
|
|
5
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { pathToFileURL } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const SCHEMA = 'tradejs-final-composition-board/v1';
|
|
10
|
+
const REQUIRED_WINDOWS = [365, 180, 90, 30, 7];
|
|
11
|
+
const SHA256_RE = /^[a-f0-9]{64}$/u;
|
|
12
|
+
const DIRECTION_POLICIES = new Set([
|
|
13
|
+
'both',
|
|
14
|
+
'long_only',
|
|
15
|
+
'short_only',
|
|
16
|
+
'direction_aware',
|
|
17
|
+
]);
|
|
18
|
+
const GATE_SOURCES = new Set(['current', 'variant']);
|
|
19
|
+
|
|
20
|
+
const fail = (message) => {
|
|
21
|
+
throw new Error(message);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const finite = (value, name) => {
|
|
25
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
26
|
+
fail(`${name} must be a finite number`);
|
|
27
|
+
}
|
|
28
|
+
return value;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const nonNegative = (value, name) => {
|
|
32
|
+
const resolved = finite(value, name);
|
|
33
|
+
if (resolved < 0) fail(`${name} must be non-negative`);
|
|
34
|
+
return resolved;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const integer = (value, name) => {
|
|
38
|
+
const resolved = finite(value, name);
|
|
39
|
+
if (!Number.isInteger(resolved)) fail(`${name} must be an integer`);
|
|
40
|
+
return resolved;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const textValue = (value, name) => {
|
|
44
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
45
|
+
fail(`${name} must be a non-empty string`);
|
|
46
|
+
}
|
|
47
|
+
return value.trim();
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const shaValue = (value, name) => {
|
|
51
|
+
const resolved = textValue(value, name);
|
|
52
|
+
if (!SHA256_RE.test(resolved)) fail(`${name} must be a lowercase SHA-256`);
|
|
53
|
+
return resolved;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const stableValue = (value) => {
|
|
57
|
+
if (Array.isArray(value)) return value.map(stableValue);
|
|
58
|
+
if (value && typeof value === 'object') {
|
|
59
|
+
return Object.fromEntries(
|
|
60
|
+
Object.entries(value)
|
|
61
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
62
|
+
.map(([key, nested]) => [key, stableValue(nested)]),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
return value;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export const stableStringify = (value) => JSON.stringify(stableValue(value));
|
|
69
|
+
|
|
70
|
+
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
|
|
71
|
+
|
|
72
|
+
export const sha256File = async (filePath) => {
|
|
73
|
+
const hash = createHash('sha256');
|
|
74
|
+
await new Promise((resolve, reject) => {
|
|
75
|
+
const stream = createReadStream(filePath);
|
|
76
|
+
stream.on('data', (chunk) => hash.update(chunk));
|
|
77
|
+
stream.on('error', reject);
|
|
78
|
+
stream.on('end', resolve);
|
|
79
|
+
});
|
|
80
|
+
return hash.digest('hex');
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const resolveArtifact = (artifactRoot, artifact, name) => {
|
|
84
|
+
if (!artifact || typeof artifact !== 'object') {
|
|
85
|
+
fail(`${name} must be an artifact object`);
|
|
86
|
+
}
|
|
87
|
+
const declaredPath = textValue(artifact.path, `${name}.path`);
|
|
88
|
+
const expectedSha256 = shaValue(artifact.sha256, `${name}.sha256`);
|
|
89
|
+
const root = path.resolve(artifactRoot);
|
|
90
|
+
const absolutePath = path.resolve(root, declaredPath);
|
|
91
|
+
if (absolutePath !== root && !absolutePath.startsWith(`${root}${path.sep}`)) {
|
|
92
|
+
fail(`${name}.path escapes artifact root: ${declaredPath}`);
|
|
93
|
+
}
|
|
94
|
+
return { declaredPath, absolutePath, expectedSha256 };
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const validateMetrics = (metrics, name) => {
|
|
98
|
+
if (!metrics || typeof metrics !== 'object') fail(`${name} is required`);
|
|
99
|
+
return {
|
|
100
|
+
trades: nonNegative(
|
|
101
|
+
integer(metrics.trades, `${name}.trades`),
|
|
102
|
+
`${name}.trades`,
|
|
103
|
+
),
|
|
104
|
+
pnl: finite(metrics.pnl, `${name}.pnl`),
|
|
105
|
+
profitFactor:
|
|
106
|
+
metrics.profitFactor === null
|
|
107
|
+
? null
|
|
108
|
+
: nonNegative(metrics.profitFactor, `${name}.profitFactor`),
|
|
109
|
+
maxDrawdown: nonNegative(metrics.maxDrawdown, `${name}.maxDrawdown`),
|
|
110
|
+
};
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const validateTerminal = (terminal, name) => {
|
|
114
|
+
if (!Array.isArray(terminal)) fail(`${name} must be an array`);
|
|
115
|
+
const byDays = new Map();
|
|
116
|
+
for (const [index, row] of terminal.entries()) {
|
|
117
|
+
const days = integer(row?.days, `${name}[${index}].days`);
|
|
118
|
+
if (byDays.has(days)) fail(`${name} contains duplicate ${days}d`);
|
|
119
|
+
byDays.set(days, {
|
|
120
|
+
days,
|
|
121
|
+
trades: nonNegative(
|
|
122
|
+
integer(row?.trades, `${name}[${index}].trades`),
|
|
123
|
+
`${name}[${index}].trades`,
|
|
124
|
+
),
|
|
125
|
+
pnl: finite(row?.pnl, `${name}[${index}].pnl`),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
if (
|
|
129
|
+
REQUIRED_WINDOWS.some((days) => !byDays.has(days)) ||
|
|
130
|
+
byDays.size !== REQUIRED_WINDOWS.length
|
|
131
|
+
) {
|
|
132
|
+
fail(`${name} must contain exactly 365d, 180d, 90d, 30d, and 7d`);
|
|
133
|
+
}
|
|
134
|
+
return REQUIRED_WINDOWS.map((days) => byDays.get(days));
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const validateEquity = (equity, name, comparisonWindow, expectedPnl) => {
|
|
138
|
+
if (!Array.isArray(equity) || equity.length < 2) {
|
|
139
|
+
fail(`${name} must contain at least two [timestamp, pnl] points`);
|
|
140
|
+
}
|
|
141
|
+
let previous = -Infinity;
|
|
142
|
+
const resolved = equity.map((point, index) => {
|
|
143
|
+
if (!Array.isArray(point) || point.length !== 2) {
|
|
144
|
+
fail(`${name}[${index}] must be [timestamp, pnl]`);
|
|
145
|
+
}
|
|
146
|
+
const timestamp = integer(point[0], `${name}[${index}][0]`);
|
|
147
|
+
const pnl = finite(point[1], `${name}[${index}][1]`);
|
|
148
|
+
if (timestamp <= previous)
|
|
149
|
+
fail(`${name} timestamps must be strictly increasing`);
|
|
150
|
+
if (
|
|
151
|
+
timestamp < comparisonWindow.start ||
|
|
152
|
+
timestamp >= comparisonWindow.end
|
|
153
|
+
) {
|
|
154
|
+
fail(`${name}[${index}] is outside the common comparison window`);
|
|
155
|
+
}
|
|
156
|
+
previous = timestamp;
|
|
157
|
+
return [timestamp, pnl];
|
|
158
|
+
});
|
|
159
|
+
if (Math.abs(resolved.at(-1)[1] - expectedPnl) > 0.01) {
|
|
160
|
+
fail(`${name} final PnL must match metrics.pnl within 0.01`);
|
|
161
|
+
}
|
|
162
|
+
return resolved;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const compositionFingerprint = (composition) =>
|
|
166
|
+
sha256(
|
|
167
|
+
stableStringify({
|
|
168
|
+
kind: composition.kind,
|
|
169
|
+
gateSource: composition.gateSource,
|
|
170
|
+
coreResearchId: composition.coreResearchId,
|
|
171
|
+
coreConfigSha256: composition.coreConfigSha256,
|
|
172
|
+
coreResultSha256: composition.coreResult.sha256,
|
|
173
|
+
coreExportSha256: composition.coreExport.sha256,
|
|
174
|
+
gateReportSha256: composition.gateReport.sha256,
|
|
175
|
+
gateAuthoritySha256: composition.gateAuthority?.sha256 ?? null,
|
|
176
|
+
gateFingerprint: composition.gateFingerprint,
|
|
177
|
+
configFingerprint: composition.configFingerprint,
|
|
178
|
+
contextFingerprint: composition.contextFingerprint,
|
|
179
|
+
directionPolicy: composition.directionPolicy,
|
|
180
|
+
minQuality: composition.minQuality,
|
|
181
|
+
}),
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
export const validateBoardSpec = (input) => {
|
|
185
|
+
if (!input || typeof input !== 'object') fail('spec must be an object');
|
|
186
|
+
if (input.schema !== SCHEMA) fail(`spec.schema must be ${SCHEMA}`);
|
|
187
|
+
const strategy = textValue(input.strategy, 'strategy');
|
|
188
|
+
const researchId = textValue(input.researchId, 'researchId');
|
|
189
|
+
const title = textValue(input.title, 'title');
|
|
190
|
+
const subtitle = textValue(input.subtitle, 'subtitle');
|
|
191
|
+
const baselineId = textValue(input.baselineId, 'baselineId');
|
|
192
|
+
const selectedId = textValue(input.selectedId, 'selectedId');
|
|
193
|
+
const comparisonWindow = {
|
|
194
|
+
start: integer(input.comparisonWindow?.start, 'comparisonWindow.start'),
|
|
195
|
+
end: integer(input.comparisonWindow?.end, 'comparisonWindow.end'),
|
|
196
|
+
};
|
|
197
|
+
if (comparisonWindow.end <= comparisonWindow.start) {
|
|
198
|
+
fail('comparisonWindow.end must be greater than comparisonWindow.start');
|
|
199
|
+
}
|
|
200
|
+
const normalization = {
|
|
201
|
+
pnlUnit: textValue(input.normalization?.pnlUnit, 'normalization.pnlUnit'),
|
|
202
|
+
maxLossValue: nonNegative(
|
|
203
|
+
input.normalization?.maxLossValue,
|
|
204
|
+
'normalization.maxLossValue',
|
|
205
|
+
),
|
|
206
|
+
};
|
|
207
|
+
if (!Array.isArray(input.candidates) || input.candidates.length < 2) {
|
|
208
|
+
fail('candidates must contain a gated baseline and at least one candidate');
|
|
209
|
+
}
|
|
210
|
+
const ids = new Set();
|
|
211
|
+
const candidates = input.candidates.map((candidate, index) => {
|
|
212
|
+
const name = `candidates[${index}]`;
|
|
213
|
+
const id = textValue(candidate?.id, `${name}.id`);
|
|
214
|
+
if (ids.has(id)) fail(`duplicate candidate id: ${id}`);
|
|
215
|
+
ids.add(id);
|
|
216
|
+
const role = textValue(candidate.role, `${name}.role`);
|
|
217
|
+
if (!['baseline', 'candidate'].includes(role)) {
|
|
218
|
+
fail(`${name}.role must be baseline or candidate`);
|
|
219
|
+
}
|
|
220
|
+
const riskUnit = nonNegative(candidate.riskUnit, `${name}.riskUnit`);
|
|
221
|
+
if (riskUnit !== normalization.maxLossValue) {
|
|
222
|
+
fail(`${name}.riskUnit must equal normalization.maxLossValue`);
|
|
223
|
+
}
|
|
224
|
+
const composition = candidate.composition;
|
|
225
|
+
if (!composition || composition.kind !== 'core+deterministic-gate') {
|
|
226
|
+
fail(`${name}.composition.kind must be core+deterministic-gate`);
|
|
227
|
+
}
|
|
228
|
+
const directionPolicy = textValue(
|
|
229
|
+
composition.directionPolicy,
|
|
230
|
+
`${name}.composition.directionPolicy`,
|
|
231
|
+
);
|
|
232
|
+
if (!DIRECTION_POLICIES.has(directionPolicy)) {
|
|
233
|
+
fail(`${name}.composition.directionPolicy is invalid`);
|
|
234
|
+
}
|
|
235
|
+
const gateSource = textValue(
|
|
236
|
+
composition.gateSource,
|
|
237
|
+
`${name}.composition.gateSource`,
|
|
238
|
+
);
|
|
239
|
+
if (!GATE_SOURCES.has(gateSource)) {
|
|
240
|
+
fail(`${name}.composition.gateSource must be current or variant`);
|
|
241
|
+
}
|
|
242
|
+
if (role === 'baseline' && gateSource !== 'current') {
|
|
243
|
+
fail(`${name} baseline must use the current AI-gate`);
|
|
244
|
+
}
|
|
245
|
+
if (role === 'candidate' && gateSource !== 'variant') {
|
|
246
|
+
fail(`${name} candidate must use its own frozen gate variant`);
|
|
247
|
+
}
|
|
248
|
+
if (gateSource === 'current' && !composition.gateAuthority) {
|
|
249
|
+
fail(`${name}.composition.gateAuthority is required for current gate`);
|
|
250
|
+
}
|
|
251
|
+
if (gateSource === 'variant' && composition.gateAuthority !== undefined) {
|
|
252
|
+
fail(`${name}.composition.gateAuthority is reserved for current gate`);
|
|
253
|
+
}
|
|
254
|
+
const resolvedComposition = {
|
|
255
|
+
kind: composition.kind,
|
|
256
|
+
gateSource,
|
|
257
|
+
coreResearchId: textValue(
|
|
258
|
+
composition.coreResearchId,
|
|
259
|
+
`${name}.composition.coreResearchId`,
|
|
260
|
+
),
|
|
261
|
+
coreConfigSha256: shaValue(
|
|
262
|
+
composition.coreConfigSha256,
|
|
263
|
+
`${name}.composition.coreConfigSha256`,
|
|
264
|
+
),
|
|
265
|
+
coreResult: composition.coreResult,
|
|
266
|
+
coreExport: composition.coreExport,
|
|
267
|
+
gateReport: composition.gateReport,
|
|
268
|
+
...(gateSource === 'current'
|
|
269
|
+
? { gateAuthority: composition.gateAuthority }
|
|
270
|
+
: {}),
|
|
271
|
+
gateFingerprint: shaValue(
|
|
272
|
+
composition.gateFingerprint,
|
|
273
|
+
`${name}.composition.gateFingerprint`,
|
|
274
|
+
),
|
|
275
|
+
configFingerprint: shaValue(
|
|
276
|
+
composition.configFingerprint,
|
|
277
|
+
`${name}.composition.configFingerprint`,
|
|
278
|
+
),
|
|
279
|
+
contextFingerprint: shaValue(
|
|
280
|
+
composition.contextFingerprint,
|
|
281
|
+
`${name}.composition.contextFingerprint`,
|
|
282
|
+
),
|
|
283
|
+
directionPolicy,
|
|
284
|
+
minQuality: nonNegative(
|
|
285
|
+
integer(composition.minQuality, `${name}.composition.minQuality`),
|
|
286
|
+
`${name}.composition.minQuality`,
|
|
287
|
+
),
|
|
288
|
+
};
|
|
289
|
+
const metrics = validateMetrics(candidate.metrics, `${name}.metrics`);
|
|
290
|
+
return {
|
|
291
|
+
id,
|
|
292
|
+
label: textValue(candidate.label, `${name}.label`),
|
|
293
|
+
role,
|
|
294
|
+
status: textValue(candidate.status, `${name}.status`),
|
|
295
|
+
color: textValue(candidate.color, `${name}.color`),
|
|
296
|
+
riskUnit,
|
|
297
|
+
composition: resolvedComposition,
|
|
298
|
+
metrics,
|
|
299
|
+
terminal: validateTerminal(candidate.terminal, `${name}.terminal`),
|
|
300
|
+
equity: validateEquity(
|
|
301
|
+
candidate.equity,
|
|
302
|
+
`${name}.equity`,
|
|
303
|
+
comparisonWindow,
|
|
304
|
+
metrics.pnl,
|
|
305
|
+
),
|
|
306
|
+
compositionFingerprint: compositionFingerprint(resolvedComposition),
|
|
307
|
+
};
|
|
308
|
+
});
|
|
309
|
+
const baseline = candidates.find(({ id }) => id === baselineId);
|
|
310
|
+
if (!baseline || baseline.role !== 'baseline') {
|
|
311
|
+
fail('baselineId must identify production core + current AI-gate');
|
|
312
|
+
}
|
|
313
|
+
if (candidates.filter(({ role }) => role === 'baseline').length !== 1) {
|
|
314
|
+
fail('the board must contain exactly one gated baseline');
|
|
315
|
+
}
|
|
316
|
+
const selected = candidates.find(({ id }) => id === selectedId);
|
|
317
|
+
if (!selected) {
|
|
318
|
+
fail('selectedId must identify a final composition');
|
|
319
|
+
}
|
|
320
|
+
const terminalComparisonInput =
|
|
321
|
+
input.terminalComparisonIds === undefined
|
|
322
|
+
? selected.role === 'candidate'
|
|
323
|
+
? [selectedId]
|
|
324
|
+
: candidates
|
|
325
|
+
.filter(({ role }) => role === 'candidate')
|
|
326
|
+
.slice(0, 1)
|
|
327
|
+
.map(({ id }) => id)
|
|
328
|
+
: input.terminalComparisonIds;
|
|
329
|
+
if (
|
|
330
|
+
!Array.isArray(terminalComparisonInput) ||
|
|
331
|
+
terminalComparisonInput.length < 1 ||
|
|
332
|
+
terminalComparisonInput.length > 3
|
|
333
|
+
) {
|
|
334
|
+
fail(
|
|
335
|
+
'terminalComparisonIds must contain between one and three candidate ids',
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
const terminalComparisonIds = terminalComparisonInput.map((value, index) =>
|
|
339
|
+
textValue(value, `terminalComparisonIds[${index}]`),
|
|
340
|
+
);
|
|
341
|
+
if (new Set(terminalComparisonIds).size !== terminalComparisonIds.length) {
|
|
342
|
+
fail('terminalComparisonIds must not contain duplicates');
|
|
343
|
+
}
|
|
344
|
+
if (
|
|
345
|
+
selected.role === 'candidate' &&
|
|
346
|
+
!terminalComparisonIds.includes(selectedId)
|
|
347
|
+
) {
|
|
348
|
+
fail('terminalComparisonIds must include selectedId');
|
|
349
|
+
}
|
|
350
|
+
for (const id of terminalComparisonIds) {
|
|
351
|
+
const candidate = candidates.find((entry) => entry.id === id);
|
|
352
|
+
if (!candidate || candidate.role !== 'candidate') {
|
|
353
|
+
fail(`terminalComparisonIds must identify candidates: ${id}`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
const limitations = Array.isArray(input.limitations)
|
|
357
|
+
? input.limitations.map((value, index) =>
|
|
358
|
+
textValue(value, `limitations[${index}]`),
|
|
359
|
+
)
|
|
360
|
+
: fail('limitations must be an array');
|
|
361
|
+
return {
|
|
362
|
+
schema: SCHEMA,
|
|
363
|
+
strategy,
|
|
364
|
+
researchId,
|
|
365
|
+
title,
|
|
366
|
+
subtitle,
|
|
367
|
+
baselineId,
|
|
368
|
+
selectedId,
|
|
369
|
+
terminalComparisonIds,
|
|
370
|
+
comparisonWindow,
|
|
371
|
+
normalization,
|
|
372
|
+
limitations,
|
|
373
|
+
candidates,
|
|
374
|
+
};
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
const escapeXml = (value) =>
|
|
378
|
+
String(value)
|
|
379
|
+
.replaceAll('&', '&')
|
|
380
|
+
.replaceAll('<', '<')
|
|
381
|
+
.replaceAll('>', '>')
|
|
382
|
+
.replaceAll('"', '"')
|
|
383
|
+
.replaceAll("'", ''');
|
|
384
|
+
|
|
385
|
+
const formatNumber = (value, digits = 2) =>
|
|
386
|
+
value == null ? 'n/a' : Number(value).toFixed(digits);
|
|
387
|
+
|
|
388
|
+
const formatCompact = (value) => {
|
|
389
|
+
const absolute = Math.abs(value);
|
|
390
|
+
if (absolute >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}m`;
|
|
391
|
+
if (absolute >= 1_000) return `${(value / 1_000).toFixed(1)}k`;
|
|
392
|
+
return value.toFixed(0);
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
const pctDelta = (value, baseline, lowerIsBetter = false) => {
|
|
396
|
+
if (baseline === 0) return 'n/a';
|
|
397
|
+
const raw = ((value - baseline) / Math.abs(baseline)) * 100;
|
|
398
|
+
const signed = `${raw >= 0 ? '+' : ''}${raw.toFixed(1)}%`;
|
|
399
|
+
return lowerIsBetter ? `${signed} vs baseline` : signed;
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
const niceBounds = (values, includeZero = true) => {
|
|
403
|
+
const source = includeZero ? [...values, 0] : values;
|
|
404
|
+
let min = Math.min(...source);
|
|
405
|
+
let max = Math.max(...source);
|
|
406
|
+
if (min === max) {
|
|
407
|
+
const padding = Math.max(1, Math.abs(min) * 0.1);
|
|
408
|
+
min -= padding;
|
|
409
|
+
max += padding;
|
|
410
|
+
}
|
|
411
|
+
const padding = (max - min) * 0.1;
|
|
412
|
+
return [min - padding, max + padding];
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
const svgTextLines = ({
|
|
416
|
+
lines,
|
|
417
|
+
x,
|
|
418
|
+
y,
|
|
419
|
+
lineHeight,
|
|
420
|
+
className,
|
|
421
|
+
anchor = 'start',
|
|
422
|
+
}) =>
|
|
423
|
+
lines
|
|
424
|
+
.map(
|
|
425
|
+
(line, index) =>
|
|
426
|
+
`<text x="${x}" y="${y + index * lineHeight}" text-anchor="${anchor}" class="${className}">${escapeXml(line)}</text>`,
|
|
427
|
+
)
|
|
428
|
+
.join('');
|
|
429
|
+
|
|
430
|
+
const truncate = (value, limit) =>
|
|
431
|
+
value.length <= limit ? value : `${value.slice(0, Math.max(1, limit - 1))}…`;
|
|
432
|
+
|
|
433
|
+
const dashboardSvg = (board) => {
|
|
434
|
+
const width = 1800;
|
|
435
|
+
const height = 1200;
|
|
436
|
+
const baseline = board.candidates.find(({ id }) => id === board.baselineId);
|
|
437
|
+
const selected = board.candidates.find(({ id }) => id === board.selectedId);
|
|
438
|
+
const terminalCandidates = board.terminalComparisonIds.map((id) =>
|
|
439
|
+
board.candidates.find((candidate) => candidate.id === id),
|
|
440
|
+
);
|
|
441
|
+
const terminalSeries = [baseline, ...terminalCandidates];
|
|
442
|
+
const selectedColor = selected.color;
|
|
443
|
+
const cards = [
|
|
444
|
+
{
|
|
445
|
+
label: 'Trades',
|
|
446
|
+
value: String(selected.metrics.trades),
|
|
447
|
+
detail: `baseline ${baseline.metrics.trades} · ${selected.metrics.trades - baseline.metrics.trades >= 0 ? '+' : ''}${selected.metrics.trades - baseline.metrics.trades}`,
|
|
448
|
+
},
|
|
449
|
+
{
|
|
450
|
+
label: 'PnL',
|
|
451
|
+
value: formatNumber(selected.metrics.pnl),
|
|
452
|
+
detail: pctDelta(selected.metrics.pnl, baseline.metrics.pnl),
|
|
453
|
+
},
|
|
454
|
+
{
|
|
455
|
+
label: 'Profit factor',
|
|
456
|
+
value: formatNumber(selected.metrics.profitFactor, 3),
|
|
457
|
+
detail: `baseline ${formatNumber(baseline.metrics.profitFactor, 3)}`,
|
|
458
|
+
},
|
|
459
|
+
{
|
|
460
|
+
label: 'Max drawdown',
|
|
461
|
+
value: formatNumber(selected.metrics.maxDrawdown),
|
|
462
|
+
detail: pctDelta(
|
|
463
|
+
selected.metrics.maxDrawdown,
|
|
464
|
+
baseline.metrics.maxDrawdown,
|
|
465
|
+
true,
|
|
466
|
+
),
|
|
467
|
+
},
|
|
468
|
+
];
|
|
469
|
+
const cardWidth = 385;
|
|
470
|
+
const cardGap = 22;
|
|
471
|
+
const cardsMarkup = cards
|
|
472
|
+
.map(({ label, value, detail }, index) => {
|
|
473
|
+
const x = 72 + index * (cardWidth + cardGap);
|
|
474
|
+
return `<g><rect x="${x}" y="150" width="${cardWidth}" height="140" rx="16" class="panel"/><text x="${x + 28}" y="190" class="cardLabel">${escapeXml(label)}</text><text x="${x + 28}" y="248" class="cardValue" fill="${index === 1 ? selectedColor : '#17211d'}">${escapeXml(value)}</text><text x="${x + cardWidth - 28}" y="247" text-anchor="end" class="cardDetail">${escapeXml(detail)}</text></g>`;
|
|
475
|
+
})
|
|
476
|
+
.join('');
|
|
477
|
+
|
|
478
|
+
const bar = { x: 100, y: 445, width: 980, height: 425 };
|
|
479
|
+
const terminalLegendWidth = bar.width / terminalSeries.length;
|
|
480
|
+
const terminalLegend = terminalSeries
|
|
481
|
+
.map((candidate, index) => {
|
|
482
|
+
const x = bar.x + index * terminalLegendWidth;
|
|
483
|
+
return `<g data-terminal-legend="${escapeXml(candidate.id)}"><rect x="${x}" y="402" width="16" height="16" rx="3" fill="${candidate.color}"/><text x="${x + 24}" y="416" class="axis">${escapeXml(truncate(candidate.label, 40))}</text></g>`;
|
|
484
|
+
})
|
|
485
|
+
.join('');
|
|
486
|
+
const pnlValues = terminalSeries.flatMap(({ terminal }) =>
|
|
487
|
+
terminal.map(({ pnl }) => pnl),
|
|
488
|
+
);
|
|
489
|
+
const [barMin, barMax] = niceBounds(pnlValues);
|
|
490
|
+
const barY = (value) =>
|
|
491
|
+
bar.y + ((barMax - value) / (barMax - barMin)) * bar.height;
|
|
492
|
+
const zeroY = barY(0);
|
|
493
|
+
const barGrid = Array.from({ length: 5 }, (_, index) => {
|
|
494
|
+
const value = barMin + ((barMax - barMin) * index) / 4;
|
|
495
|
+
const y = barY(value);
|
|
496
|
+
return `<line x1="${bar.x}" x2="${bar.x + bar.width}" y1="${y}" y2="${y}" class="grid"/><text x="${bar.x - 16}" y="${y + 5}" text-anchor="end" class="axis">${escapeXml(formatCompact(value))}</text>`;
|
|
497
|
+
}).join('');
|
|
498
|
+
const groupWidth = bar.width / REQUIRED_WINDOWS.length;
|
|
499
|
+
const barGap = 5;
|
|
500
|
+
const singleBarWidth = Math.min(
|
|
501
|
+
38,
|
|
502
|
+
Math.max(
|
|
503
|
+
18,
|
|
504
|
+
(groupWidth - 20 - barGap * (terminalSeries.length - 1)) /
|
|
505
|
+
terminalSeries.length,
|
|
506
|
+
),
|
|
507
|
+
);
|
|
508
|
+
const bars = REQUIRED_WINDOWS.map((days, index) => {
|
|
509
|
+
const center = bar.x + groupWidth * (index + 0.5);
|
|
510
|
+
const totalBarsWidth =
|
|
511
|
+
singleBarWidth * terminalSeries.length +
|
|
512
|
+
barGap * (terminalSeries.length - 1);
|
|
513
|
+
const firstBarX = center - totalBarsWidth / 2;
|
|
514
|
+
const renderBar = (candidate, value, x) => {
|
|
515
|
+
const y = barY(value);
|
|
516
|
+
const top = Math.min(y, zeroY);
|
|
517
|
+
const h = Math.max(1, Math.abs(zeroY - y));
|
|
518
|
+
return `<rect data-terminal-series="${escapeXml(candidate.id)}" x="${x}" y="${top}" width="${singleBarWidth}" height="${h}" rx="4" fill="${candidate.color}"/><text x="${x + singleBarWidth / 2}" y="${value >= 0 ? top - 10 : top + h + 20}" text-anchor="middle" class="barValue">${escapeXml(formatCompact(value))}</text>`;
|
|
519
|
+
};
|
|
520
|
+
const windowBars = terminalSeries
|
|
521
|
+
.map((candidate, seriesIndex) =>
|
|
522
|
+
renderBar(
|
|
523
|
+
candidate,
|
|
524
|
+
candidate.terminal[index].pnl,
|
|
525
|
+
firstBarX + seriesIndex * (singleBarWidth + barGap),
|
|
526
|
+
),
|
|
527
|
+
)
|
|
528
|
+
.join('');
|
|
529
|
+
const tradeCounts = terminalSeries
|
|
530
|
+
.map((candidate) => candidate.terminal[index].trades)
|
|
531
|
+
.join(' / ');
|
|
532
|
+
return `${windowBars}<text x="${center}" y="${bar.y + bar.height + 38}" text-anchor="middle" class="windowLabel">${days}d</text><text x="${center}" y="${bar.y + bar.height + 64}" text-anchor="middle" class="windowCount">N ${escapeXml(tradeCounts)}</text>`;
|
|
533
|
+
}).join('');
|
|
534
|
+
|
|
535
|
+
const scatter = { x: 1180, y: 400, width: 500, height: 470 };
|
|
536
|
+
const [ddMin, ddMax] = niceBounds(
|
|
537
|
+
board.candidates.map(({ metrics }) => metrics.maxDrawdown),
|
|
538
|
+
false,
|
|
539
|
+
);
|
|
540
|
+
const [pnlMin, pnlMax] = niceBounds(
|
|
541
|
+
board.candidates.map(({ metrics }) => metrics.pnl),
|
|
542
|
+
);
|
|
543
|
+
const sx = (value) =>
|
|
544
|
+
scatter.x + ((value - ddMin) / (ddMax - ddMin)) * scatter.width;
|
|
545
|
+
const sy = (value) =>
|
|
546
|
+
scatter.y + ((pnlMax - value) / (pnlMax - pnlMin)) * scatter.height;
|
|
547
|
+
const scatterGrid = Array.from({ length: 5 }, (_, index) => {
|
|
548
|
+
const pnl = pnlMin + ((pnlMax - pnlMin) * index) / 4;
|
|
549
|
+
const dd = ddMin + ((ddMax - ddMin) * index) / 4;
|
|
550
|
+
const y = sy(pnl);
|
|
551
|
+
const x = sx(dd);
|
|
552
|
+
return `<line x1="${scatter.x}" x2="${scatter.x + scatter.width}" y1="${y}" y2="${y}" class="grid"/><text x="${scatter.x - 12}" y="${y + 5}" text-anchor="end" class="axis">${escapeXml(formatCompact(pnl))}</text><line x1="${x}" x2="${x}" y1="${scatter.y}" y2="${scatter.y + scatter.height}" class="grid faint"/><text x="${x}" y="${scatter.y + scatter.height + 28}" text-anchor="middle" class="axis">${escapeXml(formatCompact(dd))}</text>`;
|
|
553
|
+
}).join('');
|
|
554
|
+
const points = board.candidates
|
|
555
|
+
.map((candidate, index) => {
|
|
556
|
+
const x = sx(candidate.metrics.maxDrawdown);
|
|
557
|
+
const y = sy(candidate.metrics.pnl);
|
|
558
|
+
const selectedPoint = candidate.id === selected.id;
|
|
559
|
+
const labelY = y + ((index % 3) - 1) * 24;
|
|
560
|
+
const labelOnLeft = x > scatter.x + scatter.width * 0.66;
|
|
561
|
+
const labelX = x + (labelOnLeft ? -14 : 14);
|
|
562
|
+
const anchor = labelOnLeft ? 'end' : 'start';
|
|
563
|
+
return `${selectedPoint ? `<circle cx="${x}" cy="${y}" r="24" fill="${candidate.color}" opacity="0.18"/>` : ''}<circle cx="${x}" cy="${y}" r="${selectedPoint ? 12 : 9}" fill="${candidate.color}" stroke="${selectedPoint ? '#7a321b' : '#ffffff'}" stroke-width="${selectedPoint ? 3 : 2}"/><text x="${labelX}" y="${labelY}" text-anchor="${anchor}" class="pointLabel" fill="${selectedPoint ? candidate.color : '#24302a'}">${escapeXml(truncate(candidate.label, 25))}</text>`;
|
|
564
|
+
})
|
|
565
|
+
.join('');
|
|
566
|
+
|
|
567
|
+
const limitations = board.limitations.length
|
|
568
|
+
? `Limitations: ${board.limitations.join(' · ')}`
|
|
569
|
+
: 'Limitations: none recorded';
|
|
570
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-label="${escapeXml(board.strategy)} final composition dashboard"><style>.bg{fill:#f5f4ef}.panel{fill:#fff;stroke:#ddd9d0;stroke-width:2}.title{font:500 42px Arial,sans-serif;fill:#17211d}.subtitle{font:22px Arial,sans-serif;fill:#38433e}.cardLabel{font:21px Arial,sans-serif;fill:#303a35}.cardValue{font:500 44px Arial,sans-serif}.cardDetail{font:18px Arial,sans-serif;fill:#303a35}.section{font:500 27px Arial,sans-serif;fill:#17211d}.muted{font:18px Arial,sans-serif;fill:#45514a}.axis{font:15px Arial,sans-serif;fill:#58645e}.grid{stroke:#d9ddd9;stroke-width:1.5}.faint{opacity:.5}.barValue{font:16px Arial,sans-serif;fill:#24302a}.windowLabel{font:20px Arial,sans-serif;fill:#24302a}.windowCount{font:15px Arial,sans-serif;fill:#45514a}.pointLabel{font:16px Arial,sans-serif}.footer{font:18px Arial,sans-serif;fill:#6b4b1f}</style><rect width="100%" height="100%" class="bg"/><text x="72" y="70" class="title">${escapeXml(board.strategy)} · ${escapeXml(selected.label)}</text><text x="72" y="108" class="subtitle">${escapeXml(board.subtitle)}</text>${cardsMarkup}<rect x="72" y="330" width="1040" height="650" rx="20" class="panel"/><text x="100" y="380" class="section">PnL in terminal windows</text>${terminalLegend}<rect x="1145" y="330" width="585" height="650" rx="20" class="panel"/><text x="1180" y="380" class="section">Final compositions: PnL ↔ drawdown</text><text x="1180" y="412" class="muted">Higher and farther left is preferable</text><g>${barGrid}<line x1="${bar.x}" x2="${bar.x + bar.width}" y1="${zeroY}" y2="${zeroY}" stroke="#8f9994" stroke-width="1.5"/>${bars}</g><g>${scatterGrid}${points}<text x="${scatter.x + scatter.width / 2}" y="${scatter.y + scatter.height + 65}" text-anchor="middle" class="muted">Realized MaxDD</text></g><g><rect x="72" y="1020" width="1658" height="112" rx="16" fill="#fff5dd" stroke="#efd79c" stroke-width="2"/>${svgTextLines({ lines: [truncate(limitations, 155), `Risk normalization: MAX_LOSS_VALUE=${board.normalization.maxLossValue} · ${board.normalization.pnlUnit} · ${board.researchId}`], x: 98, y: 1062, lineHeight: 30, className: 'footer' })}</g></svg>`;
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
const downsample = (points, maxPoints = 1200) => {
|
|
574
|
+
if (points.length <= maxPoints) return points;
|
|
575
|
+
const selected = [points[0]];
|
|
576
|
+
const buckets = Math.max(1, Math.floor((maxPoints - 2) / 2));
|
|
577
|
+
const bucketSize = (points.length - 2) / buckets;
|
|
578
|
+
for (let bucket = 0; bucket < buckets; bucket += 1) {
|
|
579
|
+
const start = 1 + Math.floor(bucket * bucketSize);
|
|
580
|
+
const end = Math.min(
|
|
581
|
+
points.length - 1,
|
|
582
|
+
1 + Math.floor((bucket + 1) * bucketSize),
|
|
583
|
+
);
|
|
584
|
+
const slice = points.slice(start, Math.max(start + 1, end));
|
|
585
|
+
const min = slice.reduce((best, point) =>
|
|
586
|
+
point[1] < best[1] ? point : best,
|
|
587
|
+
);
|
|
588
|
+
const max = slice.reduce((best, point) =>
|
|
589
|
+
point[1] > best[1] ? point : best,
|
|
590
|
+
);
|
|
591
|
+
for (const point of [min, max].sort((left, right) => left[0] - right[0])) {
|
|
592
|
+
if (selected.at(-1) !== point) selected.push(point);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
selected.push(points.at(-1));
|
|
596
|
+
return selected;
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
const equitySvg = (board) => {
|
|
600
|
+
const width = 1800;
|
|
601
|
+
const height = 1200;
|
|
602
|
+
const left = 110;
|
|
603
|
+
const right = 70;
|
|
604
|
+
const top = 150;
|
|
605
|
+
const bottom = 300;
|
|
606
|
+
const plotWidth = width - left - right;
|
|
607
|
+
const plotHeight = height - top - bottom;
|
|
608
|
+
const minTime = board.comparisonWindow.start;
|
|
609
|
+
const maxTime = board.comparisonWindow.end - 1;
|
|
610
|
+
const values = board.candidates.flatMap(({ equity }) =>
|
|
611
|
+
equity.map(([, pnl]) => pnl),
|
|
612
|
+
);
|
|
613
|
+
const [minPnl, maxPnl] = niceBounds(values);
|
|
614
|
+
const x = (timestamp) =>
|
|
615
|
+
left + ((timestamp - minTime) / (maxTime - minTime)) * plotWidth;
|
|
616
|
+
const y = (pnl) => top + ((maxPnl - pnl) / (maxPnl - minPnl)) * plotHeight;
|
|
617
|
+
const yGrid = Array.from({ length: 7 }, (_, index) => {
|
|
618
|
+
const value = minPnl + ((maxPnl - minPnl) * index) / 6;
|
|
619
|
+
const position = y(value);
|
|
620
|
+
return `<line x1="${left}" x2="${width - right}" y1="${position}" y2="${position}" class="grid"/><text x="${left - 18}" y="${position + 5}" text-anchor="end" class="axis">${escapeXml(formatCompact(value))}</text>`;
|
|
621
|
+
}).join('');
|
|
622
|
+
const years = [];
|
|
623
|
+
const startYear = new Date(minTime).getUTCFullYear();
|
|
624
|
+
const endYear = new Date(maxTime).getUTCFullYear();
|
|
625
|
+
for (let year = startYear; year <= endYear; year += 1) {
|
|
626
|
+
const timestamp = Date.UTC(year, 0, 1);
|
|
627
|
+
if (timestamp >= minTime && timestamp <= maxTime)
|
|
628
|
+
years.push({ year, timestamp });
|
|
629
|
+
}
|
|
630
|
+
const xGrid = years
|
|
631
|
+
.map(({ year, timestamp }) => {
|
|
632
|
+
const position = x(timestamp);
|
|
633
|
+
return `<line x1="${position}" x2="${position}" y1="${top}" y2="${height - bottom}" class="grid faint"/><text x="${position}" y="${height - bottom + 34}" text-anchor="middle" class="axis">${year}</text>`;
|
|
634
|
+
})
|
|
635
|
+
.join('');
|
|
636
|
+
const curves = board.candidates
|
|
637
|
+
.map((candidate) => {
|
|
638
|
+
const points = downsample(candidate.equity)
|
|
639
|
+
.map(
|
|
640
|
+
([timestamp, pnl]) =>
|
|
641
|
+
`${x(timestamp).toFixed(1)},${y(pnl).toFixed(1)}`,
|
|
642
|
+
)
|
|
643
|
+
.join(' ');
|
|
644
|
+
const selected = candidate.id === board.selectedId;
|
|
645
|
+
return `<polyline points="${points}" fill="none" stroke="${candidate.color}" stroke-width="${selected ? 4.5 : 3}" opacity="${selected ? 1 : 0.82}" stroke-linejoin="round" stroke-linecap="round"/>`;
|
|
646
|
+
})
|
|
647
|
+
.join('');
|
|
648
|
+
const columns = 3;
|
|
649
|
+
const legendWidth = (width - left - right) / columns;
|
|
650
|
+
const legend = board.candidates
|
|
651
|
+
.map((candidate, index) => {
|
|
652
|
+
const column = index % columns;
|
|
653
|
+
const row = Math.floor(index / columns);
|
|
654
|
+
const lx = left + column * legendWidth;
|
|
655
|
+
const ly = height - bottom + 85 + row * 58;
|
|
656
|
+
return `<g transform="translate(${lx},${ly})"><rect width="18" height="18" rx="3" fill="${candidate.color}"/><text x="28" y="15" class="legendLabel">${escapeXml(truncate(candidate.label, 35))}</text><text x="28" y="37" class="legendMetric">N=${candidate.metrics.trades} · PnL=${formatNumber(candidate.metrics.pnl, 1)} · DD=${formatNumber(candidate.metrics.maxDrawdown, 1)}</text></g>`;
|
|
657
|
+
})
|
|
658
|
+
.join('');
|
|
659
|
+
const selected = board.candidates.find(({ id }) => id === board.selectedId);
|
|
660
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-label="${escapeXml(board.strategy)} final composition equity"><style>.bg{fill:#fff}.title{font:700 36px Arial,sans-serif;fill:#172033}.subtitle{font:18px Arial,sans-serif;fill:#687184}.axis{font:15px Arial,sans-serif;fill:#687184}.grid{stroke:#e2e6eb;stroke-width:1.5}.faint{opacity:.55}.legendLabel{font:600 17px Arial,sans-serif;fill:#263044}.legendMetric{font:15px Arial,sans-serif;fill:#687184}.axisTitle{font:17px Arial,sans-serif;fill:#394459}</style><rect width="100%" height="100%" class="bg"/><text x="${left}" y="58" class="title">${escapeXml(board.title)}</text><text x="${left}" y="92" class="subtitle">Baseline = production core + current AI-gate · candidates = core + own deterministic gate</text><text x="${left}" y="120" class="subtitle">Selected: ${escapeXml(selected.label)} · ${escapeXml(board.subtitle)}</text>${yGrid}${xGrid}<line x1="${left}" x2="${width - right}" y1="${y(0)}" y2="${y(0)}" stroke="#aab2bd" stroke-width="1.5"/>${curves}<line x1="${left}" x2="${left}" y1="${top}" y2="${height - bottom}" stroke="#7d8795" stroke-width="1.5"/><line x1="${left}" x2="${width - right}" y1="${height - bottom}" y2="${height - bottom}" stroke="#7d8795" stroke-width="1.5"/><text x="30" y="${top + plotHeight / 2}" transform="rotate(-90 30 ${top + plotHeight / 2})" text-anchor="middle" class="axisTitle">Cumulative PnL (${escapeXml(board.normalization.pnlUnit)})</text>${legend}</svg>`;
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
const verifyCandidateArtifacts = async (board, artifactRoot) => {
|
|
664
|
+
const verified = [];
|
|
665
|
+
for (const candidate of board.candidates) {
|
|
666
|
+
const artifacts = {};
|
|
667
|
+
const artifactKeys = [
|
|
668
|
+
'coreResult',
|
|
669
|
+
'coreExport',
|
|
670
|
+
'gateReport',
|
|
671
|
+
...(candidate.composition.gateSource === 'current'
|
|
672
|
+
? ['gateAuthority']
|
|
673
|
+
: []),
|
|
674
|
+
];
|
|
675
|
+
for (const key of artifactKeys) {
|
|
676
|
+
const resolved = resolveArtifact(
|
|
677
|
+
artifactRoot,
|
|
678
|
+
candidate.composition[key],
|
|
679
|
+
`${candidate.id}.${key}`,
|
|
680
|
+
);
|
|
681
|
+
const actualSha256 = await sha256File(resolved.absolutePath);
|
|
682
|
+
if (actualSha256 !== resolved.expectedSha256) {
|
|
683
|
+
fail(`${candidate.id}.${key} SHA-256 mismatch`);
|
|
684
|
+
}
|
|
685
|
+
artifacts[key] = {
|
|
686
|
+
path: resolved.declaredPath,
|
|
687
|
+
sha256: actualSha256,
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
verified.push({
|
|
691
|
+
...candidate,
|
|
692
|
+
composition: { ...candidate.composition, ...artifacts },
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
return { ...board, candidates: verified };
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
const renderPng = async (svg, outputPath) => {
|
|
699
|
+
const { default: sharp } = await import('sharp');
|
|
700
|
+
await sharp(Buffer.from(svg)).png().toFile(outputPath);
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
export const generateFinalCompositionBoard = async ({
|
|
704
|
+
spec,
|
|
705
|
+
artifactRoot,
|
|
706
|
+
outDir,
|
|
707
|
+
png = true,
|
|
708
|
+
}) => {
|
|
709
|
+
const validated = validateBoardSpec(spec);
|
|
710
|
+
const board = await verifyCandidateArtifacts(validated, artifactRoot);
|
|
711
|
+
const absoluteOutDir = path.resolve(outDir);
|
|
712
|
+
await mkdir(absoluteOutDir, { recursive: true });
|
|
713
|
+
const dashboard = dashboardSvg(board);
|
|
714
|
+
const equity = equitySvg(board);
|
|
715
|
+
const dashboardSvgPath = path.join(
|
|
716
|
+
absoluteOutDir,
|
|
717
|
+
'final-composition-dashboard.svg',
|
|
718
|
+
);
|
|
719
|
+
const equitySvgPath = path.join(
|
|
720
|
+
absoluteOutDir,
|
|
721
|
+
'final-composition-equity.svg',
|
|
722
|
+
);
|
|
723
|
+
await writeFile(dashboardSvgPath, `${dashboard}\n`, 'utf8');
|
|
724
|
+
await writeFile(equitySvgPath, `${equity}\n`, 'utf8');
|
|
725
|
+
const rendered = [dashboardSvgPath, equitySvgPath];
|
|
726
|
+
if (png) {
|
|
727
|
+
const dashboardPngPath = path.join(
|
|
728
|
+
absoluteOutDir,
|
|
729
|
+
'final-composition-dashboard.png',
|
|
730
|
+
);
|
|
731
|
+
const equityPngPath = path.join(
|
|
732
|
+
absoluteOutDir,
|
|
733
|
+
'final-composition-equity.png',
|
|
734
|
+
);
|
|
735
|
+
await renderPng(dashboard, dashboardPngPath);
|
|
736
|
+
await renderPng(equity, equityPngPath);
|
|
737
|
+
rendered.push(dashboardPngPath, equityPngPath);
|
|
738
|
+
}
|
|
739
|
+
const artifactHashes = Object.fromEntries(
|
|
740
|
+
await Promise.all(
|
|
741
|
+
rendered.map(async (filePath) => [
|
|
742
|
+
path.basename(filePath),
|
|
743
|
+
await sha256File(filePath),
|
|
744
|
+
]),
|
|
745
|
+
),
|
|
746
|
+
);
|
|
747
|
+
const summary = {
|
|
748
|
+
schema: 'tradejs-final-composition-summary/v1',
|
|
749
|
+
strategy: board.strategy,
|
|
750
|
+
researchId: board.researchId,
|
|
751
|
+
baselineId: board.baselineId,
|
|
752
|
+
selectedId: board.selectedId,
|
|
753
|
+
terminalComparisonIds: board.terminalComparisonIds,
|
|
754
|
+
comparisonWindow: board.comparisonWindow,
|
|
755
|
+
normalization: board.normalization,
|
|
756
|
+
limitations: board.limitations,
|
|
757
|
+
candidates: board.candidates.map((candidate) => ({
|
|
758
|
+
id: candidate.id,
|
|
759
|
+
label: candidate.label,
|
|
760
|
+
role: candidate.role,
|
|
761
|
+
status: candidate.status,
|
|
762
|
+
compositionFingerprint: candidate.compositionFingerprint,
|
|
763
|
+
composition: candidate.composition,
|
|
764
|
+
metrics: candidate.metrics,
|
|
765
|
+
terminal: candidate.terminal,
|
|
766
|
+
})),
|
|
767
|
+
artifacts: artifactHashes,
|
|
768
|
+
};
|
|
769
|
+
const summaryPath = path.join(
|
|
770
|
+
absoluteOutDir,
|
|
771
|
+
'final-composition-summary.json',
|
|
772
|
+
);
|
|
773
|
+
await writeFile(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
|
|
774
|
+
return { summary, summaryPath };
|
|
775
|
+
};
|
|
776
|
+
|
|
777
|
+
const parseArgs = (argv) => {
|
|
778
|
+
const options = { spec: '', outDir: '', artifactRoot: '', png: true };
|
|
779
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
780
|
+
const arg = argv[index];
|
|
781
|
+
const [name, inline] = arg.split('=', 2);
|
|
782
|
+
const take = () => {
|
|
783
|
+
if (inline !== undefined) return inline;
|
|
784
|
+
const value = argv[index + 1];
|
|
785
|
+
if (!value || value.startsWith('--')) fail(`${name} requires a value`);
|
|
786
|
+
index += 1;
|
|
787
|
+
return value;
|
|
788
|
+
};
|
|
789
|
+
if (name === '--spec') options.spec = take();
|
|
790
|
+
else if (name === '--outDir') options.outDir = take();
|
|
791
|
+
else if (name === '--artifactRoot') options.artifactRoot = take();
|
|
792
|
+
else if (name === '--noPng') options.png = false;
|
|
793
|
+
else fail(`unknown option: ${arg}`);
|
|
794
|
+
}
|
|
795
|
+
if (!options.spec || !options.outDir) {
|
|
796
|
+
fail(
|
|
797
|
+
'Usage: final-composition-board.mjs --spec <spec.json> --outDir <dir> [--artifactRoot <project>] [--noPng]',
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
options.artifactRoot =
|
|
801
|
+
options.artifactRoot || process.env.PROJECT_CWD || process.cwd();
|
|
802
|
+
return options;
|
|
803
|
+
};
|
|
804
|
+
|
|
805
|
+
const main = async () => {
|
|
806
|
+
const options = parseArgs(process.argv.slice(2));
|
|
807
|
+
const spec = JSON.parse(await readFile(path.resolve(options.spec), 'utf8'));
|
|
808
|
+
const result = await generateFinalCompositionBoard({
|
|
809
|
+
spec,
|
|
810
|
+
artifactRoot: options.artifactRoot,
|
|
811
|
+
outDir: options.outDir,
|
|
812
|
+
png: options.png,
|
|
813
|
+
});
|
|
814
|
+
process.stdout.write(`${JSON.stringify(result.summary, null, 2)}\n`);
|
|
815
|
+
};
|
|
816
|
+
|
|
817
|
+
if (
|
|
818
|
+
process.argv[1] &&
|
|
819
|
+
import.meta.url === pathToFileURL(process.argv[1]).href
|
|
820
|
+
) {
|
|
821
|
+
main().catch((error) => {
|
|
822
|
+
process.stderr.write(`${error.stack || error.message}\n`);
|
|
823
|
+
process.exitCode = 1;
|
|
824
|
+
});
|
|
825
|
+
}
|