@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.cjs CHANGED
@@ -899,10 +899,6 @@ const getArgs = functoolsKit.singleshot(() => {
899
899
  type: "boolean",
900
900
  default: false,
901
901
  },
902
- split: {
903
- type: "string",
904
- default: "",
905
- },
906
902
  commit: {
907
903
  type: "string",
908
904
  default: "",
@@ -3702,7 +3698,7 @@ const main$k = async () => {
3702
3698
  if (MODES.some((mode) => values[mode])) {
3703
3699
  return;
3704
3700
  }
3705
- process.stdout.write(`@backtest-kit/cli ${"16.6.0"}\n`);
3701
+ process.stdout.write(`@backtest-kit/cli ${"17.0.0"}\n`);
3706
3702
  process.stdout.write("\n");
3707
3703
  process.stdout.write(`Run with --help to see available commands.\n`);
3708
3704
  process.stdout.write("\n");
@@ -4926,6 +4922,18 @@ const main$6 = async () => {
4926
4922
  main$6();
4927
4923
 
4928
4924
  const SIMULATOR_NAME$1 = "cli_simulator";
4925
+ const CONFIG_KEYS$1 = ["gridAxes", "reportOrder"];
4926
+ const validateConfig$1 = (config) => {
4927
+ if (typeof config !== "object" || config === null || Array.isArray(config)) {
4928
+ return "config must be a JSON object";
4929
+ }
4930
+ for (const key of Object.keys(config)) {
4931
+ if (!CONFIG_KEYS$1.includes(key)) {
4932
+ return `unknown key "${key}" (expected: ${CONFIG_KEYS$1.join(", ")})`;
4933
+ }
4934
+ }
4935
+ return null;
4936
+ };
4929
4937
  const IDEA_DIRECTIONS$1 = ["LONG", "SHORT", "NEUTRAL"];
4930
4938
  const validateIdea$1 = (idea, line) => {
4931
4939
  if (typeof idea !== "object" || idea === null) {
@@ -4949,7 +4957,9 @@ const validateIdea$1 = (idea, line) => {
4949
4957
  return null;
4950
4958
  };
4951
4959
  const toMarkdown$1 = (result) => {
4952
- const profitable = result.reports.filter(({ totalPnlPercent }) => totalPnlPercent > 0).length;
4960
+ const buckets = Object.entries(result.reports).filter(([, bucket]) => bucket.reports.length > 0);
4961
+ const allReports = buckets.flatMap(([, bucket]) => bucket.reports);
4962
+ const profitable = allReports.filter(({ totalPnlPercent }) => totalPnlPercent > 0).length;
4953
4963
  const lines = [];
4954
4964
  lines.push(`# Simulator Report — ${result.symbol}`);
4955
4965
  lines.push("");
@@ -4957,33 +4967,34 @@ const toMarkdown$1 = (result) => {
4957
4967
  lines.push(`| --- | --- |`);
4958
4968
  lines.push(`| Ideas (total / directional) | ${result.ideasTotal} / ${result.ideasDirectional} |`);
4959
4969
  lines.push(`| Profiles (truncated) | ${result.profileCount} (${result.truncatedCount}) |`);
4960
- lines.push(`| Authors allowed / banned | ${result.allowedAuthors.length} / ${result.bannedAuthors.length} |`);
4961
- lines.push(`| Profitable corridor | ${profitable} / ${result.reports.length} grid points |`);
4970
+ lines.push(`| Profitable corridor | ${profitable} / ${allReports.length} grid points |`);
4962
4971
  lines.push(`| Hold minutes avg / p95 / p99 | ${result.avgHoldMinutes.toFixed(0)} / ${result.p95HoldMinutes} / ${result.p99HoldMinutes} |`);
4963
- lines.push("");
4964
- lines.push(`## Ranking winners`);
4965
- lines.push("");
4966
- lines.push(`| Criterion | Stop% | Hold | Track | HitRate | Trades | PNL% | WinRate | Sharpe | Sortino |`);
4967
- lines.push(`| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |`);
4968
- for (const best of result.best) {
4969
- if (!best.report) {
4970
- lines.push(`| ${best.criterion} | — | — | — | — | — | — | — | — | — |`);
4971
- continue;
4972
+ // каждая метрика — самодостаточная корзина: свои победители и
4973
+ // свои словари банов, между собой не склеиваются
4974
+ for (const [metric, bucket] of buckets) {
4975
+ lines.push("");
4976
+ lines.push(`## Metric: ${metric}`);
4977
+ lines.push("");
4978
+ lines.push(`| Criterion | Stop% | Hold | Track | HitRate | Trades | PNL% | WinRate | Sharpe | Sortino |`);
4979
+ lines.push(`| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |`);
4980
+ for (const best of bucket.best) {
4981
+ if (!best.report) {
4982
+ lines.push(`| ${best.criterion} | — | — | — | — | — | — | — | — | — |`);
4983
+ continue;
4984
+ }
4985
+ const { point } = best.report;
4986
+ lines.push(`| ${best.criterion} | ${point.hardStopPercent} | ${point.holdMinutes / 60}h | ${point.minAuthorTrack} | ${point.minAuthorHitRate} | ` +
4987
+ `${best.report.trades} | ${best.report.totalPnlPercent.toFixed(2)}% | ${(best.report.winRate * 100).toFixed(0)}% | ${best.report.sharpe.toFixed(2)} | ${best.report.sortino.toFixed(2)} |`);
4988
+ }
4989
+ const sharpeBest = bucket.best.find(({ criterion }) => criterion === "sharpe");
4990
+ lines.push("");
4991
+ lines.push(`### Allowed authors (sharpe winner rule)`);
4992
+ lines.push("");
4993
+ lines.push(`| Author | Ideas | Hits | HitRate |`);
4994
+ lines.push(`| --- | --- | --- | --- |`);
4995
+ for (const stat of (sharpeBest?.authorStats ?? []).filter(({ banned }) => !banned)) {
4996
+ lines.push(`| ${stat.author} | ${stat.ideas} | ${stat.hits} | ${(stat.hitRate * 100).toFixed(0)}% |`);
4972
4997
  }
4973
- const { point } = best.report;
4974
- lines.push(`| ${best.criterion} | ${point.hardStopPercent} | ${point.holdMinutes / 60}h | ${point.minAuthorTrack} | ${point.minAuthorHitRate} | ` +
4975
- `${best.report.trades} | ${best.report.totalPnlPercent.toFixed(2)}% | ${(best.report.winRate * 100).toFixed(0)}% | ${best.report.sharpe.toFixed(2)} | ${best.report.sortino.toFixed(2)} |`);
4976
- }
4977
- lines.push("");
4978
- lines.push(`## Allowed authors (whitelist of the sharpe winner rule)`);
4979
- lines.push("");
4980
- lines.push(`| Author | Ideas | Hits | HitRate |`);
4981
- lines.push(`| --- | --- | --- | --- |`);
4982
- // артефакт авторов живёт по-победительно в best[] — ран-левел
4983
- // authorStats в ISimulatorResult больше нет
4984
- const sharpeBest = result.best.find(({ criterion }) => criterion === "sharpe");
4985
- for (const stat of (sharpeBest?.authorStats ?? []).filter(({ banned }) => !banned)) {
4986
- lines.push(`| ${stat.author} | ${stat.ideas} | ${stat.hits} | ${(stat.hitRate * 100).toFixed(0)}% |`);
4987
4998
  }
4988
4999
  return lines.join("\n");
4989
5000
  };
@@ -5000,6 +5011,33 @@ const main$5 = async () => {
5000
5011
  console.error("Error: positional path to an ideas .jsonl file is required");
5001
5012
  process.exit(1);
5002
5013
  }
5014
+ // конфиг — позиционный JSON; без него подтягивается пустой объект
5015
+ // и схема живёт на дефолтах движка (оси, порядок отчёта)
5016
+ const [configPath = null] = positionals.filter((value) => value.endsWith(".json") && !value.endsWith(".jsonl"));
5017
+ let config = {};
5018
+ if (configPath) {
5019
+ let content;
5020
+ try {
5021
+ content = await fs$1.readFile(path.resolve(configPath), "utf-8");
5022
+ }
5023
+ catch (error) {
5024
+ console.error(`Error: cannot read config file: ${configPath}`);
5025
+ process.exit(1);
5026
+ }
5027
+ try {
5028
+ config = JSON.parse(content);
5029
+ }
5030
+ catch {
5031
+ console.error(`Error: invalid JSON in config file: ${configPath}`);
5032
+ process.exit(1);
5033
+ }
5034
+ const problem = validateConfig$1(config);
5035
+ if (problem) {
5036
+ console.error(`Error: simulator config does not match the structure — ${problem}`);
5037
+ console.error(`Expected shape: { "gridAxes"?: ISimulatorGridAxes, "reportOrder"?: "sharpe"|"sortino"|"pnl"|"recovery" }`);
5038
+ process.exit(1);
5039
+ }
5040
+ }
5003
5041
  let ideas = [];
5004
5042
  {
5005
5043
  let content;
@@ -5071,31 +5109,13 @@ const main$5 = async () => {
5071
5109
  });
5072
5110
  }
5073
5111
  const symbol = values.symbol || "BTCUSDT";
5074
- // Проба осуществимости, НЕ поиск заработка (подбор параметров — у
5075
- // --tune): собирающая прибыль механика выключена, остаётся вопрос
5076
- // "есть ли прибыльный коридор" по стопу x холду x правилу бана —
5077
- // 48 точек, быстрый вердикт
5112
+ // оси и порядок отчёта приходят позиционным конфигом потребителя;
5113
+ // пустой конфиг — дефолтная сетка движка из connection-сервиса
5078
5114
  BacktestKit.addSimulatorSchema({
5079
5115
  simulatorName: SIMULATOR_NAME$1,
5080
5116
  exchangeName,
5081
- gridAxes: {
5082
- // грубая шкала катастрофы: коридор должен быть широким, не точкой
5083
- hardStopPercent: [2, 3, 5, 7],
5084
- // инертен: проба не собирает прибыль, выход — по времени или стопу
5085
- trailingTakePercent: [100],
5086
- holdMinutes: [24 * 60, 2 * 24 * 60, 3 * 24 * 60],
5087
- // одного проверенного автора достаточно — консенсус не перебираем
5088
- minIdeasAligned: [1],
5089
- // правило бана — единственная перебираемая "умность" пробы
5090
- minAuthorTrack: [3, 5],
5091
- minAuthorHitRate: [0.5, 0.6],
5092
- minAuthorWilson: [0],
5093
- minWeightAligned: [0],
5094
- profitLockPercent: [0],
5095
- authorMetric: ["close"],
5096
- banCriteria: ["sharpe", "pnl"],
5097
- },
5098
- reportOrder: "sharpe",
5117
+ ...(config.gridAxes ? { gridAxes: config.gridAxes } : {}),
5118
+ ...(config.reportOrder ? { reportOrder: config.reportOrder } : {}),
5099
5119
  callbacks: {
5100
5120
  onProgress: (symbol, stage, processed, total) => {
5101
5121
  if (values.verbose) {
@@ -5147,9 +5167,8 @@ const main$5 = async () => {
5147
5167
  if (values.verbose) {
5148
5168
  console.log("onDone", {
5149
5169
  symbol,
5150
- reports: result.reports.length,
5151
- allowedAuthors: result.allowedAuthors.length,
5152
- bannedAuthors: result.bannedAuthors.length,
5170
+ reports: Object.values(result.reports).flatMap((bucket) => bucket.reports).length,
5171
+ bans: Object.values(result.reports).flatMap((bucket) => bucket.bans).length,
5153
5172
  });
5154
5173
  }
5155
5174
  },
@@ -5182,8 +5201,15 @@ const main$5 = async () => {
5182
5201
  main$5();
5183
5202
 
5184
5203
  const SIMULATOR_NAME = "cli_tune";
5185
- /** Доля времени фида, уходящая в обучение; хвост — один OOS-выстрел. */
5186
- const DEFAULT_TRAIN_SPLIT = 0.7;
5204
+ const CONFIG_KEYS = ["point", "authorStats", "gridAxes", "reportOrder"];
5205
+ const POINT_NUMBER_FIELDS = [
5206
+ "hardStopPercent",
5207
+ "trailingTakePercent",
5208
+ "holdMinutes",
5209
+ "minAuthorTrack",
5210
+ "minAuthorHitRate",
5211
+ "profitLockPercent",
5212
+ ];
5187
5213
  const IDEA_DIRECTIONS = ["LONG", "SHORT", "NEUTRAL"];
5188
5214
  const validateIdea = (idea, line) => {
5189
5215
  if (typeof idea !== "object" || idea === null) {
@@ -5206,33 +5232,65 @@ const validateIdea = (idea, line) => {
5206
5232
  }
5207
5233
  return null;
5208
5234
  };
5209
- const pointLabel = (point) => `H=${point.hardStopPercent} TT=${point.trailingTakePercent} hold=${point.holdMinutes / 60}h N=${point.minIdeasAligned} ` +
5210
- `track=${point.minAuthorTrack} rate=${point.minAuthorHitRate} wilson=${point.minAuthorWilson} W=${point.minWeightAligned} ` +
5235
+ const validateConfig = (config) => {
5236
+ if (typeof config !== "object" || config === null || Array.isArray(config)) {
5237
+ return "config must be a JSON object";
5238
+ }
5239
+ for (const key of Object.keys(config)) {
5240
+ if (!CONFIG_KEYS.includes(key)) {
5241
+ return `unknown key "${key}" (expected: ${CONFIG_KEYS.join(", ")})`;
5242
+ }
5243
+ }
5244
+ if (typeof config.point !== "object" || config.point === null) {
5245
+ return `"point" is required — the frozen grid point of a training run`;
5246
+ }
5247
+ for (const field of POINT_NUMBER_FIELDS) {
5248
+ if (typeof config.point[field] !== "number") {
5249
+ return `"point.${field}" must be a number, got ${typeof config.point[field]}`;
5250
+ }
5251
+ }
5252
+ if (typeof config.point.authorMetric !== "string") {
5253
+ return `"point.authorMetric" must be a string, got ${typeof config.point.authorMetric}`;
5254
+ }
5255
+ if (!Array.isArray(config.authorStats) || !config.authorStats.length) {
5256
+ return `"authorStats" is required — the frozen raw track record (non-empty array)`;
5257
+ }
5258
+ for (let i = 0; i < config.authorStats.length; i++) {
5259
+ const stat = config.authorStats[i];
5260
+ if (typeof stat !== "object" ||
5261
+ stat === null ||
5262
+ typeof stat.author !== "string" ||
5263
+ typeof stat.ideas !== "number" ||
5264
+ typeof stat.hits !== "number") {
5265
+ return `"authorStats[${i}]" must be { author: string, ideas: number, hits: number }`;
5266
+ }
5267
+ }
5268
+ return null;
5269
+ };
5270
+ /** Одноточечные оси, зеркалящие замороженную точку — сетка инертна. */
5271
+ const axesFromPoint = (point) => ({
5272
+ hardStopPercent: [point.hardStopPercent],
5273
+ trailingTakePercent: [point.trailingTakePercent],
5274
+ holdMinutes: [point.holdMinutes],
5275
+ minAuthorTrack: [point.minAuthorTrack],
5276
+ minAuthorHitRate: [point.minAuthorHitRate],
5277
+ profitLockPercent: [point.profitLockPercent],
5278
+ authorMetric: [point.authorMetric],
5279
+ });
5280
+ const pointLabel = (point) => `H=${point.hardStopPercent} TT=${point.trailingTakePercent} hold=${point.holdMinutes / 60}h ` +
5281
+ `track=${point.minAuthorTrack} rate=${point.minAuthorHitRate} ` +
5211
5282
  `lock=${point.profitLockPercent} metric=${point.authorMetric}`;
5212
5283
  const fmtRatio = (value) => Number.isFinite(value) ? value.toFixed(2) : "inf";
5213
- const toMarkdown = (train, test, trainSplit) => {
5284
+ const toMarkdown = (test) => {
5214
5285
  const lines = [];
5215
- lines.push(`# Tune Report — ${train.symbol}`);
5216
- lines.push("");
5217
- 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.`);
5218
- lines.push("");
5219
- lines.push(`## Train (${train.ideasDirectional} directional ideas, ${train.reports.length} grid points)`);
5220
- lines.push("");
5221
- lines.push(`| Criterion | Point | Trades | PNL% | WinRate | DD% | Sharpe | Sortino |`);
5222
- lines.push(`| --- | --- | --- | --- | --- | --- | --- | --- |`);
5223
- for (const best of train.best) {
5224
- if (!best.report) {
5225
- lines.push(`| ${best.criterion} | — | — | — | — | — | — | — |`);
5226
- continue;
5227
- }
5228
- lines.push(`| ${best.criterion} | ${pointLabel(best.report.point)} | ${best.report.trades} | ${best.report.totalPnlPercent.toFixed(2)}% | ` +
5229
- `${(best.report.winRate * 100).toFixed(0)}% | ${best.report.maxSeriesDrawdownPercent.toFixed(2)} | ${fmtRatio(best.report.sharpe)} | ${fmtRatio(best.report.sortino)} |`);
5230
- }
5286
+ lines.push(`# Tune Report — ${test.symbol}`);
5231
5287
  lines.push("");
5232
- lines.push(`## Out-of-sample (frozen sharpe winner, ${test.ideasDirectional} tail ideas)`);
5288
+ 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.`);
5233
5289
  lines.push("");
5234
5290
  lines.push(`Frozen point: ${pointLabel(test.point)}`);
5235
5291
  lines.push("");
5292
+ lines.push(`## Out-of-sample (${test.ideasDirectional} directional ideas)`);
5293
+ lines.push("");
5236
5294
  lines.push(`| Metric | Value |`);
5237
5295
  lines.push(`| --- | --- |`);
5238
5296
  lines.push(`| Trades (skipped busy) | ${test.report.trades} (${test.report.skippedBusy}) |`);
@@ -5252,9 +5310,7 @@ const toMarkdown = (train, test, trainSplit) => {
5252
5310
  lines.push(`| ${trade.direction} | ${trade.exitReason} | ${trade.pnlPercent.toFixed(2)} | ${trade.holdMinutesActual}m | ${new Date(trade.entryTimestamp).toISOString()} |`);
5253
5311
  }
5254
5312
  lines.push("");
5255
- lines.push(`## Frozen author track record (train range, sharpe winner rule)`);
5256
- lines.push("");
5257
- 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.`);
5313
+ lines.push(`## Frozen author track record (re-derived bans under the point's rule)`);
5258
5314
  lines.push("");
5259
5315
  lines.push(`| Author | Ideas | Hits | Banned |`);
5260
5316
  lines.push(`| --- | --- | --- | --- |`);
@@ -5276,6 +5332,35 @@ const main$4 = async () => {
5276
5332
  console.error("Error: positional path to an ideas .jsonl file is required");
5277
5333
  process.exit(1);
5278
5334
  }
5335
+ // конфиг — позиционный JSON; без него подтягивается пустой объект
5336
+ // (и валидация честно скажет, что замороженной точки нет)
5337
+ const [configPath = null] = positionals.filter((value) => value.endsWith(".json") && !value.endsWith(".jsonl"));
5338
+ let config = {};
5339
+ if (configPath) {
5340
+ let content;
5341
+ try {
5342
+ content = await fs$1.readFile(path.resolve(configPath), "utf-8");
5343
+ }
5344
+ catch (error) {
5345
+ console.error(`Error: cannot read config file: ${configPath}`);
5346
+ process.exit(1);
5347
+ }
5348
+ try {
5349
+ config = JSON.parse(content);
5350
+ }
5351
+ catch {
5352
+ console.error(`Error: invalid JSON in config file: ${configPath}`);
5353
+ process.exit(1);
5354
+ }
5355
+ }
5356
+ {
5357
+ const problem = validateConfig(config);
5358
+ if (problem) {
5359
+ console.error(`Error: tune config does not match the structure — ${problem}`);
5360
+ console.error(`Expected shape: { "point": ISimulatorGridPoint, "authorStats": [{ "author", "ideas", "hits" }], "gridAxes"?, "reportOrder"? }`);
5361
+ process.exit(1);
5362
+ }
5363
+ }
5279
5364
  let ideas = [];
5280
5365
  {
5281
5366
  let content;
@@ -5309,13 +5394,6 @@ const main$4 = async () => {
5309
5394
  ideas.push(idea);
5310
5395
  }
5311
5396
  }
5312
- const trainSplit = values.split
5313
- ? parseFloat(values.split)
5314
- : DEFAULT_TRAIN_SPLIT;
5315
- if (!Number.isFinite(trainSplit) || trainSplit <= 0 || trainSplit >= 1) {
5316
- console.error(`Error: --split must be a fraction between 0 and 1, got ${values.split}`);
5317
- process.exit(1);
5318
- }
5319
5397
  {
5320
5398
  const cwd = process.cwd();
5321
5399
  dotenv.config({ path: path.join(cwd, '.env'), override: true, quiet: true });
@@ -5354,28 +5432,13 @@ const main$4 = async () => {
5354
5432
  });
5355
5433
  }
5356
5434
  const symbol = values.symbol || "BTCUSDT";
5357
- // Подбор параметров: полная сетка с собирающей прибыль механикой —
5358
- // замок, трейлинг, взвешенный консенсус, Вильсон, обе метрики бана.
5359
- // Честность обеспечивается сплитом: обучение видит только голову,
5360
- // хвост тратится на один выстрел замороженного sharpe-победителя
5435
+ // сетка для test() инертна (оценивается ровно замороженная точка):
5436
+ // без явных осей в конфиге они одноточечно зеркалят точку
5361
5437
  BacktestKit.addSimulatorSchema({
5362
5438
  simulatorName: SIMULATOR_NAME,
5363
5439
  exchangeName,
5364
- gridAxes: {
5365
- // стопы < 2% сидят внутри медианного шейкаута и не выигрывают
5366
- hardStopPercent: [2, 2.5, 3, 4, 5, 7],
5367
- trailingTakePercent: [1, 1.5, 2, 3, 4],
5368
- holdMinutes: [24 * 60, 2 * 24 * 60, 3 * 24 * 60],
5369
- minIdeasAligned: [1, 2],
5370
- minAuthorTrack: [2, 3, 5],
5371
- minAuthorHitRate: [0.5, 0.6],
5372
- minAuthorWilson: [0, 0.6],
5373
- minWeightAligned: [0, 0.6, 1.2],
5374
- profitLockPercent: [0, 1.5, 2.5],
5375
- authorMetric: ["close", "reach"],
5376
- banCriteria: ["sharpe"],
5377
- },
5378
- reportOrder: "sharpe",
5440
+ gridAxes: config.gridAxes ?? axesFromPoint(config.point),
5441
+ ...(config.reportOrder ? { reportOrder: config.reportOrder } : {}),
5379
5442
  callbacks: {
5380
5443
  onProgress: (symbol, stage, processed, total) => {
5381
5444
  if (values.verbose) {
@@ -5392,26 +5455,6 @@ const main$4 = async () => {
5392
5455
  console.log("onProfiles", { symbol, profiles: profiles.length, truncatedCount });
5393
5456
  }
5394
5457
  },
5395
- onAuthorsTrained: (symbol, stats, bannedIdeas) => {
5396
- if (values.verbose) {
5397
- console.log("onAuthorsTrained", {
5398
- symbol,
5399
- authors: stats.length,
5400
- banned: stats.filter(({ banned }) => banned).length,
5401
- bannedIdeas,
5402
- });
5403
- }
5404
- },
5405
- onRanking: (symbol, criterion, _sorted, best) => {
5406
- if (values.verbose) {
5407
- console.log("onRanking", {
5408
- symbol,
5409
- criterion,
5410
- point: best.report?.point ?? null,
5411
- pnl: best.report ? +best.report.totalPnlPercent.toFixed(2) : null,
5412
- });
5413
- }
5414
- },
5415
5458
  onTestDone: (symbol, result) => {
5416
5459
  if (values.verbose) {
5417
5460
  console.log("onTestDone", {
@@ -5423,48 +5466,33 @@ const main$4 = async () => {
5423
5466
  },
5424
5467
  },
5425
5468
  });
5426
- // обучение видит ТОЛЬКО голову ленты; хвост — один OOS-выстрел
5427
- const sorted = [...ideas].sort((a, b) => a.ts - b.ts);
5428
- const cutoff = sorted[0].ts + (sorted[sorted.length - 1].ts - sorted[0].ts) * trainSplit;
5429
- const trainIdeas = sorted.filter(({ ts }) => ts < cutoff);
5430
- const testIdeas = sorted.filter(({ ts }) => ts >= cutoff);
5431
- const train = await BacktestKit.Simulator.run({
5432
- symbol,
5433
- simulatorName: SIMULATOR_NAME,
5434
- ideas: trainIdeas,
5435
- });
5436
- const sharpeBest = train.best.find(({ criterion }) => criterion === "sharpe");
5437
- if (!sharpeBest?.report) {
5438
- console.error("Error: training produced no sharpe winner — nothing to freeze for the out-of-sample shot");
5439
- process.exit(1);
5440
- }
5441
- // заморозка: точка и трек-рекорд sharpe-победителя как есть — из
5442
- // замороженных статов test() читает только author/ideas/hits и сам
5443
- // перевыводит баны и веса под правило точки
5469
+ // только out-of-sample: обучения нет, точка и трек-рекорд приходят
5470
+ // из конфига verbatim — test() перевыводит баны под правило точки,
5471
+ // невиданные авторы забанены по умолчанию
5444
5472
  const test = await BacktestKit.Simulator.test({
5445
5473
  symbol,
5446
5474
  simulatorName: SIMULATOR_NAME,
5447
- ideas: testIdeas,
5448
- point: sharpeBest.report.point,
5449
- authorStats: sharpeBest.authorStats,
5475
+ ideas,
5476
+ point: config.point,
5477
+ authorStats: config.authorStats,
5450
5478
  });
5451
5479
  const dumpName = values.output || `tune_${symbol}_${Date.now()}`;
5452
5480
  const dumpDir = path.join(process.cwd(), "dump");
5453
5481
  if (values.json) {
5454
5482
  const filePath = path.resolve(dumpDir, `${dumpName}.json`);
5455
5483
  await fs$1.mkdir(dumpDir, { recursive: true });
5456
- await fs$1.writeFile(filePath, JSON.stringify({ trainSplit, train, test }, null, 2), "utf-8");
5484
+ await fs$1.writeFile(filePath, JSON.stringify(test, null, 2), "utf-8");
5457
5485
  console.log(`Saved: ${filePath}`);
5458
5486
  process.exit(0);
5459
5487
  }
5460
5488
  if (values.markdown) {
5461
5489
  const filePath = path.resolve(dumpDir, `${dumpName}.md`);
5462
5490
  await fs$1.mkdir(dumpDir, { recursive: true });
5463
- await fs$1.writeFile(filePath, toMarkdown(train, test, trainSplit), "utf-8");
5491
+ await fs$1.writeFile(filePath, toMarkdown(test), "utf-8");
5464
5492
  console.log(`Saved: ${filePath}`);
5465
5493
  process.exit(0);
5466
5494
  }
5467
- console.log(toMarkdown(train, test, trainSplit));
5495
+ console.log(toMarkdown(test));
5468
5496
  process.exit(0);
5469
5497
  };
5470
5498
  main$4();
@@ -5665,8 +5693,8 @@ Modes:
5665
5693
  --dump Fetch and save raw OHLCV candles
5666
5694
  --pnldebug Simulate PnL per minute for a given entry price and direction
5667
5695
  --brokerdebug Fire a single broker commit against the live broker adapter
5668
- --simulator <ideas.jsonl> Feasibility probe over crowd ideas: is there a profitable corridor at all
5669
- --tune <ideas.jsonl> Walk-forward parameter search: train on the head, one frozen shot on the tail
5696
+ --simulator <ideas.jsonl> [config.json] Feasibility probe over crowd ideas: is there a profitable corridor at all
5697
+ --tune <ideas.jsonl> <config.json> ONE out-of-sample shot of a frozen training artifact (point + author track)
5670
5698
  --flush <entry...> Delete report/log/markdown/agent folders from strategy dump dir
5671
5699
  --init Scaffold a new project in the current directory
5672
5700
  --docker Scaffold a Docker workspace for running strategies in a container
@@ -5796,19 +5824,22 @@ Simulator flags (--simulator):
5796
5824
  --markdown Save summary report to ./dump/<output>.md
5797
5825
  --verbose Log every simulator lifecycle callback to the console
5798
5826
 
5799
- Positional: path to an ideas .jsonl file — one idea per line with the exact shape
5827
+ Positionals: path to an ideas .jsonl file — one idea per line with the exact shape
5800
5828
  { "id": number, "ts": number, "symbol": string, "direction": "LONG"|"SHORT"|"NEUTRAL",
5801
- "author": string }. The file is validated BEFORE any work; a line that does not
5802
- match the structure aborts the run with an error.
5803
-
5804
- A FEASIBILITY PROBE, not a parameter search (that is --tune): the profit-harvesting
5805
- machinery is off (no profit lock, inert trailing, no weighted consensus), and a
5806
- fast 48-point grid of hard stop x hold x ban rule answers one question — does the
5807
- feed contain a profitable corridor at all. One 5-day candle pass per idea, flood
5808
- dedupe (one idea per author per direction per 8h), default-ban author filter,
5809
- time-based Sharpe/Sortino, four ranking winners. The report carries the corridor
5810
- share and the whitelist of the sharpe winner's ban rule as evidence.
5811
- Ideas of other symbols are filtered out — one shared feed serves any --symbol.
5829
+ "author": string } — and an OPTIONAL config .json with the shape
5830
+ { "gridAxes"?: ISimulatorGridAxes, "reportOrder"?: "sharpe"|"sortino"|"pnl"|"recovery" }.
5831
+ Both files are validated BEFORE any work; a mismatch aborts the run with an error.
5832
+ No config -> an empty object is used and the engine defaults apply (the full
5833
+ default grid axes and reportOrder "sharpe" of the connection service).
5834
+
5835
+ A FEASIBILITY PROBE, not an out-of-sample shot (that is --tune): the grid from
5836
+ the config (or the engine default) answers one question — does the feed contain
5837
+ a profitable corridor at all. One candle pass per idea to the grid's longest
5838
+ hold, flood dedupe (one idea per author per direction per 8h), default-ban
5839
+ author filter graded inside each point's own hold window, time-based
5840
+ Sharpe/Sortino, per-metric buckets with their own ranking winners and ban
5841
+ dictionaries. Ideas of other symbols are filtered out — one shared feed serves
5842
+ any --symbol.
5812
5843
 
5813
5844
  No output flag → print the Markdown summary to stdout. With --verbose every
5814
5845
  simulator lifecycle callback (onProgress, onIdeas, onProfiles, onAuthorsTrained,
@@ -5819,25 +5850,27 @@ Simulator flags (--simulator):
5819
5850
 
5820
5851
  Tune flags (--tune):
5821
5852
 
5822
- --symbol <string> Trading pair to tune (default: BTCUSDT)
5853
+ --symbol <string> Trading pair to test (default: BTCUSDT)
5823
5854
  --exchange <string> Exchange name (default: first registered)
5824
- --split <string> Train share of the feed time range, 0..1 (default: 0.7)
5825
5855
  --output <string> Output file base name (default: tune_{SYMBOL}_{TIMESTAMP})
5826
- --json Save { trainSplit, train, test } to ./dump/<output>.json
5827
- --markdown Save walk-forward report to ./dump/<output>.md
5856
+ --json Save the full ISimulatorTestResult to ./dump/<output>.json
5857
+ --markdown Save the out-of-sample report to ./dump/<output>.md
5828
5858
  --verbose Log simulator lifecycle callbacks to the console
5829
5859
 
5830
- Positional: path to an ideas .jsonl file — same shape and validation as --simulator.
5831
-
5832
- The PARAMETER SEARCH counterpart of the --simulator probe: the full grid with the
5833
- profit-harvesting machinery on — profit lock, trailing take, weighted consensus,
5834
- Wilson-bound ban, both author-hit metrics (close/reach). Honesty is structural:
5835
- training sees ONLY the head of the feed (--split of its time range), then the
5836
- sharpe winner is frozen — point and raw author track record — and fired exactly
5837
- once on the tail via Simulator.test. Nothing is trained on the tail; authors
5838
- unseen in training are banned by default. The report carries the train winners,
5839
- the out-of-sample result with the trade list, and the frozen author track record
5840
- to hardcode for production Simulator.test.
5860
+ Positionals: path to an ideas .jsonl file (same shape and validation as
5861
+ --simulator) and a config .json carrying the FROZEN training artifact:
5862
+ { "point": ISimulatorGridPoint, "authorStats": [{ "author", "ideas", "hits" }],
5863
+ "gridAxes"?, "reportOrder"? }. The point and authorStats are REQUIRED — without
5864
+ them there is nothing to test and the run aborts with an error. gridAxes are
5865
+ optional: by default they mirror the frozen point one value per axis (the grid
5866
+ is inert for a test).
5867
+
5868
+ ONE OUT-OF-SAMPLE SHOT, no training: the CLI never runs the sweep — pick your
5869
+ candidate elsewhere (a Simulator.run of your own), freeze its point and raw
5870
+ author track record into the config, and fire it once here via Simulator.test.
5871
+ Bans are re-derived from the frozen numbers under the point's rule; authors
5872
+ unseen in the config are banned by default. The report carries the result with
5873
+ the trade list and the frozen track record with re-derived ban flags.
5841
5874
 
5842
5875
  Module file ./modules/tune.module is loaded automatically if it exists
5843
5876
  (register your exchange there); without it CCXT Binance is used by default.
@@ -5906,9 +5939,9 @@ Examples:
5906
5939
  node ${ENTRY_PATH} --brokerdebug --commit signal-open --symbol BTCUSDT
5907
5940
  node ${ENTRY_PATH} --brokerdebug --commit partial-profit --symbol ETHUSDT
5908
5941
  node ${ENTRY_PATH} --simulator --symbol BTCUSDT ./assets/tv-ideas.normalized.jsonl
5909
- node ${ENTRY_PATH} --simulator --symbol BTCUSDT --json --output jun_2026_probe ./assets/tv-ideas.normalized.jsonl
5942
+ node ${ENTRY_PATH} --simulator --symbol BTCUSDT --json --output jun_2026_probe ./assets/tv-ideas.normalized.jsonl ./assets/probe.config.json
5910
5943
  node ${ENTRY_PATH} --tune --symbol BTCUSDT ./assets/tv-ideas.normalized.jsonl
5911
- node ${ENTRY_PATH} --tune --symbol BTCUSDT --split 0.7 --markdown --output jun_2026_tune ./assets/tv-ideas.normalized.jsonl
5944
+ node ${ENTRY_PATH} --tune --symbol BTCUSDT --markdown --output jun_2026_tune ./assets/tv-ideas.normalized.jsonl ./assets/tune.config.json
5912
5945
  node ${ENTRY_PATH} --flush ./content/feb_2026.strategy/feb_2026.strategy.ts
5913
5946
  node ${ENTRY_PATH} --flush ./content/feb_2026.strategy/feb_2026.strategy.ts ./content/feb_2026.strategy/feb_2026.test.ts
5914
5947
  node ${ENTRY_PATH} --init --output my-trading-bot
@@ -5922,7 +5955,7 @@ const main$1 = async () => {
5922
5955
  if (!values.help) {
5923
5956
  return;
5924
5957
  }
5925
- process.stdout.write(`@backtest-kit/cli ${"16.6.0"}\n\n`);
5958
+ process.stdout.write(`@backtest-kit/cli ${"17.0.0"}\n\n`);
5926
5959
  process.stdout.write(HELP_TEXT);
5927
5960
  process.exit(0);
5928
5961
  };
@@ -5936,7 +5969,7 @@ const main = async () => {
5936
5969
  if (!values.version) {
5937
5970
  return;
5938
5971
  }
5939
- process.stdout.write(`@backtest-kit/cli ${"16.6.0"}\n`);
5972
+ process.stdout.write(`@backtest-kit/cli ${"17.0.0"}\n`);
5940
5973
  process.exit(0);
5941
5974
  };
5942
5975
  main();