@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/README.md +18 -18
- package/build/index.cjs +217 -184
- package/build/index.mjs +217 -184
- package/docker/package.json +6 -6
- package/package.json +13 -13
- package/template/project/package.mustache +5 -5
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 ${"
|
|
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
|
|
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(`|
|
|
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
|
-
|
|
4964
|
-
|
|
4965
|
-
|
|
4966
|
-
|
|
4967
|
-
|
|
4968
|
-
|
|
4969
|
-
|
|
4970
|
-
|
|
4971
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
5186
|
-
const
|
|
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
|
|
5210
|
-
|
|
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 = (
|
|
5284
|
+
const toMarkdown = (test) => {
|
|
5214
5285
|
const lines = [];
|
|
5215
|
-
lines.push(`# Tune Report — ${
|
|
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(
|
|
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 (
|
|
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
|
-
|
|
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
|
-
//
|
|
5427
|
-
|
|
5428
|
-
|
|
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
|
|
5448
|
-
point:
|
|
5449
|
-
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(
|
|
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(
|
|
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(
|
|
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>
|
|
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
|
-
|
|
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 }
|
|
5802
|
-
|
|
5803
|
-
|
|
5804
|
-
|
|
5805
|
-
|
|
5806
|
-
|
|
5807
|
-
|
|
5808
|
-
|
|
5809
|
-
|
|
5810
|
-
|
|
5811
|
-
|
|
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
|
|
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
|
|
5827
|
-
--markdown Save
|
|
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
|
-
|
|
5831
|
-
|
|
5832
|
-
|
|
5833
|
-
|
|
5834
|
-
|
|
5835
|
-
|
|
5836
|
-
|
|
5837
|
-
|
|
5838
|
-
|
|
5839
|
-
|
|
5840
|
-
|
|
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 --
|
|
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 ${"
|
|
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 ${"
|
|
5972
|
+
process.stdout.write(`@backtest-kit/cli ${"17.0.0"}\n`);
|
|
5940
5973
|
process.exit(0);
|
|
5941
5974
|
};
|
|
5942
5975
|
main();
|