@alphafox/cli 0.2.0 → 0.3.2

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.
Files changed (72) hide show
  1. package/README.md +42 -4
  2. package/dist/catalog/command-tree.d.ts +1 -0
  3. package/dist/catalog/command-tree.js +5 -3
  4. package/dist/catalog/validate-body.d.ts +22 -0
  5. package/dist/catalog/validate-body.js +98 -0
  6. package/dist/commands/request-body.d.ts +21 -0
  7. package/dist/commands/request-body.js +92 -0
  8. package/dist/commands/run.js +113 -10
  9. package/dist/engine-backtest/errors.d.ts +18 -0
  10. package/dist/engine-backtest/errors.js +26 -0
  11. package/dist/engine-backtest/fetch-runtime.d.ts +38 -0
  12. package/dist/engine-backtest/fetch-runtime.js +170 -0
  13. package/dist/engine-backtest/native-import.d.ts +1 -0
  14. package/dist/engine-backtest/native-import.js +10 -0
  15. package/dist/engine-backtest/parse-args.d.ts +6 -0
  16. package/dist/engine-backtest/parse-args.js +281 -0
  17. package/dist/engine-backtest/persist.d.ts +32 -0
  18. package/dist/engine-backtest/persist.js +94 -0
  19. package/dist/engine-backtest/replay-timeframe.d.ts +16 -0
  20. package/dist/engine-backtest/replay-timeframe.js +48 -0
  21. package/dist/engine-backtest/resolve-packages.d.ts +27 -0
  22. package/dist/engine-backtest/resolve-packages.js +288 -0
  23. package/dist/engine-backtest/run-command.d.ts +38 -0
  24. package/dist/engine-backtest/run-command.js +540 -0
  25. package/dist/engine-backtest/types.d.ts +249 -0
  26. package/dist/engine-backtest/types.js +2 -0
  27. package/dist/index.d.ts +4 -0
  28. package/dist/index.js +16 -1
  29. package/dist/install/exec.d.ts +13 -0
  30. package/dist/install/exec.js +73 -0
  31. package/dist/install/package-root.d.ts +4 -0
  32. package/dist/install/package-root.js +59 -0
  33. package/dist/install/types.d.ts +69 -0
  34. package/dist/install/types.js +26 -0
  35. package/dist/install/wizard.d.ts +21 -0
  36. package/dist/install/wizard.js +332 -0
  37. package/dist/version.d.ts +1 -1
  38. package/dist/version.js +1 -1
  39. package/docs/.nojekyll +0 -0
  40. package/docs/alphafox-cli-installation-guide.md +105 -0
  41. package/docs/favicon.svg +4 -0
  42. package/docs/index.html +748 -0
  43. package/docs/logo/alphafox-mark.svg +4 -0
  44. package/docs/logo/alphafox-wordmark-black-en.svg +17 -0
  45. package/docs/logo/alphafox-wordmark-white-en.svg +16 -0
  46. package/docs/release-supply-chain.md +10 -2
  47. package/package.json +7 -2
  48. package/skills/account/SKILL.md +2 -2
  49. package/skills/admin/SKILL.md +2 -2
  50. package/skills/alphafox-shared/SKILL.md +22 -1
  51. package/skills/auth/SKILL.md +1 -1
  52. package/skills/engine-backtest/SKILL.md +71 -0
  53. package/skills/exchange/SKILL.md +2 -2
  54. package/skills/market/SKILL.md +1 -1
  55. package/skills/notification/SKILL.md +2 -2
  56. package/skills/strategy/SKILL.md +10 -2
  57. package/skills/trading/SKILL.md +6 -3
  58. package/vendor/backtest-runner/NOTICE.md +1 -0
  59. package/vendor/backtest-runner/README.md +97 -0
  60. package/vendor/backtest-runner/index.d.ts +423 -0
  61. package/vendor/backtest-runner/index.mjs +59 -0
  62. package/vendor/backtest-runner/lib/abortable.mjs +23 -0
  63. package/vendor/backtest-runner/lib/cache.mjs +159 -0
  64. package/vendor/backtest-runner/lib/coverage.mjs +238 -0
  65. package/vendor/backtest-runner/lib/encode.mjs +28 -0
  66. package/vendor/backtest-runner/lib/exchanges.mjs +143 -0
  67. package/vendor/backtest-runner/lib/proxy.mjs +78 -0
  68. package/vendor/backtest-runner/lib/scenario.mjs +66 -0
  69. package/vendor/backtest-runner/lib/series.mjs +370 -0
  70. package/vendor/backtest-runner/lib/tape-loader.mjs +614 -0
  71. package/vendor/backtest-runner/lib/timeframes.mjs +55 -0
  72. package/vendor/backtest-runner/package.json +13 -0
@@ -0,0 +1,238 @@
1
+ export class TapeDataUnavailableError extends Error {
2
+ /**
3
+ * @param {readonly import("../index.d.ts").TapeDataIssue[]} issues
4
+ */
5
+ constructor(issues) {
6
+ super(formatTapeDataIssues(issues));
7
+ this.name = "TapeDataUnavailableError";
8
+ this.issues = issues;
9
+ }
10
+ }
11
+
12
+ const HARD_ISSUE_CODES = new Set([
13
+ "market_missing",
14
+ "ohlcv_missing",
15
+ "invalid_ohlcv",
16
+ "non_monotonic",
17
+ "load_failed",
18
+ ]);
19
+
20
+ export function isHardTapeDataIssue(code) {
21
+ return HARD_ISSUE_CODES.has(code);
22
+ }
23
+
24
+ export function isTapeDataUnavailableError(value) {
25
+ return value instanceof TapeDataUnavailableError;
26
+ }
27
+
28
+ export function analyzeOhlcvCoverage(input) {
29
+ const issues = [];
30
+ const expectedReplayCandles = Math.max(
31
+ 0,
32
+ Math.floor((input.toMs - input.fromMs) / input.stepMs)
33
+ );
34
+
35
+ if (input.rows.length === 0) {
36
+ issues.push(issue(input, "ohlcv_missing"));
37
+ return {
38
+ symbol: input.symbol,
39
+ timeframe: input.timeframe,
40
+ rowCount: 0,
41
+ warmupCandles: 0,
42
+ expectedReplayCandles,
43
+ actualReplayCandles: 0,
44
+ coverageRatio: 0,
45
+ issues,
46
+ };
47
+ }
48
+
49
+ let previousTimestamp;
50
+ for (const row of input.rows) {
51
+ const [timestamp, open, high, low, close, volume] = row;
52
+ if (
53
+ ![timestamp, open, high, low, close, volume].every(Number.isFinite) ||
54
+ timestamp <= 0 ||
55
+ !Number.isInteger(timestamp) ||
56
+ open <= 0 ||
57
+ high <= 0 ||
58
+ low <= 0 ||
59
+ close <= 0 ||
60
+ volume < 0 ||
61
+ high < Math.max(open, close, low) ||
62
+ low > Math.min(open, close, high)
63
+ ) {
64
+ issues.push(issue(input, "invalid_ohlcv", { timestamp }));
65
+ break;
66
+ }
67
+ if (previousTimestamp !== undefined) {
68
+ const delta = timestamp - previousTimestamp;
69
+ if (delta <= 0) {
70
+ issues.push(
71
+ issue(input, "non_monotonic", {
72
+ expected: previousTimestamp + input.stepMs,
73
+ actual: timestamp,
74
+ timestamp,
75
+ })
76
+ );
77
+ break;
78
+ }
79
+ if (delta !== input.stepMs) {
80
+ issues.push(
81
+ issue(input, "internal_gap", {
82
+ expected: previousTimestamp + input.stepMs,
83
+ actual: timestamp,
84
+ timestamp,
85
+ })
86
+ );
87
+ break;
88
+ }
89
+ }
90
+ previousTimestamp = timestamp;
91
+ }
92
+
93
+ const warmupCandles = input.rows.filter(
94
+ (row) => row[0] + input.stepMs <= input.fromMs
95
+ ).length;
96
+ if (warmupCandles < input.minWarmupCandles) {
97
+ issues.push(
98
+ issue(input, "warmup_insufficient", {
99
+ expected: input.minWarmupCandles,
100
+ actual: warmupCandles,
101
+ })
102
+ );
103
+ }
104
+
105
+ const replayRows = input.rows.filter(
106
+ (row) => row[0] >= input.fromMs && row[0] < input.toMs
107
+ );
108
+ const actualReplayCandles = replayRows.length;
109
+ const coverageRatio =
110
+ expectedReplayCandles > 0
111
+ ? actualReplayCandles / expectedReplayCandles
112
+ : actualReplayCandles > 0
113
+ ? 1
114
+ : 0;
115
+
116
+ if (input.requireFullReplayCoverage) {
117
+ const alignmentTimestamp = input.rows.find((row) =>
118
+ Number.isInteger(row?.[0])
119
+ )?.[0];
120
+ const expectedStart =
121
+ alignmentTimestamp === undefined
122
+ ? input.fromMs
123
+ : alignmentTimestamp +
124
+ Math.ceil(
125
+ (input.fromMs - alignmentTimestamp) / input.stepMs
126
+ ) *
127
+ input.stepMs;
128
+ const firstReplayTimestamp = replayRows[0]?.[0];
129
+ const lastReplayTimestamp = replayRows.at(-1)?.[0];
130
+ if (
131
+ firstReplayTimestamp === undefined ||
132
+ firstReplayTimestamp > expectedStart
133
+ ) {
134
+ issues.push(
135
+ issue(input, "prefix_gap", {
136
+ expected: expectedStart,
137
+ actual: firstReplayTimestamp,
138
+ })
139
+ );
140
+ }
141
+ if (
142
+ lastReplayTimestamp === undefined ||
143
+ lastReplayTimestamp + input.stepMs < input.toMs
144
+ ) {
145
+ issues.push(
146
+ issue(input, "suffix_gap", {
147
+ expected: input.toMs - input.stepMs,
148
+ actual: lastReplayTimestamp,
149
+ })
150
+ );
151
+ }
152
+ } else {
153
+ const lastTimestamp = input.rows.at(-1)?.[0];
154
+ if (
155
+ lastTimestamp === undefined ||
156
+ lastTimestamp + input.stepMs * 2 <= input.toMs
157
+ ) {
158
+ issues.push(
159
+ issue(input, "suffix_gap", {
160
+ expected: input.toMs - input.stepMs,
161
+ actual: lastTimestamp,
162
+ })
163
+ );
164
+ }
165
+ }
166
+
167
+ return {
168
+ symbol: input.symbol,
169
+ timeframe: input.timeframe,
170
+ firstTimestamp: input.rows[0]?.[0],
171
+ lastTimestamp: input.rows.at(-1)?.[0],
172
+ rowCount: input.rows.length,
173
+ warmupCandles,
174
+ expectedReplayCandles,
175
+ actualReplayCandles,
176
+ coverageRatio,
177
+ issues,
178
+ };
179
+ }
180
+
181
+ export function evaluateOhlcvCoverage(input) {
182
+ const report = analyzeOhlcvCoverage(input);
183
+
184
+ if (input.mode === "strict" || report.issues.length === 0) {
185
+ return {
186
+ report,
187
+ accepted: report.issues.length === 0,
188
+ blockingIssues: report.issues,
189
+ acceptedSoftIssues: [],
190
+ };
191
+ }
192
+
193
+ const hardIssues = report.issues.filter((item) =>
194
+ isHardTapeDataIssue(item.code)
195
+ );
196
+ if (hardIssues.length > 0) {
197
+ return {
198
+ report,
199
+ accepted: false,
200
+ blockingIssues: hardIssues,
201
+ acceptedSoftIssues: [],
202
+ };
203
+ }
204
+
205
+ return {
206
+ report,
207
+ accepted: true,
208
+ blockingIssues: [],
209
+ acceptedSoftIssues: report.issues,
210
+ };
211
+ }
212
+
213
+ export function formatCoverageSoftWarning(issues, coverageRatio) {
214
+ const codes = [...new Set(issues.map((item) => item.code))].join(", ");
215
+ const sample = issues[0];
216
+ const symbol = sample?.symbol ?? "unknown";
217
+ const timeframe = sample?.timeframe ?? "*";
218
+ return `${symbol} ${timeframe}: accepted soft data issues (${codes}) with ${(coverageRatio * 100).toFixed(1)}% replay coverage`;
219
+ }
220
+
221
+ function issue(input, code, detail = {}) {
222
+ return {
223
+ code,
224
+ symbol: input.symbol,
225
+ timeframe: input.timeframe,
226
+ ...detail,
227
+ };
228
+ }
229
+
230
+ function formatTapeDataIssues(issues) {
231
+ if (issues.length === 0) {
232
+ return "Backtest market data is unavailable.";
233
+ }
234
+ const first = issues[0];
235
+ const suffix =
236
+ issues.length > 1 ? ` (+${issues.length - 1} more issues)` : "";
237
+ return `${first.symbol} ${first.timeframe}: ${first.code}${suffix}`;
238
+ }
@@ -0,0 +1,28 @@
1
+ export const TAPE_SERIES_COLUMNS = [
2
+ "timestamp",
3
+ "open",
4
+ "high",
5
+ "low",
6
+ "close",
7
+ "volume",
8
+ ];
9
+
10
+ /**
11
+ * Encode ascending OHLCV rows ([ts, o, h, l, c, v]) into the columnar
12
+ * little-endian float64 buffer consumed by the wasm runtime.
13
+ * Layout matches `@alphafoxai/backtest-wasm` `encodeOhlcvColumns`.
14
+ *
15
+ * @param {ReadonlyArray<readonly [number, number, number, number, number, number]>} rows
16
+ * @returns {ArrayBuffer}
17
+ */
18
+ export function encodeOhlcvColumns(rows) {
19
+ const count = rows.length;
20
+ const view = new Float64Array(TAPE_SERIES_COLUMNS.length * count);
21
+ for (let i = 0; i < count; i++) {
22
+ const row = rows[i];
23
+ for (let column = 0; column < TAPE_SERIES_COLUMNS.length; column++) {
24
+ view[column * count + i] = row[column];
25
+ }
26
+ }
27
+ return view.buffer;
28
+ }
@@ -0,0 +1,143 @@
1
+ export const PUBLIC_MARKET_EXCHANGE_BINANCE = "binance_perp_usdt";
2
+ export const PUBLIC_MARKET_EXCHANGE_OKX = "okx_perp_usdt";
3
+ export const PUBLIC_MARKET_EXCHANGE_BYBIT = "bybit_perp_usdt";
4
+ export const PUBLIC_MARKET_EXCHANGE_BITGET = "bitget_perp_usdt";
5
+ export const PUBLIC_MARKET_EXCHANGE_HYPERLIQUID = "hyperliquid_perp_usdc";
6
+
7
+ /**
8
+ * HIP-3 builder DEXes the web public-market picker exposes.
9
+ * The Node runner does not discover the live HIP-3 catalog.
10
+ */
11
+ export const HYPERLIQUID_PUBLIC_MARKET_DEXES = Object.freeze(["", "xyz"]);
12
+ const HYPERLIQUID_HIP3_DEXES = HYPERLIQUID_PUBLIC_MARKET_DEXES.filter(
13
+ (dex) => dex !== ""
14
+ );
15
+
16
+ export const TAPE_EXCHANGES = Object.freeze([
17
+ Object.freeze({
18
+ id: PUBLIC_MARKET_EXCHANGE_BINANCE,
19
+ label: "Binance",
20
+ ccxtId: "binanceusdm",
21
+ marketType: "swap",
22
+ quoteAsset: "USDT",
23
+ }),
24
+ Object.freeze({
25
+ id: PUBLIC_MARKET_EXCHANGE_OKX,
26
+ label: "OKX",
27
+ ccxtId: "okx",
28
+ marketType: "swap",
29
+ quoteAsset: "USDT",
30
+ }),
31
+ Object.freeze({
32
+ id: PUBLIC_MARKET_EXCHANGE_BYBIT,
33
+ label: "Bybit",
34
+ ccxtId: "bybit",
35
+ marketType: "swap",
36
+ quoteAsset: "USDT",
37
+ }),
38
+ Object.freeze({
39
+ id: PUBLIC_MARKET_EXCHANGE_BITGET,
40
+ label: "Bitget",
41
+ ccxtId: "bitget",
42
+ marketType: "swap",
43
+ quoteAsset: "USDT",
44
+ }),
45
+ Object.freeze({
46
+ id: PUBLIC_MARKET_EXCHANGE_HYPERLIQUID,
47
+ label: "HyperLiquid",
48
+ ccxtId: "hyperliquid",
49
+ marketType: "swap",
50
+ quoteAsset: "USDC",
51
+ }),
52
+ ]);
53
+
54
+ const TAPE_EXCHANGE_BY_ID = new Map(
55
+ TAPE_EXCHANGES.map((exchange) => [exchange.id, exchange])
56
+ );
57
+
58
+ const PLATFORM_ALIASES = Object.freeze({
59
+ binance: PUBLIC_MARKET_EXCHANGE_BINANCE,
60
+ binanceusdm: PUBLIC_MARKET_EXCHANGE_BINANCE,
61
+ [PUBLIC_MARKET_EXCHANGE_BINANCE]: PUBLIC_MARKET_EXCHANGE_BINANCE,
62
+ okx: PUBLIC_MARKET_EXCHANGE_OKX,
63
+ [PUBLIC_MARKET_EXCHANGE_OKX]: PUBLIC_MARKET_EXCHANGE_OKX,
64
+ bybit: PUBLIC_MARKET_EXCHANGE_BYBIT,
65
+ [PUBLIC_MARKET_EXCHANGE_BYBIT]: PUBLIC_MARKET_EXCHANGE_BYBIT,
66
+ bitget: PUBLIC_MARKET_EXCHANGE_BITGET,
67
+ [PUBLIC_MARKET_EXCHANGE_BITGET]: PUBLIC_MARKET_EXCHANGE_BITGET,
68
+ hyperliquid: PUBLIC_MARKET_EXCHANGE_HYPERLIQUID,
69
+ [PUBLIC_MARKET_EXCHANGE_HYPERLIQUID]: PUBLIC_MARKET_EXCHANGE_HYPERLIQUID,
70
+ });
71
+
72
+ export function resolveTapeExchange(exchangeId) {
73
+ if (exchangeId && typeof exchangeId === "object") {
74
+ const id = exchangeId.id;
75
+ if (typeof id === "string" && TAPE_EXCHANGE_BY_ID.has(id)) {
76
+ return TAPE_EXCHANGE_BY_ID.get(id);
77
+ }
78
+ if (exchangeId.ccxtId) {
79
+ tapeExchangeRuntimeConfig(exchangeId);
80
+ return exchangeId;
81
+ }
82
+ throw new Error(
83
+ `Unsupported tape exchange: ${id ?? JSON.stringify(exchangeId)}`
84
+ );
85
+ }
86
+ if (typeof exchangeId !== "string" || exchangeId.trim() === "") {
87
+ throw new Error("Tape exchange id is required");
88
+ }
89
+ const normalized = exchangeId.trim().toLowerCase();
90
+ const id = PLATFORM_ALIASES[normalized];
91
+ const exchange = id ? TAPE_EXCHANGE_BY_ID.get(id) : undefined;
92
+ if (!exchange) {
93
+ throw new Error(`Unsupported tape exchange: ${exchangeId}`);
94
+ }
95
+ return exchange;
96
+ }
97
+
98
+ export function tapeExchangeRuntimeConfig(exchange) {
99
+ switch (exchange.ccxtId) {
100
+ case "binanceusdm":
101
+ return {
102
+ ohlcvPageLimit: 1500,
103
+ fundingPageLimit: 1000,
104
+ requestParams: {},
105
+ };
106
+ case "okx":
107
+ return {
108
+ ohlcvPageLimit: 100,
109
+ fundingPageLimit: 100,
110
+ requestParams: { instType: "SWAP" },
111
+ constructorOptions: {
112
+ fetchMarkets: { types: ["swap"] },
113
+ },
114
+ };
115
+ case "bybit":
116
+ return {
117
+ ohlcvPageLimit: 1000,
118
+ fundingPageLimit: 200,
119
+ requestParams: { category: "linear" },
120
+ };
121
+ case "bitget":
122
+ return {
123
+ ohlcvPageLimit: 1000,
124
+ fundingPageLimit: 100,
125
+ requestParams: { productType: "USDT-FUTURES" },
126
+ };
127
+ case "hyperliquid":
128
+ return {
129
+ ohlcvPageLimit: 5000,
130
+ fundingPageLimit: 500,
131
+ requestParams: {},
132
+ constructorOptions: {
133
+ fetchMarkets: {
134
+ types: ["swap", "hip3"],
135
+ hip3: { dexes: [...HYPERLIQUID_HIP3_DEXES] },
136
+ },
137
+ },
138
+ unsupportedTimeframes: ["6h"],
139
+ };
140
+ default:
141
+ throw new Error(`Engine Backtest 不支持 ${exchange.label ?? exchange.ccxtId} 数据源`);
142
+ }
143
+ }
@@ -0,0 +1,78 @@
1
+ const agentCacheIds = new WeakMap();
2
+ let nextAgentCacheId = 1;
3
+
4
+ /**
5
+ * Resolve ccxt constructor proxy fields.
6
+ *
7
+ * Explicit `httpsProxy` / `httpProxy` / `agent` win. Otherwise read
8
+ * `HTTPS_PROXY` / `https_proxy` / `HTTP_PROXY` / `http_proxy`.
9
+ * HTTPS requests fall back to HTTP_PROXY when HTTPS_PROXY is unset.
10
+ *
11
+ * These keys are passed through to the ccxt constructor (`httpsProxy`,
12
+ * `httpProxy`, `agent`). They are never silently dropped.
13
+ *
14
+ * @param {import("../index.d.ts").TapeProxyOptions | undefined} options
15
+ * @param {NodeJS.ProcessEnv} [env]
16
+ */
17
+ export function resolveCcxtProxyOptions(options = {}, env = process.env) {
18
+ const httpsProxy =
19
+ options.httpsProxy ??
20
+ env.HTTPS_PROXY ??
21
+ env.https_proxy ??
22
+ env.HTTP_PROXY ??
23
+ env.http_proxy;
24
+ const httpProxy =
25
+ options.httpProxy ?? env.HTTP_PROXY ?? env.http_proxy;
26
+ const agent = options.agent;
27
+ const resolved = {};
28
+ if (httpsProxy) {
29
+ resolved.httpsProxy = httpsProxy;
30
+ }
31
+ if (httpProxy) {
32
+ resolved.httpProxy = httpProxy;
33
+ }
34
+ if (agent) {
35
+ resolved.agent = agent;
36
+ }
37
+ return resolved;
38
+ }
39
+
40
+ export function ccxtExchangeClientCacheKey(exchangeId, options = {}) {
41
+ const proxy = resolveCcxtProxyOptions(options);
42
+ return JSON.stringify([
43
+ exchangeId,
44
+ proxy.httpsProxy ?? null,
45
+ proxy.httpProxy ?? null,
46
+ agentCacheIdentity(proxy.agent),
47
+ ]);
48
+ }
49
+
50
+ export function buildCcxtConstructorOptions(exchange, options = {}) {
51
+ const runtimeConfig = options.runtimeConfig;
52
+ const proxy = resolveCcxtProxyOptions(options);
53
+ return {
54
+ enableRateLimit: true,
55
+ options: {
56
+ defaultType: exchange.marketType,
57
+ ...(runtimeConfig?.constructorOptions ?? {}),
58
+ },
59
+ ...proxy,
60
+ };
61
+ }
62
+
63
+ function agentCacheIdentity(agent) {
64
+ if (
65
+ agent === undefined ||
66
+ agent === null ||
67
+ (typeof agent !== "object" && typeof agent !== "function")
68
+ ) {
69
+ return agent ?? null;
70
+ }
71
+ let id = agentCacheIds.get(agent);
72
+ if (id === undefined) {
73
+ id = nextAgentCacheId;
74
+ nextAgentCacheId += 1;
75
+ agentCacheIds.set(agent, id);
76
+ }
77
+ return id;
78
+ }
@@ -0,0 +1,66 @@
1
+ export const DEFAULT_EXECUTION_MODEL = Object.freeze({
2
+ pricePath: "ohlc_path_4",
3
+ makerFeeRate: 0.0002,
4
+ takerFeeRate: 0.0005,
5
+ slippageRate: 0.0001,
6
+ });
7
+
8
+ const PRICE_PATHS = new Set(["ohlc_path_4", "close_only"]);
9
+
10
+ /**
11
+ * Assemble an EngineBacktestScenario from a prepared tape.
12
+ * Does not execute wasm.
13
+ *
14
+ * @param {import("../index.d.ts").AssembleScenarioInput} input
15
+ * @returns {import("../index.d.ts").EngineBacktestScenario}
16
+ */
17
+ export function assembleScenario(input) {
18
+ const tape = input?.tape ?? input?.preparedTape?.tape ?? input?.prepared?.tape;
19
+ if (!tape) {
20
+ throw new Error("assembleScenario requires a prepared tape");
21
+ }
22
+ if (typeof input.runId !== "string" || input.runId.trim() === "") {
23
+ throw new Error("assembleScenario requires runId");
24
+ }
25
+ if (
26
+ typeof input.definitionId !== "string" ||
27
+ input.definitionId.trim() === ""
28
+ ) {
29
+ throw new Error("assembleScenario requires definitionId");
30
+ }
31
+ if (!Number.isFinite(input.initialEquity)) {
32
+ throw new Error("assembleScenario requires initialEquity");
33
+ }
34
+
35
+ return {
36
+ version: 1,
37
+ runId: input.runId,
38
+ trader: {
39
+ ...(input.traderId ? { id: input.traderId } : {}),
40
+ ...(input.traderName ? { name: input.traderName } : {}),
41
+ strategyDefinitionId: input.definitionId,
42
+ configSchemaVersion: input.configSchemaVersion,
43
+ subscriptionTier: input.subscriptionTier,
44
+ config: structuredClone(input.config),
45
+ },
46
+ exchange: {
47
+ positionSideDual: true,
48
+ initialEquity: input.initialEquity,
49
+ },
50
+ executionModel: resolveExecutionModel(input.executionModel),
51
+ tape,
52
+ };
53
+ }
54
+
55
+ function resolveExecutionModel(override) {
56
+ const merged = {
57
+ pricePath: override?.pricePath ?? DEFAULT_EXECUTION_MODEL.pricePath,
58
+ makerFeeRate: override?.makerFeeRate ?? DEFAULT_EXECUTION_MODEL.makerFeeRate,
59
+ takerFeeRate: override?.takerFeeRate ?? DEFAULT_EXECUTION_MODEL.takerFeeRate,
60
+ slippageRate: override?.slippageRate ?? DEFAULT_EXECUTION_MODEL.slippageRate,
61
+ };
62
+ if (!PRICE_PATHS.has(merged.pricePath)) {
63
+ throw new Error(`Unsupported executionModel.pricePath: ${merged.pricePath}`);
64
+ }
65
+ return merged;
66
+ }