@backtest-kit/cli 16.5.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
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import * as BacktestKit from 'backtest-kit';
3
- import { setConfig, listenExit, emitters, Notification, Cron, Recent, Storage, Markdown, Report, Dump, State, Memory, MarkdownWriter, ReportWriter, PersistSignalAdapter, PersistRiskAdapter, PersistScheduleAdapter, PersistPartialAdapter, PersistBreakevenAdapter, PersistCandleAdapter, PersistStorageAdapter, PersistNotificationAdapter, PersistLogAdapter, PersistMeasureAdapter, PersistIntervalAdapter, PersistMemoryAdapter, PersistRecentAdapter, PersistStateAdapter, PersistSessionAdapter, PersistStrategyAdapter, Log, StorageLive, StorageBacktest, NotificationLive, NotificationBacktest, RecentLive, RecentBacktest, Broker, SessionLive, SessionBacktest, MemoryLive, MemoryBacktest, StateLive, StateBacktest, listExchangeSchema, addExchangeSchema, roundTicks, listFrameSchema, addFrameSchema, listenDoneLive, listenDoneBacktest, shutdown, listenSignal, listStrategySchema, overrideExchangeSchema, Backtest, System, Cache, Interval, alignToInterval, addWalkerSchema, overrideWalkerSchema, Walker, listenDoneWalker, Live, getCandles, checkCandles, warmCandles, listenRisk, listenStrategyCommit, listenOrderFill, listenOrderReject, listenOrderContinue, listenOrderStop, listenSignalNotify, Exchange, addSimulatorSchema, Simulator } from 'backtest-kit';
4
- import { singleshot, sleep, getErrorMessage, errorData, BehaviorSubject, Subject, str, compose, createAwaiter, execpool, queued, randomString, TIMEOUT_SYMBOL, typo, retry, trycatch, ttl, memoize, isObject } from 'functools-kit';
3
+ import { setConfig, listenExit, emitters, Notification, Cron, Recent, Storage, Markdown, Report, Dump, State, Memory, MarkdownWriter, ReportWriter, PersistSignalAdapter, PersistRiskAdapter, PersistScheduleAdapter, PersistPartialAdapter, PersistBreakevenAdapter, PersistCandleAdapter, PersistStorageAdapter, PersistNotificationAdapter, PersistLogAdapter, PersistMeasureAdapter, PersistIntervalAdapter, PersistMemoryAdapter, PersistRecentAdapter, PersistStateAdapter, PersistSessionAdapter, PersistStrategyAdapter, Log, StorageLive, StorageBacktest, NotificationLive, NotificationBacktest, RecentLive, RecentBacktest, Broker, SessionLive, SessionBacktest, MemoryLive, MemoryBacktest, StateLive, StateBacktest, listExchangeSchema, addExchangeSchema, roundTicks, listFrameSchema, addFrameSchema, listenDoneLive, listenDoneBacktest, shutdown, listenSignal, listStrategySchema, overrideExchangeSchema, Backtest, System, Cache, Interval, alignToInterval, addWalkerSchema, overrideWalkerSchema, Walker, listenDoneWalker, Live, getCandles, checkCandles, warmCandles, listenRisk, listenStrategyCommit, listenOrderFill, listenOrderReject, listenOrderContinue, listenOrderStop, listenSignalNotify, Exchange, runInMockContext, addSimulatorSchema, Simulator } from 'backtest-kit';
4
+ import { singleshot, sleep, getErrorMessage, errorData, BehaviorSubject, Subject, str, compose, createAwaiter, execpool, queued, randomString, TIMEOUT_SYMBOL, typo, retry, trycatch, ttl, Source, memoize, isObject } from 'functools-kit';
5
5
  import { exec, spawn } from 'child_process';
6
6
  import fs, { constants, realpathSync } from 'fs';
7
7
  import * as stackTrace from 'stack-trace';
@@ -28,7 +28,7 @@ import Module, { createRequire } from 'module';
28
28
  import * as BacktestKitGraph from '@backtest-kit/graph';
29
29
  import * as BacktestKitOllama from '@backtest-kit/ollama';
30
30
  import * as BacktestKitPinets from '@backtest-kit/pinets';
31
- import { run as run$1, Code, toMarkdown as toMarkdown$1 } from '@backtest-kit/pinets';
31
+ import { run as run$1, Code, toMarkdown as toMarkdown$2 } from '@backtest-kit/pinets';
32
32
  import * as BacktestKitSignals from '@backtest-kit/signals';
33
33
  import open from 'open';
34
34
 
@@ -208,14 +208,14 @@ const getReadySubject = singleshot(() => {
208
208
  return new Subject();
209
209
  });
210
210
 
211
- const main$j = () => {
211
+ const main$l = () => {
212
212
  const entrySubject = getEntrySubject();
213
213
  entrySubject.subscribe(async (path) => {
214
214
  console.log("Running", path);
215
215
  await getReadySubject().next();
216
216
  });
217
217
  };
218
- main$j();
218
+ main$l();
219
219
 
220
220
  const ERROR_HANDLER_INSTALLED = Symbol.for("error-handler-installed");
221
221
  function dumpStackTrace(error) {
@@ -870,6 +870,10 @@ const getArgs = singleshot(() => {
870
870
  type: "boolean",
871
871
  default: false,
872
872
  },
873
+ tune: {
874
+ type: "boolean",
875
+ default: false,
876
+ },
873
877
  commit: {
874
878
  type: "string",
875
879
  default: "",
@@ -2702,8 +2706,11 @@ class TelegramLogicService {
2702
2706
  const unSignalNotify = listenSignalNotify(async (event) => {
2703
2707
  await this.notifySignalInfo(event);
2704
2708
  });
2709
+ const unGc = Source.fromInterval(90000).connect(() => {
2710
+ this.notifyOrderContinue.gc();
2711
+ });
2705
2712
  const unConnect = () => this.connect.clear();
2706
- const unListen = compose(() => unRisk(), () => unSignal(), () => unCommit(), () => unOrderFill(), () => unOrderReject(), () => unOrderContinue(), () => unOrderStop(), () => this.notifyOrderContinue.clear(), () => unSignalNotify(), () => unConnect());
2713
+ const unListen = compose(() => unRisk(), () => unSignal(), () => unCommit(), () => unOrderFill(), () => unOrderReject(), () => unOrderContinue(), () => unOrderStop(), () => this.notifyOrderContinue.clear(), () => unGc(), () => unSignalNotify(), () => unConnect());
2707
2714
  return () => {
2708
2715
  STOP_BOT_FN();
2709
2716
  unListen();
@@ -3644,14 +3651,14 @@ const cli = {
3644
3651
  };
3645
3652
  init();
3646
3653
 
3647
- const MODES = ["backtest", "walker", "paper", "live", "main", "pine", "editor", "dump", "pnldebug", "brokerdebug", "simulator", "flush", "init", "docker", "help", "version"];
3654
+ const MODES = ["backtest", "walker", "paper", "live", "main", "pine", "editor", "dump", "pnldebug", "brokerdebug", "simulator", "tune", "flush", "init", "docker", "help", "version"];
3648
3655
  const ENTRY_PATH$1 = "./node_modules/@backtest-kit/cli/build/index.mjs";
3649
3656
  const HELP_TEXT$1 = `
3650
3657
  Example:
3651
3658
 
3652
3659
  node ${ENTRY_PATH$1} --help
3653
3660
  `.trimStart();
3654
- const main$i = async () => {
3661
+ const main$k = async () => {
3655
3662
  if (!getEntry(import.meta.url)) {
3656
3663
  return;
3657
3664
  }
@@ -3659,14 +3666,14 @@ const main$i = async () => {
3659
3666
  if (MODES.some((mode) => values[mode])) {
3660
3667
  return;
3661
3668
  }
3662
- process.stdout.write(`@backtest-kit/cli ${"16.5.0"}\n`);
3669
+ process.stdout.write(`@backtest-kit/cli ${"17.0.0"}\n`);
3663
3670
  process.stdout.write("\n");
3664
3671
  process.stdout.write(`Run with --help to see available commands.\n`);
3665
3672
  process.stdout.write("\n");
3666
3673
  process.stdout.write(HELP_TEXT$1);
3667
3674
  process.exit(0);
3668
3675
  };
3669
- main$i();
3676
+ main$k();
3670
3677
 
3671
3678
  const notifyShutdown = singleshot(async () => {
3672
3679
  console.log("Graceful shutdown initiated.");
@@ -3682,7 +3689,7 @@ const flush = async (entryPoint) => {
3682
3689
  console.log(`Removed: ${target}`);
3683
3690
  }
3684
3691
  };
3685
- const main$h = async () => {
3692
+ const main$j = async () => {
3686
3693
  if (!getEntry(import.meta.url)) {
3687
3694
  return;
3688
3695
  }
@@ -3699,7 +3706,7 @@ const main$h = async () => {
3699
3706
  }
3700
3707
  process.exit(0);
3701
3708
  };
3702
- main$h();
3709
+ main$j();
3703
3710
 
3704
3711
  const BEFORE_EXIT_FN$6 = singleshot(async () => {
3705
3712
  process.off("SIGINT", BEFORE_EXIT_FN$6);
@@ -3722,7 +3729,7 @@ const BEFORE_EXIT_FN$6 = singleshot(async () => {
3722
3729
  const listenGracefulShutdown$6 = singleshot(() => {
3723
3730
  process.on("SIGINT", BEFORE_EXIT_FN$6);
3724
3731
  });
3725
- const main$g = async () => {
3732
+ const main$i = async () => {
3726
3733
  if (!getEntry(import.meta.url)) {
3727
3734
  return;
3728
3735
  }
@@ -3740,7 +3747,7 @@ const main$g = async () => {
3740
3747
  await cli.backtestMainService.connect();
3741
3748
  listenGracefulShutdown$6();
3742
3749
  };
3743
- main$g();
3750
+ main$i();
3744
3751
 
3745
3752
  const BEFORE_EXIT_FN$5 = singleshot(async () => {
3746
3753
  process.off("SIGINT", BEFORE_EXIT_FN$5);
@@ -3759,7 +3766,7 @@ const BEFORE_EXIT_FN$5 = singleshot(async () => {
3759
3766
  const listenGracefulShutdown$5 = singleshot(() => {
3760
3767
  process.on("SIGINT", BEFORE_EXIT_FN$5);
3761
3768
  });
3762
- const main$f = async () => {
3769
+ const main$h = async () => {
3763
3770
  if (!getEntry(import.meta.url)) {
3764
3771
  return;
3765
3772
  }
@@ -3778,7 +3785,7 @@ const main$f = async () => {
3778
3785
  listenGracefulShutdown$5();
3779
3786
  await cli.walkerMainService.connect();
3780
3787
  };
3781
- main$f();
3788
+ main$h();
3782
3789
 
3783
3790
  const BEFORE_EXIT_FN$4 = singleshot(async () => {
3784
3791
  process.off("SIGINT", BEFORE_EXIT_FN$4);
@@ -3800,7 +3807,7 @@ const BEFORE_EXIT_FN$4 = singleshot(async () => {
3800
3807
  const listenGracefulShutdown$4 = singleshot(() => {
3801
3808
  process.on("SIGINT", BEFORE_EXIT_FN$4);
3802
3809
  });
3803
- const main$e = async () => {
3810
+ const main$g = async () => {
3804
3811
  if (!getEntry(import.meta.url)) {
3805
3812
  return;
3806
3813
  }
@@ -3814,7 +3821,7 @@ const main$e = async () => {
3814
3821
  await cli.paperMainService.connect();
3815
3822
  listenGracefulShutdown$4();
3816
3823
  };
3817
- main$e();
3824
+ main$g();
3818
3825
 
3819
3826
  const BEFORE_EXIT_FN$3 = singleshot(async () => {
3820
3827
  process.off("SIGINT", BEFORE_EXIT_FN$3);
@@ -3836,7 +3843,7 @@ const BEFORE_EXIT_FN$3 = singleshot(async () => {
3836
3843
  const listenGracefulShutdown$3 = singleshot(() => {
3837
3844
  process.on("SIGINT", BEFORE_EXIT_FN$3);
3838
3845
  });
3839
- const main$d = async () => {
3846
+ const main$f = async () => {
3840
3847
  if (!getEntry(import.meta.url)) {
3841
3848
  return;
3842
3849
  }
@@ -3850,7 +3857,7 @@ const main$d = async () => {
3850
3857
  await cli.liveMainService.connect();
3851
3858
  listenGracefulShutdown$3();
3852
3859
  };
3853
- main$d();
3860
+ main$f();
3854
3861
 
3855
3862
  const stopBacktestList$1 = async () => {
3856
3863
  for (const item of await Backtest.list()) {
@@ -3914,7 +3921,7 @@ const BEFORE_EXIT_FN$2 = singleshot(async () => {
3914
3921
  const listenGracefulShutdown$2 = singleshot(() => {
3915
3922
  process.on("SIGINT", BEFORE_EXIT_FN$2);
3916
3923
  });
3917
- const main$c = async () => {
3924
+ const main$e = async () => {
3918
3925
  if (!getEntry(import.meta.url)) {
3919
3926
  return;
3920
3927
  }
@@ -3970,7 +3977,7 @@ const main$c = async () => {
3970
3977
  listenGracefulShutdown$2();
3971
3978
  await cli.resolveService.attachEntry(path.resolve(cwd, entryPoint));
3972
3979
  };
3973
- main$c();
3980
+ main$e();
3974
3981
 
3975
3982
  const MODE_MODULE = {
3976
3983
  backtest: "backtest.module",
@@ -4073,7 +4080,7 @@ const createGracefulShutdown = (mode) => {
4073
4080
  process.on("SIGINT", handler);
4074
4081
  });
4075
4082
  };
4076
- const main$b = async () => {
4083
+ const main$d = async () => {
4077
4084
  if (!getEntry(import.meta.url)) {
4078
4085
  return;
4079
4086
  }
@@ -4149,7 +4156,7 @@ const main$b = async () => {
4149
4156
  }
4150
4157
  await getEntrySubject().next(absolutePath);
4151
4158
  };
4152
- main$b();
4159
+ main$d();
4153
4160
 
4154
4161
  const BEFORE_EXIT_FN$1 = singleshot(async () => {
4155
4162
  process.off("SIGINT", BEFORE_EXIT_FN$1);
@@ -4160,7 +4167,7 @@ const BEFORE_EXIT_FN$1 = singleshot(async () => {
4160
4167
  const listenGracefulShutdown$1 = singleshot(() => {
4161
4168
  process.on("SIGINT", BEFORE_EXIT_FN$1);
4162
4169
  });
4163
- const main$a = async () => {
4170
+ const main$c = async () => {
4164
4171
  if (!getEntry(import.meta.url)) {
4165
4172
  return;
4166
4173
  }
@@ -4170,7 +4177,7 @@ const main$a = async () => {
4170
4177
  }
4171
4178
  listenGracefulShutdown$1();
4172
4179
  };
4173
- main$a();
4180
+ main$c();
4174
4181
 
4175
4182
  const BEFORE_EXIT_FN = singleshot(async () => {
4176
4183
  process.off("SIGINT", BEFORE_EXIT_FN);
@@ -4181,7 +4188,7 @@ const BEFORE_EXIT_FN = singleshot(async () => {
4181
4188
  const listenGracefulShutdown = singleshot(() => {
4182
4189
  process.on("SIGINT", BEFORE_EXIT_FN);
4183
4190
  });
4184
- const main$9 = async () => {
4191
+ const main$b = async () => {
4185
4192
  if (!getEntry(import.meta.url)) {
4186
4193
  return;
4187
4194
  }
@@ -4191,7 +4198,7 @@ const main$9 = async () => {
4191
4198
  }
4192
4199
  listenGracefulShutdown();
4193
4200
  };
4194
- main$9();
4201
+ main$b();
4195
4202
 
4196
4203
  const EXTRACT_ROWS_FN = (plots, schema) => {
4197
4204
  const keys = Object.keys(schema);
@@ -4213,7 +4220,7 @@ const EXTRACT_ROWS_FN = (plots, schema) => {
4213
4220
  }
4214
4221
  return rows;
4215
4222
  };
4216
- const main$8 = async () => {
4223
+ const main$a = async () => {
4217
4224
  if (!getEntry(import.meta.url)) {
4218
4225
  return;
4219
4226
  }
@@ -4305,17 +4312,17 @@ const main$8 = async () => {
4305
4312
  if (values.markdown) {
4306
4313
  const filePath = resolve(dumpDir, `${dumpName}.md`);
4307
4314
  await mkdir(dumpDir, { recursive: true });
4308
- await writeFile(filePath, await toMarkdown$1(signalId, plots, signalSchema), "utf-8");
4315
+ await writeFile(filePath, await toMarkdown$2(signalId, plots, signalSchema), "utf-8");
4309
4316
  console.log(`Saved: ${filePath}`);
4310
4317
  process.exit(0);
4311
4318
  return;
4312
4319
  }
4313
- console.log(await toMarkdown$1(signalId, plots, signalSchema));
4320
+ console.log(await toMarkdown$2(signalId, plots, signalSchema));
4314
4321
  process.exit(0);
4315
4322
  };
4316
- main$8();
4323
+ main$a();
4317
4324
 
4318
- const main$7 = async () => {
4325
+ const main$9 = async () => {
4319
4326
  if (!getEntry(import.meta.url)) {
4320
4327
  return;
4321
4328
  }
@@ -4378,9 +4385,9 @@ const main$7 = async () => {
4378
4385
  };
4379
4386
  process.on("SIGINT", beforeExit);
4380
4387
  };
4381
- main$7();
4388
+ main$9();
4382
4389
 
4383
- const main$6 = async () => {
4390
+ const main$8 = async () => {
4384
4391
  if (!getEntry(import.meta.url)) {
4385
4392
  return;
4386
4393
  }
@@ -4461,9 +4468,9 @@ const main$6 = async () => {
4461
4468
  console.log(JSON.stringify(candles, null, 2));
4462
4469
  process.exit(0);
4463
4470
  };
4464
- main$6();
4471
+ main$8();
4465
4472
 
4466
- const main$5 = async () => {
4473
+ const main$7 = async () => {
4467
4474
  if (!getEntry(import.meta.url)) {
4468
4475
  return;
4469
4476
  }
@@ -4577,11 +4584,326 @@ const main$5 = async () => {
4577
4584
  }
4578
4585
  process.exit(0);
4579
4586
  };
4580
- main$5();
4587
+ main$7();
4581
4588
 
4582
- const SIMULATOR_NAME = "cli_simulator";
4583
- const IDEA_DIRECTIONS = ["LONG", "SHORT", "NEUTRAL"];
4584
- const validateIdea = (idea, line) => {
4589
+ const getBroker = singleshot(() => {
4590
+ // Reaches into BrokerAdapter internals on purpose: the public commit*
4591
+ // methods skip when payload.backtest=true and require enable(), while
4592
+ // brokerdebug must fire exactly one commit at the raw adapter proxy.
4593
+ const broker = Broker["getInstance"]();
4594
+ if (!broker) {
4595
+ throw new Error("Broker instance is not initialized.");
4596
+ }
4597
+ return broker;
4598
+ });
4599
+ /** Called when a new signal is opened (position entry confirmed). */
4600
+ const commitOrderOpen = trycatch(async (payload) => {
4601
+ const broker = getBroker();
4602
+ return await broker.onOrderOpenCommit(payload);
4603
+ }, {
4604
+ fallback: (error) => {
4605
+ console.log(error);
4606
+ process.exit(-1);
4607
+ },
4608
+ });
4609
+ /** Called when a new signal is closed (take-profit, stop-loss, or manual close). */
4610
+ const commitOrderClose = trycatch(async (payload) => {
4611
+ const broker = getBroker();
4612
+ return await broker.onOrderCloseCommit(payload);
4613
+ }, {
4614
+ fallback: (error) => {
4615
+ console.log(error);
4616
+ process.exit(-1);
4617
+ },
4618
+ });
4619
+ /** Called when a partial profit close is committed. */
4620
+ const commitPartialProfit = trycatch(async (payload) => {
4621
+ const broker = getBroker();
4622
+ return await broker.onPartialProfitCommit(payload);
4623
+ }, {
4624
+ fallback: (error) => {
4625
+ console.log(error);
4626
+ process.exit(-1);
4627
+ },
4628
+ });
4629
+ /** Called when a partial loss close is committed. */
4630
+ const commitPartialLoss = trycatch(async (payload) => {
4631
+ const broker = getBroker();
4632
+ return await broker.onPartialLossCommit(payload);
4633
+ }, {
4634
+ fallback: (error) => {
4635
+ console.log(error);
4636
+ process.exit(-1);
4637
+ },
4638
+ });
4639
+ /** Called when a trailing stop update is committed. */
4640
+ const commitTrailingStop = trycatch(async (payload) => {
4641
+ const broker = getBroker();
4642
+ return await broker.onTrailingStopCommit(payload);
4643
+ }, {
4644
+ fallback: (error) => {
4645
+ console.log(error);
4646
+ process.exit(-1);
4647
+ },
4648
+ });
4649
+ /** Called when a trailing take-profit update is committed. */
4650
+ const commitTrailingTake = trycatch(async (payload) => {
4651
+ const broker = getBroker();
4652
+ return await broker.onTrailingTakeCommit(payload);
4653
+ }, {
4654
+ fallback: (error) => {
4655
+ console.log(error);
4656
+ process.exit(-1);
4657
+ },
4658
+ });
4659
+ /** Called when a breakeven stop is committed (stop loss moved to entry price). */
4660
+ const commitBreakeven = trycatch(async (payload) => {
4661
+ const broker = getBroker();
4662
+ return await broker.onBreakevenCommit(payload);
4663
+ }, {
4664
+ fallback: (error) => {
4665
+ console.log(error);
4666
+ process.exit(-1);
4667
+ },
4668
+ });
4669
+ /** Called when a DCA (average-buy) entry is committed. */
4670
+ const commitAverageBuy = trycatch(async (payload) => {
4671
+ const broker = getBroker();
4672
+ return await broker.onAverageBuyCommit(payload);
4673
+ }, {
4674
+ fallback: (error) => {
4675
+ console.log(error);
4676
+ process.exit(-1);
4677
+ },
4678
+ });
4679
+ const COMMITS = [
4680
+ "signal-open",
4681
+ "signal-close",
4682
+ "partial-profit",
4683
+ "partial-loss",
4684
+ "average-buy",
4685
+ "trailing-stop",
4686
+ "trailing-take",
4687
+ "breakeven",
4688
+ ];
4689
+ const ZERO_PNL = {
4690
+ pnlPercentage: 0,
4691
+ priceOpen: 0,
4692
+ priceClose: 0,
4693
+ pnlCost: 0,
4694
+ pnlEntries: 0,
4695
+ };
4696
+ const main$6 = async () => {
4697
+ if (!getEntry(import.meta.url)) {
4698
+ return;
4699
+ }
4700
+ const { values } = getArgs();
4701
+ if (!values.brokerdebug) {
4702
+ return;
4703
+ }
4704
+ {
4705
+ const cwd = process.cwd();
4706
+ dotenv.config({ path: path.join(cwd, '.env'), override: true, quiet: true });
4707
+ }
4708
+ await cli.configConnectionService.loadConfig("setup.config");
4709
+ {
4710
+ const loader = await cli.configConnectionService.loadConfig("loader.config");
4711
+ try {
4712
+ if (typeof loader === "function") {
4713
+ await loader();
4714
+ }
4715
+ if (typeof loader?.loader === "function") {
4716
+ await loader.loader();
4717
+ }
4718
+ }
4719
+ catch (error) {
4720
+ console.error("Module loader failed", error);
4721
+ process.exit(-1);
4722
+ }
4723
+ }
4724
+ await cli.moduleConnectionService.loadModule("brokerdebug.module");
4725
+ const [defaultExchangeName = null] = await listExchangeSchema();
4726
+ const exchangeName = values.exchange || defaultExchangeName?.exchangeName;
4727
+ const symbol = values.symbol || "BTCUSDT";
4728
+ const commit = values.commit || "signal-open";
4729
+ if (!COMMITS.includes(commit)) {
4730
+ console.error(`Unknown --commit value: ${commit}`);
4731
+ console.error(`Available: ${COMMITS.join(", ")}`);
4732
+ process.exit(1);
4733
+ }
4734
+ const candles = await Exchange.getCandles(symbol, "1m", 5, { exchangeName });
4735
+ const lastCandle = candles[candles.length - 1];
4736
+ const currentPrice = lastCandle.close;
4737
+ const priceTakeProfit = currentPrice * 1.02;
4738
+ const priceStopLoss = currentPrice * 0.98;
4739
+ const context = {
4740
+ strategyName: "broker-test",
4741
+ exchangeName: exchangeName,
4742
+ };
4743
+ const signalId = `broker-debug-${Date.now()}`;
4744
+ const when = alignToInterval(new Date(), "1m");
4745
+ {
4746
+ console.log(`symbol=${symbol} price=${currentPrice} commit=${commit}`);
4747
+ console.time(commit);
4748
+ }
4749
+ const run = async () => {
4750
+ if (commit === "signal-open") {
4751
+ await commitOrderOpen({
4752
+ type: "active",
4753
+ symbol,
4754
+ signalId,
4755
+ cost: 100,
4756
+ position: "long",
4757
+ priceOpen: currentPrice,
4758
+ priceTakeProfit,
4759
+ priceStopLoss,
4760
+ pnl: ZERO_PNL,
4761
+ peakProfit: ZERO_PNL,
4762
+ maxDrawdown: ZERO_PNL,
4763
+ attempt: 0,
4764
+ context,
4765
+ when,
4766
+ backtest: true,
4767
+ });
4768
+ }
4769
+ if (commit === "signal-close") {
4770
+ await commitOrderClose({
4771
+ symbol,
4772
+ signalId,
4773
+ cost: 100,
4774
+ position: "long",
4775
+ currentPrice,
4776
+ priceOpen: currentPrice,
4777
+ priceTakeProfit,
4778
+ priceStopLoss,
4779
+ totalEntries: 1,
4780
+ totalPartials: 0,
4781
+ attempt: 0,
4782
+ pnl: ZERO_PNL,
4783
+ peakProfit: ZERO_PNL,
4784
+ maxDrawdown: ZERO_PNL,
4785
+ context,
4786
+ when,
4787
+ backtest: true,
4788
+ });
4789
+ }
4790
+ if (commit === "partial-profit") {
4791
+ await commitPartialProfit({
4792
+ symbol,
4793
+ signalId,
4794
+ percentToClose: 50,
4795
+ cost: 50,
4796
+ currentPrice,
4797
+ position: "long",
4798
+ priceTakeProfit,
4799
+ priceStopLoss,
4800
+ context,
4801
+ when,
4802
+ backtest: true,
4803
+ });
4804
+ }
4805
+ if (commit === "partial-loss") {
4806
+ await commitPartialLoss({
4807
+ symbol,
4808
+ signalId,
4809
+ percentToClose: 50,
4810
+ cost: 50,
4811
+ currentPrice,
4812
+ position: "long",
4813
+ priceTakeProfit,
4814
+ priceStopLoss,
4815
+ context,
4816
+ when,
4817
+ backtest: true,
4818
+ });
4819
+ }
4820
+ if (commit === "average-buy") {
4821
+ await commitAverageBuy({
4822
+ symbol,
4823
+ signalId,
4824
+ currentPrice,
4825
+ cost: 100,
4826
+ position: "long",
4827
+ priceTakeProfit,
4828
+ priceStopLoss,
4829
+ context,
4830
+ when,
4831
+ backtest: true,
4832
+ });
4833
+ }
4834
+ if (commit === "trailing-stop") {
4835
+ await commitTrailingStop({
4836
+ symbol,
4837
+ signalId,
4838
+ percentShift: -50,
4839
+ currentPrice,
4840
+ newStopLossPrice: priceStopLoss + (currentPrice - priceStopLoss) * 0.5,
4841
+ takeProfitPrice: priceTakeProfit,
4842
+ position: "long",
4843
+ context,
4844
+ when,
4845
+ backtest: true,
4846
+ });
4847
+ }
4848
+ if (commit === "trailing-take") {
4849
+ await commitTrailingTake({
4850
+ symbol,
4851
+ signalId,
4852
+ percentShift: 50,
4853
+ currentPrice,
4854
+ newTakeProfitPrice: priceTakeProfit + (priceTakeProfit - currentPrice) * 0.5,
4855
+ takeProfitPrice: priceTakeProfit,
4856
+ position: "long",
4857
+ context,
4858
+ when,
4859
+ backtest: true,
4860
+ });
4861
+ }
4862
+ if (commit === "breakeven") {
4863
+ await commitBreakeven({
4864
+ symbol,
4865
+ signalId,
4866
+ currentPrice,
4867
+ newStopLossPrice: currentPrice,
4868
+ newTakeProfitPrice: priceTakeProfit,
4869
+ position: "long",
4870
+ context,
4871
+ when,
4872
+ backtest: true,
4873
+ });
4874
+ }
4875
+ };
4876
+ await runInMockContext(run, {
4877
+ when,
4878
+ strategyName: context.strategyName,
4879
+ exchangeName: context.exchangeName,
4880
+ frameName: "",
4881
+ symbol,
4882
+ backtest: true,
4883
+ });
4884
+ {
4885
+ console.log(`${commit} OK`);
4886
+ console.timeEnd(commit);
4887
+ }
4888
+ process.exit(0);
4889
+ };
4890
+ main$6();
4891
+
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
+ };
4905
+ const IDEA_DIRECTIONS$1 = ["LONG", "SHORT", "NEUTRAL"];
4906
+ const validateIdea$1 = (idea, line) => {
4585
4907
  if (typeof idea !== "object" || idea === null) {
4586
4908
  return `line ${line}: not an object`;
4587
4909
  }
@@ -4594,15 +4916,18 @@ const validateIdea = (idea, line) => {
4594
4916
  if (typeof idea.symbol !== "string" || !idea.symbol) {
4595
4917
  return `line ${line}: "symbol" must be a non-empty string`;
4596
4918
  }
4597
- if (!IDEA_DIRECTIONS.includes(idea.direction)) {
4598
- return `line ${line}: "direction" must be one of ${IDEA_DIRECTIONS.join("|")}, got ${JSON.stringify(idea.direction)}`;
4919
+ if (!IDEA_DIRECTIONS$1.includes(idea.direction)) {
4920
+ return `line ${line}: "direction" must be one of ${IDEA_DIRECTIONS$1.join("|")}, got ${JSON.stringify(idea.direction)}`;
4599
4921
  }
4600
4922
  if (typeof idea.author !== "string" || !idea.author) {
4601
4923
  return `line ${line}: "author" must be a non-empty string`;
4602
4924
  }
4603
4925
  return null;
4604
4926
  };
4605
- const toMarkdown = (result) => {
4927
+ const toMarkdown$1 = (result) => {
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;
4606
4931
  const lines = [];
4607
4932
  lines.push(`# Simulator Report — ${result.symbol}`);
4608
4933
  lines.push("");
@@ -4610,34 +4935,38 @@ const toMarkdown = (result) => {
4610
4935
  lines.push(`| --- | --- |`);
4611
4936
  lines.push(`| Ideas (total / directional) | ${result.ideasTotal} / ${result.ideasDirectional} |`);
4612
4937
  lines.push(`| Profiles (truncated) | ${result.profileCount} (${result.truncatedCount}) |`);
4613
- lines.push(`| Authors allowed / banned | ${result.allowedAuthors.length} / ${result.bannedAuthors.length} |`);
4614
- lines.push(`| Grid points | ${result.reports.length} |`);
4938
+ lines.push(`| Profitable corridor | ${profitable} / ${allReports.length} grid points |`);
4615
4939
  lines.push(`| Hold minutes avg / p95 / p99 | ${result.avgHoldMinutes.toFixed(0)} / ${result.p95HoldMinutes} / ${result.p99HoldMinutes} |`);
4616
- lines.push("");
4617
- lines.push(`## Ranking winners`);
4618
- lines.push("");
4619
- lines.push(`| Criterion | Stop% | Trailing% | Hold | Aligned | Track | HitRate | Trades | PNL% | WinRate | Sharpe | Sortino |`);
4620
- lines.push(`| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |`);
4621
- for (const best of result.best) {
4622
- if (!best.report) {
4623
- lines.push(`| ${best.criterion} | — | — | — | — | — | — | — | — | — | — | — |`);
4624
- 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)}% |`);
4625
4965
  }
4626
- const { point } = best.report;
4627
- lines.push(`| ${best.criterion} | ${point.hardStopPercent} | ${point.trailingTakePercent} | ${point.holdMinutes / 60}h | ${point.minIdeasAligned} | ${point.minAuthorTrack} | ${point.minAuthorHitRate} | ` +
4628
- `${best.report.trades} | ${best.report.totalPnlPercent.toFixed(2)}% | ${(best.report.winRate * 100).toFixed(0)}% | ${best.report.sharpe.toFixed(2)} | ${best.report.sortino.toFixed(2)} |`);
4629
- }
4630
- lines.push("");
4631
- lines.push(`## Allowed authors (production whitelist)`);
4632
- lines.push("");
4633
- lines.push(`| Author | Ideas | Hits | HitRate |`);
4634
- lines.push(`| --- | --- | --- | --- |`);
4635
- for (const stat of result.authorStats.filter(({ banned }) => !banned)) {
4636
- lines.push(`| ${stat.author} | ${stat.ideas} | ${stat.hits} | ${(stat.hitRate * 100).toFixed(0)}% |`);
4637
4966
  }
4638
4967
  return lines.join("\n");
4639
4968
  };
4640
- const main$4 = async () => {
4969
+ const main$5 = async () => {
4641
4970
  if (!getEntry(import.meta.url)) {
4642
4971
  return;
4643
4972
  }
@@ -4650,6 +4979,33 @@ const main$4 = async () => {
4650
4979
  console.error("Error: positional path to an ideas .jsonl file is required");
4651
4980
  process.exit(1);
4652
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
+ }
4653
5009
  let ideas = [];
4654
5010
  {
4655
5011
  let content;
@@ -4674,7 +5030,7 @@ const main$4 = async () => {
4674
5030
  console.error(`Error: invalid JSON in ideas file, line ${i + 1}`);
4675
5031
  process.exit(1);
4676
5032
  }
4677
- const problem = validateIdea(idea, i + 1);
5033
+ const problem = validateIdea$1(idea, i + 1);
4678
5034
  if (problem) {
4679
5035
  console.error(`Error: ideas file does not match the idea structure — ${problem}`);
4680
5036
  console.error(`Expected shape: { "id": number, "ts": number, "symbol": string, "direction": "LONG"|"SHORT"|"NEUTRAL", "author": string }`);
@@ -4710,20 +5066,24 @@ const main$4 = async () => {
4710
5066
  }
4711
5067
  const [defaultExchangeName = null] = await listExchangeSchema();
4712
5068
  const exchangeName = values.exchange || defaultExchangeName?.exchangeName;
5069
+ if (values.verbose) {
5070
+ overrideExchangeSchema({
5071
+ exchangeName,
5072
+ callbacks: {
5073
+ onCandleData(symbol, interval, since) {
5074
+ console.log(`Received candle data for symbol: ${symbol}, interval: ${interval}, since: ${since.toUTCString()}`);
5075
+ },
5076
+ },
5077
+ });
5078
+ }
4713
5079
  const symbol = values.symbol || "BTCUSDT";
5080
+ // оси и порядок отчёта приходят позиционным конфигом потребителя;
5081
+ // пустой конфиг — дефолтная сетка движка из connection-сервиса
4714
5082
  addSimulatorSchema({
4715
- simulatorName: SIMULATOR_NAME,
5083
+ simulatorName: SIMULATOR_NAME$1,
4716
5084
  exchangeName,
4717
- gridAxes: {
4718
- hardStopPercent: [1, 1.5, 2, 2.5, 3, 4, 5, 7],
4719
- trailingTakePercent: [0.5, 1, 1.5, 2, 3, 4],
4720
- holdMinutes: [24 * 60, 2 * 24 * 60, 3 * 24 * 60],
4721
- minIdeasAligned: [1, 2, 3],
4722
- minAuthorTrack: [2, 3, 5],
4723
- minAuthorHitRate: [0.5, 0.6],
4724
- minWeightAligned: [0, 0.6, 1.2],
4725
- profitLockPercent: [0, 1.5, 2.5],
4726
- },
5085
+ ...(config.gridAxes ? { gridAxes: config.gridAxes } : {}),
5086
+ ...(config.reportOrder ? { reportOrder: config.reportOrder } : {}),
4727
5087
  callbacks: {
4728
5088
  onProgress: (symbol, stage, processed, total) => {
4729
5089
  if (values.verbose) {
@@ -4775,9 +5135,8 @@ const main$4 = async () => {
4775
5135
  if (values.verbose) {
4776
5136
  console.log("onDone", {
4777
5137
  symbol,
4778
- reports: result.reports.length,
4779
- allowedAuthors: result.allowedAuthors.length,
4780
- 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,
4781
5140
  });
4782
5141
  }
4783
5142
  },
@@ -4785,7 +5144,7 @@ const main$4 = async () => {
4785
5144
  });
4786
5145
  const result = await Simulator.run({
4787
5146
  symbol,
4788
- simulatorName: SIMULATOR_NAME,
5147
+ simulatorName: SIMULATOR_NAME$1,
4789
5148
  ideas,
4790
5149
  });
4791
5150
  const dumpName = values.output || `simulator_${symbol}_${Date.now()}`;
@@ -4800,11 +5159,308 @@ const main$4 = async () => {
4800
5159
  if (values.markdown) {
4801
5160
  const filePath = resolve(dumpDir, `${dumpName}.md`);
4802
5161
  await mkdir(dumpDir, { recursive: true });
4803
- await writeFile(filePath, toMarkdown(result), "utf-8");
5162
+ await writeFile(filePath, toMarkdown$1(result), "utf-8");
4804
5163
  console.log(`Saved: ${filePath}`);
4805
5164
  process.exit(0);
4806
5165
  }
4807
- console.log(toMarkdown(result));
5166
+ console.log(toMarkdown$1(result));
5167
+ process.exit(0);
5168
+ };
5169
+ main$5();
5170
+
5171
+ const SIMULATOR_NAME = "cli_tune";
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
+ ];
5181
+ const IDEA_DIRECTIONS = ["LONG", "SHORT", "NEUTRAL"];
5182
+ const validateIdea = (idea, line) => {
5183
+ if (typeof idea !== "object" || idea === null) {
5184
+ return `line ${line}: not an object`;
5185
+ }
5186
+ if (typeof idea.id !== "number") {
5187
+ return `line ${line}: "id" must be a number, got ${typeof idea.id}`;
5188
+ }
5189
+ if (typeof idea.ts !== "number") {
5190
+ return `line ${line}: "ts" must be a number (unix ms), got ${typeof idea.ts}`;
5191
+ }
5192
+ if (typeof idea.symbol !== "string" || !idea.symbol) {
5193
+ return `line ${line}: "symbol" must be a non-empty string`;
5194
+ }
5195
+ if (!IDEA_DIRECTIONS.includes(idea.direction)) {
5196
+ return `line ${line}: "direction" must be one of ${IDEA_DIRECTIONS.join("|")}, got ${JSON.stringify(idea.direction)}`;
5197
+ }
5198
+ if (typeof idea.author !== "string" || !idea.author) {
5199
+ return `line ${line}: "author" must be a non-empty string`;
5200
+ }
5201
+ return null;
5202
+ };
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} ` +
5250
+ `lock=${point.profitLockPercent} metric=${point.authorMetric}`;
5251
+ const fmtRatio = (value) => Number.isFinite(value) ? value.toFixed(2) : "inf";
5252
+ const toMarkdown = (test) => {
5253
+ const lines = [];
5254
+ lines.push(`# Tune Report — ${test.symbol}`);
5255
+ lines.push("");
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.`);
5257
+ lines.push("");
5258
+ lines.push(`Frozen point: ${pointLabel(test.point)}`);
5259
+ lines.push("");
5260
+ lines.push(`## Out-of-sample (${test.ideasDirectional} directional ideas)`);
5261
+ lines.push("");
5262
+ lines.push(`| Metric | Value |`);
5263
+ lines.push(`| --- | --- |`);
5264
+ lines.push(`| Trades (skipped busy) | ${test.report.trades} (${test.report.skippedBusy}) |`);
5265
+ lines.push(`| PNL | ${test.report.totalPnlPercent.toFixed(2)}% |`);
5266
+ lines.push(`| Win rate | ${(test.report.winRate * 100).toFixed(0)}% |`);
5267
+ lines.push(`| Profit factor | ${fmtRatio(test.report.profitFactor)} |`);
5268
+ lines.push(`| Max series drawdown | ${test.report.maxSeriesDrawdownPercent.toFixed(2)}% |`);
5269
+ lines.push(`| Sharpe / Sortino | ${fmtRatio(test.report.sharpe)} / ${fmtRatio(test.report.sortino)} |`);
5270
+ lines.push(`| Recovery factor | ${fmtRatio(test.report.recoveryFactor)} |`);
5271
+ lines.push(`| Exits | ${Object.entries(test.report.exitReasons).map(([reason, count]) => `${reason}=${count}`).join(", ")} |`);
5272
+ lines.push("");
5273
+ lines.push(`## Test trades`);
5274
+ lines.push("");
5275
+ lines.push(`| Direction | Exit | PNL% | Hold | Entry (UTC) |`);
5276
+ lines.push(`| --- | --- | --- | --- | --- |`);
5277
+ for (const trade of test.trades) {
5278
+ lines.push(`| ${trade.direction} | ${trade.exitReason} | ${trade.pnlPercent.toFixed(2)} | ${trade.holdMinutesActual}m | ${new Date(trade.entryTimestamp).toISOString()} |`);
5279
+ }
5280
+ lines.push("");
5281
+ lines.push(`## Frozen author track record (re-derived bans under the point's rule)`);
5282
+ lines.push("");
5283
+ lines.push(`| Author | Ideas | Hits | Banned |`);
5284
+ lines.push(`| --- | --- | --- | --- |`);
5285
+ for (const stat of test.authorStats) {
5286
+ lines.push(`| ${stat.author} | ${stat.ideas} | ${stat.hits} | ${stat.banned ? "yes" : ""} |`);
5287
+ }
5288
+ return lines.join("\n");
5289
+ };
5290
+ const main$4 = async () => {
5291
+ if (!getEntry(import.meta.url)) {
5292
+ return;
5293
+ }
5294
+ const { values, positionals } = getArgs();
5295
+ if (!values.tune) {
5296
+ return;
5297
+ }
5298
+ const [ideasPath = null] = positionals.filter((value) => value.endsWith(".jsonl"));
5299
+ if (!ideasPath) {
5300
+ console.error("Error: positional path to an ideas .jsonl file is required");
5301
+ process.exit(1);
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
+ }
5332
+ let ideas = [];
5333
+ {
5334
+ let content;
5335
+ try {
5336
+ content = await readFile(resolve(ideasPath), "utf-8");
5337
+ }
5338
+ catch (error) {
5339
+ console.error(`Error: cannot read ideas file: ${ideasPath}`);
5340
+ process.exit(1);
5341
+ }
5342
+ const rows = content.split("\n").filter(Boolean);
5343
+ if (!rows.length) {
5344
+ console.error(`Error: ideas file is empty: ${ideasPath}`);
5345
+ process.exit(1);
5346
+ }
5347
+ for (let i = 0; i < rows.length; i++) {
5348
+ let idea;
5349
+ try {
5350
+ idea = JSON.parse(rows[i]);
5351
+ }
5352
+ catch {
5353
+ console.error(`Error: invalid JSON in ideas file, line ${i + 1}`);
5354
+ process.exit(1);
5355
+ }
5356
+ const problem = validateIdea(idea, i + 1);
5357
+ if (problem) {
5358
+ console.error(`Error: ideas file does not match the idea structure — ${problem}`);
5359
+ console.error(`Expected shape: { "id": number, "ts": number, "symbol": string, "direction": "LONG"|"SHORT"|"NEUTRAL", "author": string }`);
5360
+ process.exit(1);
5361
+ }
5362
+ ideas.push(idea);
5363
+ }
5364
+ }
5365
+ {
5366
+ const cwd = process.cwd();
5367
+ dotenv.config({ path: path.join(cwd, '.env'), override: true, quiet: true });
5368
+ }
5369
+ await cli.configConnectionService.loadConfig("setup.config");
5370
+ {
5371
+ const loader = await cli.configConnectionService.loadConfig("loader.config");
5372
+ try {
5373
+ if (typeof loader === "function") {
5374
+ await loader();
5375
+ }
5376
+ if (typeof loader?.loader === "function") {
5377
+ await loader.loader();
5378
+ }
5379
+ }
5380
+ catch (error) {
5381
+ console.error("Module loader failed", error);
5382
+ process.exit(-1);
5383
+ }
5384
+ }
5385
+ await cli.moduleConnectionService.loadModule("tune.module");
5386
+ {
5387
+ await cli.exchangeSchemaService.addSchema();
5388
+ await cli.symbolSchemaService.addSchema();
5389
+ }
5390
+ const [defaultExchangeName = null] = await listExchangeSchema();
5391
+ const exchangeName = values.exchange || defaultExchangeName?.exchangeName;
5392
+ if (values.verbose) {
5393
+ overrideExchangeSchema({
5394
+ exchangeName,
5395
+ callbacks: {
5396
+ onCandleData(symbol, interval, since) {
5397
+ console.log(`Received candle data for symbol: ${symbol}, interval: ${interval}, since: ${since.toUTCString()}`);
5398
+ },
5399
+ },
5400
+ });
5401
+ }
5402
+ const symbol = values.symbol || "BTCUSDT";
5403
+ // сетка для test() инертна (оценивается ровно замороженная точка):
5404
+ // без явных осей в конфиге они одноточечно зеркалят точку
5405
+ addSimulatorSchema({
5406
+ simulatorName: SIMULATOR_NAME,
5407
+ exchangeName,
5408
+ gridAxes: config.gridAxes ?? axesFromPoint(config.point),
5409
+ ...(config.reportOrder ? { reportOrder: config.reportOrder } : {}),
5410
+ callbacks: {
5411
+ onProgress: (symbol, stage, processed, total) => {
5412
+ if (values.verbose) {
5413
+ console.log("onProgress", { symbol, stage, processed, total });
5414
+ }
5415
+ },
5416
+ onIdeas: (symbol, ideasTotal, ideasDirectional) => {
5417
+ if (values.verbose) {
5418
+ console.log("onIdeas", { symbol, ideasTotal, ideasDirectional });
5419
+ }
5420
+ },
5421
+ onProfiles: (symbol, profiles, truncatedCount) => {
5422
+ if (values.verbose) {
5423
+ console.log("onProfiles", { symbol, profiles: profiles.length, truncatedCount });
5424
+ }
5425
+ },
5426
+ onTestDone: (symbol, result) => {
5427
+ if (values.verbose) {
5428
+ console.log("onTestDone", {
5429
+ symbol,
5430
+ trades: result.report.trades,
5431
+ pnl: +result.report.totalPnlPercent.toFixed(2),
5432
+ });
5433
+ }
5434
+ },
5435
+ },
5436
+ });
5437
+ // только out-of-sample: обучения нет, точка и трек-рекорд приходят
5438
+ // из конфига verbatim — test() перевыводит баны под правило точки,
5439
+ // невиданные авторы забанены по умолчанию
5440
+ const test = await Simulator.test({
5441
+ symbol,
5442
+ simulatorName: SIMULATOR_NAME,
5443
+ ideas,
5444
+ point: config.point,
5445
+ authorStats: config.authorStats,
5446
+ });
5447
+ const dumpName = values.output || `tune_${symbol}_${Date.now()}`;
5448
+ const dumpDir = join(process.cwd(), "dump");
5449
+ if (values.json) {
5450
+ const filePath = resolve(dumpDir, `${dumpName}.json`);
5451
+ await mkdir(dumpDir, { recursive: true });
5452
+ await writeFile(filePath, JSON.stringify(test, null, 2), "utf-8");
5453
+ console.log(`Saved: ${filePath}`);
5454
+ process.exit(0);
5455
+ }
5456
+ if (values.markdown) {
5457
+ const filePath = resolve(dumpDir, `${dumpName}.md`);
5458
+ await mkdir(dumpDir, { recursive: true });
5459
+ await writeFile(filePath, toMarkdown(test), "utf-8");
5460
+ console.log(`Saved: ${filePath}`);
5461
+ process.exit(0);
5462
+ }
5463
+ console.log(toMarkdown(test));
4808
5464
  process.exit(0);
4809
5465
  };
4810
5466
  main$4();
@@ -5005,7 +5661,8 @@ Modes:
5005
5661
  --dump Fetch and save raw OHLCV candles
5006
5662
  --pnldebug Simulate PnL per minute for a given entry price and direction
5007
5663
  --brokerdebug Fire a single broker commit against the live broker adapter
5008
- --simulator <ideas.jsonl> Sweep exit/entry parameters over crowd ideas, train author whitelist
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)
5009
5666
  --flush <entry...> Delete report/log/markdown/agent folders from strategy dump dir
5010
5667
  --init Scaffold a new project in the current directory
5011
5668
  --docker Scaffold a Docker workspace for running strategies in a container
@@ -5135,16 +5792,22 @@ Simulator flags (--simulator):
5135
5792
  --markdown Save summary report to ./dump/<output>.md
5136
5793
  --verbose Log every simulator lifecycle callback to the console
5137
5794
 
5138
- 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
5139
5796
  { "id": number, "ts": number, "symbol": string, "direction": "LONG"|"SHORT"|"NEUTRAL",
5140
- "author": string }. The file is validated BEFORE any work; a line that does not
5141
- match the structure aborts the run with an error.
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).
5142
5802
 
5143
- Runs the backtest-kit Simulator entity: one 5-day candle pass per idea, flood
5144
- dedupe (one idea per author per direction per 8h), default-ban author filter
5145
- (ban thresholds are grid axes), grid evaluation with time-based Sharpe/Sortino,
5146
- four ranking winners. The report includes the production author whitelist.
5147
- Ideas of other symbols are filtered out — one shared feed serves any --symbol.
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.
5148
5811
 
5149
5812
  No output flag → print the Markdown summary to stdout. With --verbose every
5150
5813
  simulator lifecycle callback (onProgress, onIdeas, onProfiles, onAuthorsTrained,
@@ -5153,6 +5816,33 @@ Simulator flags (--simulator):
5153
5816
  Module file ./modules/simulator.module is loaded automatically if it exists
5154
5817
  (register your exchange there); without it CCXT Binance is used by default.
5155
5818
 
5819
+ Tune flags (--tune):
5820
+
5821
+ --symbol <string> Trading pair to test (default: BTCUSDT)
5822
+ --exchange <string> Exchange name (default: first registered)
5823
+ --output <string> Output file base name (default: tune_{SYMBOL}_{TIMESTAMP})
5824
+ --json Save the full ISimulatorTestResult to ./dump/<output>.json
5825
+ --markdown Save the out-of-sample report to ./dump/<output>.md
5826
+ --verbose Log simulator lifecycle callbacks to the console
5827
+
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.
5842
+
5843
+ Module file ./modules/tune.module is loaded automatically if it exists
5844
+ (register your exchange there); without it CCXT Binance is used by default.
5845
+
5156
5846
  Flush flags (--flush):
5157
5847
 
5158
5848
  One or more positional entry points. For each entry point the following
@@ -5186,7 +5876,8 @@ Module hooks (loaded automatically by each mode):
5186
5876
  modules/dump.module --dump Exchange schema for candle dumps
5187
5877
  modules/pnldebug.module --pnldebug Exchange schema for PnL debug runs
5188
5878
  modules/brokerdebug.module --brokerdebug Broker adapter used for broker commit testing
5189
- modules/simulator.module --simulator Exchange schema for crowd-ideas simulation
5879
+ modules/simulator.module --simulator Exchange schema for the crowd-ideas feasibility probe
5880
+ modules/tune.module --tune Exchange schema for the walk-forward parameter search
5190
5881
 
5191
5882
  --flush has no associated module. It only removes dump subdirectories.
5192
5883
 
@@ -5216,7 +5907,9 @@ Examples:
5216
5907
  node ${ENTRY_PATH} --brokerdebug --commit signal-open --symbol BTCUSDT
5217
5908
  node ${ENTRY_PATH} --brokerdebug --commit partial-profit --symbol ETHUSDT
5218
5909
  node ${ENTRY_PATH} --simulator --symbol BTCUSDT ./assets/tv-ideas.normalized.jsonl
5219
- node ${ENTRY_PATH} --simulator --symbol BTCUSDT --json --output jun_2026_sweep ./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
5911
+ node ${ENTRY_PATH} --tune --symbol BTCUSDT ./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
5220
5913
  node ${ENTRY_PATH} --flush ./content/feb_2026.strategy/feb_2026.strategy.ts
5221
5914
  node ${ENTRY_PATH} --flush ./content/feb_2026.strategy/feb_2026.strategy.ts ./content/feb_2026.strategy/feb_2026.test.ts
5222
5915
  node ${ENTRY_PATH} --init --output my-trading-bot
@@ -5230,7 +5923,7 @@ const main$1 = async () => {
5230
5923
  if (!values.help) {
5231
5924
  return;
5232
5925
  }
5233
- process.stdout.write(`@backtest-kit/cli ${"16.5.0"}\n\n`);
5926
+ process.stdout.write(`@backtest-kit/cli ${"17.0.0"}\n\n`);
5234
5927
  process.stdout.write(HELP_TEXT);
5235
5928
  process.exit(0);
5236
5929
  };
@@ -5244,7 +5937,7 @@ const main = async () => {
5244
5937
  if (!values.version) {
5245
5938
  return;
5246
5939
  }
5247
- process.stdout.write(`@backtest-kit/cli ${"16.5.0"}\n`);
5940
+ process.stdout.write(`@backtest-kit/cli ${"17.0.0"}\n`);
5248
5941
  process.exit(0);
5249
5942
  };
5250
5943
  main();