create-tradejs 3.1.28-beta.254 → 3.1.28-beta.256

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.
@@ -120,6 +120,16 @@ JSON reports include timestamp-grouped cumulative `equity` arrays for the
120
120
  current-gate baseline and every variant. Use these checksum-bound arrays for
121
121
  final-composition charts instead of reconstructing curves by hand.
122
122
 
123
+ JSON reports also include `approvedSignals` for the baseline and every variant
124
+ at `run.minQuality`. Each entry records the source row `sequence`, `signalId`,
125
+ timestamp, symbol, direction, and profit. The trace uses the same selector as
126
+ the metrics, after the common half-open window, direction rule, placebo, and
127
+ optional capacity limit. Use these identities to join approval decisions to
128
+ the frozen export for loss localization and overlapping-position analysis.
129
+ Do not reconstruct a second gate or infer individual approvals from equity
130
+ points. The trace describes historical gate decisions, not submitted or filled
131
+ runtime orders.
132
+
123
133
  For a deterministic timestamp-local portfolio limit, a JSON spec variant may
124
134
  also define `selection`. The tool first evaluates the gate, then keeps the
125
135
  highest-ranked rows independently inside each decision timestamp. Missing rank
@@ -0,0 +1,43 @@
1
+ # Excluding features before pocket search
2
+
3
+ `ai-pocket-search --excludeFeaturePattern <regex>` (short form `-x`) applies an
4
+ optional JavaScript regular expression to dot-separated feature paths before
5
+ feature buckets and atomic predicates are ranked. The expression is case
6
+ sensitive, without slash delimiters or flags. An invalid expression fails during
7
+ option normalization, before loading a dataset or strategy. Omission or an empty
8
+ string preserves the existing search behavior.
9
+
10
+ Use this option when the research protocol excludes a provenance family that the
11
+ general `causal-stationary` classification permits. For example:
12
+
13
+ ```bash
14
+ yarn ai-pocket-search --strategy AdaptiveMomentumRibbon --file <exact-export.jsonl> \
15
+ -n 0 --featurePolicy causal-stationary --featureProfile all \
16
+ --excludeFeaturePattern 'marketBreadths|(^|\.)cmc|referenceContexts\.(?!BTCUSDT(\.|$))'
17
+ ```
18
+
19
+ Freeze the actual expression with the experiment before inspecting pocket
20
+ outcomes. This example is only a provenance filter; it is not a complete feature
21
+ policy or evidence of profitability. Audit the remaining feature paths against
22
+ the protocol, including source metadata and clock fields. A symbol-specific
23
+ exception requires a fixed benchmark established in the protocol.
24
+
25
+ Matching a source branch skips its descendants. Filtering takes place before
26
+ derived features are calculated, so an excluded source value cannot populate a
27
+ derived alias. Generated feature paths are also filtered before search. Existing
28
+ outcome, coverage, and stationarity exclusions remain in force; this option can
29
+ only remove features. Removing a source field can also remove a derived feature
30
+ that depended on it.
31
+
32
+ The JSON run metadata records `excludeFeaturePattern`. JSON
33
+ `featurePolicyAudit["operator-excluded"]` reports the number of distinct excluded
34
+ paths and up to five sample paths. These are research policy exclusions, not
35
+ missing-data or data-quality failures. A matched branch counts once, rather than
36
+ counting its unvisited descendant leaves. Text and Markdown reports also record
37
+ the expression. Preserve the expression, report, source revision, exact dataset,
38
+ and checksums together.
39
+
40
+ A post-search filter does not provide the same contract: prohibited predicates
41
+ may have already consumed the atomic ranking budget and displaced allowed ones.
42
+ This option does not change trade labels, PnL, gate decisions, or execution, and
43
+ does not automatically impose the same expression on a separate gate replay.
@@ -4089,7 +4089,17 @@ export const buildAblationReport = ({
4089
4089
  : {}),
4090
4090
  };
4091
4091
  const baselineSelector = (row) => baselineSelectedAt(row, minQuality);
4092
+ const approvedSignalTrace = (selector) =>
4093
+ selectRows(rows, selector).map((row) => ({
4094
+ sequence: row.sequence ?? null,
4095
+ signalId: row.signalId ?? null,
4096
+ timestamp: row.timestamp,
4097
+ symbol: row.symbol,
4098
+ direction: row.direction,
4099
+ profit: row.profit,
4100
+ }));
4092
4101
  const baseline = {
4102
+ approvedSignals: approvedSignalTrace(baselineSelector),
4093
4103
  equity: buildEquitySeries(
4094
4104
  rows,
4095
4105
  baselineSelector,
@@ -4152,6 +4162,7 @@ export const buildAblationReport = ({
4152
4162
  expression: variant.expression,
4153
4163
  selection: variant.selection ?? null,
4154
4164
  placebo: variant.placebo ?? null,
4165
+ approvedSignals: approvedSignalTrace(candidateSelector),
4155
4166
  equity: buildEquitySeries(
4156
4167
  rows,
4157
4168
  candidateSelector,
@@ -1016,6 +1016,60 @@ test('applies variant capacity before every report slice', () => {
1016
1016
  assert.equal(report.variants[0].periods.full.totalProfit, 5);
1017
1017
  assert.equal(report.variants[0].train.trades, 2);
1018
1018
  assert.deepEqual(report.variants[0].selection, variant.selection);
1019
+ assert.deepEqual(
1020
+ report.variants[0].approvedSignals.map((row) => row.sequence),
1021
+ [1, 2],
1022
+ );
1023
+ });
1024
+
1025
+ test('traces the exact approved signals without replaying the gate', () => {
1026
+ const start = Date.UTC(2026, 0, 1);
1027
+ const rows = [4, 3, 5, 4].map((quality, sequence) => ({
1028
+ timestamp: start + sequence * 900000,
1029
+ signalId: `signal-${sequence}`,
1030
+ sequence,
1031
+ symbol: 'A',
1032
+ direction: 'LONG',
1033
+ directionMatches: sequence !== 2,
1034
+ quality,
1035
+ profit: sequence - 1,
1036
+ variantMatches: [sequence === 1],
1037
+ }));
1038
+ const report = buildAblationReport({
1039
+ rows,
1040
+ variants: [parseVariant('replacement::replace@4::true')],
1041
+ minQuality: 4,
1042
+ qualityThresholds: [4],
1043
+ terminalWindows: [7],
1044
+ validationSplit: 0,
1045
+ filePaths: ['fixture.jsonl'],
1046
+ windowStart: start,
1047
+ windowEnd: start + 3 * 900000,
1048
+ });
1049
+ assert.deepEqual(report.baseline.approvedSignals, [
1050
+ {
1051
+ sequence: 0,
1052
+ signalId: 'signal-0',
1053
+ timestamp: start,
1054
+ symbol: 'A',
1055
+ direction: 'LONG',
1056
+ profit: -1,
1057
+ },
1058
+ ]);
1059
+ assert.deepEqual(
1060
+ report.variants[0].approvedSignals.map((row) => row.signalId),
1061
+ ['signal-1'],
1062
+ );
1063
+ for (const composition of [report.baseline, ...report.variants]) {
1064
+ assert.equal(
1065
+ composition.approvedSignals.length,
1066
+ composition.periods.full.trades,
1067
+ );
1068
+ assert.equal(
1069
+ composition.approvedSignals.reduce((sum, row) => sum + row.profit, 0),
1070
+ composition.periods.full.totalProfit,
1071
+ );
1072
+ }
1019
1073
  });
1020
1074
 
1021
1075
  test('uses common half-open comparison bounds for sparse gate exports', () => {
@@ -1068,6 +1122,8 @@ test('uses common half-open comparison bounds for sparse gate exports', () => {
1068
1122
  });
1069
1123
  assert.equal(empty.variants[0].periods.full.trades, 0);
1070
1124
  assert.equal(empty.variants[0].periods.full.totalProfit, 0);
1125
+ assert.deepEqual(empty.baseline.approvedSignals, []);
1126
+ assert.deepEqual(empty.variants[0].approvedSignals, []);
1071
1127
  const parsed = parseCliArgs([
1072
1128
  '--windowStart',
1073
1129
  String(start),
@@ -115,6 +115,16 @@ of the same SVG. Link or display both charts in the final answer and immutable
115
115
  research note. Store the complete board spec and summary in Project-owned
116
116
  evidence; paths alone do not replace the note's machine-readable metrics.
117
117
 
118
+ For these research comparison charts, use continuous straight segments between
119
+ the recorded equity samples by default, not staircase corners. This matches the
120
+ preferred presentation without averaging returns or rounding away drawdowns.
121
+ Keep the observed endpoints and extrema; do not use moving averages or splines
122
+ that invent peaks or troughs. The connecting line is a visual interpolation,
123
+ not a claim about account value between events or an intratrade mark-to-market
124
+ path. Record `rendering.equityInterpolation = linear` in the chart summary.
125
+ For presentation-only updates, render to a new output directory and retain the
126
+ frozen reports, equity arrays, metrics, selections, and original chart files.
127
+
118
128
  ## Spec shape
119
129
 
120
130
  ```json
@@ -551,23 +551,40 @@ const dashboardSvg = (board) => {
551
551
  const x = sx(dd);
552
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
553
  }).join('');
554
+ const pointGroups = new Map();
554
555
  const points = board.candidates
555
556
  .map((candidate, index) => {
556
557
  const x = sx(candidate.metrics.maxDrawdown);
557
558
  const y = sy(candidate.metrics.pnl);
558
559
  const selectedPoint = candidate.id === selected.id;
559
- const labelY = y + 5;
560
+ const key = stableStringify([
561
+ candidate.metrics.maxDrawdown,
562
+ candidate.metrics.pnl,
563
+ ]);
564
+ const group = pointGroups.get(key) ?? [];
565
+ group.push({ candidate, index, x, y });
566
+ pointGroups.set(key, group);
567
+ return `${selectedPoint ? `<circle cx="${x}" cy="${y}" r="24" fill="${candidate.color}" opacity="0.18"/>` : ''}<circle data-scatter-point="${escapeXml(candidate.id)}" cx="${x}" cy="${y}" r="${selectedPoint ? 12 : 9}" fill="${candidate.color}" stroke="${selectedPoint ? '#7a321b' : '#ffffff'}" stroke-width="${selectedPoint ? 3 : 2}"/>`;
568
+ })
569
+ .join('');
570
+ const pointLabels = [...pointGroups.values()]
571
+ .map((group) => {
572
+ const { candidate, x, y } =
573
+ group.find((point) => point.candidate.id === selected.id) ?? group[0];
560
574
  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'}">${index === 0 ? '0' : String.fromCharCode(64 + index)}</text>`;
575
+ const label = group
576
+ .map(({ index }) =>
577
+ index === 0 ? '0' : String.fromCharCode(64 + index),
578
+ )
579
+ .join(' / ');
580
+ return `<text data-point-label-ids="${escapeXml(group.map(({ candidate }) => candidate.id).join(','))}" x="${x + (labelOnLeft ? -14 : 14)}" y="${y + 5}" text-anchor="${labelOnLeft ? 'end' : 'start'}" class="pointLabel" fill="${candidate.id === selected.id ? candidate.color : '#24302a'}">${label}</text>`;
564
581
  })
565
582
  .join('');
566
583
 
567
584
  const limitations = board.limitations.length
568
585
  ? `Limitations: ${board.limitations.join(' · ')}`
569
586
  : '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">MaxDD (trade stream)</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>`;
587
+ 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}${pointLabels}<text x="${scatter.x + scatter.width / 2}" y="${scatter.y + scatter.height + 65}" text-anchor="middle" class="muted">MaxDD (trade stream)</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
588
  };
572
589
 
573
590
  const downsample = (points, maxPoints = 1200) => {
@@ -598,13 +615,17 @@ const downsample = (points, maxPoints = 1200) => {
598
615
 
599
616
  const equitySvg = (board) => {
600
617
  const width = 1800;
601
- const height = 1200;
602
618
  const left = 110;
603
619
  const right = 70;
604
620
  const top = 150;
605
- const bottom = 300;
621
+ const columns = 3;
622
+ const legendRows = Math.ceil(board.candidates.length / columns);
623
+ const legendTop = 110;
624
+ const legendRowHeight = 58;
625
+ const bottom = Math.max(300, legendTop + legendRows * legendRowHeight + 32);
626
+ const plotHeight = 750;
627
+ const height = top + plotHeight + bottom;
606
628
  const plotWidth = width - left - right;
607
- const plotHeight = height - top - bottom;
608
629
  const minTime = board.comparisonWindow.start;
609
630
  const maxTime = board.comparisonWindow.end - 1;
610
631
  const values = board.candidates.flatMap(({ equity }) =>
@@ -648,9 +669,6 @@ const equitySvg = (board) => {
648
669
  const curves = board.candidates
649
670
  .map((candidate) => {
650
671
  const points = downsample(candidate.equity)
651
- .flatMap((point, index, series) =>
652
- index === 0 ? [point] : [[point[0], series[index - 1][1]], point],
653
- )
654
672
  .map(
655
673
  ([timestamp, pnl]) =>
656
674
  `${x(timestamp).toFixed(1)},${y(pnl).toFixed(1)}`,
@@ -660,15 +678,14 @@ const equitySvg = (board) => {
660
678
  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"/>`;
661
679
  })
662
680
  .join('');
663
- const columns = 3;
664
681
  const legendWidth = (width - left - right) / columns;
665
682
  const legend = board.candidates
666
683
  .map((candidate, index) => {
667
684
  const column = index % columns;
668
685
  const row = Math.floor(index / columns);
669
686
  const lx = left + column * legendWidth;
670
- const ly = height - bottom + 110 + row * 58;
671
- return `<g transform="translate(${lx},${ly})"><rect width="18" height="18" rx="3" fill="${candidate.color}"/><text x="28" y="15" class="legendLabel">${index === 0 ? '0' : String.fromCharCode(64 + index)} · ${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>`;
687
+ const ly = height - bottom + legendTop + row * legendRowHeight;
688
+ return `<g data-equity-legend="${escapeXml(candidate.id)}" transform="translate(${lx},${ly})"><rect width="18" height="18" rx="3" fill="${candidate.color}"/><text x="28" y="15" class="legendLabel">${index === 0 ? '0' : String.fromCharCode(64 + index)} · ${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>`;
672
689
  })
673
690
  .join('');
674
691
  const selected = board.candidates.find(({ id }) => id === board.selectedId);
@@ -768,6 +785,12 @@ export const generateFinalCompositionBoard = async ({
768
785
  terminalComparisonIds: board.terminalComparisonIds,
769
786
  comparisonWindow: board.comparisonWindow,
770
787
  normalization: board.normalization,
788
+ rendering: {
789
+ equityInterpolation: 'linear',
790
+ meaning:
791
+ 'Lines connect event samples; not an intratrade or mark-to-market path.',
792
+ metricInputsChanged: false,
793
+ },
771
794
  limitations: board.limitations,
772
795
  candidates: board.candidates.map((candidate) => ({
773
796
  id: candidate.id,
@@ -183,18 +183,83 @@ test('verifies candidate artifacts and renders the dashboard and equity board',
183
183
  const points = coordinates
184
184
  .split(' ')
185
185
  .map((point) => point.split(',').map(Number));
186
- for (let index = 1; index < points.length; index += 1) {
187
- assert.ok(
188
- points[index][0] === points[index - 1][0] ||
189
- points[index][1] === points[index - 1][1],
190
- 'Cumulative PnL must not interpolate gains between trade events',
191
- );
192
- }
186
+ assert.equal(points.length, 2, 'Do not insert synthetic step corners');
187
+ assert.ok(points[1][0] > points[0][0]);
188
+ assert.ok(points[1][1] < points[0][1], 'Join event samples directly');
193
189
  }
190
+ assert.equal(summary.rendering.equityInterpolation, 'linear');
191
+ assert.equal(summary.rendering.metricInputsChanged, false);
194
192
  assert.match(equity, /Nov 2023/u);
195
193
  assert.match(equity, /Mar 2024/u);
196
194
  });
197
195
 
196
+ test('linear presentation preserves flat samples, peaks, troughs and frozen metrics', async () => {
197
+ const root = await mkdtemp(path.join(os.tmpdir(), 'tradejs-final-board-'));
198
+ const contents = 'immutable-evidence\n';
199
+ const spec = makeSpec(hash(contents));
200
+ spec.candidates[0].equity = [
201
+ [1_700_000_000_000, 0],
202
+ [1_701_000_000_000, 0],
203
+ [1_703_000_000_000, 180],
204
+ [1_706_000_000_000, -20],
205
+ [1_710_000_000_000, 100],
206
+ ];
207
+ spec.candidates[0].metrics.maxDrawdown = 200;
208
+ const frozenSpec = structuredClone(spec);
209
+ for (const candidate of spec.candidates) {
210
+ for (const key of [
211
+ 'coreResult',
212
+ 'coreExport',
213
+ 'gateReport',
214
+ 'gateAuthority',
215
+ ]) {
216
+ const reference = candidate.composition[key];
217
+ if (reference) await writeFile(path.join(root, reference.path), contents);
218
+ }
219
+ }
220
+ const { summary } = await generateFinalCompositionBoard({
221
+ spec,
222
+ artifactRoot: root,
223
+ outDir: path.join(root, 'charts'),
224
+ png: false,
225
+ });
226
+ const svg = await readFile(
227
+ path.join(root, 'charts', 'final-composition-equity.svg'),
228
+ 'utf8',
229
+ );
230
+ const points = /<polyline points="([^"]+)"/u
231
+ .exec(svg)[1]
232
+ .split(' ')
233
+ .map((point) => point.split(',').map(Number));
234
+ assert.equal(points.length, frozenSpec.candidates[0].equity.length);
235
+ assert.equal(
236
+ points[0][1],
237
+ points[1][1],
238
+ 'A flat observed interval stays flat',
239
+ );
240
+ assert.equal(
241
+ Math.min(...points.map((p) => p[1])),
242
+ points[2][1],
243
+ 'Keep the observed peak',
244
+ );
245
+ assert.equal(
246
+ Math.max(...points.map((p) => p[1])),
247
+ points[3][1],
248
+ 'Keep the observed trough',
249
+ );
250
+ for (let i = 1; i < points.length; i++)
251
+ assert.ok(points[i][0] > points[i - 1][0]);
252
+ assert.deepEqual(spec, frozenSpec, 'Rendering must not mutate evidence');
253
+ assert.deepEqual(
254
+ summary.candidates.map((c) => c.metrics),
255
+ spec.candidates.map((c) => c.metrics),
256
+ );
257
+ assert.deepEqual(
258
+ summary.candidates.map((c) => c.terminal),
259
+ spec.candidates.map((c) => c.terminal),
260
+ );
261
+ });
262
+
198
263
  test('renders an additional terminal comparison without changing selectedId', async () => {
199
264
  const root = await mkdtemp(path.join(os.tmpdir(), 'tradejs-final-board-'));
200
265
  const contents = 'immutable-evidence\n';
@@ -238,3 +303,103 @@ test('renders an additional terminal comparison without changing selectedId', as
238
303
  assert.match(dashboard, /data-terminal-series="transition"/u);
239
304
  assert.match(dashboard, /Transition breakout \+ own gate/u);
240
305
  });
306
+
307
+ for (const candidateCount of [14, 20]) {
308
+ test(`keeps every legend row visible for ${candidateCount} compositions and groups coincident point labels`, async () => {
309
+ const root = await mkdtemp(path.join(os.tmpdir(), 'tradejs-final-board-'));
310
+ const contents = 'immutable-evidence\n';
311
+ const artifactSha = hash(contents);
312
+ const spec = makeSpec(artifactSha);
313
+ for (let index = 2; index < candidateCount; index += 1) {
314
+ spec.candidates.push(
315
+ makeCandidate({
316
+ id: `candidate-${index}`,
317
+ role: 'candidate',
318
+ label: `Composition ${index}`,
319
+ color: '#315f7d',
320
+ pnl: index < 4 ? 160 : index * 30,
321
+ trades: 28,
322
+ drawdown: index < 4 ? 26 : index * 7,
323
+ artifactSha,
324
+ }),
325
+ );
326
+ }
327
+ for (const candidate of spec.candidates) {
328
+ for (const artifact of [
329
+ 'coreResult',
330
+ 'coreExport',
331
+ 'gateReport',
332
+ 'gateAuthority',
333
+ ]) {
334
+ const reference = candidate.composition[artifact];
335
+ if (reference)
336
+ await writeFile(path.join(root, reference.path), contents);
337
+ }
338
+ }
339
+ const { summary } = await generateFinalCompositionBoard({
340
+ spec,
341
+ artifactRoot: root,
342
+ outDir: path.join(root, 'charts'),
343
+ });
344
+ assert.deepEqual(
345
+ summary.candidates.map(({ metrics }) => metrics),
346
+ spec.candidates.map(({ metrics }) => metrics),
347
+ );
348
+ const equity = await readFile(
349
+ path.join(root, 'charts', 'final-composition-equity.svg'),
350
+ 'utf8',
351
+ );
352
+ const height = Number(/<svg[^>]* height="(\d+)"/u.exec(equity)[1]);
353
+ const legends = [
354
+ ...equity.matchAll(
355
+ /data-equity-legend="([^"]+)" transform="translate\([^,]+,([\d.]+)\)"/gu,
356
+ ),
357
+ ];
358
+ assert.equal(legends.length, candidateCount);
359
+ for (const [, id, top] of legends) {
360
+ assert.ok(
361
+ Number(top) + 42 < height - 24,
362
+ `${id}: legend label and metrics must fit with bottom margin`,
363
+ );
364
+ }
365
+ const { default: sharp } = await import('sharp');
366
+ const image = sharp(
367
+ await readFile(path.join(root, 'charts', 'final-composition-equity.png')),
368
+ );
369
+ assert.equal((await image.metadata()).height, height);
370
+ const lastRowTop = Number(legends.at(-1)[2]);
371
+ const pixels = await image
372
+ .extract({ left: 110, top: lastRowTop, width: 500, height: 42 })
373
+ .stats();
374
+ assert.ok(
375
+ pixels.channels.some(({ min }) => min < 200),
376
+ 'Last legend row must be painted inside the PNG',
377
+ );
378
+ const dashboard = await readFile(
379
+ path.join(root, 'charts', 'final-composition-dashboard.svg'),
380
+ 'utf8',
381
+ );
382
+ const points = [
383
+ ...dashboard.matchAll(
384
+ /data-scatter-point="([^"]+)" cx="([^"]+)" cy="([^"]+)"/gu,
385
+ ),
386
+ ];
387
+ assert.equal(points.length, candidateCount);
388
+ const coincident = points.filter(([, id]) =>
389
+ ['candidate', 'candidate-2', 'candidate-3'].includes(id),
390
+ );
391
+ assert.equal(
392
+ new Set(coincident.map(([, , x, y]) => `${x},${y}`)).size,
393
+ 1,
394
+ 'Coincident data points must not be jittered',
395
+ );
396
+ assert.match(
397
+ dashboard,
398
+ /data-point-label-ids="candidate,candidate-2,candidate-3"[^>]*>A \/ B \/ C<\/text>/u,
399
+ );
400
+ assert.equal(
401
+ [...dashboard.matchAll(/data-point-label-ids=/gu)].length,
402
+ candidateCount - 2,
403
+ );
404
+ });
405
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema": "tradejs-skill-bundle/v1",
3
3
  "source": "TradeJS-Dev/TradeJS:.codex/skills",
4
- "bundleSha256": "be995341ae9459133ad1bb60da5c8fac01c5ffbe6d72309c1d68448abbe03c0e",
4
+ "bundleSha256": "f31f221397fa300534800c147d1ea57e549627083723acc7c97b75364d31da11",
5
5
  "skills": [
6
6
  "ai-train-local-research",
7
7
  "backtest-config-redis",
@@ -19,10 +19,11 @@
19
19
  "strategy-release"
20
20
  ],
21
21
  "files": {
22
- ".codex/skills/ai-train-local-research/references/gate-ablation.md": "a32ca7272a74678aca97dfce6b098ed15ca41fc385b936f25492f47bce4ec621",
22
+ ".codex/skills/ai-train-local-research/references/gate-ablation.md": "afc8334de0282fd0fac3886f467a1fca8e67c624a48cb5de66351635a039421e",
23
+ ".codex/skills/ai-train-local-research/references/pocket-feature-exclusion.md": "c7af65fcfcd404d5324f0222678126d5e166fb77c4c27afea96369ef427f4bb0",
23
24
  ".codex/skills/ai-train-local-research/references/reporting.md": "a5a5cc6438a8cc228560e302e944f1ff320f4c963988af83e1ba1443728f7149",
24
- ".codex/skills/ai-train-local-research/scripts/ai-gate-ablation.mjs": "4275088307342ef35d848f1bdd39528c22d2c66cf41aa7d3ed26f851c2fe803b",
25
- ".codex/skills/ai-train-local-research/scripts/ai-gate-ablation.test.mjs": "f9adcb02a779fb1da23e9c0aa99dead6152c5f45464138dd1de4283ece4ed363",
25
+ ".codex/skills/ai-train-local-research/scripts/ai-gate-ablation.mjs": "f68a1a7c898f4c2f46860709a52cabd3abd4cc8cd14443de2b5f827d261be935",
26
+ ".codex/skills/ai-train-local-research/scripts/ai-gate-ablation.test.mjs": "f13d2284a4c04a5220588652be81d104d2acde9650481cf569fa0efe56118f08",
26
27
  ".codex/skills/ai-train-local-research/SKILL.md": "479e44aad9cb6ab6b5c8931c807c63cf2a1938a59d01f553c20f44645716a23a",
27
28
  ".codex/skills/backtest-config-redis/scripts/get_backtest_config.sh": "833e950d6348c5b7a4bd3f00d25af60f6394190bff744bd5ab64db9e43d826d5",
28
29
  ".codex/skills/backtest-config-redis/SKILL.md": "85e7b1fa425aa23dfa7950de05c1d3ce046d044dc3872f3665705e9e2f2a1977",
@@ -41,11 +42,11 @@
41
42
  ".codex/skills/strategy-forward-start/SKILL.md": "a86311aeb4c5b46e43011806ffa0f665c4ec0fddce8d7f8c1232184e4a14bfa7",
42
43
  ".codex/skills/strategy-forward-status/SKILL.md": "b41f117680259fd7b4d5cbe3c0a1be75a42f08c8cf78f9ad7ccb0d90bfc66269",
43
44
  ".codex/skills/strategy-improvement-plan/SKILL.md": "f5f0f390941dd3332890a2f0ffae1248abde4925db2cf49a41c0c0c1b47e7864",
44
- ".codex/skills/strategy-improvement-research/references/final-composition-board.md": "05b49ea4aba85792ccea0aeaf4ee2637b808157962f0a38631d6468c342349d6",
45
+ ".codex/skills/strategy-improvement-research/references/final-composition-board.md": "bc88878e088744ff933f06b54f73af96231d1f22dc162a44c9eb2e81d6cb51aa",
45
46
  ".codex/skills/strategy-improvement-research/scripts/build-final-composition-spec.mjs": "b51873691fafbcf2dd70d8c9a6da842fec5f8358346c632cfe79211db08b18ba",
46
47
  ".codex/skills/strategy-improvement-research/scripts/build-final-composition-spec.test.mjs": "85d06b8fe09ee7611a3fb989173c361ba32f0e9364422925ff221bfe127b2bfa",
47
- ".codex/skills/strategy-improvement-research/scripts/final-composition-board.mjs": "b00f0160823a25c31afbcab66ce0c16fc70055928463a5741b3ffe8063ee8a81",
48
- ".codex/skills/strategy-improvement-research/scripts/final-composition-board.test.mjs": "a40e8b395ed1c758dc9862b44887e71e8cb9352ce26443d244397f76af891b62",
48
+ ".codex/skills/strategy-improvement-research/scripts/final-composition-board.mjs": "c7834c9d567d3f02c39fcca2b0116a0097bc0384891a5185edbcafa23eeea1db",
49
+ ".codex/skills/strategy-improvement-research/scripts/final-composition-board.test.mjs": "14572bbb8083f83b7e05b6dad2a9c93a88c212425b09cc31ee15ab7a684ea8c5",
49
50
  ".codex/skills/strategy-improvement-research/scripts/freeze-gate-variants.mjs": "17b3c868880ddd5b326d309e6e42eaeef1ea22d625a5db947997ac73b221ad6d",
50
51
  ".codex/skills/strategy-improvement-research/scripts/freeze-gate-variants.test.mjs": "963e1c64ab3deef11fd48a96e10f3827af38036dfe2eb28daf7849f19dd414d8",
51
52
  ".codex/skills/strategy-improvement-research/SKILL.md": "6964eb154e40b0b775aa2735b2ebbe998984479f46333ed03eab88d2ee150d48",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-tradejs",
3
- "version": "3.1.28-beta.254",
3
+ "version": "3.1.28-beta.256",
4
4
  "description": "Create a ready-to-run TradeJS project with local infrastructure and the Web UI.",
5
5
  "keywords": [
6
6
  "tradejs",