@backtest-kit/cli 16.6.0 → 17.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.
package/build/index.mjs CHANGED
@@ -874,10 +874,6 @@ const getArgs = singleshot(() => {
874
874
  type: "boolean",
875
875
  default: false,
876
876
  },
877
- split: {
878
- type: "string",
879
- default: "",
880
- },
881
877
  commit: {
882
878
  type: "string",
883
879
  default: "",
@@ -3670,7 +3666,7 @@ const main$k = async () => {
3670
3666
  if (MODES.some((mode) => values[mode])) {
3671
3667
  return;
3672
3668
  }
3673
- process.stdout.write(`@backtest-kit/cli ${"16.6.0"}\n`);
3669
+ process.stdout.write(`@backtest-kit/cli ${"17.0.0"}\n`);
3674
3670
  process.stdout.write("\n");
3675
3671
  process.stdout.write(`Run with --help to see available commands.\n`);
3676
3672
  process.stdout.write("\n");
@@ -4894,6 +4890,18 @@ const main$6 = async () => {
4894
4890
  main$6();
4895
4891
 
4896
4892
  const SIMULATOR_NAME$1 = "cli_simulator";
4893
+ const CONFIG_KEYS$1 = ["gridAxes", "reportOrder"];
4894
+ const validateConfig$1 = (config) => {
4895
+ if (typeof config !== "object" || config === null || Array.isArray(config)) {
4896
+ return "config must be a JSON object";
4897
+ }
4898
+ for (const key of Object.keys(config)) {
4899
+ if (!CONFIG_KEYS$1.includes(key)) {
4900
+ return `unknown key "${key}" (expected: ${CONFIG_KEYS$1.join(", ")})`;
4901
+ }
4902
+ }
4903
+ return null;
4904
+ };
4897
4905
  const IDEA_DIRECTIONS$1 = ["LONG", "SHORT", "NEUTRAL"];
4898
4906
  const validateIdea$1 = (idea, line) => {
4899
4907
  if (typeof idea !== "object" || idea === null) {
@@ -4917,7 +4925,9 @@ const validateIdea$1 = (idea, line) => {
4917
4925
  return null;
4918
4926
  };
4919
4927
  const toMarkdown$1 = (result) => {
4920
- const profitable = result.reports.filter(({ totalPnlPercent }) => totalPnlPercent > 0).length;
4928
+ const buckets = Object.entries(result.reports).filter(([, bucket]) => bucket.reports.length > 0);
4929
+ const allReports = buckets.flatMap(([, bucket]) => bucket.reports);
4930
+ const profitable = allReports.filter(({ totalPnlPercent }) => totalPnlPercent > 0).length;
4921
4931
  const lines = [];
4922
4932
  lines.push(`# Simulator Report — ${result.symbol}`);
4923
4933
  lines.push("");
@@ -4925,33 +4935,34 @@ const toMarkdown$1 = (result) => {
4925
4935
  lines.push(`| --- | --- |`);
4926
4936
  lines.push(`| Ideas (total / directional) | ${result.ideasTotal} / ${result.ideasDirectional} |`);
4927
4937
  lines.push(`| Profiles (truncated) | ${result.profileCount} (${result.truncatedCount}) |`);
4928
- lines.push(`| Authors allowed / banned | ${result.allowedAuthors.length} / ${result.bannedAuthors.length} |`);
4929
- lines.push(`| Profitable corridor | ${profitable} / ${result.reports.length} grid points |`);
4938
+ lines.push(`| Profitable corridor | ${profitable} / ${allReports.length} grid points |`);
4930
4939
  lines.push(`| Hold minutes avg / p95 / p99 | ${result.avgHoldMinutes.toFixed(0)} / ${result.p95HoldMinutes} / ${result.p99HoldMinutes} |`);
4931
- lines.push("");
4932
- lines.push(`## Ranking winners`);
4933
- lines.push("");
4934
- lines.push(`| Criterion | Stop% | Hold | Track | HitRate | Trades | PNL% | WinRate | Sharpe | Sortino |`);
4935
- lines.push(`| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |`);
4936
- for (const best of result.best) {
4937
- if (!best.report) {
4938
- lines.push(`| ${best.criterion} | — | — | — | — | — | — | — | — | — |`);
4939
- continue;
4940
+ // каждая метрика — самодостаточная корзина: свои победители и
4941
+ // свои словари банов, между собой не склеиваются
4942
+ for (const [metric, bucket] of buckets) {
4943
+ lines.push("");
4944
+ lines.push(`## Metric: ${metric}`);
4945
+ lines.push("");
4946
+ lines.push(`| Criterion | Stop% | Hold | Track | HitRate | Trades | PNL% | WinRate | Sharpe | Sortino |`);
4947
+ lines.push(`| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |`);
4948
+ for (const best of bucket.best) {
4949
+ if (!best.report) {
4950
+ lines.push(`| ${best.criterion} | — | — | — | — | — | — | — | — | — |`);
4951
+ continue;
4952
+ }
4953
+ const { point } = best.report;
4954
+ lines.push(`| ${best.criterion} | ${point.hardStopPercent} | ${point.holdMinutes / 60}h | ${point.minAuthorTrack} | ${point.minAuthorHitRate} | ` +
4955
+ `${best.report.trades} | ${best.report.totalPnlPercent.toFixed(2)}% | ${(best.report.winRate * 100).toFixed(0)}% | ${best.report.sharpe.toFixed(2)} | ${best.report.sortino.toFixed(2)} |`);
4956
+ }
4957
+ const sharpeBest = bucket.best.find(({ criterion }) => criterion === "sharpe");
4958
+ lines.push("");
4959
+ lines.push(`### Allowed authors (sharpe winner rule)`);
4960
+ lines.push("");
4961
+ lines.push(`| Author | Ideas | Hits | HitRate |`);
4962
+ lines.push(`| --- | --- | --- | --- |`);
4963
+ for (const stat of (sharpeBest?.authorStats ?? []).filter(({ banned }) => !banned)) {
4964
+ lines.push(`| ${stat.author} | ${stat.ideas} | ${stat.hits} | ${(stat.hitRate * 100).toFixed(0)}% |`);
4940
4965
  }
4941
- const { point } = best.report;
4942
- lines.push(`| ${best.criterion} | ${point.hardStopPercent} | ${point.holdMinutes / 60}h | ${point.minAuthorTrack} | ${point.minAuthorHitRate} | ` +
4943
- `${best.report.trades} | ${best.report.totalPnlPercent.toFixed(2)}% | ${(best.report.winRate * 100).toFixed(0)}% | ${best.report.sharpe.toFixed(2)} | ${best.report.sortino.toFixed(2)} |`);
4944
- }
4945
- lines.push("");
4946
- lines.push(`## Allowed authors (whitelist of the sharpe winner rule)`);
4947
- lines.push("");
4948
- lines.push(`| Author | Ideas | Hits | HitRate |`);
4949
- lines.push(`| --- | --- | --- | --- |`);
4950
- // артефакт авторов живёт по-победительно в best[] — ран-левел
4951
- // authorStats в ISimulatorResult больше нет
4952
- const sharpeBest = result.best.find(({ criterion }) => criterion === "sharpe");
4953
- for (const stat of (sharpeBest?.authorStats ?? []).filter(({ banned }) => !banned)) {
4954
- lines.push(`| ${stat.author} | ${stat.ideas} | ${stat.hits} | ${(stat.hitRate * 100).toFixed(0)}% |`);
4955
4966
  }
4956
4967
  return lines.join("\n");
4957
4968
  };
@@ -4968,6 +4979,33 @@ const main$5 = async () => {
4968
4979
  console.error("Error: positional path to an ideas .jsonl file is required");
4969
4980
  process.exit(1);
4970
4981
  }
4982
+ // конфиг — позиционный JSON; без него подтягивается пустой объект
4983
+ // и схема живёт на дефолтах движка (оси, порядок отчёта)
4984
+ const [configPath = null] = positionals.filter((value) => value.endsWith(".json") && !value.endsWith(".jsonl"));
4985
+ let config = {};
4986
+ if (configPath) {
4987
+ let content;
4988
+ try {
4989
+ content = await readFile(resolve(configPath), "utf-8");
4990
+ }
4991
+ catch (error) {
4992
+ console.error(`Error: cannot read config file: ${configPath}`);
4993
+ process.exit(1);
4994
+ }
4995
+ try {
4996
+ config = JSON.parse(content);
4997
+ }
4998
+ catch {
4999
+ console.error(`Error: invalid JSON in config file: ${configPath}`);
5000
+ process.exit(1);
5001
+ }
5002
+ const problem = validateConfig$1(config);
5003
+ if (problem) {
5004
+ console.error(`Error: simulator config does not match the structure — ${problem}`);
5005
+ console.error(`Expected shape: { "gridAxes"?: ISimulatorGridAxes, "reportOrder"?: "sharpe"|"sortino"|"pnl"|"recovery" }`);
5006
+ process.exit(1);
5007
+ }
5008
+ }
4971
5009
  let ideas = [];
4972
5010
  {
4973
5011
  let content;
@@ -5039,31 +5077,13 @@ const main$5 = async () => {
5039
5077
  });
5040
5078
  }
5041
5079
  const symbol = values.symbol || "BTCUSDT";
5042
- // Проба осуществимости, НЕ поиск заработка (подбор параметров — у
5043
- // --tune): собирающая прибыль механика выключена, остаётся вопрос
5044
- // "есть ли прибыльный коридор" по стопу x холду x правилу бана —
5045
- // 48 точек, быстрый вердикт
5080
+ // оси и порядок отчёта приходят позиционным конфигом потребителя;
5081
+ // пустой конфиг — дефолтная сетка движка из connection-сервиса
5046
5082
  addSimulatorSchema({
5047
5083
  simulatorName: SIMULATOR_NAME$1,
5048
5084
  exchangeName,
5049
- gridAxes: {
5050
- // грубая шкала катастрофы: коридор должен быть широким, не точкой
5051
- hardStopPercent: [2, 3, 5, 7],
5052
- // инертен: проба не собирает прибыль, выход — по времени или стопу
5053
- trailingTakePercent: [100],
5054
- holdMinutes: [24 * 60, 2 * 24 * 60, 3 * 24 * 60],
5055
- // одного проверенного автора достаточно — консенсус не перебираем
5056
- minIdeasAligned: [1],
5057
- // правило бана — единственная перебираемая "умность" пробы
5058
- minAuthorTrack: [3, 5],
5059
- minAuthorHitRate: [0.5, 0.6],
5060
- minAuthorWilson: [0],
5061
- minWeightAligned: [0],
5062
- profitLockPercent: [0],
5063
- authorMetric: ["close"],
5064
- banCriteria: ["sharpe", "pnl"],
5065
- },
5066
- reportOrder: "sharpe",
5085
+ ...(config.gridAxes ? { gridAxes: config.gridAxes } : {}),
5086
+ ...(config.reportOrder ? { reportOrder: config.reportOrder } : {}),
5067
5087
  callbacks: {
5068
5088
  onProgress: (symbol, stage, processed, total) => {
5069
5089
  if (values.verbose) {
@@ -5115,9 +5135,8 @@ const main$5 = async () => {
5115
5135
  if (values.verbose) {
5116
5136
  console.log("onDone", {
5117
5137
  symbol,
5118
- reports: result.reports.length,
5119
- allowedAuthors: result.allowedAuthors.length,
5120
- bannedAuthors: result.bannedAuthors.length,
5138
+ reports: Object.values(result.reports).flatMap((bucket) => bucket.reports).length,
5139
+ bans: Object.values(result.reports).flatMap((bucket) => bucket.bans).length,
5121
5140
  });
5122
5141
  }
5123
5142
  },
@@ -5150,8 +5169,15 @@ const main$5 = async () => {
5150
5169
  main$5();
5151
5170
 
5152
5171
  const SIMULATOR_NAME = "cli_tune";
5153
- /** Доля времени фида, уходящая в обучение; хвост — один OOS-выстрел. */
5154
- const DEFAULT_TRAIN_SPLIT = 0.7;
5172
+ const CONFIG_KEYS = ["point", "authorStats", "gridAxes", "reportOrder"];
5173
+ const POINT_NUMBER_FIELDS = [
5174
+ "hardStopPercent",
5175
+ "trailingTakePercent",
5176
+ "holdMinutes",
5177
+ "minAuthorTrack",
5178
+ "minAuthorHitRate",
5179
+ "profitLockPercent",
5180
+ ];
5155
5181
  const IDEA_DIRECTIONS = ["LONG", "SHORT", "NEUTRAL"];
5156
5182
  const validateIdea = (idea, line) => {
5157
5183
  if (typeof idea !== "object" || idea === null) {
@@ -5174,33 +5200,65 @@ const validateIdea = (idea, line) => {
5174
5200
  }
5175
5201
  return null;
5176
5202
  };
5177
- const pointLabel = (point) => `H=${point.hardStopPercent} TT=${point.trailingTakePercent} hold=${point.holdMinutes / 60}h N=${point.minIdeasAligned} ` +
5178
- `track=${point.minAuthorTrack} rate=${point.minAuthorHitRate} wilson=${point.minAuthorWilson} W=${point.minWeightAligned} ` +
5203
+ const validateConfig = (config) => {
5204
+ if (typeof config !== "object" || config === null || Array.isArray(config)) {
5205
+ return "config must be a JSON object";
5206
+ }
5207
+ for (const key of Object.keys(config)) {
5208
+ if (!CONFIG_KEYS.includes(key)) {
5209
+ return `unknown key "${key}" (expected: ${CONFIG_KEYS.join(", ")})`;
5210
+ }
5211
+ }
5212
+ if (typeof config.point !== "object" || config.point === null) {
5213
+ return `"point" is required — the frozen grid point of a training run`;
5214
+ }
5215
+ for (const field of POINT_NUMBER_FIELDS) {
5216
+ if (typeof config.point[field] !== "number") {
5217
+ return `"point.${field}" must be a number, got ${typeof config.point[field]}`;
5218
+ }
5219
+ }
5220
+ if (typeof config.point.authorMetric !== "string") {
5221
+ return `"point.authorMetric" must be a string, got ${typeof config.point.authorMetric}`;
5222
+ }
5223
+ if (!Array.isArray(config.authorStats) || !config.authorStats.length) {
5224
+ return `"authorStats" is required — the frozen raw track record (non-empty array)`;
5225
+ }
5226
+ for (let i = 0; i < config.authorStats.length; i++) {
5227
+ const stat = config.authorStats[i];
5228
+ if (typeof stat !== "object" ||
5229
+ stat === null ||
5230
+ typeof stat.author !== "string" ||
5231
+ typeof stat.ideas !== "number" ||
5232
+ typeof stat.hits !== "number") {
5233
+ return `"authorStats[${i}]" must be { author: string, ideas: number, hits: number }`;
5234
+ }
5235
+ }
5236
+ return null;
5237
+ };
5238
+ /** Одноточечные оси, зеркалящие замороженную точку — сетка инертна. */
5239
+ const axesFromPoint = (point) => ({
5240
+ hardStopPercent: [point.hardStopPercent],
5241
+ trailingTakePercent: [point.trailingTakePercent],
5242
+ holdMinutes: [point.holdMinutes],
5243
+ minAuthorTrack: [point.minAuthorTrack],
5244
+ minAuthorHitRate: [point.minAuthorHitRate],
5245
+ profitLockPercent: [point.profitLockPercent],
5246
+ authorMetric: [point.authorMetric],
5247
+ });
5248
+ const pointLabel = (point) => `H=${point.hardStopPercent} TT=${point.trailingTakePercent} hold=${point.holdMinutes / 60}h ` +
5249
+ `track=${point.minAuthorTrack} rate=${point.minAuthorHitRate} ` +
5179
5250
  `lock=${point.profitLockPercent} metric=${point.authorMetric}`;
5180
5251
  const fmtRatio = (value) => Number.isFinite(value) ? value.toFixed(2) : "inf";
5181
- const toMarkdown = (train, test, trainSplit) => {
5252
+ const toMarkdown = (test) => {
5182
5253
  const lines = [];
5183
- lines.push(`# Tune Report — ${train.symbol}`);
5184
- lines.push("");
5185
- lines.push(`Walk-forward: train on the first ${Math.round(trainSplit * 100)}% of the feed time range, ONE out-of-sample shot of the frozen sharpe winner on the tail.`);
5186
- lines.push("");
5187
- lines.push(`## Train (${train.ideasDirectional} directional ideas, ${train.reports.length} grid points)`);
5188
- lines.push("");
5189
- lines.push(`| Criterion | Point | Trades | PNL% | WinRate | DD% | Sharpe | Sortino |`);
5190
- lines.push(`| --- | --- | --- | --- | --- | --- | --- | --- |`);
5191
- for (const best of train.best) {
5192
- if (!best.report) {
5193
- lines.push(`| ${best.criterion} | — | — | — | — | — | — | — |`);
5194
- continue;
5195
- }
5196
- lines.push(`| ${best.criterion} | ${pointLabel(best.report.point)} | ${best.report.trades} | ${best.report.totalPnlPercent.toFixed(2)}% | ` +
5197
- `${(best.report.winRate * 100).toFixed(0)}% | ${best.report.maxSeriesDrawdownPercent.toFixed(2)} | ${fmtRatio(best.report.sharpe)} | ${fmtRatio(best.report.sortino)} |`);
5198
- }
5254
+ lines.push(`# Tune Report — ${test.symbol}`);
5199
5255
  lines.push("");
5200
- lines.push(`## Out-of-sample (frozen sharpe winner, ${test.ideasDirectional} tail ideas)`);
5256
+ lines.push(`ONE out-of-sample shot of a FROZEN training artifact: nothing is trained here — the point and the raw author track record come from the input config verbatim, bans are re-derived from the frozen numbers under the point's rule, unseen authors are banned by default.`);
5201
5257
  lines.push("");
5202
5258
  lines.push(`Frozen point: ${pointLabel(test.point)}`);
5203
5259
  lines.push("");
5260
+ lines.push(`## Out-of-sample (${test.ideasDirectional} directional ideas)`);
5261
+ lines.push("");
5204
5262
  lines.push(`| Metric | Value |`);
5205
5263
  lines.push(`| --- | --- |`);
5206
5264
  lines.push(`| Trades (skipped busy) | ${test.report.trades} (${test.report.skippedBusy}) |`);
@@ -5220,9 +5278,7 @@ const toMarkdown = (train, test, trainSplit) => {
5220
5278
  lines.push(`| ${trade.direction} | ${trade.exitReason} | ${trade.pnlPercent.toFixed(2)} | ${trade.holdMinutesActual}m | ${new Date(trade.entryTimestamp).toISOString()} |`);
5221
5279
  }
5222
5280
  lines.push("");
5223
- lines.push(`## Frozen author track record (train range, sharpe winner rule)`);
5224
- lines.push("");
5225
- lines.push(`Freeze these raw numbers for production \`Simulator.test\` — banned flags and vote weights are re-derived from them under the frozen point's rule; an author absent from the list is banned by default.`);
5281
+ lines.push(`## Frozen author track record (re-derived bans under the point's rule)`);
5226
5282
  lines.push("");
5227
5283
  lines.push(`| Author | Ideas | Hits | Banned |`);
5228
5284
  lines.push(`| --- | --- | --- | --- |`);
@@ -5244,6 +5300,35 @@ const main$4 = async () => {
5244
5300
  console.error("Error: positional path to an ideas .jsonl file is required");
5245
5301
  process.exit(1);
5246
5302
  }
5303
+ // конфиг — позиционный JSON; без него подтягивается пустой объект
5304
+ // (и валидация честно скажет, что замороженной точки нет)
5305
+ const [configPath = null] = positionals.filter((value) => value.endsWith(".json") && !value.endsWith(".jsonl"));
5306
+ let config = {};
5307
+ if (configPath) {
5308
+ let content;
5309
+ try {
5310
+ content = await readFile(resolve(configPath), "utf-8");
5311
+ }
5312
+ catch (error) {
5313
+ console.error(`Error: cannot read config file: ${configPath}`);
5314
+ process.exit(1);
5315
+ }
5316
+ try {
5317
+ config = JSON.parse(content);
5318
+ }
5319
+ catch {
5320
+ console.error(`Error: invalid JSON in config file: ${configPath}`);
5321
+ process.exit(1);
5322
+ }
5323
+ }
5324
+ {
5325
+ const problem = validateConfig(config);
5326
+ if (problem) {
5327
+ console.error(`Error: tune config does not match the structure — ${problem}`);
5328
+ console.error(`Expected shape: { "point": ISimulatorGridPoint, "authorStats": [{ "author", "ideas", "hits" }], "gridAxes"?, "reportOrder"? }`);
5329
+ process.exit(1);
5330
+ }
5331
+ }
5247
5332
  let ideas = [];
5248
5333
  {
5249
5334
  let content;
@@ -5277,13 +5362,6 @@ const main$4 = async () => {
5277
5362
  ideas.push(idea);
5278
5363
  }
5279
5364
  }
5280
- const trainSplit = values.split
5281
- ? parseFloat(values.split)
5282
- : DEFAULT_TRAIN_SPLIT;
5283
- if (!Number.isFinite(trainSplit) || trainSplit <= 0 || trainSplit >= 1) {
5284
- console.error(`Error: --split must be a fraction between 0 and 1, got ${values.split}`);
5285
- process.exit(1);
5286
- }
5287
5365
  {
5288
5366
  const cwd = process.cwd();
5289
5367
  dotenv.config({ path: path.join(cwd, '.env'), override: true, quiet: true });
@@ -5322,28 +5400,13 @@ const main$4 = async () => {
5322
5400
  });
5323
5401
  }
5324
5402
  const symbol = values.symbol || "BTCUSDT";
5325
- // Подбор параметров: полная сетка с собирающей прибыль механикой —
5326
- // замок, трейлинг, взвешенный консенсус, Вильсон, обе метрики бана.
5327
- // Честность обеспечивается сплитом: обучение видит только голову,
5328
- // хвост тратится на один выстрел замороженного sharpe-победителя
5403
+ // сетка для test() инертна (оценивается ровно замороженная точка):
5404
+ // без явных осей в конфиге они одноточечно зеркалят точку
5329
5405
  addSimulatorSchema({
5330
5406
  simulatorName: SIMULATOR_NAME,
5331
5407
  exchangeName,
5332
- gridAxes: {
5333
- // стопы < 2% сидят внутри медианного шейкаута и не выигрывают
5334
- hardStopPercent: [2, 2.5, 3, 4, 5, 7],
5335
- trailingTakePercent: [1, 1.5, 2, 3, 4],
5336
- holdMinutes: [24 * 60, 2 * 24 * 60, 3 * 24 * 60],
5337
- minIdeasAligned: [1, 2],
5338
- minAuthorTrack: [2, 3, 5],
5339
- minAuthorHitRate: [0.5, 0.6],
5340
- minAuthorWilson: [0, 0.6],
5341
- minWeightAligned: [0, 0.6, 1.2],
5342
- profitLockPercent: [0, 1.5, 2.5],
5343
- authorMetric: ["close", "reach"],
5344
- banCriteria: ["sharpe"],
5345
- },
5346
- reportOrder: "sharpe",
5408
+ gridAxes: config.gridAxes ?? axesFromPoint(config.point),
5409
+ ...(config.reportOrder ? { reportOrder: config.reportOrder } : {}),
5347
5410
  callbacks: {
5348
5411
  onProgress: (symbol, stage, processed, total) => {
5349
5412
  if (values.verbose) {
@@ -5360,26 +5423,6 @@ const main$4 = async () => {
5360
5423
  console.log("onProfiles", { symbol, profiles: profiles.length, truncatedCount });
5361
5424
  }
5362
5425
  },
5363
- onAuthorsTrained: (symbol, stats, bannedIdeas) => {
5364
- if (values.verbose) {
5365
- console.log("onAuthorsTrained", {
5366
- symbol,
5367
- authors: stats.length,
5368
- banned: stats.filter(({ banned }) => banned).length,
5369
- bannedIdeas,
5370
- });
5371
- }
5372
- },
5373
- onRanking: (symbol, criterion, _sorted, best) => {
5374
- if (values.verbose) {
5375
- console.log("onRanking", {
5376
- symbol,
5377
- criterion,
5378
- point: best.report?.point ?? null,
5379
- pnl: best.report ? +best.report.totalPnlPercent.toFixed(2) : null,
5380
- });
5381
- }
5382
- },
5383
5426
  onTestDone: (symbol, result) => {
5384
5427
  if (values.verbose) {
5385
5428
  console.log("onTestDone", {
@@ -5391,48 +5434,33 @@ const main$4 = async () => {
5391
5434
  },
5392
5435
  },
5393
5436
  });
5394
- // обучение видит ТОЛЬКО голову ленты; хвост — один OOS-выстрел
5395
- const sorted = [...ideas].sort((a, b) => a.ts - b.ts);
5396
- const cutoff = sorted[0].ts + (sorted[sorted.length - 1].ts - sorted[0].ts) * trainSplit;
5397
- const trainIdeas = sorted.filter(({ ts }) => ts < cutoff);
5398
- const testIdeas = sorted.filter(({ ts }) => ts >= cutoff);
5399
- const train = await Simulator.run({
5400
- symbol,
5401
- simulatorName: SIMULATOR_NAME,
5402
- ideas: trainIdeas,
5403
- });
5404
- const sharpeBest = train.best.find(({ criterion }) => criterion === "sharpe");
5405
- if (!sharpeBest?.report) {
5406
- console.error("Error: training produced no sharpe winner — nothing to freeze for the out-of-sample shot");
5407
- process.exit(1);
5408
- }
5409
- // заморозка: точка и трек-рекорд sharpe-победителя как есть — из
5410
- // замороженных статов test() читает только author/ideas/hits и сам
5411
- // перевыводит баны и веса под правило точки
5437
+ // только out-of-sample: обучения нет, точка и трек-рекорд приходят
5438
+ // из конфига verbatim — test() перевыводит баны под правило точки,
5439
+ // невиданные авторы забанены по умолчанию
5412
5440
  const test = await Simulator.test({
5413
5441
  symbol,
5414
5442
  simulatorName: SIMULATOR_NAME,
5415
- ideas: testIdeas,
5416
- point: sharpeBest.report.point,
5417
- authorStats: sharpeBest.authorStats,
5443
+ ideas,
5444
+ point: config.point,
5445
+ authorStats: config.authorStats,
5418
5446
  });
5419
5447
  const dumpName = values.output || `tune_${symbol}_${Date.now()}`;
5420
5448
  const dumpDir = join(process.cwd(), "dump");
5421
5449
  if (values.json) {
5422
5450
  const filePath = resolve(dumpDir, `${dumpName}.json`);
5423
5451
  await mkdir(dumpDir, { recursive: true });
5424
- await writeFile(filePath, JSON.stringify({ trainSplit, train, test }, null, 2), "utf-8");
5452
+ await writeFile(filePath, JSON.stringify(test, null, 2), "utf-8");
5425
5453
  console.log(`Saved: ${filePath}`);
5426
5454
  process.exit(0);
5427
5455
  }
5428
5456
  if (values.markdown) {
5429
5457
  const filePath = resolve(dumpDir, `${dumpName}.md`);
5430
5458
  await mkdir(dumpDir, { recursive: true });
5431
- await writeFile(filePath, toMarkdown(train, test, trainSplit), "utf-8");
5459
+ await writeFile(filePath, toMarkdown(test), "utf-8");
5432
5460
  console.log(`Saved: ${filePath}`);
5433
5461
  process.exit(0);
5434
5462
  }
5435
- console.log(toMarkdown(train, test, trainSplit));
5463
+ console.log(toMarkdown(test));
5436
5464
  process.exit(0);
5437
5465
  };
5438
5466
  main$4();
@@ -5633,8 +5661,8 @@ Modes:
5633
5661
  --dump Fetch and save raw OHLCV candles
5634
5662
  --pnldebug Simulate PnL per minute for a given entry price and direction
5635
5663
  --brokerdebug Fire a single broker commit against the live broker adapter
5636
- --simulator <ideas.jsonl> Feasibility probe over crowd ideas: is there a profitable corridor at all
5637
- --tune <ideas.jsonl> Walk-forward parameter search: train on the head, one frozen shot on the tail
5664
+ --simulator <ideas.jsonl> [config.json] Feasibility probe over crowd ideas: is there a profitable corridor at all
5665
+ --tune <ideas.jsonl> <config.json> ONE out-of-sample shot of a frozen training artifact (point + author track)
5638
5666
  --flush <entry...> Delete report/log/markdown/agent folders from strategy dump dir
5639
5667
  --init Scaffold a new project in the current directory
5640
5668
  --docker Scaffold a Docker workspace for running strategies in a container
@@ -5764,19 +5792,22 @@ Simulator flags (--simulator):
5764
5792
  --markdown Save summary report to ./dump/<output>.md
5765
5793
  --verbose Log every simulator lifecycle callback to the console
5766
5794
 
5767
- Positional: path to an ideas .jsonl file — one idea per line with the exact shape
5795
+ Positionals: path to an ideas .jsonl file — one idea per line with the exact shape
5768
5796
  { "id": number, "ts": number, "symbol": string, "direction": "LONG"|"SHORT"|"NEUTRAL",
5769
- "author": string }. The file is validated BEFORE any work; a line that does not
5770
- match the structure aborts the run with an error.
5771
-
5772
- A FEASIBILITY PROBE, not a parameter search (that is --tune): the profit-harvesting
5773
- machinery is off (no profit lock, inert trailing, no weighted consensus), and a
5774
- fast 48-point grid of hard stop x hold x ban rule answers one question — does the
5775
- feed contain a profitable corridor at all. One 5-day candle pass per idea, flood
5776
- dedupe (one idea per author per direction per 8h), default-ban author filter,
5777
- time-based Sharpe/Sortino, four ranking winners. The report carries the corridor
5778
- share and the whitelist of the sharpe winner's ban rule as evidence.
5779
- Ideas of other symbols are filtered out — one shared feed serves any --symbol.
5797
+ "author": string } — and an OPTIONAL config .json with the shape
5798
+ { "gridAxes"?: ISimulatorGridAxes, "reportOrder"?: "sharpe"|"sortino"|"pnl"|"recovery" }.
5799
+ Both files are validated BEFORE any work; a mismatch aborts the run with an error.
5800
+ No config -> an empty object is used and the engine defaults apply (the full
5801
+ default grid axes and reportOrder "sharpe" of the connection service).
5802
+
5803
+ A FEASIBILITY PROBE, not an out-of-sample shot (that is --tune): the grid from
5804
+ the config (or the engine default) answers one question — does the feed contain
5805
+ a profitable corridor at all. One candle pass per idea to the grid's longest
5806
+ hold, flood dedupe (one idea per author per direction per 8h), default-ban
5807
+ author filter graded inside each point's own hold window, time-based
5808
+ Sharpe/Sortino, per-metric buckets with their own ranking winners and ban
5809
+ dictionaries. Ideas of other symbols are filtered out — one shared feed serves
5810
+ any --symbol.
5780
5811
 
5781
5812
  No output flag → print the Markdown summary to stdout. With --verbose every
5782
5813
  simulator lifecycle callback (onProgress, onIdeas, onProfiles, onAuthorsTrained,
@@ -5787,25 +5818,27 @@ Simulator flags (--simulator):
5787
5818
 
5788
5819
  Tune flags (--tune):
5789
5820
 
5790
- --symbol <string> Trading pair to tune (default: BTCUSDT)
5821
+ --symbol <string> Trading pair to test (default: BTCUSDT)
5791
5822
  --exchange <string> Exchange name (default: first registered)
5792
- --split <string> Train share of the feed time range, 0..1 (default: 0.7)
5793
5823
  --output <string> Output file base name (default: tune_{SYMBOL}_{TIMESTAMP})
5794
- --json Save { trainSplit, train, test } to ./dump/<output>.json
5795
- --markdown Save walk-forward report to ./dump/<output>.md
5824
+ --json Save the full ISimulatorTestResult to ./dump/<output>.json
5825
+ --markdown Save the out-of-sample report to ./dump/<output>.md
5796
5826
  --verbose Log simulator lifecycle callbacks to the console
5797
5827
 
5798
- Positional: path to an ideas .jsonl file — same shape and validation as --simulator.
5799
-
5800
- The PARAMETER SEARCH counterpart of the --simulator probe: the full grid with the
5801
- profit-harvesting machinery on — profit lock, trailing take, weighted consensus,
5802
- Wilson-bound ban, both author-hit metrics (close/reach). Honesty is structural:
5803
- training sees ONLY the head of the feed (--split of its time range), then the
5804
- sharpe winner is frozen — point and raw author track record — and fired exactly
5805
- once on the tail via Simulator.test. Nothing is trained on the tail; authors
5806
- unseen in training are banned by default. The report carries the train winners,
5807
- the out-of-sample result with the trade list, and the frozen author track record
5808
- to hardcode for production Simulator.test.
5828
+ Positionals: path to an ideas .jsonl file (same shape and validation as
5829
+ --simulator) and a config .json carrying the FROZEN training artifact:
5830
+ { "point": ISimulatorGridPoint, "authorStats": [{ "author", "ideas", "hits" }],
5831
+ "gridAxes"?, "reportOrder"? }. The point and authorStats are REQUIRED — without
5832
+ them there is nothing to test and the run aborts with an error. gridAxes are
5833
+ optional: by default they mirror the frozen point one value per axis (the grid
5834
+ is inert for a test).
5835
+
5836
+ ONE OUT-OF-SAMPLE SHOT, no training: the CLI never runs the sweep — pick your
5837
+ candidate elsewhere (a Simulator.run of your own), freeze its point and raw
5838
+ author track record into the config, and fire it once here via Simulator.test.
5839
+ Bans are re-derived from the frozen numbers under the point's rule; authors
5840
+ unseen in the config are banned by default. The report carries the result with
5841
+ the trade list and the frozen track record with re-derived ban flags.
5809
5842
 
5810
5843
  Module file ./modules/tune.module is loaded automatically if it exists
5811
5844
  (register your exchange there); without it CCXT Binance is used by default.
@@ -5874,9 +5907,9 @@ Examples:
5874
5907
  node ${ENTRY_PATH} --brokerdebug --commit signal-open --symbol BTCUSDT
5875
5908
  node ${ENTRY_PATH} --brokerdebug --commit partial-profit --symbol ETHUSDT
5876
5909
  node ${ENTRY_PATH} --simulator --symbol BTCUSDT ./assets/tv-ideas.normalized.jsonl
5877
- node ${ENTRY_PATH} --simulator --symbol BTCUSDT --json --output jun_2026_probe ./assets/tv-ideas.normalized.jsonl
5910
+ node ${ENTRY_PATH} --simulator --symbol BTCUSDT --json --output jun_2026_probe ./assets/tv-ideas.normalized.jsonl ./assets/probe.config.json
5878
5911
  node ${ENTRY_PATH} --tune --symbol BTCUSDT ./assets/tv-ideas.normalized.jsonl
5879
- node ${ENTRY_PATH} --tune --symbol BTCUSDT --split 0.7 --markdown --output jun_2026_tune ./assets/tv-ideas.normalized.jsonl
5912
+ node ${ENTRY_PATH} --tune --symbol BTCUSDT --markdown --output jun_2026_tune ./assets/tv-ideas.normalized.jsonl ./assets/tune.config.json
5880
5913
  node ${ENTRY_PATH} --flush ./content/feb_2026.strategy/feb_2026.strategy.ts
5881
5914
  node ${ENTRY_PATH} --flush ./content/feb_2026.strategy/feb_2026.strategy.ts ./content/feb_2026.strategy/feb_2026.test.ts
5882
5915
  node ${ENTRY_PATH} --init --output my-trading-bot
@@ -5890,7 +5923,7 @@ const main$1 = async () => {
5890
5923
  if (!values.help) {
5891
5924
  return;
5892
5925
  }
5893
- process.stdout.write(`@backtest-kit/cli ${"16.6.0"}\n\n`);
5926
+ process.stdout.write(`@backtest-kit/cli ${"17.0.0"}\n\n`);
5894
5927
  process.stdout.write(HELP_TEXT);
5895
5928
  process.exit(0);
5896
5929
  };
@@ -5904,7 +5937,7 @@ const main = async () => {
5904
5937
  if (!values.version) {
5905
5938
  return;
5906
5939
  }
5907
- process.stdout.write(`@backtest-kit/cli ${"16.6.0"}\n`);
5940
+ process.stdout.write(`@backtest-kit/cli ${"17.0.0"}\n`);
5908
5941
  process.exit(0);
5909
5942
  };
5910
5943
  main();
@@ -15,17 +15,17 @@
15
15
  "@types/node": "25.6.0"
16
16
  },
17
17
  "dependencies": {
18
- "@backtest-kit/cli": "16.6.0",
19
- "@backtest-kit/graph": "16.6.0",
20
- "@backtest-kit/pinets": "16.6.0",
21
- "@backtest-kit/signals": "16.6.0",
22
- "@backtest-kit/ui": "16.6.0",
18
+ "@backtest-kit/cli": "17.0.0",
19
+ "@backtest-kit/graph": "17.0.0",
20
+ "@backtest-kit/pinets": "17.0.0",
21
+ "@backtest-kit/signals": "17.0.0",
22
+ "@backtest-kit/ui": "17.0.0",
23
23
  "@tavily/core": "0.7.2",
24
24
  "@tensorflow/tfjs": "4.22.0",
25
25
  "@tensorflow/tfjs-backend-wasm": "4.22.0",
26
26
  "@tensorflow/tfjs-core": "4.22.0",
27
27
  "agent-swarm-kit": "6.0.0",
28
- "backtest-kit": "16.6.0",
28
+ "backtest-kit": "17.0.0",
29
29
  "dayjs": "1.11.20",
30
30
  "functools-kit": "5.0.0",
31
31
  "garch": "2.0.0",