@tradejs/cli 2.0.21 → 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(
@@ -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) {
@@ -115,7 +117,7 @@ var deriveStrategyReleaseResearchDecision = async (input) => {
115
117
  maxLossValue: null,
116
118
  requiresRuntimeBinding: false,
117
119
  blockers,
118
- 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.`
119
121
  };
120
122
  }
121
123
  const repairAllowed = Boolean(
@@ -242,6 +244,7 @@ var deriveReleaseGates = (evidence) => {
242
244
  };
243
245
  };
244
246
  var verifyEvidenceComposition = (strategy, composition, evidence) => {
247
+ const directionPolicy = composition.directionPolicy ?? "both";
245
248
  const requireEqual = (actual, expected, label, artifactId) => assert(
246
249
  actual === expected,
247
250
  `evidence ${artifactId} ${label} does not match the frozen composition`
@@ -312,6 +315,14 @@ var verifyEvidenceComposition = (strategy, composition, evidence) => {
312
315
  "gateContextFingerprint",
313
316
  reference.artifactId
314
317
  );
318
+ if (directionPolicy !== "both" || lineage?.directionPolicy != null) {
319
+ requireEqual(
320
+ lineage?.directionPolicy,
321
+ directionPolicy,
322
+ "directionPolicy",
323
+ reference.artifactId
324
+ );
325
+ }
315
326
  assert(
316
327
  lineage?.sourceSha256s.includes(composition.coreExportSha256),
317
328
  `evidence ${reference.artifactId} does not use the frozen core export`
@@ -356,6 +367,14 @@ var verifyEvidenceComposition = (strategy, composition, evidence) => {
356
367
  "MAX_LOSS_VALUE",
357
368
  reference.artifactId
358
369
  );
370
+ if (directionPolicy !== "both" || lineage?.directionPolicy != null) {
371
+ requireEqual(
372
+ lineage?.directionPolicy,
373
+ directionPolicy,
374
+ "directionPolicy",
375
+ reference.artifactId
376
+ );
377
+ }
359
378
  continue;
360
379
  }
361
380
  if (reference.kind === "execution_calibration") {
@@ -396,6 +415,14 @@ var verifyEvidenceComposition = (strategy, composition, evidence) => {
396
415
  "MAX_LOSS_VALUE",
397
416
  reference.artifactId
398
417
  );
418
+ if (directionPolicy !== "both" || lineage?.directionPolicy != null) {
419
+ requireEqual(
420
+ lineage?.directionPolicy,
421
+ directionPolicy,
422
+ "directionPolicy",
423
+ reference.artifactId
424
+ );
425
+ }
399
426
  }
400
427
  }
401
428
  };
@@ -431,6 +458,12 @@ var createStrategyReleaseManifest = (input) => {
431
458
  input.composition.longEnabled && input.composition.shortEnabled,
432
459
  "LONG and SHORT must both remain enabled"
433
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
+ );
434
467
  assert(
435
468
  Number.isInteger(input.monitoring.minimumProspectiveClosedTrades) && input.monitoring.minimumProspectiveClosedTrades > 0,
436
469
  "monitoring.minimumProspectiveClosedTrades must be positive"
@@ -495,7 +528,8 @@ var createStrategyReleaseManifest = (input) => {
495
528
  gateFingerprint: input.composition.gateFingerprint,
496
529
  gateContextFingerprint: input.composition.gateContextFingerprint,
497
530
  runtimeContextFingerprint: input.composition.runtimeContextFingerprint,
498
- maxLossValue: input.composition.maxLossValue
531
+ maxLossValue: input.composition.maxLossValue,
532
+ ...input.composition.directionPolicy == null ? {} : { directionPolicy: input.composition.directionPolicy }
499
533
  };
500
534
  const compositionId = `${safeSegment(input.strategy, "strategy")}_${strategyReleaseSha256(compositionIdentity).slice(0, 16)}`;
501
535
  const reasons = releaseReasons({ ...input, gates: evidenceGates });
@@ -585,7 +619,8 @@ var verifyStrategyReleaseEnvelope = async (valueOrPath) => {
585
619
  runtimeContextFingerprint: manifest.composition.runtimeContextFingerprint,
586
620
  maxLossValue: manifest.composition.maxLossValue,
587
621
  longEnabled: manifest.composition.longEnabled,
588
- shortEnabled: manifest.composition.shortEnabled
622
+ shortEnabled: manifest.composition.shortEnabled,
623
+ directionPolicy: manifest.composition.directionPolicy
589
624
  },
590
625
  marketWindow: manifest.marketWindow,
591
626
  researchBudget: manifest.researchBudget,
@@ -1009,6 +1044,7 @@ var finiteNumber = (value) => {
1009
1044
  const number = Number(value);
1010
1045
  return Number.isFinite(number) ? number : null;
1011
1046
  };
1047
+ var finiteDirectionPolicy = (value) => ["both", "long_only", "short_only", "direction_aware"].includes(String(value)) ? value : null;
1012
1048
  var uniqueStrings = (values) => [
1013
1049
  ...new Set(
1014
1050
  values.map(finiteString).filter((value) => value != null)
@@ -1025,6 +1061,7 @@ var incompleteEvidenceLineage = () => ({
1025
1061
  gateContextFingerprint: null,
1026
1062
  runtimeContextFingerprint: null,
1027
1063
  maxLossValue: null,
1064
+ directionPolicy: null,
1028
1065
  sourceSha256s: []
1029
1066
  });
1030
1067
  var extractEvidenceLineage = ({
@@ -1087,6 +1124,7 @@ var extractEvidenceLineage = ({
1087
1124
  gateConfigIdsFingerprint: finiteString(lineage?.configIdsFingerprint),
1088
1125
  gateFingerprint: finiteString(lineage?.gateFingerprint),
1089
1126
  gateContextFingerprint: finiteString(lineage?.contextFingerprint),
1127
+ directionPolicy: finiteDirectionPolicy(lineage?.directionPolicy),
1090
1128
  sourceSha256s: uniqueStrings(array(lineage?.sourceSha256s))
1091
1129
  };
1092
1130
  }
@@ -1122,7 +1160,8 @@ var extractEvidenceLineage = ({
1122
1160
  gateFingerprint: one("gateFingerprint"),
1123
1161
  runtimeConfigFingerprint: one("configFingerprint"),
1124
1162
  runtimeContextFingerprint: one("contextFingerprint"),
1125
- maxLossValue: maxLossValues.length === 1 ? maxLossValues[0] : null
1163
+ maxLossValue: maxLossValues.length === 1 ? maxLossValues[0] : null,
1164
+ directionPolicy: finiteDirectionPolicy(one("directionPolicy"))
1126
1165
  };
1127
1166
  }
1128
1167
  if (reference.kind === "execution_calibration") {
@@ -1159,6 +1198,7 @@ var extractEvidenceLineage = ({
1159
1198
  runtimeConfigFingerprint: one("configFingerprint"),
1160
1199
  runtimeContextFingerprint: one("contextFingerprint"),
1161
1200
  maxLossValue: maxLossValues.length === 1 ? maxLossValues[0] : null,
1201
+ directionPolicy: finiteDirectionPolicy(one("directionPolicy")),
1162
1202
  sourceSha256s: uniqueStrings([
1163
1203
  ...array(nested(artifact.sources)?.sha256s),
1164
1204
  ...array(artifact.sourceSha256s)
@@ -1295,9 +1335,10 @@ async function collectReleaseEvidenceReferences(references) {
1295
1335
  if (reference.kind === "ai_gate") {
1296
1336
  const outcome = nested(artifact.outcome);
1297
1337
  const approvedRisk = nested(outcome?.approvedRisk);
1298
- const terminalWindows = array(
1299
- nested(artifact.research)?.terminalWindows
1300
- ).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);
1301
1342
  const hasLongShortDirections = (value) => {
1302
1343
  const directions = new Map(
1303
1344
  array(value).map(nested).filter(
@@ -1311,6 +1352,22 @@ async function collectReleaseEvidenceReferences(references) {
1311
1352
  const directionalEvidenceComplete = hasLongShortDirections(artifact.byDirection) && terminalWindows.every(
1312
1353
  (window) => hasLongShortDirections(window.byDirection)
1313
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
+ ));
1314
1371
  const terminalsPass = terminalWindows.length > 0 && terminalWindows.every((window) => {
1315
1372
  const terminalOutcome = nested(window.outcome);
1316
1373
  return window.complete === true && Number(terminalOutcome?.approved ?? 0) > 0 && Number(
@@ -1318,7 +1375,7 @@ async function collectReleaseEvidenceReferences(references) {
1318
1375
  ) > 0;
1319
1376
  });
1320
1377
  return {
1321
- 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
1322
1379
  };
1323
1380
  }
1324
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,
@@ -43,6 +43,7 @@ var import_ai = require("@tradejs/infra/ai");
43
43
  var import_strategyReleaseEvidence = require("@tradejs/infra/strategyReleaseEvidence");
44
44
  var import_redis = require("@tradejs/infra/redis");
45
45
  var import_ai2 = require("@tradejs/node/ai");
46
+ var import_registry = require("@tradejs/node/registry");
46
47
  var import_constants2 = require("@tradejs/node/constants");
47
48
 
48
49
  // src/lib/aiTrainMetrics.ts
@@ -920,6 +921,38 @@ var parseTerminalWindowDays = (value) => {
920
921
  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);
921
922
  return [...new Set(raw)].sort((left, right) => right - left);
922
923
  };
924
+ var parseAiTrainDirectionPolicy = (value) => {
925
+ const normalized = String(value ?? "both").trim().toLowerCase().replaceAll("-", "_");
926
+ if (normalized === "both" || normalized === "long_only" || normalized === "short_only" || normalized === "direction_aware") {
927
+ return normalized;
928
+ }
929
+ throw new Error(
930
+ `Invalid --directionPolicy value "${String(value)}". Use both, long_only, short_only, or direction_aware.`
931
+ );
932
+ };
933
+ var assertAiTrainDirectionPolicy = ({
934
+ directionPolicy,
935
+ byDirection,
936
+ terminalWindows
937
+ }) => {
938
+ const suppressedDirection = directionPolicy === "long_only" ? "SHORT" : directionPolicy === "short_only" ? "LONG" : null;
939
+ if (!suppressedDirection) return;
940
+ for (const scope of [{ label: "full", byDirection }, ...terminalWindows]) {
941
+ const summary = scope.byDirection.find(
942
+ (entry) => entry.direction === suppressedDirection
943
+ );
944
+ if (!summary) {
945
+ throw new Error(
946
+ `${scope.label} ${suppressedDirection} direction summary is required for ${directionPolicy}`
947
+ );
948
+ }
949
+ if (summary.summary.approved !== 0) {
950
+ throw new Error(
951
+ `${scope.label} has ${summary.summary.approved} ${suppressedDirection} approved rows under ${directionPolicy}`
952
+ );
953
+ }
954
+ }
955
+ };
923
956
  var parseDumpFeatureMode = (value) => {
924
957
  const text = String(value ?? "none").trim().toLowerCase();
925
958
  if (!text || text === "none" || text === "false" || text === "0") {
@@ -1416,6 +1449,11 @@ import_args.default.option(
1416
1449
  "Minimum AI quality required to approve entry",
1417
1450
  4
1418
1451
  );
1452
+ import_args.default.option(
1453
+ ["Y", "directionPolicy"],
1454
+ "Evidence label for the enforced gate policy: both, long_only, short_only, or direction_aware",
1455
+ "both"
1456
+ );
1419
1457
  import_args.default.option(
1420
1458
  ["l", "localOnly"],
1421
1459
  "Replay deterministic adapter gate without AI provider calls",
@@ -1806,6 +1844,7 @@ var main = async () => {
1806
1844
  });
1807
1845
  const qualityThresholds = parseQualityThresholds(flags.qualityThresholds);
1808
1846
  const terminalWindowDays = parseTerminalWindowDays(flags.terminalWindows);
1847
+ const directionPolicy = parseAiTrainDirectionPolicy(flags.directionPolicy);
1809
1848
  const dumpEvaluationsPath = String(flags.dumpEvaluations || "").trim();
1810
1849
  const dumpFeatureMode = parseDumpFeatureMode(flags.dumpFeatures);
1811
1850
  if (dumpFeatureMode !== "none" && !dumpEvaluationsPath) {
@@ -1827,7 +1866,7 @@ var main = async () => {
1827
1866
  const userName = String(flags.user || "root").trim() || "root";
1828
1867
  const model = String(flags.model || import_ai2.DEFAULT_AI_MODEL).trim() || import_ai2.DEFAULT_AI_MODEL;
1829
1868
  const parallel = normalizePositiveInt(flags.parallel, import_constants2.AI_CONCURRENCY_LIMIT);
1830
- await (0, import_ai2.ensureAiStrategyPluginsLoaded)();
1869
+ await (0, import_registry.ensureStrategyPluginsLoaded)();
1831
1870
  const filePaths = await resolveDatasetFiles();
1832
1871
  const datasetId = getMergedGroupId(filePaths[0] || "")?.mergeId;
1833
1872
  const { totalRows, selectedRows } = await (0, import_ai.countAiDatasetRows)({
@@ -2020,18 +2059,29 @@ var main = async () => {
2020
2059
  finalEvaluations,
2021
2060
  terminalWindowDays
2022
2061
  );
2023
- const topRejectReasons = summarizeAiTrainRejectReasons(finalEvaluations);
2024
- const lineage = await buildAiTrainLineage({
2025
- projectRoot: String(process.env.PROJECT_CWD || process.cwd()).trim() || process.cwd(),
2026
- strategyName,
2027
- configIds: finalEvaluations.map((evaluation) => evaluation.configId),
2028
- sourceSha256s: await Promise.all(filePaths.map(import_strategyReleaseEvidence.strategyEvidenceFileSha256)),
2029
- runContext: {
2030
- mode: localOnly ? "local-deterministic" : "llm",
2031
- model: localOnly ? "local-deterministic" : model,
2032
- minQuality
2033
- }
2062
+ assertAiTrainDirectionPolicy({
2063
+ directionPolicy,
2064
+ byDirection: directionSummaries,
2065
+ terminalWindows
2034
2066
  });
2067
+ const topRejectReasons = summarizeAiTrainRejectReasons(finalEvaluations);
2068
+ const lineage = {
2069
+ ...await buildAiTrainLineage({
2070
+ projectRoot: String(process.env.PROJECT_CWD || process.cwd()).trim() || process.cwd(),
2071
+ strategyName,
2072
+ configIds: finalEvaluations.map((evaluation) => evaluation.configId),
2073
+ sourceSha256s: await Promise.all(
2074
+ filePaths.map(import_strategyReleaseEvidence.strategyEvidenceFileSha256)
2075
+ ),
2076
+ runContext: {
2077
+ mode: localOnly ? "local-deterministic" : "llm",
2078
+ model: localOnly ? "local-deterministic" : model,
2079
+ minQuality,
2080
+ directionPolicy
2081
+ }
2082
+ }),
2083
+ directionPolicy
2084
+ };
2035
2085
  const evaluated = summary.correct + summary.incorrect;
2036
2086
  const result = {
2037
2087
  run: {
@@ -2053,7 +2103,8 @@ var main = async () => {
2053
2103
  model: localOnly ? "local-deterministic" : model,
2054
2104
  parallel: concurrency,
2055
2105
  dumpFeatures: dumpFeatureMode,
2056
- terminalWindows: terminalWindowDays
2106
+ terminalWindows: terminalWindowDays,
2107
+ directionPolicy
2057
2108
  },
2058
2109
  outcome: summary,
2059
2110
  byDirection: directionSummaries,