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