@tradejs/cli 2.0.20 → 3.0.0

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.
@@ -20,7 +20,9 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/lib/aiTrainOptions.ts
21
21
  var aiTrainOptions_exports = {};
22
22
  __export(aiTrainOptions_exports, {
23
+ assertAiTrainDirectionPolicy: () => assertAiTrainDirectionPolicy,
23
24
  hasCliOption: () => hasCliOption,
25
+ parseAiTrainDirectionPolicy: () => parseAiTrainDirectionPolicy,
24
26
  parseDumpFeatureMode: () => parseDumpFeatureMode,
25
27
  parseQualityThresholds: () => parseQualityThresholds,
26
28
  parseTerminalWindowDays: () => parseTerminalWindowDays,
@@ -81,6 +83,38 @@ var parseTerminalWindowDays = (value) => {
81
83
  const raw = String(value ?? "90,30,7").split(",").map((part) => Number(part.trim())).filter((part) => Number.isFinite(part)).map((part) => Math.trunc(part)).filter((part) => part > 0);
82
84
  return [...new Set(raw)].sort((left, right) => right - left);
83
85
  };
86
+ var parseAiTrainDirectionPolicy = (value) => {
87
+ const normalized = String(value ?? "both").trim().toLowerCase().replaceAll("-", "_");
88
+ if (normalized === "both" || normalized === "long_only" || normalized === "short_only" || normalized === "direction_aware") {
89
+ return normalized;
90
+ }
91
+ throw new Error(
92
+ `Invalid --directionPolicy value "${String(value)}". Use both, long_only, short_only, or direction_aware.`
93
+ );
94
+ };
95
+ var assertAiTrainDirectionPolicy = ({
96
+ directionPolicy,
97
+ byDirection,
98
+ terminalWindows
99
+ }) => {
100
+ const suppressedDirection = directionPolicy === "long_only" ? "SHORT" : directionPolicy === "short_only" ? "LONG" : null;
101
+ if (!suppressedDirection) return;
102
+ for (const scope of [{ label: "full", byDirection }, ...terminalWindows]) {
103
+ const summary = scope.byDirection.find(
104
+ (entry) => entry.direction === suppressedDirection
105
+ );
106
+ if (!summary) {
107
+ throw new Error(
108
+ `${scope.label} ${suppressedDirection} direction summary is required for ${directionPolicy}`
109
+ );
110
+ }
111
+ if (summary.summary.approved !== 0) {
112
+ throw new Error(
113
+ `${scope.label} has ${summary.summary.approved} ${suppressedDirection} approved rows under ${directionPolicy}`
114
+ );
115
+ }
116
+ }
117
+ };
84
118
  var parseDumpFeatureMode = (value) => {
85
119
  const text = String(value ?? "none").trim().toLowerCase();
86
120
  if (!text || text === "none" || text === "false" || text === "0") {
@@ -120,7 +154,9 @@ var resolveAiTrainRecentLimit = ({
120
154
  };
121
155
  // Annotate the CommonJS export names for ESM import in node:
122
156
  0 && (module.exports = {
157
+ assertAiTrainDirectionPolicy,
123
158
  hasCliOption,
159
+ parseAiTrainDirectionPolicy,
124
160
  parseDumpFeatureMode,
125
161
  parseQualityThresholds,
126
162
  parseTerminalWindowDays,
@@ -1513,6 +1513,7 @@ var normalizeCompletedTrade = (params) => {
1513
1513
  runId: row.backtestRunId ?? null,
1514
1514
  configId: row.configId?.trim() || "<missing-config-id>",
1515
1515
  signalId,
1516
+ positionCycleId: typeof result.positionCycleId === "string" && result.positionCycleId.trim() ? result.positionCycleId.trim() : null,
1516
1517
  setupIdentity,
1517
1518
  setupIdentitySource: explicitIdentity ? "research.setupIdentity" : strategyIdentity ? "strategy-context" : "signal-time-fallback",
1518
1519
  strategy: row.strategyName,
@@ -1536,6 +1537,69 @@ var normalizeCompletedTrade = (params) => {
1536
1537
  };
1537
1538
  };
1538
1539
  var completedTradeIdentity = (trade) => [trade.runId ?? "", trade.configId, trade.signalId, trade.symbol].join("|");
1540
+ var positionCycleIdentity = (trade) => trade.positionCycleId ? [
1541
+ trade.runId ?? "",
1542
+ trade.configId,
1543
+ trade.strategy,
1544
+ trade.symbol,
1545
+ trade.direction,
1546
+ trade.positionCycleId
1547
+ ].join("|") : null;
1548
+ var sumBy = (trades, select) => trades.reduce((sum, trade) => sum + select(trade), 0);
1549
+ var weightedPrice = (trades, select) => {
1550
+ const withPrice = trades.filter((trade) => select(trade) != null);
1551
+ const qty = sumBy(withPrice, (trade) => trade.qty);
1552
+ if (qty <= 0) return null;
1553
+ return sumBy(withPrice, (trade) => Number(select(trade)) * trade.qty) / qty;
1554
+ };
1555
+ var aggregatePositionCycles = (trades) => {
1556
+ const standalone = [];
1557
+ const cycles = /* @__PURE__ */ new Map();
1558
+ for (const trade of trades) {
1559
+ const identity = positionCycleIdentity(trade);
1560
+ if (!identity) {
1561
+ standalone.push(trade);
1562
+ continue;
1563
+ }
1564
+ const cycle = cycles.get(identity) ?? [];
1565
+ cycle.push(trade);
1566
+ cycles.set(identity, cycle);
1567
+ }
1568
+ for (const [identity, cycle] of cycles) {
1569
+ const primaryRows = cycle.filter(
1570
+ (trade) => trade.signalId === trade.positionCycleId
1571
+ );
1572
+ if (primaryRows.length !== 1) {
1573
+ throw new Error(
1574
+ `Position cycle ${identity} must contain exactly one opening row; found ${primaryRows.length}`
1575
+ );
1576
+ }
1577
+ const primary = primaryRows[0];
1578
+ if (cycle.some(
1579
+ (trade) => trade.exitTimestamp !== primary.exitTimestamp || trade.exitReason !== primary.exitReason
1580
+ )) {
1581
+ throw new Error(
1582
+ `Position cycle ${identity} contains inconsistent final exits`
1583
+ );
1584
+ }
1585
+ if (cycle.length === 1) {
1586
+ standalone.push(primary);
1587
+ continue;
1588
+ }
1589
+ standalone.push({
1590
+ ...primary,
1591
+ entryTimestamp: Math.min(...cycle.map((trade) => trade.entryTimestamp)),
1592
+ entryPrice: weightedPrice(cycle, (trade) => trade.entryPrice) ?? 0,
1593
+ exitPrice: weightedPrice(cycle, (trade) => trade.exitPrice),
1594
+ qty: sumBy(cycle, (trade) => trade.qty),
1595
+ netProfit: sumBy(cycle, (trade) => trade.netProfit),
1596
+ grossProfit: sumBy(cycle, (trade) => trade.grossProfit),
1597
+ totalFee: sumBy(cycle, (trade) => trade.totalFee),
1598
+ totalSlippageCost: sumBy(cycle, (trade) => trade.totalSlippageCost)
1599
+ });
1600
+ }
1601
+ return standalone;
1602
+ };
1539
1603
  var compareText = (left, right) => left < right ? -1 : left > right ? 1 : 0;
1540
1604
  var compareTrades = (left, right) => left.exitTimestamp - right.exitTimestamp || compareText(left.symbol, right.symbol) || compareText(left.direction, right.direction) || compareText(left.signalId, right.signalId) || compareText(left.sourceFile, right.sourceFile) || left.sourceLine - right.sourceLine;
1541
1605
  var readCoreResearchVariant = async (variant) => {
@@ -1612,7 +1676,7 @@ var readCoreResearchVariant = async (variant) => {
1612
1676
  variant,
1613
1677
  files: sources,
1614
1678
  duplicateRowsDropped,
1615
- trades: trades.sort(compareTrades)
1679
+ trades: aggregatePositionCycles(trades).sort(compareTrades)
1616
1680
  };
1617
1681
  };
1618
1682
 
@@ -2093,13 +2157,17 @@ var analyzeCoreResearchPrepared = async (spec, options = {}) => {
2093
2157
  const loaded = await Promise.all(
2094
2158
  analysisSpec.variants.map(readCoreResearchVariant)
2095
2159
  );
2160
+ const periodDays = (spec.window.end - spec.window.start) / DAY_MS;
2096
2161
  const windowed = loaded.map((entry) => ({
2097
2162
  ...entry,
2163
+ reconciliationMetrics: summarizeCoreResearchCohorts(
2164
+ entry.trades,
2165
+ periodDays
2166
+ ).ALL,
2098
2167
  trades: entry.trades.filter(
2099
2168
  (trade) => trade.exitTimestamp >= spec.window.start && trade.exitTimestamp < spec.window.end
2100
2169
  )
2101
2170
  }));
2102
- const periodDays = (spec.window.end - spec.window.start) / DAY_MS;
2103
2171
  const analyses = await Promise.all(
2104
2172
  windowed.map(async (entry) => ({
2105
2173
  variant: entry.variant,
@@ -2157,12 +2225,9 @@ var analyzeCoreResearchPrepared = async (spec, options = {}) => {
2157
2225
  reconciliation: await reconcileCoreResearchVariant({
2158
2226
  variant: entry.variant,
2159
2227
  spec,
2160
- exportMetrics: summarizeCoreResearchWindow({
2161
- trades: entry.trades,
2162
- label: "full",
2163
- start: spec.window.start,
2164
- end: spec.window.end
2165
- }).cohorts.ALL
2228
+ // Redis result.stat includes the runner's terminal close at window.end.
2229
+ // Keep half-open research metrics, but reconcile the complete run export.
2230
+ exportMetrics: entry.reconciliationMetrics
2166
2231
  }),
2167
2232
  supplemental: {
2168
2233
  coldStart: Object.fromEntries(
@@ -43,6 +43,7 @@ var import_promises2 = __toESM(require("fs/promises"));
43
43
  var import_node_path2 = __toESM(require("path"));
44
44
  var import_types = require("@tradejs/types");
45
45
  var import_strategies = require("@tradejs/node/strategies");
46
+ var import_strategyReleaseEvidence = require("@tradejs/infra/strategyReleaseEvidence");
46
47
 
47
48
  // src/lib/strategyGateFingerprint.ts
48
49
  var import_node_crypto = require("crypto");
@@ -137,17 +138,6 @@ var normalizeForStableJson = (value) => {
137
138
  }
138
139
  return value;
139
140
  };
140
- var normalizeRuntimeLogicConfig = (value) => {
141
- if (Array.isArray(value)) {
142
- return value.map(normalizeRuntimeLogicConfig);
143
- }
144
- if (value && typeof value === "object") {
145
- return Object.fromEntries(
146
- Object.entries(value).filter(([key]) => key !== "MAX_LOSS_VALUE").sort(([left], [right]) => left.localeCompare(right)).map(([key, entry]) => [key, normalizeRuntimeLogicConfig(entry)])
147
- );
148
- }
149
- return value;
150
- };
151
141
  var resolveRuntimeMaxLossValue = (config) => {
152
142
  if (!config || typeof config !== "object" || Array.isArray(config)) {
153
143
  return null;
@@ -311,9 +301,7 @@ var buildRuntimeLineage = async ({
311
301
  })).gateFingerprint,
312
302
  // Position risk is tracked separately so changing trade size does not
313
303
  // masquerade as a change to the core + gate decision logic.
314
- configFingerprint: fingerprintRuntimeValue(
315
- normalizeRuntimeLogicConfig(config)
316
- ),
304
+ configFingerprint: (0, import_strategyReleaseEvidence.strategyLogicConfigFingerprint)(config),
317
305
  contextFingerprint: fingerprintRuntimeValue(context),
318
306
  maxLossValue: resolveRuntimeMaxLossValue(config)
319
307
  };
@@ -81,6 +81,12 @@ var hasExactRuntimeTarget = (target) => Boolean(
81
81
  );
82
82
  var deriveStrategyReleaseResearchDecision = async (input) => {
83
83
  const blockers = [];
84
+ const directionPolicy = input.directionPolicy ?? "both";
85
+ const activeDirections = directionPolicy === "long_only" ? ["long"] : directionPolicy === "short_only" ? ["short"] : ["long", "short"];
86
+ const suppressedDirection = directionPolicy === "long_only" ? "short" : directionPolicy === "short_only" ? "long" : null;
87
+ const hasHistoricalEdge = (entry) => entry.pnl > 0 && entry.profitFactor >= 1 && activeDirections.every(
88
+ (direction) => entry[direction].pnl > 0 && entry[direction].profitFactor >= 1
89
+ ) && (suppressedDirection == null || entry[suppressedDirection].trades === 0 && entry[suppressedDirection].pnl === 0);
84
90
  const byDays = new Map(
85
91
  input.historicalWindows.map((entry) => [entry.days, entry])
86
92
  );
@@ -93,17 +99,13 @@ var deriveStrategyReleaseResearchDecision = async (input) => {
93
99
  const longWindows = REQUIRED_LONG_WINDOWS.map(
94
100
  (days) => byDays.get(days)
95
101
  ).filter((entry) => Boolean(entry));
96
- if (longWindows.some(
97
- (entry) => entry.pnl <= 0 || entry.profitFactor < 1 || entry.long.pnl <= 0 || entry.long.profitFactor < 1 || entry.short.pnl <= 0 || entry.short.profitFactor < 1
98
- )) {
102
+ if (longWindows.some((entry) => !hasHistoricalEdge(entry))) {
99
103
  blockers.push("HISTORICAL_EDGE_FAILED");
100
104
  }
101
105
  const stableTerminalWindows = REQUIRED_STABLE_TERMINAL_WINDOWS.map(
102
106
  (days) => byDays.get(days)
103
107
  ).filter((entry) => Boolean(entry));
104
- if (stableTerminalWindows.some(
105
- (entry) => entry.pnl <= 0 || entry.profitFactor < 1 || entry.long.pnl <= 0 || entry.long.profitFactor < 1 || entry.short.pnl <= 0 || entry.short.profitFactor < 1
106
- )) {
108
+ if (stableTerminalWindows.some((entry) => !hasHistoricalEdge(entry))) {
107
109
  blockers.push("HISTORICAL_EDGE_FAILED");
108
110
  }
109
111
  if (blockers.length) {
@@ -113,8 +115,9 @@ var deriveStrategyReleaseResearchDecision = async (input) => {
113
115
  repairAllowed: false,
114
116
  targetDirection: input.recentFailure?.direction ?? null,
115
117
  maxLossValue: null,
118
+ requiresRuntimeBinding: false,
116
119
  blockers,
117
- summary: "The final composition does not have complete positive ALL/LONG/SHORT evidence on the frozen 3y/4y/max-window matrix."
120
+ summary: `The final ${directionPolicy} composition does not have complete positive evidence for ALL and every active direction on the frozen 3y/4y/max-window matrix.`
118
121
  };
119
122
  }
120
123
  const repairAllowed = Boolean(
@@ -127,6 +130,7 @@ var deriveStrategyReleaseResearchDecision = async (input) => {
127
130
  repairAllowed: true,
128
131
  targetDirection: input.recentFailure.direction,
129
132
  maxLossValue: null,
133
+ requiresRuntimeBinding: false,
130
134
  blockers: [],
131
135
  summary: "Spend the single terminal-direction repair round on the preregistered causal mechanism, then rebuild the full evidence matrix."
132
136
  };
@@ -145,6 +149,7 @@ var deriveStrategyReleaseResearchDecision = async (input) => {
145
149
  repairAllowed: false,
146
150
  targetDirection: input.recentFailure?.direction ?? null,
147
151
  maxLossValue: null,
152
+ requiresRuntimeBinding: false,
148
153
  blockers,
149
154
  summary: "Complete the frozen candidate implementation and full-period chart before any micro-forward execution."
150
155
  };
@@ -156,6 +161,9 @@ var deriveStrategyReleaseResearchDecision = async (input) => {
156
161
  repairAllowed: false,
157
162
  targetDirection: input.recentFailure?.direction ?? null,
158
163
  maxLossValue: 1,
164
+ requiresRuntimeBinding: !hasExactRuntimeTarget(
165
+ input.forwardTest.runtimeTarget
166
+ ),
159
167
  blockers: ["FORWARD_NOT_AUTHORIZED"],
160
168
  summary: "The candidate is ready for a separately authorized micro-forward test at MAX_LOSS_VALUE=1."
161
169
  };
@@ -163,12 +171,13 @@ var deriveStrategyReleaseResearchDecision = async (input) => {
163
171
  if (!hasExactRuntimeTarget(input.forwardTest.runtimeTarget)) {
164
172
  return {
165
173
  strategy: input.strategy,
166
- action: "FORWARD_BLOCKED",
174
+ action: "MICRO_FORWARD_READY",
167
175
  repairAllowed: false,
168
176
  targetDirection: input.recentFailure?.direction ?? null,
169
177
  maxLossValue: 1,
170
- blockers: ["RUNTIME_TARGET_UNRESOLVED"],
171
- summary: "Forward testing is authorized, but an exact runtime deployment/account target must be resolved first."
178
+ requiresRuntimeBinding: true,
179
+ blockers: [],
180
+ summary: "The portable micro-forward handoff is ready. Bind it on the runtime server to that server's deployment/account ids and verify canonical composition fingerprints there."
172
181
  };
173
182
  }
174
183
  return {
@@ -177,6 +186,7 @@ var deriveStrategyReleaseResearchDecision = async (input) => {
177
186
  repairAllowed: false,
178
187
  targetDirection: input.recentFailure?.direction ?? null,
179
188
  maxLossValue: 1,
189
+ requiresRuntimeBinding: false,
180
190
  blockers: [],
181
191
  summary: "Start the exact frozen composition as a micro-forward test at MAX_LOSS_VALUE=1; do not retune an underpowered or exposed recent tail."
182
192
  };
@@ -234,6 +244,7 @@ var deriveReleaseGates = (evidence) => {
234
244
  };
235
245
  };
236
246
  var verifyEvidenceComposition = (strategy, composition, evidence) => {
247
+ const directionPolicy = composition.directionPolicy ?? "both";
237
248
  const requireEqual = (actual, expected, label, artifactId) => assert(
238
249
  actual === expected,
239
250
  `evidence ${artifactId} ${label} does not match the frozen composition`
@@ -304,6 +315,14 @@ var verifyEvidenceComposition = (strategy, composition, evidence) => {
304
315
  "gateContextFingerprint",
305
316
  reference.artifactId
306
317
  );
318
+ if (directionPolicy !== "both" || lineage?.directionPolicy != null) {
319
+ requireEqual(
320
+ lineage?.directionPolicy,
321
+ directionPolicy,
322
+ "directionPolicy",
323
+ reference.artifactId
324
+ );
325
+ }
307
326
  assert(
308
327
  lineage?.sourceSha256s.includes(composition.coreExportSha256),
309
328
  `evidence ${reference.artifactId} does not use the frozen core export`
@@ -348,6 +367,14 @@ var verifyEvidenceComposition = (strategy, composition, evidence) => {
348
367
  "MAX_LOSS_VALUE",
349
368
  reference.artifactId
350
369
  );
370
+ if (directionPolicy !== "both" || lineage?.directionPolicy != null) {
371
+ requireEqual(
372
+ lineage?.directionPolicy,
373
+ directionPolicy,
374
+ "directionPolicy",
375
+ reference.artifactId
376
+ );
377
+ }
351
378
  continue;
352
379
  }
353
380
  if (reference.kind === "execution_calibration") {
@@ -388,6 +415,14 @@ var verifyEvidenceComposition = (strategy, composition, evidence) => {
388
415
  "MAX_LOSS_VALUE",
389
416
  reference.artifactId
390
417
  );
418
+ if (directionPolicy !== "both" || lineage?.directionPolicy != null) {
419
+ requireEqual(
420
+ lineage?.directionPolicy,
421
+ directionPolicy,
422
+ "directionPolicy",
423
+ reference.artifactId
424
+ );
425
+ }
391
426
  }
392
427
  }
393
428
  };
@@ -423,6 +458,12 @@ var createStrategyReleaseManifest = (input) => {
423
458
  input.composition.longEnabled && input.composition.shortEnabled,
424
459
  "LONG and SHORT must both remain enabled"
425
460
  );
461
+ assert(
462
+ input.composition.directionPolicy == null || ["both", "long_only", "short_only", "direction_aware"].includes(
463
+ input.composition.directionPolicy
464
+ ),
465
+ "composition.directionPolicy is invalid"
466
+ );
426
467
  assert(
427
468
  Number.isInteger(input.monitoring.minimumProspectiveClosedTrades) && input.monitoring.minimumProspectiveClosedTrades > 0,
428
469
  "monitoring.minimumProspectiveClosedTrades must be positive"
@@ -487,7 +528,8 @@ var createStrategyReleaseManifest = (input) => {
487
528
  gateFingerprint: input.composition.gateFingerprint,
488
529
  gateContextFingerprint: input.composition.gateContextFingerprint,
489
530
  runtimeContextFingerprint: input.composition.runtimeContextFingerprint,
490
- maxLossValue: input.composition.maxLossValue
531
+ maxLossValue: input.composition.maxLossValue,
532
+ ...input.composition.directionPolicy == null ? {} : { directionPolicy: input.composition.directionPolicy }
491
533
  };
492
534
  const compositionId = `${safeSegment(input.strategy, "strategy")}_${strategyReleaseSha256(compositionIdentity).slice(0, 16)}`;
493
535
  const reasons = releaseReasons({ ...input, gates: evidenceGates });
@@ -577,7 +619,8 @@ var verifyStrategyReleaseEnvelope = async (valueOrPath) => {
577
619
  runtimeContextFingerprint: manifest.composition.runtimeContextFingerprint,
578
620
  maxLossValue: manifest.composition.maxLossValue,
579
621
  longEnabled: manifest.composition.longEnabled,
580
- shortEnabled: manifest.composition.shortEnabled
622
+ shortEnabled: manifest.composition.shortEnabled,
623
+ directionPolicy: manifest.composition.directionPolicy
581
624
  },
582
625
  marketWindow: manifest.marketWindow,
583
626
  researchBudget: manifest.researchBudget,
@@ -1001,6 +1044,7 @@ var finiteNumber = (value) => {
1001
1044
  const number = Number(value);
1002
1045
  return Number.isFinite(number) ? number : null;
1003
1046
  };
1047
+ var finiteDirectionPolicy = (value) => ["both", "long_only", "short_only", "direction_aware"].includes(String(value)) ? value : null;
1004
1048
  var uniqueStrings = (values) => [
1005
1049
  ...new Set(
1006
1050
  values.map(finiteString).filter((value) => value != null)
@@ -1017,6 +1061,7 @@ var incompleteEvidenceLineage = () => ({
1017
1061
  gateContextFingerprint: null,
1018
1062
  runtimeContextFingerprint: null,
1019
1063
  maxLossValue: null,
1064
+ directionPolicy: null,
1020
1065
  sourceSha256s: []
1021
1066
  });
1022
1067
  var extractEvidenceLineage = ({
@@ -1079,6 +1124,7 @@ var extractEvidenceLineage = ({
1079
1124
  gateConfigIdsFingerprint: finiteString(lineage?.configIdsFingerprint),
1080
1125
  gateFingerprint: finiteString(lineage?.gateFingerprint),
1081
1126
  gateContextFingerprint: finiteString(lineage?.contextFingerprint),
1127
+ directionPolicy: finiteDirectionPolicy(lineage?.directionPolicy),
1082
1128
  sourceSha256s: uniqueStrings(array(lineage?.sourceSha256s))
1083
1129
  };
1084
1130
  }
@@ -1114,7 +1160,8 @@ var extractEvidenceLineage = ({
1114
1160
  gateFingerprint: one("gateFingerprint"),
1115
1161
  runtimeConfigFingerprint: one("configFingerprint"),
1116
1162
  runtimeContextFingerprint: one("contextFingerprint"),
1117
- maxLossValue: maxLossValues.length === 1 ? maxLossValues[0] : null
1163
+ maxLossValue: maxLossValues.length === 1 ? maxLossValues[0] : null,
1164
+ directionPolicy: finiteDirectionPolicy(one("directionPolicy"))
1118
1165
  };
1119
1166
  }
1120
1167
  if (reference.kind === "execution_calibration") {
@@ -1151,6 +1198,7 @@ var extractEvidenceLineage = ({
1151
1198
  runtimeConfigFingerprint: one("configFingerprint"),
1152
1199
  runtimeContextFingerprint: one("contextFingerprint"),
1153
1200
  maxLossValue: maxLossValues.length === 1 ? maxLossValues[0] : null,
1201
+ directionPolicy: finiteDirectionPolicy(one("directionPolicy")),
1154
1202
  sourceSha256s: uniqueStrings([
1155
1203
  ...array(nested(artifact.sources)?.sha256s),
1156
1204
  ...array(artifact.sourceSha256s)
@@ -1287,9 +1335,10 @@ async function collectReleaseEvidenceReferences(references) {
1287
1335
  if (reference.kind === "ai_gate") {
1288
1336
  const outcome = nested(artifact.outcome);
1289
1337
  const approvedRisk = nested(outcome?.approvedRisk);
1290
- const terminalWindows = array(
1291
- nested(artifact.research)?.terminalWindows
1292
- ).map(nested).filter((entry) => entry != null);
1338
+ const run = nested(artifact.run);
1339
+ const research = nested(artifact.research);
1340
+ const researchLineage = nested(research?.lineage);
1341
+ const terminalWindows = array(research?.terminalWindows).map(nested).filter((entry) => entry != null);
1293
1342
  const hasLongShortDirections = (value) => {
1294
1343
  const directions = new Map(
1295
1344
  array(value).map(nested).filter(
@@ -1303,6 +1352,22 @@ async function collectReleaseEvidenceReferences(references) {
1303
1352
  const directionalEvidenceComplete = hasLongShortDirections(artifact.byDirection) && terminalWindows.every(
1304
1353
  (window) => hasLongShortDirections(window.byDirection)
1305
1354
  );
1355
+ const directionPolicy = finiteDirectionPolicy(researchLineage?.directionPolicy) ?? "both";
1356
+ const runDirectionPolicy = finiteDirectionPolicy(run?.directionPolicy) ?? "both";
1357
+ const suppressedDirection = directionPolicy === "long_only" ? "SHORT" : directionPolicy === "short_only" ? "LONG" : null;
1358
+ const approvedForDirection = (value, direction) => {
1359
+ const entry = array(value).map(nested).find((candidate) => candidate?.direction === direction);
1360
+ return Number(nested(entry?.summary)?.approved ?? Number.NaN);
1361
+ };
1362
+ const directionPolicyValid = runDirectionPolicy === directionPolicy && (suppressedDirection == null || approvedForDirection(
1363
+ artifact.byDirection,
1364
+ suppressedDirection
1365
+ ) === 0 && terminalWindows.every(
1366
+ (window) => approvedForDirection(
1367
+ window.byDirection,
1368
+ suppressedDirection
1369
+ ) === 0
1370
+ ));
1306
1371
  const terminalsPass = terminalWindows.length > 0 && terminalWindows.every((window) => {
1307
1372
  const terminalOutcome = nested(window.outcome);
1308
1373
  return window.complete === true && Number(terminalOutcome?.approved ?? 0) > 0 && Number(
@@ -1310,7 +1375,7 @@ async function collectReleaseEvidenceReferences(references) {
1310
1375
  ) > 0;
1311
1376
  });
1312
1377
  return {
1313
- aiGateAddsValue: Number(outcome?.expectancyDelta ?? Number.NEGATIVE_INFINITY) > 0 && Number(approvedRisk?.totalProfit ?? Number.NEGATIVE_INFINITY) > 0 && Number(approvedRisk?.profitFactor ?? Number.NEGATIVE_INFINITY) > 1 && directionalEvidenceComplete && terminalsPass
1378
+ aiGateAddsValue: Number(outcome?.expectancyDelta ?? Number.NEGATIVE_INFINITY) > 0 && Number(approvedRisk?.totalProfit ?? Number.NEGATIVE_INFINITY) > 0 && Number(approvedRisk?.profitFactor ?? Number.NEGATIVE_INFINITY) > 1 && directionalEvidenceComplete && directionPolicyValid && terminalsPass
1314
1379
  };
1315
1380
  }
1316
1381
  if (reference.kind === "runtime_parity") {
@@ -40,6 +40,7 @@ var import_node_os = __toESM(require("os"));
40
40
  var import_node_path = __toESM(require("path"));
41
41
  var import_node_child_process = require("child_process");
42
42
  var import_constants = require("@tradejs/core/constants");
43
+ var import_aiEndpoints = require("@tradejs/core/aiEndpoints");
43
44
  var import_redis = require("@tradejs/infra/redis");
44
45
  var import_userSettings = require("@tradejs/infra/userSettings");
45
46
 
@@ -415,35 +416,33 @@ var extractAssistantContent = (value) => {
415
416
  };
416
417
  var requestAgentDiff = async (userName, messages) => {
417
418
  const settings = await (0, import_userSettings.getUserSettings)(userName);
418
- if (!settings.AI_API_KEY || !settings.AI_API_ENDPOINT) {
419
+ const aiApiEndpoint = (0, import_aiEndpoints.normalizeAiEndpoint)(settings.AI_API_ENDPOINT);
420
+ if (!settings.AI_API_KEY || !aiApiEndpoint) {
419
421
  throw new Error(`AI settings are incomplete for user ${userName}`);
420
422
  }
421
- if (!isOpenRouterEndpoint(settings.AI_API_ENDPOINT)) {
423
+ if (!isOpenRouterEndpoint(aiApiEndpoint)) {
422
424
  throw new Error(
423
- `Research agent requires OpenRouter endpoint, got: ${settings.AI_API_ENDPOINT}`
425
+ `Research agent requires OpenRouter endpoint, got: ${aiApiEndpoint}`
424
426
  );
425
427
  }
426
- const response = await fetch(
427
- resolveChatCompletionsUrl(settings.AI_API_ENDPOINT),
428
- {
429
- method: "POST",
430
- headers: {
431
- Authorization: `Bearer ${settings.AI_API_KEY}`,
432
- "Content-Type": "application/json",
433
- "HTTP-Referer": "https://tradejs.dev",
434
- "X-Title": "TradeJS Research Agent"
428
+ const response = await fetch(resolveChatCompletionsUrl(aiApiEndpoint), {
429
+ method: "POST",
430
+ headers: {
431
+ Authorization: `Bearer ${settings.AI_API_KEY}`,
432
+ "Content-Type": "application/json",
433
+ "HTTP-Referer": "https://tradejs.dev",
434
+ "X-Title": "TradeJS Research Agent"
435
+ },
436
+ body: JSON.stringify({
437
+ model: RESEARCH_AGENT_MODEL,
438
+ messages,
439
+ reasoning: {
440
+ effort: RESEARCH_AGENT_REASONING_EFFORT,
441
+ exclude: true
435
442
  },
436
- body: JSON.stringify({
437
- model: RESEARCH_AGENT_MODEL,
438
- messages,
439
- reasoning: {
440
- effort: RESEARCH_AGENT_REASONING_EFFORT,
441
- exclude: true
442
- },
443
- temperature: 0.2
444
- })
445
- }
446
- );
443
+ temperature: 0.2
444
+ })
445
+ });
447
446
  if (!response.ok) {
448
447
  throw new Error(
449
448
  `OpenRouter request failed (${response.status}): ${await response.text()}`
@@ -40,6 +40,7 @@ var import_path = __toESM(require("path"));
40
40
  var import_progress = __toESM(require("progress"));
41
41
  var import_ai = require("@tradejs/infra/ai");
42
42
  var import_ai2 = require("@tradejs/node/ai");
43
+ var import_registry = require("@tradejs/node/registry");
43
44
 
44
45
  // src/lib/aiTrainDataset.ts
45
46
  var extractSignalFromAiDatasetRow = (row) => {
@@ -3482,7 +3483,7 @@ var main = async () => {
3482
3483
  const explicitReportFile = String(flags.reportFile || "").trim();
3483
3484
  const generatedAt = Date.now();
3484
3485
  const excludedFeaturePaths = /* @__PURE__ */ new Map();
3485
- await (0, import_ai2.ensureAiStrategyPluginsLoaded)();
3486
+ await (0, import_registry.ensureStrategyPluginsLoaded)();
3486
3487
  const filePaths = await resolveMergedDatasetFiles({
3487
3488
  outDir,
3488
3489
  strategyName,