create-tradejs 3.1.27 → 3.1.28-beta.252

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,55 @@ 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
+ For a deterministic timestamp-local portfolio limit, a JSON spec variant may
124
+ also define `selection`. The tool first evaluates the gate, then keeps the
125
+ highest-ranked rows independently inside each decision timestamp. Missing rank
126
+ values sort last; symbol and source sequence are stable tie-breakers.
127
+
128
+ ```json
129
+ {
130
+ "name": "capacity-five",
131
+ "mode": "replace",
132
+ "quality": 4,
133
+ "expression": "derived.direction == SHORT && feature.margin >= 0",
134
+ "selection": {
135
+ "capacity": 5,
136
+ "rankBy": [
137
+ { "path": "feature.margin", "order": "desc" },
138
+ {
139
+ "path": "additionalIndicators.volumeDivergenceSetup.reclaimPct",
140
+ "order": "desc"
141
+ }
142
+ ]
143
+ }
144
+ }
145
+ ```
146
+
147
+ Treat every rank path like an approval feature: it must be causal, available at
148
+ decision time, stationary enough for the intended use, and documented with its
149
+ scope and environment dependencies. Capacity ranking is not permission to use
150
+ outcome, current-gate output, or data-availability fields.
151
+
152
+ A JSON spec can also define an event-count-preserving timestamp-rotation
153
+ placebo. Its own expression defines eligible rows. Inside each train, tuning,
154
+ and test partition, the referenced variant's approved event timestamps are
155
+ shifted through the eligible timestamp sequence. Trade outcomes are never read
156
+ while constructing the shift.
157
+
158
+ ```json
159
+ {
160
+ "name": "rotated-placebo",
161
+ "mode": "replace",
162
+ "quality": 4,
163
+ "expression": "derived.direction == SHORT",
164
+ "placebo": {
165
+ "type": "timestamp-rotation",
166
+ "referenceVariant": "frozen-gate",
167
+ "offsetEvents": 37
168
+ }
169
+ }
170
+ ```
171
+
123
172
  ## Expression Grammar
124
173
 
125
174
  Expressions support parentheses, `&&`, `||`, and comparisons:
@@ -160,6 +209,12 @@ When several core candidates must be compared, pass the same exact UTC
160
209
  Exact boundaries take precedence over ratio splits and keep sparse candidates
161
210
  on one calendar partition contract.
162
211
 
212
+ Use `--windowStart <UTC> --windowEnd <UTC>` to compare candidates over the same
213
+ calendar window. The start is inclusive, and the end is exclusive. Full-period
214
+ cadence and terminal windows use these bounds instead of each export's first
215
+ and last trade. The report includes zero-trade terminal windows. Without these
216
+ options, the existing export-based window remains unchanged.
217
+
163
218
  ## Cross-Strategy Feasibility
164
219
 
165
220
  Use `--crossStrategy` to test whether the latest merged export for every
@@ -36,6 +36,8 @@ Options:
36
36
  --testSplit <ratio> Later timestamp-grouped test share (default: 0)
37
37
  --tuningSince <timestamp> Exact UTC boundary where tuning starts
38
38
  --testSince <timestamp> Exact UTC boundary where test starts
39
+ --windowStart <timestamp> Common comparison start, inclusive
40
+ --windowEnd <timestamp> Common comparison end, exclusive
39
41
  --capacities <list> Capacity stress limits (default: 1,3,5)
40
42
  --maxLossValue <n> Per-order loss budget for capacity stress
41
43
  --featurePattern <regex> Inventory matching causal feature paths
@@ -165,7 +167,9 @@ export const parseCliArgs = (argv) => {
165
167
  options.testSplit = Number.isFinite(parsed)
166
168
  ? Math.max(0, Math.min(0.9, parsed))
167
169
  : 0;
168
- } else if (name === 'tuningSince' || name === 'testSince') {
170
+ } else if (
171
+ ['tuningSince', 'testSince', 'windowStart', 'windowEnd'].includes(name)
172
+ ) {
169
173
  const numeric = Number(value);
170
174
  const parsed = Number.isFinite(numeric) ? numeric : Date.parse(value);
171
175
  if (!Number.isFinite(parsed)) {
@@ -668,11 +672,55 @@ const loadVariants = async (inlineVariants, specPath) => {
668
672
  entry.quality == null ? '' : `@${Math.trunc(Number(entry.quality))}`;
669
673
  const directionSuffix =
670
674
  entry.direction == null ? '' : `[${String(entry.direction)}]`;
671
- variants.push(
672
- parseVariant(
673
- `${entry.name}::${entry.mode}${qualitySuffix}${directionSuffix}::${entry.expression}`,
674
- ),
675
+ const variant = parseVariant(
676
+ `${entry.name}::${entry.mode}${qualitySuffix}${directionSuffix}::${entry.expression}`,
675
677
  );
678
+ if (entry.selection != null) {
679
+ const capacity = Math.trunc(Number(entry.selection.capacity));
680
+ const rankBy = entry.selection.rankBy;
681
+ if (!Number.isFinite(capacity) || capacity < 1) {
682
+ throw new Error(
683
+ `Variant ${entry.name} selection.capacity must be a positive integer`,
684
+ );
685
+ }
686
+ if (!Array.isArray(rankBy) || rankBy.length === 0) {
687
+ throw new Error(
688
+ `Variant ${entry.name} selection.rankBy must be a non-empty array`,
689
+ );
690
+ }
691
+ variant.selection = {
692
+ capacity,
693
+ rankBy: rankBy.map((rank, index) => {
694
+ const pathValue = String(rank?.path ?? '').trim();
695
+ const order = String(rank?.order ?? 'desc').toLowerCase();
696
+ if (!pathValue || !['asc', 'desc'].includes(order)) {
697
+ throw new Error(
698
+ `Variant ${entry.name} selection.rankBy[${index}] requires path and asc|desc order`,
699
+ );
700
+ }
701
+ return { path: pathValue, order };
702
+ }),
703
+ };
704
+ }
705
+ if (entry.placebo != null) {
706
+ const type = String(entry.placebo.type ?? '').trim();
707
+ const referenceVariant = String(
708
+ entry.placebo.referenceVariant ?? '',
709
+ ).trim();
710
+ const offsetEvents = Math.trunc(Number(entry.placebo.offsetEvents));
711
+ if (
712
+ type !== 'timestamp-rotation' ||
713
+ !referenceVariant ||
714
+ !Number.isFinite(offsetEvents) ||
715
+ offsetEvents < 1
716
+ ) {
717
+ throw new Error(
718
+ `Variant ${entry.name} placebo requires type=timestamp-rotation, referenceVariant, and positive offsetEvents`,
719
+ );
720
+ }
721
+ variant.placebo = { type, referenceVariant, offsetEvents };
722
+ }
723
+ variants.push(variant);
676
724
  }
677
725
  const names = new Set();
678
726
  for (const variant of variants) {
@@ -681,6 +729,16 @@ const loadVariants = async (inlineVariants, specPath) => {
681
729
  }
682
730
  names.add(variant.name);
683
731
  }
732
+ for (const variant of variants) {
733
+ if (
734
+ variant.placebo != null &&
735
+ !names.has(variant.placebo.referenceVariant)
736
+ ) {
737
+ throw new Error(
738
+ `Variant ${variant.name} references unknown placebo variant ${variant.placebo.referenceVariant}`,
739
+ );
740
+ }
741
+ }
684
742
  return variants;
685
743
  };
686
744
 
@@ -962,6 +1020,111 @@ const candidateSelectedAt = (
962
1020
  defaultQuality: minQuality,
963
1021
  });
964
1022
 
1023
+ const compareRankValue = (left, right, order) => {
1024
+ const leftMissing = left == null || Number.isNaN(left);
1025
+ const rightMissing = right == null || Number.isNaN(right);
1026
+ if (leftMissing || rightMissing) {
1027
+ if (leftMissing && rightMissing) return 0;
1028
+ return leftMissing ? 1 : -1;
1029
+ }
1030
+ let comparison;
1031
+ if (typeof left === 'number' && typeof right === 'number') {
1032
+ comparison = left - right;
1033
+ } else {
1034
+ comparison = String(left).localeCompare(String(right));
1035
+ }
1036
+ return order === 'asc' ? comparison : -comparison;
1037
+ };
1038
+
1039
+ export const selectRowsWithinCapacity = ({ rows, selector, selection }) => {
1040
+ const selected = rows.filter(selector);
1041
+ if (selection == null) return new Set(selected);
1042
+ const byTimestamp = new Map();
1043
+ for (const row of selected) {
1044
+ const eventRows = byTimestamp.get(row.timestamp) ?? [];
1045
+ eventRows.push(row);
1046
+ byTimestamp.set(row.timestamp, eventRows);
1047
+ }
1048
+ const accepted = new Set();
1049
+ for (const eventRows of byTimestamp.values()) {
1050
+ eventRows.sort((left, right) => {
1051
+ for (const rank of selection.rankBy) {
1052
+ const comparison = compareRankValue(
1053
+ left.features?.[rank.path],
1054
+ right.features?.[rank.path],
1055
+ rank.order,
1056
+ );
1057
+ if (comparison !== 0) return comparison;
1058
+ }
1059
+ return (
1060
+ String(left.symbol ?? '').localeCompare(String(right.symbol ?? '')) ||
1061
+ Number(left.sequence ?? 0) - Number(right.sequence ?? 0)
1062
+ );
1063
+ });
1064
+ for (const row of eventRows.slice(0, selection.capacity)) {
1065
+ accepted.add(row);
1066
+ }
1067
+ }
1068
+ return accepted;
1069
+ };
1070
+
1071
+ export const applyTimestampRotationPlacebos = (
1072
+ rows,
1073
+ variants,
1074
+ partitions = [rows],
1075
+ ) => {
1076
+ const variantIndexes = new Map(
1077
+ variants.map((variant, index) => [variant.name, index]),
1078
+ );
1079
+ for (
1080
+ let placeboIndex = 0;
1081
+ placeboIndex < variants.length;
1082
+ placeboIndex += 1
1083
+ ) {
1084
+ const placebo = variants[placeboIndex].placebo;
1085
+ if (placebo?.type !== 'timestamp-rotation') continue;
1086
+ const referenceIndex = variantIndexes.get(placebo.referenceVariant);
1087
+ if (referenceIndex == null) {
1088
+ throw new Error(
1089
+ `Unknown timestamp-rotation reference ${placebo.referenceVariant}`,
1090
+ );
1091
+ }
1092
+ for (const partitionRows of partitions) {
1093
+ const eligibleTimestamps = [
1094
+ ...new Set(
1095
+ partitionRows
1096
+ .filter((row) => row.variantMatches[placeboIndex])
1097
+ .map((row) => row.timestamp),
1098
+ ),
1099
+ ].sort((left, right) => left - right);
1100
+ if (eligibleTimestamps.length === 0) continue;
1101
+ const eligibleIndex = new Map(
1102
+ eligibleTimestamps.map((timestamp, index) => [timestamp, index]),
1103
+ );
1104
+ const referenceTimestamps = new Set(
1105
+ partitionRows
1106
+ .filter((row) => row.variantMatches[referenceIndex])
1107
+ .map((row) => row.timestamp),
1108
+ );
1109
+ const rotatedTimestamps = new Set();
1110
+ for (const timestamp of referenceTimestamps) {
1111
+ const index = eligibleIndex.get(timestamp);
1112
+ if (index == null) continue;
1113
+ rotatedTimestamps.add(
1114
+ eligibleTimestamps[
1115
+ (index + placebo.offsetEvents) % eligibleTimestamps.length
1116
+ ],
1117
+ );
1118
+ }
1119
+ for (const row of partitionRows) {
1120
+ row.variantMatches[placeboIndex] =
1121
+ row.variantMatches[placeboIndex] &&
1122
+ rotatedTimestamps.has(row.timestamp);
1123
+ }
1124
+ }
1125
+ }
1126
+ };
1127
+
965
1128
  const selectRows = (rows, predicate) => rows.filter(predicate);
966
1129
 
967
1130
  const buildPeriodSummaries = ({
@@ -972,15 +1135,17 @@ const buildPeriodSummaries = ({
972
1135
  maxTimestamp,
973
1136
  summaryOptions,
974
1137
  }) => {
975
- const withCalendarDays = (periodRows) => ({
1138
+ const withCalendarDays = (periodRows, start, end) => ({
976
1139
  ...summaryOptions,
977
- calendarDays: getCalendarDays(periodRows),
1140
+ calendarDays: summaryOptions.comparisonWindow
1141
+ ? getCalendarDays([{ timestamp: start }, { timestamp: end - 1 }])
1142
+ : getCalendarDays(periodRows),
978
1143
  });
979
1144
  const result = {
980
1145
  full: summarizeRows(
981
1146
  selectRows(rows, selector),
982
1147
  Math.max((maxTimestamp - minTimestamp) / DAY_MS, 1),
983
- withCalendarDays(rows),
1148
+ withCalendarDays(rows, minTimestamp, maxTimestamp),
984
1149
  ),
985
1150
  };
986
1151
  for (const days of windows) {
@@ -989,7 +1154,7 @@ const buildPeriodSummaries = ({
989
1154
  result[`${days}d`] = summarizeRows(
990
1155
  selectRows(periodRows, selector),
991
1156
  days,
992
- withCalendarDays(periodRows),
1157
+ withCalendarDays(periodRows, from, maxTimestamp),
993
1158
  );
994
1159
  }
995
1160
  return result;
@@ -1027,11 +1192,7 @@ export const splitRowsByTimestamp = (rows, validationSplit, testSplit = 0) => {
1027
1192
  };
1028
1193
  };
1029
1194
 
1030
- export const splitRowsByTimestampBounds = (
1031
- rows,
1032
- tuningSince,
1033
- testSince,
1034
- ) => {
1195
+ export const splitRowsByTimestampBounds = (rows, tuningSince, testSince) => {
1035
1196
  if (!Number.isFinite(tuningSince) || !Number.isFinite(testSince)) {
1036
1197
  throw new Error(
1037
1198
  'Exact calendar partitions require both tuningSince and testSince',
@@ -1061,7 +1222,7 @@ const summarizeDirections = (rows, selector, summaryOptions) =>
1061
1222
  direction,
1062
1223
  summarizeRows(
1063
1224
  selectRows(rows, (row) => row.direction === direction && selector(row)),
1064
- getPeriodDays(rows),
1225
+ summaryOptions?.denominatorDays ?? getPeriodDays(rows),
1065
1226
  summaryOptions,
1066
1227
  ),
1067
1228
  ]),
@@ -1091,7 +1252,8 @@ export const buildEquitySeries = (
1091
1252
  if (timestamp === minTimestamp) result[0] = [timestamp, cumulative];
1092
1253
  else result.push([timestamp, cumulative]);
1093
1254
  }
1094
- if (result.at(-1)[0] !== maxTimestamp) result.push([maxTimestamp, cumulative]);
1255
+ if (result.at(-1)[0] !== maxTimestamp)
1256
+ result.push([maxTimestamp, cumulative]);
1095
1257
  return result;
1096
1258
  };
1097
1259
 
@@ -1105,7 +1267,12 @@ const buildPeriodDirectionSummaries = ({
1105
1267
  const result = {
1106
1268
  full: summarizeDirections(rows, selector, {
1107
1269
  ...summaryOptions,
1108
- calendarDays: getCalendarDays(rows),
1270
+ calendarDays: summaryOptions.comparisonWindow
1271
+ ? getCalendarDays([
1272
+ { timestamp: summaryOptions.comparisonWindow.start },
1273
+ { timestamp: maxTimestamp - 1 },
1274
+ ])
1275
+ : getCalendarDays(rows),
1109
1276
  }),
1110
1277
  };
1111
1278
  for (const days of windows) {
@@ -1113,7 +1280,13 @@ const buildPeriodDirectionSummaries = ({
1113
1280
  const periodRows = selectRows(rows, (row) => row.timestamp >= from);
1114
1281
  result[`${days}d`] = summarizeDirections(periodRows, selector, {
1115
1282
  ...summaryOptions,
1116
- calendarDays: getCalendarDays(periodRows),
1283
+ ...(summaryOptions.comparisonWindow ? { denominatorDays: days } : {}),
1284
+ calendarDays: summaryOptions.comparisonWindow
1285
+ ? getCalendarDays([
1286
+ { timestamp: from },
1287
+ { timestamp: maxTimestamp - 1 },
1288
+ ])
1289
+ : getCalendarDays(periodRows),
1117
1290
  });
1118
1291
  }
1119
1292
  return result;
@@ -1254,9 +1427,8 @@ export const loadStandaloneStrategyEntries = async (sourceRepositoryRoot) => {
1254
1427
  `Expected a standalone TradeJS strategy repository: ${sourceRepositoryRoot}`,
1255
1428
  );
1256
1429
  }
1257
- const { entrypoint, packageJson } = resolveStandaloneStrategyEntrypoint(
1258
- sourceRepositoryRoot,
1259
- );
1430
+ const { entrypoint, packageJson } =
1431
+ resolveStandaloneStrategyEntrypoint(sourceRepositoryRoot);
1260
1432
  let outputStat;
1261
1433
  try {
1262
1434
  outputStat = await fsp.stat(entrypoint);
@@ -1317,9 +1489,15 @@ export const ensureRuntimeBuild = async (frameworkRepositoryRoot) => {
1317
1489
  output: aiModulePath,
1318
1490
  sources: [
1319
1491
  path.join(frameworkRepositoryRoot, 'packages/node/src/ai.ts'),
1320
- path.join(frameworkRepositoryRoot, 'packages/node/src/aiMarketContext.ts'),
1492
+ path.join(
1493
+ frameworkRepositoryRoot,
1494
+ 'packages/node/src/aiMarketContext.ts',
1495
+ ),
1321
1496
  path.join(frameworkRepositoryRoot, 'packages/node/src/aiShared.ts'),
1322
- path.join(frameworkRepositoryRoot, 'packages/node/src/strategyAdapters'),
1497
+ path.join(
1498
+ frameworkRepositoryRoot,
1499
+ 'packages/node/src/strategyAdapters',
1500
+ ),
1323
1501
  ],
1324
1502
  command: 'yarn workspace @tradejs/node build',
1325
1503
  },
@@ -1372,9 +1550,8 @@ const loadResearchRows = async ({
1372
1550
  const require = createRequire(import.meta.url);
1373
1551
  const { collectAiPocketFeatures } = require(pocketModulePath);
1374
1552
  if (getSourceRepositoryKind(sourceRepositoryRoot) === 'strategy') {
1375
- const { strategyEntries } = await loadStandaloneStrategyEntries(
1376
- sourceRepositoryRoot,
1377
- );
1553
+ const { strategyEntries } =
1554
+ await loadStandaloneStrategyEntries(sourceRepositoryRoot);
1378
1555
  registryModule.resetStrategyRegistryCache(projectRoot);
1379
1556
  registryModule.registerStrategyEntries(strategyEntries, projectRoot);
1380
1557
  } else {
@@ -1430,6 +1607,7 @@ const loadResearchRows = async ({
1430
1607
  analysis.direction === source.direction &&
1431
1608
  quality != null &&
1432
1609
  quality >= minQuality,
1610
+ features,
1433
1611
  movingAverageSource: {
1434
1612
  provider: String(source.connectorName ?? '')
1435
1613
  .trim()
@@ -3841,6 +4019,8 @@ export const buildAblationReport = ({
3841
4019
  testSplit = 0,
3842
4020
  tuningSince = null,
3843
4021
  testSince = null,
4022
+ windowStart = null,
4023
+ windowEnd = null,
3844
4024
  capacities = DEFAULT_CAPACITIES,
3845
4025
  maxLossValue = null,
3846
4026
  filePaths,
@@ -3851,8 +4031,29 @@ export const buildAblationReport = ({
3851
4031
  featureInventory = [],
3852
4032
  }) => {
3853
4033
  if (!rows.length) throw new Error('No rows were evaluated');
3854
- const minTimestamp = rows[0].timestamp;
3855
- const maxTimestamp = rows.at(-1).timestamp;
4034
+ if ((windowStart == null) !== (windowEnd == null)) {
4035
+ throw new Error(
4036
+ 'Common comparison window requires both windowStart and windowEnd',
4037
+ );
4038
+ }
4039
+ const explicitWindow = windowStart != null;
4040
+ if (
4041
+ explicitWindow &&
4042
+ (!Number.isFinite(windowStart) ||
4043
+ !Number.isFinite(windowEnd) ||
4044
+ windowStart >= windowEnd)
4045
+ ) {
4046
+ throw new Error(
4047
+ 'Common comparison window must have finite increasing bounds',
4048
+ );
4049
+ }
4050
+ if (explicitWindow) {
4051
+ rows = rows.filter(
4052
+ (row) => row.timestamp >= windowStart && row.timestamp < windowEnd,
4053
+ );
4054
+ }
4055
+ const minTimestamp = windowStart ?? rows[0].timestamp;
4056
+ const maxTimestamp = windowEnd ?? rows.at(-1).timestamp;
3856
4057
  if ((tuningSince == null) !== (testSince == null)) {
3857
4058
  throw new Error(
3858
4059
  'Exact calendar partitions require both tuningSince and testSince',
@@ -3862,6 +4063,11 @@ export const buildAblationReport = ({
3862
4063
  const split = exactCalendarPartitions
3863
4064
  ? splitRowsByTimestampBounds(rows, tuningSince, testSince)
3864
4065
  : splitRowsByTimestamp(rows, validationSplit, testSplit);
4066
+ applyTimestampRotationPlacebos(rows, variants, [
4067
+ split.train,
4068
+ split.tuning,
4069
+ split.test,
4070
+ ]);
3865
4071
  const partitionEvidence = (partitionRows) => ({
3866
4072
  rows: partitionRows.length,
3867
4073
  events: new Set(partitionRows.map((row) => row.timestamp)).size,
@@ -3872,14 +4078,23 @@ export const buildAblationReport = ({
3872
4078
  ? new Date(partitionRows.at(-1).timestamp).toISOString()
3873
4079
  : null,
3874
4080
  });
3875
- const summaryOptions = { capacities, maxLossValue };
4081
+ const summaryOptions = {
4082
+ capacities,
4083
+ maxLossValue,
4084
+ ...(explicitWindow
4085
+ ? {
4086
+ comparisonWindow: { start: windowStart, end: windowEnd },
4087
+ denominatorDays: Math.max((windowEnd - windowStart) / DAY_MS, 1),
4088
+ }
4089
+ : {}),
4090
+ };
3876
4091
  const baselineSelector = (row) => baselineSelectedAt(row, minQuality);
3877
4092
  const baseline = {
3878
4093
  equity: buildEquitySeries(
3879
4094
  rows,
3880
4095
  baselineSelector,
3881
4096
  minTimestamp,
3882
- maxTimestamp,
4097
+ maxTimestamp - Number(explicitWindow),
3883
4098
  ),
3884
4099
  periods: buildPeriodSummaries({
3885
4100
  rows,
@@ -3913,8 +4128,17 @@ export const buildAblationReport = ({
3913
4128
  months: summarizeMonths(rows, baselineSelector, summaryOptions),
3914
4129
  };
3915
4130
  const variantReports = variants.map((variant, variantIndex) => {
3916
- const candidateSelector = (row) =>
3917
- candidateSelectedAt(row, variant, variantIndex, minQuality, minQuality);
4131
+ const candidateSelectorFor = (threshold) => {
4132
+ const baseSelector = (row) =>
4133
+ candidateSelectedAt(row, variant, variantIndex, threshold, minQuality);
4134
+ const selectedRows = selectRowsWithinCapacity({
4135
+ rows,
4136
+ selector: baseSelector,
4137
+ selection: variant.selection,
4138
+ });
4139
+ return (row) => selectedRows.has(row);
4140
+ };
4141
+ const candidateSelector = candidateSelectorFor(minQuality);
3918
4142
  const matchedSelector = (row) => row.variantMatches[variantIndex];
3919
4143
  const removedSelector = (row) =>
3920
4144
  baselineSelector(row) && !candidateSelector(row);
@@ -3926,11 +4150,13 @@ export const buildAblationReport = ({
3926
4150
  quality: variant.quality,
3927
4151
  direction: variant.direction,
3928
4152
  expression: variant.expression,
4153
+ selection: variant.selection ?? null,
4154
+ placebo: variant.placebo ?? null,
3929
4155
  equity: buildEquitySeries(
3930
4156
  rows,
3931
4157
  candidateSelector,
3932
4158
  minTimestamp,
3933
- maxTimestamp,
4159
+ maxTimestamp - Number(explicitWindow),
3934
4160
  ),
3935
4161
  periods: buildPeriodSummaries({
3936
4162
  rows,
@@ -3951,22 +4177,17 @@ export const buildAblationReport = ({
3951
4177
  tuning: summarizeSplit(split.tuning, candidateSelector, summaryOptions),
3952
4178
  test: summarizeSplit(split.test, candidateSelector, summaryOptions),
3953
4179
  qualityThresholds: Object.fromEntries(
3954
- qualityThresholds.map((threshold) => [
3955
- `q${threshold}+`,
3956
- summarizeRows(
3957
- selectRows(rows, (row) =>
3958
- candidateSelectedAt(
3959
- row,
3960
- variant,
3961
- variantIndex,
3962
- threshold,
3963
- minQuality,
3964
- ),
4180
+ qualityThresholds.map((threshold) => {
4181
+ const thresholdSelector = candidateSelectorFor(threshold);
4182
+ return [
4183
+ `q${threshold}+`,
4184
+ summarizeRows(
4185
+ selectRows(rows, thresholdSelector),
4186
+ getPeriodDays(rows),
4187
+ summaryOptions,
3965
4188
  ),
3966
- getPeriodDays(rows),
3967
- summaryOptions,
3968
- ),
3969
- ]),
4189
+ ];
4190
+ }),
3970
4191
  ),
3971
4192
  directions: summarizeDirections(rows, candidateSelector, summaryOptions),
3972
4193
  months: summarizeMonths(rows, candidateSelector, summaryOptions),
@@ -4003,6 +4224,9 @@ export const buildAblationReport = ({
4003
4224
  validationSplit,
4004
4225
  testSplit,
4005
4226
  partitionMode: exactCalendarPartitions ? 'exact-calendar' : 'ratio',
4227
+ comparisonWindow: explicitWindow
4228
+ ? { start: windowStart, end: windowEnd, interval: '[start, end)' }
4229
+ : null,
4006
4230
  tuningSince: exactCalendarPartitions
4007
4231
  ? new Date(tuningSince).toISOString()
4008
4232
  : null,
@@ -5021,9 +5245,8 @@ export const main = async () => {
5021
5245
  }
5022
5246
  const sourceRepositoryRoot = findSourceRepositoryRoot();
5023
5247
  const sourceRepositoryKind = getSourceRepositoryKind(sourceRepositoryRoot);
5024
- const frameworkRepositoryRoot = findFrameworkRepositoryRoot(
5025
- sourceRepositoryRoot,
5026
- );
5248
+ const frameworkRepositoryRoot =
5249
+ findFrameworkRepositoryRoot(sourceRepositoryRoot);
5027
5250
  if (options.crossStrategy) {
5028
5251
  if (options.tuningSince != null || options.testSince != null) {
5029
5252
  throw new Error(
@@ -5134,6 +5357,8 @@ export const main = async () => {
5134
5357
  testSplit: options.testSplit,
5135
5358
  tuningSince: options.tuningSince,
5136
5359
  testSince: options.testSince,
5360
+ windowStart: options.windowStart,
5361
+ windowEnd: options.windowEnd,
5137
5362
  capacities: options.capacities,
5138
5363
  maxLossValue: options.maxLossValue,
5139
5364
  sourceRepositoryRoot,
@@ -7,6 +7,7 @@ import test from 'node:test';
7
7
 
8
8
  import {
9
9
  aggregateBenchmarkDiscoveryRows,
10
+ applyTimestampRotationPlacebos,
10
11
  applyBenchmarkEventSnapshots,
11
12
  balanceCrossStrategyRows,
12
13
  buildAblationReport,
@@ -35,6 +36,7 @@ import {
35
36
  parseVariant,
36
37
  partitionCrossStrategyFeatures,
37
38
  resolveArtifactProjectRoot,
39
+ selectRowsWithinCapacity,
38
40
  splitRowsByTimestamp,
39
41
  splitRowsByTimestampBounds,
40
42
  summarizeRows,
@@ -959,6 +961,181 @@ test('applies filter, exclude, add, and replace selection semantics', () => {
959
961
  assert.equal(selected('replace', false, true), true);
960
962
  });
961
963
 
964
+ test('applies deterministic timestamp-local capacity ranking', () => {
965
+ const timestamp = Date.UTC(2026, 0, 1);
966
+ const rows = [
967
+ { timestamp, symbol: 'B', sequence: 0, features: { margin: 2 } },
968
+ { timestamp, symbol: 'A', sequence: 1, features: { margin: 2 } },
969
+ { timestamp, symbol: 'C', sequence: 2, features: { margin: 1 } },
970
+ { timestamp, symbol: 'D', sequence: 3, features: {} },
971
+ ];
972
+ const selected = selectRowsWithinCapacity({
973
+ rows,
974
+ selector: () => true,
975
+ selection: {
976
+ capacity: 2,
977
+ rankBy: [{ path: 'margin', order: 'desc' }],
978
+ },
979
+ });
980
+
981
+ assert.deepEqual(
982
+ rows.filter((row) => selected.has(row)).map((row) => row.symbol),
983
+ ['B', 'A'],
984
+ );
985
+ });
986
+
987
+ test('applies variant capacity before every report slice', () => {
988
+ const timestamp = Date.UTC(2026, 0, 1);
989
+ const variant = parseVariant('ranked::replace@4::true');
990
+ variant.selection = {
991
+ capacity: 2,
992
+ rankBy: [{ path: 'margin', order: 'desc' }],
993
+ };
994
+ const rows = [1, 3, 2].map((margin, sequence) => ({
995
+ timestamp,
996
+ profit: margin,
997
+ symbol: `S${sequence}`,
998
+ direction: 'SHORT',
999
+ directionMatches: true,
1000
+ quality: 4,
1001
+ variantMatches: [true],
1002
+ features: { margin },
1003
+ sequence,
1004
+ }));
1005
+ const report = buildAblationReport({
1006
+ rows,
1007
+ variants: [variant],
1008
+ minQuality: 4,
1009
+ qualityThresholds: [4],
1010
+ terminalWindows: [7],
1011
+ validationSplit: 0,
1012
+ filePaths: ['part1.jsonl'],
1013
+ });
1014
+
1015
+ assert.equal(report.variants[0].periods.full.trades, 2);
1016
+ assert.equal(report.variants[0].periods.full.totalProfit, 5);
1017
+ assert.equal(report.variants[0].train.trades, 2);
1018
+ assert.deepEqual(report.variants[0].selection, variant.selection);
1019
+ });
1020
+
1021
+ test('uses common half-open comparison bounds for sparse gate exports', () => {
1022
+ const start = Date.UTC(2025, 0, 1);
1023
+ const day = 86400000;
1024
+ const end = start + 365 * day;
1025
+ const rows = [-1, 0, 100, 365].map((offset, sequence) => ({
1026
+ timestamp: start + offset * day,
1027
+ profit: 10,
1028
+ symbol: 'A',
1029
+ direction: 'LONG',
1030
+ directionMatches: true,
1031
+ quality: 4,
1032
+ variantMatches: [true],
1033
+ sequence,
1034
+ }));
1035
+ const args = {
1036
+ rows,
1037
+ variants: [parseVariant('pass::replace@4::true')],
1038
+ minQuality: 4,
1039
+ qualityThresholds: [4],
1040
+ terminalWindows: [30, 7],
1041
+ validationSplit: 0,
1042
+ filePaths: ['fixture.jsonl'],
1043
+ windowStart: start,
1044
+ windowEnd: end,
1045
+ };
1046
+ const report = buildAblationReport(args);
1047
+ assert.equal(report.run.rows, 2);
1048
+ assert.equal(report.variants[0].periods.full.totalProfit, 20);
1049
+ assert.equal(report.variants[0].periods.full.cadencePerDay, 2 / 365);
1050
+ assert.equal(
1051
+ report.variants[0].periodDirections.full.LONG.cadencePerDay,
1052
+ 2 / 365,
1053
+ );
1054
+ assert.equal(report.variants[0].periods['30d'].trades, 0);
1055
+ assert.deepEqual(report.variants[0].equity.at(-1), [end - 1, 20]);
1056
+ assert.throws(
1057
+ () => buildAblationReport({ ...args, windowEnd: null }),
1058
+ /both windowStart/,
1059
+ );
1060
+ assert.throws(
1061
+ () => buildAblationReport({ ...args, windowEnd: start }),
1062
+ /increasing bounds/,
1063
+ );
1064
+ const empty = buildAblationReport({
1065
+ ...args,
1066
+ windowStart: end + day,
1067
+ windowEnd: end + 2 * day,
1068
+ });
1069
+ assert.equal(empty.variants[0].periods.full.trades, 0);
1070
+ assert.equal(empty.variants[0].periods.full.totalProfit, 0);
1071
+ const parsed = parseCliArgs([
1072
+ '--windowStart',
1073
+ String(start),
1074
+ '--windowEnd',
1075
+ new Date(end).toISOString(),
1076
+ ]);
1077
+ assert.equal(parsed.windowStart, start);
1078
+ assert.equal(parsed.windowEnd, end);
1079
+ });
1080
+
1081
+ test('rotates placebo approval timestamps while preserving event count', () => {
1082
+ const variants = [
1083
+ { name: 'reference' },
1084
+ {
1085
+ name: 'rotated',
1086
+ placebo: {
1087
+ type: 'timestamp-rotation',
1088
+ referenceVariant: 'reference',
1089
+ offsetEvents: 1,
1090
+ },
1091
+ },
1092
+ ];
1093
+ const rows = [0, 1, 2, 3].map((index) => ({
1094
+ timestamp: Date.UTC(2026, 0, index + 1),
1095
+ variantMatches: [index === 0 || index === 2, true],
1096
+ }));
1097
+
1098
+ applyTimestampRotationPlacebos(rows, variants);
1099
+
1100
+ assert.deepEqual(
1101
+ rows.filter((row) => row.variantMatches[1]).map((row) => row.timestamp),
1102
+ [Date.UTC(2026, 0, 2), Date.UTC(2026, 0, 4)],
1103
+ );
1104
+ });
1105
+
1106
+ test('preserves timestamp-rotation event count inside each supplied partition', () => {
1107
+ const variants = [
1108
+ { name: 'reference' },
1109
+ {
1110
+ name: 'rotated',
1111
+ placebo: {
1112
+ type: 'timestamp-rotation',
1113
+ referenceVariant: 'reference',
1114
+ offsetEvents: 1,
1115
+ },
1116
+ },
1117
+ ];
1118
+ const rows = [0, 1, 2, 3, 4, 5].map((index) => ({
1119
+ timestamp: Date.UTC(2026, 0, index + 1),
1120
+ variantMatches: [[0, 2, 3, 5].includes(index), true],
1121
+ }));
1122
+ const partitions = [rows.slice(0, 3), rows.slice(3)];
1123
+
1124
+ applyTimestampRotationPlacebos(rows, variants, partitions);
1125
+
1126
+ assert.deepEqual(
1127
+ partitions.map(
1128
+ (partition) =>
1129
+ new Set(
1130
+ partition
1131
+ .filter((row) => row.variantMatches[1])
1132
+ .map((row) => row.timestamp),
1133
+ ).size,
1134
+ ),
1135
+ [2, 2],
1136
+ );
1137
+ });
1138
+
962
1139
  test('calculates required profit, drawdown, strict-loss, and cadence metrics', () => {
963
1140
  const rows = [
964
1141
  {
@@ -1067,12 +1244,18 @@ test('uses exact calendar boundaries without splitting timestamp events', () =>
1067
1244
 
1068
1245
  const split = splitRowsByTimestampBounds(rows, tuning, testStart);
1069
1246
 
1070
- assert.deepEqual(split.train.map((row) => row.id), ['train']);
1071
- assert.deepEqual(split.tuning.map((row) => row.id), [
1072
- 'tuning-a',
1073
- 'tuning-b',
1074
- ]);
1075
- assert.deepEqual(split.test.map((row) => row.id), ['test']);
1247
+ assert.deepEqual(
1248
+ split.train.map((row) => row.id),
1249
+ ['train'],
1250
+ );
1251
+ assert.deepEqual(
1252
+ split.tuning.map((row) => row.id),
1253
+ ['tuning-a', 'tuning-b'],
1254
+ );
1255
+ assert.deepEqual(
1256
+ split.test.map((row) => row.id),
1257
+ ['test'],
1258
+ );
1076
1259
  });
1077
1260
 
1078
1261
  test('builds timestamp-grouped cumulative equity with common endpoints', () => {
@@ -480,7 +480,7 @@ const dashboardSvg = (board) => {
480
480
  const terminalLegend = terminalSeries
481
481
  .map((candidate, index) => {
482
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>`;
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, Math.floor((terminalLegendWidth - 35) / 9)))}</text></g>`;
484
484
  })
485
485
  .join('');
486
486
  const pnlValues = terminalSeries.flatMap(({ terminal }) =>
@@ -556,18 +556,18 @@ const dashboardSvg = (board) => {
556
556
  const x = sx(candidate.metrics.maxDrawdown);
557
557
  const y = sy(candidate.metrics.pnl);
558
558
  const selectedPoint = candidate.id === selected.id;
559
- const labelY = y + ((index % 3) - 1) * 24;
559
+ const labelY = y + 5;
560
560
  const labelOnLeft = x > scatter.x + scatter.width * 0.66;
561
561
  const labelX = x + (labelOnLeft ? -14 : 14);
562
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>`;
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>`;
564
564
  })
565
565
  .join('');
566
566
 
567
567
  const limitations = board.limitations.length
568
568
  ? `Limitations: ${board.limitations.join(' · ')}`
569
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>`;
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>`;
571
571
  };
572
572
 
573
573
  const downsample = (points, maxPoints = 1200) => {
@@ -621,9 +621,7 @@ const equitySvg = (board) => {
621
621
  }).join('');
622
622
  const spanDays = (maxTime - minTime) / 86_400_000;
623
623
  const xTicks = Array.from({ length: 6 }, (_, index) => {
624
- const timestamp = Math.round(
625
- minTime + ((maxTime - minTime) * index) / 5,
626
- );
624
+ const timestamp = Math.round(minTime + ((maxTime - minTime) * index) / 5);
627
625
  const date = new Date(timestamp);
628
626
  const label =
629
627
  spanDays >= 730
@@ -650,6 +648,9 @@ const equitySvg = (board) => {
650
648
  const curves = board.candidates
651
649
  .map((candidate) => {
652
650
  const points = downsample(candidate.equity)
651
+ .flatMap((point, index, series) =>
652
+ index === 0 ? [point] : [[point[0], series[index - 1][1]], point],
653
+ )
653
654
  .map(
654
655
  ([timestamp, pnl]) =>
655
656
  `${x(timestamp).toFixed(1)},${y(pnl).toFixed(1)}`,
@@ -667,11 +668,11 @@ const equitySvg = (board) => {
667
668
  const row = Math.floor(index / columns);
668
669
  const lx = left + column * legendWidth;
669
670
  const ly = height - bottom + 110 + row * 58;
670
- 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>`;
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>`;
671
672
  })
672
673
  .join('');
673
674
  const selected = board.candidates.find(({ id }) => id === board.selectedId);
674
- 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="${left + plotWidth / 2}" y="${height - bottom + 67}" text-anchor="middle" class="axisTitle">Exit date (UTC)</text><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>`;
675
+ 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 = current gate behavior; 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="${left + plotWidth / 2}" y="${height - bottom + 67}" text-anchor="middle" class="axisTitle">Date (UTC)</text><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>`;
675
676
  };
676
677
 
677
678
  const verifyCandidateArtifacts = async (board, artifactRoot) => {
@@ -174,9 +174,23 @@ test('verifies candidate artifacts and renders the dashboard and equity board',
174
174
  );
175
175
  assert.match(dashboard, /PnL in terminal windows/u);
176
176
  assert.match(dashboard, /Final compositions: PnL ↔ drawdown/u);
177
- assert.match(equity, /production core \+ current AI-gate/u);
177
+ assert.match(equity, /Baseline = current gate behavior/u);
178
178
  assert.match(equity, /candidate \+ own gate/u);
179
- assert.match(equity, /Exit date \(UTC\)/u);
179
+ assert.match(equity, /Date \(UTC\)/u);
180
+ for (const [, coordinates] of equity.matchAll(
181
+ /<polyline points="([^"]+)"/gu,
182
+ )) {
183
+ const points = coordinates
184
+ .split(' ')
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
+ }
193
+ }
180
194
  assert.match(equity, /Nov 2023/u);
181
195
  assert.match(equity, /Mar 2024/u);
182
196
  });
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema": "tradejs-skill-bundle/v1",
3
3
  "source": "TradeJS-Dev/TradeJS:.codex/skills",
4
- "bundleSha256": "80078cbf06c8239edffb266a36db3a9b7127000a26001381f7fa99b92aa3e4eb",
4
+ "bundleSha256": "be995341ae9459133ad1bb60da5c8fac01c5ffbe6d72309c1d68448abbe03c0e",
5
5
  "skills": [
6
6
  "ai-train-local-research",
7
7
  "backtest-config-redis",
@@ -19,10 +19,10 @@
19
19
  "strategy-release"
20
20
  ],
21
21
  "files": {
22
- ".codex/skills/ai-train-local-research/references/gate-ablation.md": "a1011bf0c9fcccf1692983364da168a1bac6d044b24ed3bfd7cb44e4b0ca9ed5",
22
+ ".codex/skills/ai-train-local-research/references/gate-ablation.md": "a32ca7272a74678aca97dfce6b098ed15ca41fc385b936f25492f47bce4ec621",
23
23
  ".codex/skills/ai-train-local-research/references/reporting.md": "a5a5cc6438a8cc228560e302e944f1ff320f4c963988af83e1ba1443728f7149",
24
- ".codex/skills/ai-train-local-research/scripts/ai-gate-ablation.mjs": "494dea5fc2ef02e4ab07a9aa82b8d531e576975b12bb0723f6fa0403db2e3a56",
25
- ".codex/skills/ai-train-local-research/scripts/ai-gate-ablation.test.mjs": "c7e61f5f1a607e88a9e371fade9822fa68503575cbc718c7d437b0d681691ed8",
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",
26
26
  ".codex/skills/ai-train-local-research/SKILL.md": "479e44aad9cb6ab6b5c8931c807c63cf2a1938a59d01f553c20f44645716a23a",
27
27
  ".codex/skills/backtest-config-redis/scripts/get_backtest_config.sh": "833e950d6348c5b7a4bd3f00d25af60f6394190bff744bd5ab64db9e43d826d5",
28
28
  ".codex/skills/backtest-config-redis/SKILL.md": "85e7b1fa425aa23dfa7950de05c1d3ce046d044dc3872f3665705e9e2f2a1977",
@@ -44,8 +44,8 @@
44
44
  ".codex/skills/strategy-improvement-research/references/final-composition-board.md": "05b49ea4aba85792ccea0aeaf4ee2637b808157962f0a38631d6468c342349d6",
45
45
  ".codex/skills/strategy-improvement-research/scripts/build-final-composition-spec.mjs": "b51873691fafbcf2dd70d8c9a6da842fec5f8358346c632cfe79211db08b18ba",
46
46
  ".codex/skills/strategy-improvement-research/scripts/build-final-composition-spec.test.mjs": "85d06b8fe09ee7611a3fb989173c361ba32f0e9364422925ff221bfe127b2bfa",
47
- ".codex/skills/strategy-improvement-research/scripts/final-composition-board.mjs": "759ed89f0afcfdff8c710a13d907b959f9bce648d185a15d16034b898656deda",
48
- ".codex/skills/strategy-improvement-research/scripts/final-composition-board.test.mjs": "9bcb15f7a53ca9c55a1e45fc80f6030135952c69cc71179a18f0a714e21be38c",
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",
49
49
  ".codex/skills/strategy-improvement-research/scripts/freeze-gate-variants.mjs": "17b3c868880ddd5b326d309e6e42eaeef1ea22d625a5db947997ac73b221ad6d",
50
50
  ".codex/skills/strategy-improvement-research/scripts/freeze-gate-variants.test.mjs": "963e1c64ab3deef11fd48a96e10f3827af38036dfe2eb28daf7849f19dd414d8",
51
51
  ".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.27",
3
+ "version": "3.1.28-beta.252",
4
4
  "description": "Create a ready-to-run TradeJS project with local infrastructure and the Web UI.",
5
5
  "keywords": [
6
6
  "tradejs",