@alphafox/cli 0.3.21 → 0.3.22

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.
@@ -101,8 +101,8 @@ async function runCli(argv, env = process.env) {
101
101
  "alphafox schema [operationId]",
102
102
  "alphafox catalog",
103
103
  "alphafox api METHOD PATH [--body JSON|--config @file]",
104
- "alphafox engine-backtest run --experiment <uuid> --definition <id> --config @file --exchange <id> --range FROM..TO --initial-equity N",
105
- "alphafox engine-backtest sweep --experiment <uuid> --definition <id> --config @file --axes @file --exchange <id> --range FROM..TO --initial-equity N --no-persist",
104
+ "alphafox engine-backtest run --experiment <uuid> --definition <id> --config @file --exchange <id> --range FROM..TO --initial-equity N [--verbose]",
105
+ "alphafox engine-backtest sweep --experiment <uuid> --definition <id> --config @file --axes @file --exchange <id> --range FROM..TO --initial-equity N --no-persist [--verbose]",
106
106
  "alphafox cache status|clean [--tape|--runtime|--all]",
107
107
  "alphafox resolve-symbols <query...> [--exchange binance] [--asset-class equity_perp]",
108
108
  "alphafox <domain> <resource> <action> [flags]",
@@ -16,11 +16,11 @@ const TIERS = new Set(["free", "pro", "pro_max"]);
16
16
  const DATA_QUALITY = new Set(["strict", "basic"]);
17
17
  const PRICE_PATHS = new Set(["ohlc_path_4", "close_only"]);
18
18
  exports.ENGINE_BACKTEST_RUN_USAGE = [
19
- "alphafox engine-backtest run --experiment <uuid> --definition <id> --config @file.json --exchange <id> --range YYYY-MM-DD..YYYY-MM-DD --initial-equity N [--replay-timeframe 1m]",
19
+ "alphafox engine-backtest run --experiment <uuid> --definition <id> --config @file.json --exchange <id> --range YYYY-MM-DD..YYYY-MM-DD --initial-equity N [--replay-timeframe 1m] [--verbose]",
20
20
  "alphafox engine-backtest run --create-experiment --name <name> --definition <id> --config @file.json --exchange <id> --from YYYY-MM-DD --to YYYY-MM-DD --initial-equity N",
21
21
  ];
22
22
  exports.ENGINE_BACKTEST_SWEEP_USAGE = [
23
- "alphafox engine-backtest sweep --experiment <uuid> --definition <id> --config @file.json --axes @axes.json --exchange <id> --range YYYY-MM-DD..YYYY-MM-DD --initial-equity N [--no-persist] [--mode neighborhood|range] [--search-mode standard|fast] [--concurrency N]",
23
+ "alphafox engine-backtest sweep --experiment <uuid> --definition <id> --config @file.json --axes @axes.json --exchange <id> --range YYYY-MM-DD..YYYY-MM-DD --initial-equity N [--no-persist] [--mode neighborhood|range] [--search-mode standard|fast] [--concurrency N] [--verbose]",
24
24
  ];
25
25
  const SWEEP_MODES = new Set(["neighborhood", "range"]);
26
26
  const SEARCH_MODES = new Set(["standard", "fast"]);
@@ -112,6 +112,7 @@ function parseEngineBacktestRunArgs(args) {
112
112
  let configSchemaVersion;
113
113
  let executionModelOverride;
114
114
  let persist = true;
115
+ let verbose = false;
115
116
  let help = false;
116
117
  let replayTimeframe = replay_timeframe_1.ENGINE_BACKTEST_DEFAULT_REPLAY_TIMEFRAME;
117
118
  for (let i = 0; i < args.length; i += 1) {
@@ -128,6 +129,10 @@ function parseEngineBacktestRunArgs(args) {
128
129
  persist = false;
129
130
  continue;
130
131
  }
132
+ if (a === "--verbose") {
133
+ verbose = true;
134
+ continue;
135
+ }
131
136
  if (!a.startsWith("--")) {
132
137
  usage(`Unexpected argument: ${a}`, "unexpected_arg");
133
138
  }
@@ -227,6 +232,7 @@ function parseEngineBacktestRunArgs(args) {
227
232
  tier,
228
233
  dataQualityMode,
229
234
  persist,
235
+ verbose,
230
236
  replayTimeframe,
231
237
  };
232
238
  }
@@ -284,6 +290,7 @@ function parseEngineBacktestRunArgs(args) {
284
290
  configSchemaVersion,
285
291
  executionModelOverride,
286
292
  persist,
293
+ verbose,
287
294
  replayTimeframe,
288
295
  };
289
296
  }
@@ -346,6 +353,7 @@ function parseEngineBacktestSweepArgs(args) {
346
353
  createExperiment: false,
347
354
  dataQualityMode: types_1.DEFAULT_DATA_QUALITY_MODE,
348
355
  persist: true,
356
+ verbose: false,
349
357
  replayTimeframe: replay_timeframe_1.ENGINE_BACKTEST_DEFAULT_REPLAY_TIMEFRAME,
350
358
  axesRaw,
351
359
  mode,
@@ -222,7 +222,9 @@ async function executeEngineBacktestRun(args, flags, env = process.env, deps = {
222
222
  wasm = wasmLoaded.module;
223
223
  runner = runnerLoaded.module;
224
224
  }
225
- const client = wasm.createNodeBacktestClient();
225
+ const client = wasm.createNodeBacktestClient({
226
+ verbose: args.verbose,
227
+ });
226
228
  try {
227
229
  if (typeof client.init === "function") {
228
230
  await client.init();
@@ -449,6 +451,7 @@ function engineBacktestHelpData() {
449
451
  "runs.create is write (not high-risk-write); --yes is not required",
450
452
  "Do not pass --token; use alphafox auth login",
451
453
  "--replay-timeframe defaults to 1m (min 1m). Indicator series still download their native plan timeframes.",
454
+ "Raw Engine worker logs, including per-trade output, are hidden by default. Use --verbose to forward them.",
452
455
  "--data-quality defaults to basic: hard tape failures still stop, soft gaps finish the run and appear as coverageNotice (prefix_gap less severe, internal_gap more severe). Use --data-quality strict to fail on any gap.",
453
456
  ],
454
457
  };
@@ -85,7 +85,7 @@ async function executeEngineBacktestSweep(args, flags, env = process.env, deps =
85
85
  const { wasm, runner } = await loadRuntime(deps, env);
86
86
  const clients = [];
87
87
  try {
88
- const client = wasm.createNodeBacktestClient();
88
+ const client = wasm.createNodeBacktestClient({ verbose: args.verbose });
89
89
  clients.push(client);
90
90
  if (typeof client.init === "function") {
91
91
  await client.init();
@@ -221,7 +221,7 @@ async function executeEngineBacktestSweep(args, flags, env = process.env, deps =
221
221
  }
222
222
  emitProgress("tape", 1);
223
223
  while (clients.length < concurrency) {
224
- const extra = wasm.createNodeBacktestClient();
224
+ const extra = wasm.createNodeBacktestClient({ verbose: args.verbose });
225
225
  clients.push(extra);
226
226
  if (typeof extra.init === "function") {
227
227
  await extra.init();
@@ -50,6 +50,8 @@ export interface EngineBacktestRunArgs {
50
50
  readonly configSchemaVersion?: number;
51
51
  readonly executionModelOverride?: Partial<ExecutionModel>;
52
52
  readonly persist: boolean;
53
+ /** Forward raw Engine WASM worker stdout/stderr. Disabled by default. */
54
+ readonly verbose: boolean;
53
55
  /** Replay/download bar. Defaults to 1m; never finer. */
54
56
  readonly replayTimeframe: string;
55
57
  }
@@ -256,7 +258,9 @@ export interface BacktestClientLike {
256
258
  terminate(): void;
257
259
  }
258
260
  export interface BacktestWasmModule {
259
- createNodeBacktestClient(options?: unknown): BacktestClientLike;
261
+ createNodeBacktestClient(options?: {
262
+ readonly verbose?: boolean;
263
+ }): BacktestClientLike;
260
264
  }
261
265
  export interface BacktestRunnerModule {
262
266
  loadTape(request: {
@@ -1,153 +1,153 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "packageName": "@alphafox/cli",
4
- "packageVersion": "0.3.21",
4
+ "packageVersion": "0.3.22",
5
5
  "contractVersion": "2026-08-31",
6
- "bundleHash": "3b1e1c9968daa5b78e782cdb9a3e824b9fd69dfe20c90fd81fe457dd5b051f53",
6
+ "bundleHash": "0ff8e818dd5a2f06d7d0df63d681bdf04163b9b09d8626da99c033a97aa8d438",
7
7
  "skills": [
8
8
  {
9
9
  "name": "alphafox",
10
- "version": "0.3.21",
10
+ "version": "0.3.22",
11
11
  "files": [
12
12
  {
13
13
  "path": "SKILL.md",
14
- "sha256": "2152cddf61791a680d9a12e561afa858f70ae862fe938ffdda8c9224f186664e",
14
+ "sha256": "454ddd309258874eb35bf567cd9ac3f524ecd212cd0a76599b8ae96ca50b9658",
15
15
  "size": 7950
16
16
  }
17
17
  ],
18
- "hash": "1cebc25d526364cf90f859407485d7431fb04921e7b7f2102c3f2cc1c52ae1f6"
18
+ "hash": "10dc9fcb503d2e09dda7284818fcebd1223d97fb3c8e95e7757ea6db86a239b2"
19
19
  },
20
20
  {
21
21
  "name": "alphafox-account",
22
- "version": "0.3.21",
22
+ "version": "0.3.22",
23
23
  "files": [
24
24
  {
25
25
  "path": "SKILL.md",
26
- "sha256": "9897b6cdc67bc3b439d0ee8d20a73b1b195f434282f443cdd4b386da64926148",
26
+ "sha256": "ab83a22f7960d1b94d1f3874cd464c705c9eafb77b3b132ad3d2c092b343227b",
27
27
  "size": 784
28
28
  }
29
29
  ],
30
- "hash": "c11aaaa4a6fff64003f2ed6f842eb2879da9de82d90bb86e7b95224b958621f0"
30
+ "hash": "b0de2c06865dd85be5655358e2fe20bd54dd2db29af73c225e43e3e5c4464649"
31
31
  },
32
32
  {
33
33
  "name": "alphafox-admin",
34
- "version": "0.3.21",
34
+ "version": "0.3.22",
35
35
  "files": [
36
36
  {
37
37
  "path": "SKILL.md",
38
- "sha256": "bc94cab7004cb17c8b87c4af22d3dfe95bf51c4bdde3bd84cdaebbc056584322",
38
+ "sha256": "a30aa7f40d67e32068f909481906a548da713f05dfb8562eebb05bc09d90bca7",
39
39
  "size": 2078
40
40
  }
41
41
  ],
42
- "hash": "073a7e1cc246b0c77a376e279ba09d7e049639c8e63074415fcfe26198ee1903"
42
+ "hash": "e0aa20827c3299dc996c74ef27b7708bd32275e3a61362b07be367fc565e735a"
43
43
  },
44
44
  {
45
45
  "name": "alphafox-auth",
46
- "version": "0.3.21",
46
+ "version": "0.3.22",
47
47
  "files": [
48
48
  {
49
49
  "path": "SKILL.md",
50
- "sha256": "c576ef7a5ac20933def1bc334e3347b0951ad0692ff4b4ea92fe89a4052b5ee4",
50
+ "sha256": "eab220c69f31de56f7bdfde7722723c2bc9a667539f62bc8faae7234642412be",
51
51
  "size": 2371
52
52
  }
53
53
  ],
54
- "hash": "41c831e8be2311f3abf393a9a502b2bbe6f79a383dfa9ee125a6b6f70e38e2f4"
54
+ "hash": "02c24ece7d90d42afd81c249184925ec970602e0226deafd290ff1d7d2e2dfa9"
55
55
  },
56
56
  {
57
57
  "name": "alphafox-cache",
58
- "version": "0.3.21",
58
+ "version": "0.3.22",
59
59
  "files": [
60
60
  {
61
61
  "path": "SKILL.md",
62
- "sha256": "7b068ca3e06f221ea9306d441c56468852dfbb965e07f1675f1c423587520ca8",
62
+ "sha256": "ebdf0007dfb04f2e2285b2e4cf738e6b70c46b40f67b22f2ec024be79e1ff895",
63
63
  "size": 1530
64
64
  }
65
65
  ],
66
- "hash": "8d97f1d8c423dbdabdf6aaf66e2d129d76319b0d4b803690cfe454239e237088"
66
+ "hash": "7ebf4b0457e8c61fdd28601dbd95ef3b36eb2d4935e1f38448273e329ae69013"
67
67
  },
68
68
  {
69
69
  "name": "alphafox-engine-backtest",
70
- "version": "0.3.21",
70
+ "version": "0.3.22",
71
71
  "files": [
72
72
  {
73
73
  "path": "SKILL.md",
74
- "sha256": "a957f0785e76a077b6d1f9f1fa80d43e4a1474ea65d7b2960677c42cdb6f66e2",
74
+ "sha256": "2a30ee16622c6a4cf1f9f40519be544d0f089a62eba4ffdd48d726f153760de3",
75
75
  "size": 9792
76
76
  }
77
77
  ],
78
- "hash": "a046f3ef1386b5649d4a019458a790adc978c5e9a69b03a58e64a262bf545f1b"
78
+ "hash": "1aecca4608c0253089e69c1d58746984c2621ab5eccbb45c4e962a7530936663"
79
79
  },
80
80
  {
81
81
  "name": "alphafox-exchange",
82
- "version": "0.3.21",
82
+ "version": "0.3.22",
83
83
  "files": [
84
84
  {
85
85
  "path": "SKILL.md",
86
- "sha256": "9d11b9c68da4d6948c00b1f2449e170a3ef387f07ccce36db1037d0871b0842a",
86
+ "sha256": "a065c2af77150a140838580053d688defaafa2c1093c565f9759b2e4f211e3cd",
87
87
  "size": 744
88
88
  }
89
89
  ],
90
- "hash": "53b02a31ef1891bbcbb513dae2af48e8978003f3c41585ebab62d940bd8bad07"
90
+ "hash": "0dd29f8547bdbcfe89789877b32e84f130e88c886cd07cab2785d515ff807a3c"
91
91
  },
92
92
  {
93
93
  "name": "alphafox-market",
94
- "version": "0.3.21",
94
+ "version": "0.3.22",
95
95
  "files": [
96
96
  {
97
97
  "path": "SKILL.md",
98
- "sha256": "3131b98faddf4a85c87a2a4b0b7fb9c0b6c97159bf38dfc61a1c2a2367fc0020",
98
+ "sha256": "ca9d73c21724cf2e9b2f4ab9128f5ca883c70530ee4dc7268ea05c3a9d226083",
99
99
  "size": 3079
100
100
  }
101
101
  ],
102
- "hash": "df030475f2d0c4b53312bc1be8f1c3555062076074c0c207e3d8cd7a223d4f05"
102
+ "hash": "e5582689e8c8ca0e2ffcb334ba454b12400e9591070c9728b31e39db42c276a0"
103
103
  },
104
104
  {
105
105
  "name": "alphafox-notification",
106
- "version": "0.3.21",
106
+ "version": "0.3.22",
107
107
  "files": [
108
108
  {
109
109
  "path": "SKILL.md",
110
- "sha256": "163a697d7c107152dc0a20dcabd06f1d9eb5b9395289e940b228cf599e7e901e",
110
+ "sha256": "05485ee0d3bec411583f57ecb711555a5353d50fbf76ab92aaa45e2de4abdf20",
111
111
  "size": 699
112
112
  }
113
113
  ],
114
- "hash": "eab1329002b3e97d8a71800de23595a1df35cd0db3e7751382820df5244c9865"
114
+ "hash": "a139b26ee840f4f6c3bf46e92809ef5db1af29b446e572b5254808a639f86f27"
115
115
  },
116
116
  {
117
117
  "name": "alphafox-shared",
118
- "version": "0.3.21",
118
+ "version": "0.3.22",
119
119
  "files": [
120
120
  {
121
121
  "path": "SKILL.md",
122
- "sha256": "95c311b9b091d5c6c21928c499568bba7d78bbf48ffc78204f54560c828213b8",
122
+ "sha256": "b7bbdf683b12975538c26e38a05dce89d0c0f1a19a5cc73569e2c709fbb5d04b",
123
123
  "size": 6920
124
124
  }
125
125
  ],
126
- "hash": "5b84d0c16f8d1e0b2b5caefa6a5683650450249615fe186d48e987d42eebd264"
126
+ "hash": "7c7750ba8bf2d2dbf51c8fb134ac958076773f9572f9082497925bb8b255de13"
127
127
  },
128
128
  {
129
129
  "name": "alphafox-strategy",
130
- "version": "0.3.21",
130
+ "version": "0.3.22",
131
131
  "files": [
132
132
  {
133
133
  "path": "SKILL.md",
134
- "sha256": "b61a5d3b32c0caaa29280676d0a38c32d895b13991056b8adbde584baeb751d2",
134
+ "sha256": "01540ff5e1076eab22515991199be407263d942bc7608628c377b81eb0157cd7",
135
135
  "size": 7185
136
136
  }
137
137
  ],
138
- "hash": "fa63c05d477ffb58e32c8c8e301189135d78e1a80bcb57cd7997a8533869f5f7"
138
+ "hash": "e2e0e0a6bae21e5f97ed6ba42d66e90cf51c76b6601cdad606630c3743441909"
139
139
  },
140
140
  {
141
141
  "name": "alphafox-trading",
142
- "version": "0.3.21",
142
+ "version": "0.3.22",
143
143
  "files": [
144
144
  {
145
145
  "path": "SKILL.md",
146
- "sha256": "2142e880981ec69bcc4e2605e1f7fd950b0b058ea70d47e7f67b5147c0e6313b",
146
+ "sha256": "4b862b2f01534e0fda2c6667bc39a972f2cd9692f8967a0496b941411b745967",
147
147
  "size": 4921
148
148
  }
149
149
  ],
150
- "hash": "cd4386cd59f54ca31eac44cc6fa17eb9b74d0be925f06087d083d9d2370481bd"
150
+ "hash": "d8c0388da25fd4964d6ca8d4668ec0a0778669e0fbc22e85952b7eca80869205"
151
151
  }
152
152
  ]
153
153
  }
package/dist/version.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export declare const CLI_NAME = "alphafox";
2
2
  export declare const CLI_PACKAGE = "@alphafox/cli";
3
- export declare const CLI_VERSION = "0.3.21";
3
+ export declare const CLI_VERSION = "0.3.22";
4
4
  export { CATALOG_VERSION as CLI_CONTRACT_VERSION } from "./catalog/operations";
package/dist/version.js CHANGED
@@ -3,6 +3,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.CLI_CONTRACT_VERSION = exports.CLI_VERSION = exports.CLI_PACKAGE = exports.CLI_NAME = void 0;
4
4
  exports.CLI_NAME = "alphafox";
5
5
  exports.CLI_PACKAGE = "@alphafox/cli";
6
- exports.CLI_VERSION = "0.3.21";
6
+ exports.CLI_VERSION = "0.3.22";
7
7
  var operations_1 = require("./catalog/operations");
8
8
  Object.defineProperty(exports, "CLI_CONTRACT_VERSION", { enumerable: true, get: function () { return operations_1.CATALOG_VERSION; } });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alphafox/cli",
3
- "version": "0.3.21",
3
+ "version": "0.3.22",
4
4
  "description": "AlphaFox CLI — Agent/Human entry for the public Application API.",
5
5
  "type": "commonjs",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: alphafox-account
3
3
  description: Account, wallet, and subscription read paths.
4
- version: 0.3.21
4
+ version: 0.3.22
5
5
  ---
6
6
 
7
7
  # Account / wallet
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: alphafox-admin
3
3
  description: Admin-only operations reusing Web role authorization.
4
- version: 0.3.21
4
+ version: 0.3.22
5
5
  ---
6
6
 
7
7
  # Admin
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: alphafox
3
3
  description: AlphaFox CLI entry router. Use for any AlphaFox request — install, update, login, whoami, 回测, engine backtest, 清理回测缓存 / 历史数据, strategy definitions, create/list/start/stop a running strategy (trader), ticker/标的 resolve (美股 or crypto), market data, exchange connectors, wallet, subscriptions, notifications, or admin. After 回测 or 运行策略, include the dashboard URL from the domain skill. When the user asks 排行榜, include https://www.alphafox.app/zh/dashboard/leaderboard. After a successful install and login, present the 新人引导 in this file (Lite square 带单员 + classic strategies). If a CLI command prints `[alphafox] update available`, ask the user「检测到新的版本,是否需要我帮你升级?」and only then run `alphafox update --format json --no-input`. After a large backtest, if tape cache is large, ask「回测下载的历史数据比较大,要不要我帮你清理本地缓存?」then open `alphafox-cache`. Start here, then open the routed domain skill. Do not guess alphafox-engine-backtest vs alphafox-strategy vs alphafox-trading from memory.
4
- version: 0.3.21
4
+ version: 0.3.22
5
5
  ---
6
6
 
7
7
  # AlphaFox
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: alphafox-shared
3
3
  description: Shared AlphaFox CLI rules for Agents — auth, profiles, envelopes, risk gates, public operationIds, and dashboard links after 回测 / 运行策略 / 排行榜.
4
- version: 0.3.21
4
+ version: 0.3.22
5
5
  ---
6
6
 
7
7
  # AlphaFox shared Agent contract
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: alphafox-auth
3
3
  description: Login, status, logout, whoami, and environment isolation for AlphaFox CLI.
4
- version: 0.3.21
4
+ version: 0.3.22
5
5
  ---
6
6
 
7
7
  # Auth Skill
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: alphafox-cache
3
3
  description: Inspect and clean local Engine backtest caches (downloaded OHLCV tape and wasm runtime). Use when the user asks to 清理缓存, free disk, or after a large historical backtest.
4
- version: 0.3.21
4
+ version: 0.3.22
5
5
  ---
6
6
 
7
7
  # Cache
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: alphafox-engine-backtest
3
3
  description: Local Engine WASM backtest (alphafox engine-backtest run|sweep) vs catalog experiment CRUD. After a persisted run, include https://www.alphafox.app/zh/dashboard/traders/backtest/{experimentId}.
4
- version: 0.3.21
4
+ version: 0.3.22
5
5
  ---
6
6
 
7
7
  # Engine Backtest
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: alphafox-exchange
3
3
  description: Exchange connectors list and connection management via Public API.
4
- version: 0.3.21
4
+ version: 0.3.22
5
5
  ---
6
6
 
7
7
  # Exchange connectors
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: alphafox-market
3
3
  description: Market data and ticker resolution for US equity perps, RWAs, and crypto on the same perp catalog. Use when the user names 美股, NVDA, AAPL, BTC, or any 标的. Keep the operator's asset class via symbolMetadata — do not rewrite NVDA into a crypto coin.
4
- version: 0.3.21
4
+ version: 0.3.22
5
5
  ---
6
6
 
7
7
  # Market
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: alphafox-notification
3
3
  description: Notification channels and subscriptions.
4
- version: 0.3.21
4
+ version: 0.3.22
5
5
  ---
6
6
 
7
7
  # Notification
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: alphafox-strategy
3
3
  description: Strategy definitions — list types, read a definition's contract, and validate config. Creating a running strategy is creating a trader; use alphafox-trading for that. Local Engine backtest is alphafox-engine-backtest.
4
- version: 0.3.21
4
+ version: 0.3.22
5
5
  ---
6
6
 
7
7
  # Strategy definitions
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: alphafox-trading
3
3
  description: Running strategies (traders) — create, list, start, and stop. A trader is a live or paper strategy instance (grid, dca, copy, …), not a person. Default Engine create uses autoStart true (创建即开始). Use autoStart false only when the user asks to create without starting. After create or start, include https://www.alphafox.app/zh/dashboard/traders/{traderId}.
4
- version: 0.3.21
4
+ version: 0.3.22
5
5
  ---
6
6
 
7
7
  # Running strategies (traders)
@@ -80,6 +80,8 @@ const scenario = assembleScenario({
80
80
 
81
81
  与 web `tape-loader.series.ts` 对齐:Binance 1500、OKX 100、Bybit / Bitget 1000、Hyperliquid 5000。Bitget 另受约 89 天请求跨度限制;Hyperliquid 分页带 `until` 窗口。
82
82
 
83
+ 长区间会拆成最多 8 个可独立下载的时间窗口;不同 `symbol × timeframe` 与同一序列的时间窗口共用一个全局 8 请求池,避免嵌套并发放大。相邻窗口保留一根重叠 K 线,合并时按时间去重;同时间戳内容不一致会抛出 `invalid_ohlcv`,不会静默选取其中一份。
84
+
83
85
  ## 数据质量
84
86
 
85
87
  - `basic`(默认):硬失败(缺市场、空序列、非法 K 线、拉数失败)仍抛错;软缺口进入 `coverageIssues` 与 `coverageWarnings`。起始缺口(`prefix_gap`)较轻,中间缺口(`internal_gap`)较重。
@@ -316,7 +316,7 @@ export interface TapeProxyOptions {
316
316
  export interface TapeLoadOptions extends TapeProxyOptions {
317
317
  readonly cache?: FileTapeCache | false | "disable";
318
318
  readonly cacheDir?: string;
319
- /** Independent series fetches; default 4, hard cap 8. Pagination stays serial. */
319
+ /** Shared series and time-window request budget; default and hard cap are 8. */
320
320
  readonly seriesConcurrency?: number;
321
321
  readonly nowMs?: number;
322
322
  readonly onProgress?: (progress: TapeLoadProgress) => void;
@@ -353,7 +353,7 @@ export interface TapeLoadResult {
353
353
  };
354
354
  }
355
355
 
356
- export const DEFAULT_TAPE_SERIES_CONCURRENCY: 4;
356
+ export const DEFAULT_TAPE_SERIES_CONCURRENCY: 8;
357
357
  export const MAX_TAPE_SERIES_CONCURRENCY: 8;
358
358
 
359
359
  export function resolveTapeSeriesConcurrency(value?: number): number;
@@ -364,6 +364,12 @@ export function mapWithConcurrency<T, R>(
364
364
  worker: (item: T, index: number) => Promise<R>
365
365
  ): Promise<R[]>;
366
366
 
367
+ export function limitTapeOhlcvConcurrency(
368
+ exchange: Pick<TapeRuntimeExchange, "fetchOHLCV">,
369
+ concurrency: number,
370
+ signal?: AbortSignal
371
+ ): Pick<TapeRuntimeExchange, "fetchOHLCV">;
372
+
367
373
  export function loadTape(
368
374
  request: TapeLoadRequest,
369
375
  options?: TapeLoadOptions
@@ -55,10 +55,13 @@ export {
55
55
  export {
56
56
  DEFAULT_TAPE_SERIES_CONCURRENCY,
57
57
  MAX_TAPE_SERIES_CONCURRENCY,
58
- loadTape,
58
+ limitTapeOhlcvConcurrency,
59
59
  mapWithConcurrency,
60
- resolveTapeCache,
61
60
  resolveTapeSeriesConcurrency,
61
+ } from "./lib/tape-loader-concurrency.mjs";
62
+ export {
63
+ loadTape,
64
+ resolveTapeCache,
62
65
  effectiveTapeEndMs,
63
66
  inferFundingIntervals,
64
67
  classifyTapeSymbolsForPreflight,
@@ -12,19 +12,14 @@ import {
12
12
  ENGINE_BACKTEST_WARMUP_CANDLES,
13
13
  TIMEFRAME_MS,
14
14
  } from "./timeframes.mjs";
15
+ import {
16
+ fetchClosedOhlcvRange,
17
+ isClosedCandle,
18
+ } from "./tape-loader-range.mjs";
15
19
 
16
20
  const WARMUP_CANDLES = ENGINE_BACKTEST_WARMUP_CANDLES;
17
- const BITGET_OHLCV_MAX_REQUEST_SPAN_MS = 89 * 86_400_000;
18
-
19
- export { TIMEFRAME_MS };
20
21
 
21
- export function isClosedCandle(timestampMs, timeframe, toMs) {
22
- const stepMs = TIMEFRAME_MS[timeframe];
23
- if (!stepMs) {
24
- throw new Error(`不支持的 timeframe:${timeframe}`);
25
- }
26
- return timestampMs + stepMs <= toMs;
27
- }
22
+ export { fetchClosedOhlcvRange, isClosedCandle, TIMEFRAME_MS };
28
23
 
29
24
  export function ohlcvSeriesStartMs(fromMs, timeframe, market) {
30
25
  const stepMs = TIMEFRAME_MS[timeframe];
@@ -207,164 +202,3 @@ function concatMonotonicRows(head, tail) {
207
202
  const lastHeadTimestamp = head[head.length - 1][0];
208
203
  return [...head, ...tail.filter((row) => row[0] > lastHeadTimestamp)];
209
204
  }
210
-
211
- export async function fetchClosedOhlcvRange(request) {
212
- const stepMs = TIMEFRAME_MS[request.timeframe];
213
- if (!stepMs) {
214
- throw new Error(`不支持的 timeframe:${request.timeframe}`);
215
- }
216
- const pageLimit = ohlcvPageLimitForTimeframe(
217
- request.exchangeDefinition,
218
- request.runtimeConfig,
219
- request.timeframe
220
- );
221
- const rows = [];
222
- const totalSpan = Math.max(1, request.toMs - request.sinceMs);
223
- let cursor = request.sinceMs;
224
- let emptyPrefixLowerMs = null;
225
- let nonEmptyPrefixUpperMs = null;
226
- let prefixResolved = false;
227
-
228
- while (cursor < request.toMs) {
229
- request.signal?.throwIfAborted();
230
- const fetchSinceMs =
231
- request.exchangeDefinition.ccxtId === "bitget"
232
- ? Math.max(0, cursor - 1)
233
- : cursor;
234
- const requestParams = {
235
- ...request.runtimeConfig.requestParams,
236
- };
237
- if (request.exchangeDefinition.ccxtId === "hyperliquid") {
238
- requestParams.until = Math.min(
239
- request.toMs,
240
- fetchSinceMs + (pageLimit - 1) * stepMs
241
- );
242
- }
243
- const page = await abortable(
244
- request.exchange.fetchOHLCV(
245
- request.symbol,
246
- request.timeframe,
247
- fetchSinceMs,
248
- pageLimit,
249
- requestParams
250
- ),
251
- request.signal
252
- );
253
- request.signal?.throwIfAborted();
254
-
255
- if (!prefixResolved && rows.length === 0 && emptyPrefixLowerMs !== null) {
256
- if (page.length === 0) {
257
- emptyPrefixLowerMs = cursor;
258
- if (cursor >= request.fromMs) break;
259
- } else {
260
- nonEmptyPrefixUpperMs = cursor;
261
- }
262
- const upperMs = nonEmptyPrefixUpperMs ?? request.fromMs;
263
- if (
264
- nonEmptyPrefixUpperMs !== null &&
265
- upperMs - emptyPrefixLowerMs <= stepMs
266
- ) {
267
- cursor = upperMs;
268
- prefixResolved = true;
269
- continue;
270
- }
271
- cursor = midpointCursorMs(emptyPrefixLowerMs, upperMs, stepMs);
272
- continue;
273
- }
274
-
275
- if (page.length === 0) {
276
- if (!prefixResolved && rows.length === 0 && cursor < request.fromMs) {
277
- emptyPrefixLowerMs = cursor;
278
- cursor = midpointCursorMs(cursor, request.fromMs, stepMs);
279
- continue;
280
- }
281
- break;
282
- }
283
- prefixResolved = true;
284
-
285
- for (const raw of page) {
286
- if (raw.length < 6) {
287
- throw new TapeDataUnavailableError([
288
- {
289
- code: "invalid_ohlcv",
290
- symbol: request.symbol,
291
- timeframe: request.timeframe,
292
- },
293
- ]);
294
- }
295
- const timestamp = Number(raw[0]);
296
- if (!Number.isFinite(timestamp)) {
297
- throw new TapeDataUnavailableError([
298
- {
299
- code: "invalid_ohlcv",
300
- symbol: request.symbol,
301
- timeframe: request.timeframe,
302
- },
303
- ]);
304
- }
305
- if (!isClosedCandle(timestamp, request.timeframe, request.toMs)) {
306
- continue;
307
- }
308
- if (rows.length > 0 && timestamp <= rows[rows.length - 1][0]) {
309
- throw new TapeDataUnavailableError([
310
- {
311
- code: "non_monotonic",
312
- symbol: request.symbol,
313
- timeframe: request.timeframe,
314
- expected: rows[rows.length - 1][0] + stepMs,
315
- actual: timestamp,
316
- timestamp,
317
- },
318
- ]);
319
- }
320
- rows.push([
321
- timestamp,
322
- Number(raw[1]),
323
- Number(raw[2]),
324
- Number(raw[3]),
325
- Number(raw[4]),
326
- Number(raw[5]),
327
- ]);
328
- }
329
- const lastTimestamp = Number(page[page.length - 1][0]);
330
- const nextCursor = lastTimestamp + stepMs;
331
- if (nextCursor <= cursor) {
332
- break;
333
- }
334
- cursor = nextCursor;
335
- request.onFraction?.(Math.min(1, (cursor - request.sinceMs) / totalSpan));
336
- }
337
- if (rows.length === 0 && !request.allowEmpty) {
338
- throw new TapeDataUnavailableError([
339
- {
340
- code: "ohlcv_missing",
341
- symbol: request.symbol,
342
- timeframe: request.timeframe,
343
- },
344
- ]);
345
- }
346
- return rows;
347
- }
348
-
349
- function ohlcvPageLimitForTimeframe(exchange, runtimeConfig, timeframe) {
350
- if (exchange.ccxtId !== "bitget") {
351
- return runtimeConfig.ohlcvPageLimit;
352
- }
353
- const stepMs = TIMEFRAME_MS[timeframe];
354
- return Math.max(
355
- 1,
356
- Math.min(
357
- runtimeConfig.ohlcvPageLimit,
358
- Math.floor(BITGET_OHLCV_MAX_REQUEST_SPAN_MS / stepMs)
359
- )
360
- );
361
- }
362
-
363
- function midpointCursorMs(lowerMs, upperMs, stepMs) {
364
- if (upperMs - lowerMs <= stepMs) {
365
- return upperMs;
366
- }
367
- const midpointMs = lowerMs + Math.floor((upperMs - lowerMs) / 2);
368
- const alignedMs = Math.floor(midpointMs / stepMs) * stepMs;
369
- return Math.min(upperMs, Math.max(lowerMs + 1, alignedMs));
370
- }
@@ -0,0 +1,72 @@
1
+ export const DEFAULT_TAPE_SERIES_CONCURRENCY = 8;
2
+ export const MAX_TAPE_SERIES_CONCURRENCY = 8;
3
+
4
+ export function resolveTapeSeriesConcurrency(value) {
5
+ if (typeof value === "number" && Number.isFinite(value) && value >= 1) {
6
+ return Math.min(
7
+ MAX_TAPE_SERIES_CONCURRENCY,
8
+ Math.max(1, Math.floor(value))
9
+ );
10
+ }
11
+ return DEFAULT_TAPE_SERIES_CONCURRENCY;
12
+ }
13
+
14
+ export async function mapWithConcurrency(items, concurrency, worker) {
15
+ const list = [...items];
16
+ if (list.length === 0) return [];
17
+
18
+ const limit = Math.min(
19
+ list.length,
20
+ Math.max(1, Math.floor(Number(concurrency)) || 1)
21
+ );
22
+ const results = new Array(list.length);
23
+ let nextIndex = 0;
24
+ async function runWorker() {
25
+ while (true) {
26
+ const index = nextIndex;
27
+ nextIndex += 1;
28
+ if (index >= list.length) return;
29
+ results[index] = await worker(list[index], index);
30
+ }
31
+ }
32
+ await Promise.all(Array.from({ length: limit }, () => runWorker()));
33
+ return results;
34
+ }
35
+
36
+ /** Shares one request budget across series workers and their time windows. */
37
+ export function limitTapeOhlcvConcurrency(exchange, concurrency, signal) {
38
+ const limit = resolveTapeSeriesConcurrency(concurrency);
39
+ const queue = [];
40
+ let active = 0;
41
+
42
+ const drain = () => {
43
+ while (active < limit) {
44
+ const task = queue.shift();
45
+ if (!task) return;
46
+ active += 1;
47
+ void task().finally(() => {
48
+ active -= 1;
49
+ drain();
50
+ });
51
+ }
52
+ };
53
+ const schedule = (operation) =>
54
+ new Promise((resolve, reject) => {
55
+ queue.push(async () => {
56
+ try {
57
+ signal?.throwIfAborted();
58
+ resolve(await operation());
59
+ } catch (error) {
60
+ reject(error);
61
+ }
62
+ });
63
+ drain();
64
+ });
65
+
66
+ return {
67
+ fetchOHLCV: (symbol, timeframe, since, pageLimit, params) =>
68
+ schedule(() =>
69
+ exchange.fetchOHLCV(symbol, timeframe, since, pageLimit, params)
70
+ ),
71
+ };
72
+ }
@@ -0,0 +1,77 @@
1
+ import { TapeDataUnavailableError } from "./coverage.mjs";
2
+ import { MAX_TAPE_SERIES_CONCURRENCY } from "./tape-loader-concurrency.mjs";
3
+
4
+ export function planOhlcvTimeSegments(request, stepMs, pageLimit) {
5
+ const candleCount = Math.max(
6
+ 0,
7
+ Math.ceil((request.toMs - request.sinceMs) / stepMs)
8
+ );
9
+ const segmentCount = Math.min(
10
+ MAX_TAPE_SERIES_CONCURRENCY,
11
+ Math.max(1, Math.ceil(candleCount / pageLimit))
12
+ );
13
+ if (segmentCount === 1) {
14
+ return [{ sinceMs: request.sinceMs, untilMs: request.toMs }];
15
+ }
16
+
17
+ const candlesPerSegment = Math.ceil(candleCount / segmentCount);
18
+ return Array.from({ length: segmentCount }, (_, index) => {
19
+ const logicalStartMs = request.sinceMs + index * candlesPerSegment * stepMs;
20
+ return {
21
+ // Overlap preserves bars whose exchange open time is not split-aligned.
22
+ sinceMs:
23
+ index === 0
24
+ ? request.sinceMs
25
+ : Math.max(request.sinceMs, logicalStartMs - stepMs),
26
+ untilMs: Math.min(
27
+ request.toMs,
28
+ request.sinceMs + (index + 1) * candlesPerSegment * stepMs
29
+ ),
30
+ };
31
+ }).filter((segment) => segment.untilMs > segment.sinceMs);
32
+ }
33
+
34
+ export function createSegmentRequest(request, segment, onFraction) {
35
+ return {
36
+ ...request,
37
+ sinceMs: segment.sinceMs,
38
+ toMs: segment.untilMs,
39
+ fromMs: Math.min(
40
+ segment.untilMs,
41
+ Math.max(segment.sinceMs, request.fromMs)
42
+ ),
43
+ allowEmpty: true,
44
+ onFraction,
45
+ };
46
+ }
47
+
48
+ export function mergeOhlcvTimeSegments(request, segments) {
49
+ const rowsByTimestamp = new Map();
50
+ for (const rows of segments) {
51
+ for (const row of rows) {
52
+ if (row[0] < request.sinceMs) continue;
53
+ const existing = rowsByTimestamp.get(row[0]);
54
+ if (existing && !sameOhlcvRow(existing, row)) {
55
+ throw new TapeDataUnavailableError([
56
+ {
57
+ code: "invalid_ohlcv",
58
+ symbol: request.symbol,
59
+ timeframe: request.timeframe,
60
+ timestamp: row[0],
61
+ message: "concurrent OHLCV windows returned conflicting candles",
62
+ },
63
+ ]);
64
+ }
65
+ rowsByTimestamp.set(row[0], row);
66
+ }
67
+ }
68
+ return [...rowsByTimestamp.values()].sort((left, right) => left[0] - right[0]);
69
+ }
70
+
71
+ export function averageProgress(values) {
72
+ return values.reduce((sum, value) => sum + value, 0) / values.length;
73
+ }
74
+
75
+ function sameOhlcvRow(left, right) {
76
+ return left.every((value, index) => value === right[index]);
77
+ }
@@ -0,0 +1,222 @@
1
+ import { abortable } from "./abortable.mjs";
2
+ import { TapeDataUnavailableError } from "./coverage.mjs";
3
+ import {
4
+ averageProgress,
5
+ createSegmentRequest,
6
+ mergeOhlcvTimeSegments,
7
+ planOhlcvTimeSegments,
8
+ } from "./tape-loader-range-plan.mjs";
9
+ import { TIMEFRAME_MS } from "./timeframes.mjs";
10
+
11
+ const BITGET_OHLCV_MAX_REQUEST_SPAN_MS = 89 * 86_400_000;
12
+
13
+ export function isClosedCandle(timestampMs, timeframe, toMs) {
14
+ return timestampMs + timeframeStepMs(timeframe) <= toMs;
15
+ }
16
+
17
+ export async function fetchClosedOhlcvRange(request) {
18
+ const stepMs = timeframeStepMs(request.timeframe);
19
+ const pageLimit = ohlcvPageLimitForTimeframe(
20
+ request.exchangeDefinition,
21
+ request.runtimeConfig,
22
+ request.timeframe
23
+ );
24
+ const segments = planOhlcvTimeSegments(request, stepMs, pageLimit);
25
+ if (segments.length === 1) {
26
+ return fetchClosedOhlcvTimeSegment(request, pageLimit);
27
+ }
28
+
29
+ const progress = new Array(segments.length).fill(0);
30
+ const segmentRows = await Promise.all(
31
+ segments.map((segment, index) =>
32
+ fetchClosedOhlcvTimeSegment(
33
+ createSegmentRequest(request, segment, (fraction) => {
34
+ progress[index] = fraction;
35
+ request.onFraction?.(averageProgress(progress));
36
+ }),
37
+ pageLimit
38
+ )
39
+ )
40
+ );
41
+ const rows = mergeOhlcvTimeSegments(request, segmentRows);
42
+ if (rows.length === 0 && !request.allowEmpty) {
43
+ throw missingOhlcvError(request);
44
+ }
45
+ request.onFraction?.(1);
46
+ return rows;
47
+ }
48
+
49
+ async function fetchClosedOhlcvTimeSegment(request, pageLimit) {
50
+ const stepMs = timeframeStepMs(request.timeframe);
51
+ const rows = [];
52
+ const totalSpan = Math.max(1, request.toMs - request.sinceMs);
53
+ let cursor = request.sinceMs;
54
+ let emptyPrefixLowerMs = null;
55
+ let nonEmptyPrefixUpperMs = null;
56
+ let prefixResolved = false;
57
+
58
+ while (cursor < request.toMs) {
59
+ request.signal?.throwIfAborted();
60
+ const fetchSinceMs = resolveFetchSinceMs(request, cursor);
61
+ const page = await fetchOhlcvPage(request, fetchSinceMs, pageLimit, stepMs);
62
+ request.signal?.throwIfAborted();
63
+
64
+ const prefix = resolveEmptyPrefix({
65
+ request,
66
+ page,
67
+ rows,
68
+ cursor,
69
+ stepMs,
70
+ emptyPrefixLowerMs,
71
+ nonEmptyPrefixUpperMs,
72
+ prefixResolved,
73
+ });
74
+ if (prefix) {
75
+ ({ cursor, emptyPrefixLowerMs, nonEmptyPrefixUpperMs, prefixResolved } =
76
+ prefix);
77
+ if (prefix.done) break;
78
+ continue;
79
+ }
80
+ if (page.length === 0) break;
81
+ prefixResolved = true;
82
+ appendPageRows(request, rows, page, stepMs);
83
+ const nextCursor = Number(page[page.length - 1][0]) + stepMs;
84
+ if (nextCursor <= cursor) break;
85
+ cursor = nextCursor;
86
+ request.onFraction?.(Math.min(1, (cursor - request.sinceMs) / totalSpan));
87
+ }
88
+ if (rows.length === 0 && !request.allowEmpty) {
89
+ throw missingOhlcvError(request);
90
+ }
91
+ return rows;
92
+ }
93
+
94
+ function resolveEmptyPrefix(input) {
95
+ if (input.prefixResolved || input.rows.length > 0) return null;
96
+ if (input.page.length === 0 && input.cursor >= input.request.fromMs) {
97
+ return { ...input, done: true };
98
+ }
99
+ if (input.page.length > 0 && input.emptyPrefixLowerMs === null) return null;
100
+
101
+ const emptyPrefixLowerMs =
102
+ input.page.length === 0 ? input.cursor : input.emptyPrefixLowerMs;
103
+ const nonEmptyPrefixUpperMs =
104
+ input.page.length > 0 ? input.cursor : input.nonEmptyPrefixUpperMs;
105
+ const upperMs = nonEmptyPrefixUpperMs ?? input.request.fromMs;
106
+ const prefixResolved =
107
+ nonEmptyPrefixUpperMs !== null &&
108
+ upperMs - (emptyPrefixLowerMs ?? upperMs) <= input.stepMs;
109
+ return {
110
+ cursor: prefixResolved
111
+ ? upperMs
112
+ : midpointCursorMs(
113
+ emptyPrefixLowerMs ?? input.cursor,
114
+ upperMs,
115
+ input.stepMs
116
+ ),
117
+ emptyPrefixLowerMs,
118
+ nonEmptyPrefixUpperMs,
119
+ prefixResolved,
120
+ done: false,
121
+ };
122
+ }
123
+
124
+ async function fetchOhlcvPage(request, fetchSinceMs, pageLimit, stepMs) {
125
+ const params = { ...request.runtimeConfig.requestParams };
126
+ if (request.exchangeDefinition.ccxtId === "hyperliquid") {
127
+ params.until = Math.min(
128
+ request.toMs,
129
+ fetchSinceMs + (pageLimit - 1) * stepMs
130
+ );
131
+ }
132
+ return abortable(
133
+ request.exchange.fetchOHLCV(
134
+ request.symbol,
135
+ request.timeframe,
136
+ fetchSinceMs,
137
+ pageLimit,
138
+ params
139
+ ),
140
+ request.signal
141
+ );
142
+ }
143
+
144
+ function appendPageRows(request, rows, page, stepMs) {
145
+ for (const raw of page) {
146
+ const timestamp = Number(raw[0]);
147
+ if (raw.length < 6 || !Number.isFinite(timestamp)) {
148
+ throw invalidOhlcvError(request);
149
+ }
150
+ if (!isClosedCandle(timestamp, request.timeframe, request.toMs)) continue;
151
+ if (rows.length > 0 && timestamp <= rows[rows.length - 1][0]) {
152
+ throw new TapeDataUnavailableError([
153
+ {
154
+ code: "non_monotonic",
155
+ symbol: request.symbol,
156
+ timeframe: request.timeframe,
157
+ expected: rows[rows.length - 1][0] + stepMs,
158
+ actual: timestamp,
159
+ timestamp,
160
+ },
161
+ ]);
162
+ }
163
+ rows.push([
164
+ timestamp,
165
+ Number(raw[1]),
166
+ Number(raw[2]),
167
+ Number(raw[3]),
168
+ Number(raw[4]),
169
+ Number(raw[5]),
170
+ ]);
171
+ }
172
+ }
173
+
174
+ function resolveFetchSinceMs(request, cursor) {
175
+ return request.exchangeDefinition.ccxtId === "bitget"
176
+ ? Math.max(0, cursor - 1)
177
+ : cursor;
178
+ }
179
+
180
+ function ohlcvPageLimitForTimeframe(exchange, runtimeConfig, timeframe) {
181
+ if (exchange.ccxtId !== "bitget") return runtimeConfig.ohlcvPageLimit;
182
+ return Math.max(
183
+ 1,
184
+ Math.min(
185
+ runtimeConfig.ohlcvPageLimit,
186
+ Math.floor(BITGET_OHLCV_MAX_REQUEST_SPAN_MS / timeframeStepMs(timeframe))
187
+ )
188
+ );
189
+ }
190
+
191
+ function timeframeStepMs(timeframe) {
192
+ const stepMs = TIMEFRAME_MS[timeframe];
193
+ if (!stepMs) throw new Error(`不支持的 timeframe:${timeframe}`);
194
+ return stepMs;
195
+ }
196
+
197
+ function midpointCursorMs(lowerMs, upperMs, stepMs) {
198
+ if (upperMs - lowerMs <= stepMs) return upperMs;
199
+ const midpointMs = lowerMs + Math.floor((upperMs - lowerMs) / 2);
200
+ const alignedMs = Math.floor(midpointMs / stepMs) * stepMs;
201
+ return Math.min(upperMs, Math.max(lowerMs + 1, alignedMs));
202
+ }
203
+
204
+ function invalidOhlcvError(request) {
205
+ return new TapeDataUnavailableError([
206
+ {
207
+ code: "invalid_ohlcv",
208
+ symbol: request.symbol,
209
+ timeframe: request.timeframe,
210
+ },
211
+ ]);
212
+ }
213
+
214
+ function missingOhlcvError(request) {
215
+ return new TapeDataUnavailableError([
216
+ {
217
+ code: "ohlcv_missing",
218
+ symbol: request.symbol,
219
+ timeframe: request.timeframe,
220
+ },
221
+ ]);
222
+ }
@@ -19,9 +19,15 @@ import {
19
19
  buildCcxtConstructorOptions,
20
20
  ccxtExchangeClientCacheKey,
21
21
  } from "./proxy.mjs";
22
- import { loadSeriesWithCache, TIMEFRAME_MS } from "./series.mjs";
22
+ import { loadSeriesWithCache } from "./series.mjs";
23
+ import {
24
+ limitTapeOhlcvConcurrency,
25
+ mapWithConcurrency,
26
+ resolveTapeSeriesConcurrency,
27
+ } from "./tape-loader-concurrency.mjs";
23
28
  import {
24
29
  ENGINE_BACKTEST_BASE_TIMEFRAME,
30
+ TIMEFRAME_MS,
25
31
  baseTimeframeStepMs,
26
32
  resolvePlanBaseTimeframe,
27
33
  } from "./timeframes.mjs";
@@ -40,47 +46,8 @@ const FUNDING_INTERVALS = [
40
46
  { interval: "8h", spacingMs: 28_800_000 },
41
47
  ];
42
48
 
43
- /** Independent symbol×timeframe (and funding) fetches. Pagination stays serial. */
44
- export const DEFAULT_TAPE_SERIES_CONCURRENCY = 4;
45
- export const MAX_TAPE_SERIES_CONCURRENCY = 8;
46
-
47
49
  const exchangePromises = new Map();
48
50
 
49
- export function resolveTapeSeriesConcurrency(value) {
50
- if (typeof value === "number" && Number.isFinite(value) && value >= 1) {
51
- return Math.min(
52
- MAX_TAPE_SERIES_CONCURRENCY,
53
- Math.max(1, Math.floor(value))
54
- );
55
- }
56
- return DEFAULT_TAPE_SERIES_CONCURRENCY;
57
- }
58
-
59
- export async function mapWithConcurrency(items, concurrency, worker) {
60
- const list = [...items];
61
- if (list.length === 0) {
62
- return [];
63
- }
64
- const limit = Math.min(
65
- list.length,
66
- Math.max(1, Math.floor(Number(concurrency)) || 1)
67
- );
68
- const results = new Array(list.length);
69
- let nextIndex = 0;
70
- async function runWorker() {
71
- while (true) {
72
- const index = nextIndex;
73
- nextIndex += 1;
74
- if (index >= list.length) {
75
- return;
76
- }
77
- results[index] = await worker(list[index], index);
78
- }
79
- }
80
- await Promise.all(Array.from({ length: limit }, () => runWorker()));
81
- return results;
82
- }
83
-
84
51
  export function effectiveTapeEndMs(
85
52
  requestedToMs,
86
53
  nowMs = Date.now(),
@@ -287,6 +254,11 @@ export async function loadTape(request, options = {}) {
287
254
  const dataIssues = [];
288
255
  const coverageWarnings = [];
289
256
  const coverageIssues = [];
257
+ const ohlcvExchange = limitTapeOhlcvConcurrency(
258
+ exchange,
259
+ seriesConcurrency,
260
+ request.signal
261
+ );
290
262
  const seriesFractions = new Array(totalSeries).fill(0);
291
263
  let lastOhlcvDetail = "";
292
264
  const reportOhlcv = (index, fraction, detail) => {
@@ -311,7 +283,7 @@ export async function loadTape(request, options = {}) {
311
283
  const detail = `${symbol} ${timeframe}`;
312
284
  try {
313
285
  const loaded = await loadSeriesWithCache(
314
- exchange,
286
+ ohlcvExchange,
315
287
  exchangeDefinition,
316
288
  runtimeConfig,
317
289
  symbol,