@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.
- package/README.md +42 -4
- package/dist/catalog/command-tree.d.ts +1 -0
- package/dist/catalog/command-tree.js +5 -3
- package/dist/catalog/validate-body.d.ts +22 -0
- package/dist/catalog/validate-body.js +98 -0
- package/dist/commands/request-body.d.ts +21 -0
- package/dist/commands/request-body.js +92 -0
- package/dist/commands/run.js +113 -10
- package/dist/engine-backtest/errors.d.ts +18 -0
- package/dist/engine-backtest/errors.js +26 -0
- package/dist/engine-backtest/fetch-runtime.d.ts +38 -0
- package/dist/engine-backtest/fetch-runtime.js +170 -0
- package/dist/engine-backtest/native-import.d.ts +1 -0
- package/dist/engine-backtest/native-import.js +10 -0
- package/dist/engine-backtest/parse-args.d.ts +6 -0
- package/dist/engine-backtest/parse-args.js +281 -0
- package/dist/engine-backtest/persist.d.ts +32 -0
- package/dist/engine-backtest/persist.js +94 -0
- package/dist/engine-backtest/replay-timeframe.d.ts +16 -0
- package/dist/engine-backtest/replay-timeframe.js +48 -0
- package/dist/engine-backtest/resolve-packages.d.ts +27 -0
- package/dist/engine-backtest/resolve-packages.js +288 -0
- package/dist/engine-backtest/run-command.d.ts +38 -0
- package/dist/engine-backtest/run-command.js +540 -0
- package/dist/engine-backtest/types.d.ts +249 -0
- package/dist/engine-backtest/types.js +2 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +16 -1
- package/dist/install/exec.d.ts +13 -0
- package/dist/install/exec.js +73 -0
- package/dist/install/package-root.d.ts +4 -0
- package/dist/install/package-root.js +59 -0
- package/dist/install/types.d.ts +69 -0
- package/dist/install/types.js +26 -0
- package/dist/install/wizard.d.ts +21 -0
- package/dist/install/wizard.js +332 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/.nojekyll +0 -0
- package/docs/alphafox-cli-installation-guide.md +105 -0
- package/docs/favicon.svg +4 -0
- package/docs/index.html +748 -0
- package/docs/logo/alphafox-mark.svg +4 -0
- package/docs/logo/alphafox-wordmark-black-en.svg +17 -0
- package/docs/logo/alphafox-wordmark-white-en.svg +16 -0
- package/docs/release-supply-chain.md +10 -2
- package/package.json +7 -2
- package/skills/account/SKILL.md +2 -2
- package/skills/admin/SKILL.md +2 -2
- package/skills/alphafox-shared/SKILL.md +22 -1
- package/skills/auth/SKILL.md +1 -1
- package/skills/engine-backtest/SKILL.md +71 -0
- package/skills/exchange/SKILL.md +2 -2
- package/skills/market/SKILL.md +1 -1
- package/skills/notification/SKILL.md +2 -2
- package/skills/strategy/SKILL.md +10 -2
- package/skills/trading/SKILL.md +6 -3
- package/vendor/backtest-runner/NOTICE.md +1 -0
- package/vendor/backtest-runner/README.md +97 -0
- package/vendor/backtest-runner/index.d.ts +423 -0
- package/vendor/backtest-runner/index.mjs +59 -0
- package/vendor/backtest-runner/lib/abortable.mjs +23 -0
- package/vendor/backtest-runner/lib/cache.mjs +159 -0
- package/vendor/backtest-runner/lib/coverage.mjs +238 -0
- package/vendor/backtest-runner/lib/encode.mjs +28 -0
- package/vendor/backtest-runner/lib/exchanges.mjs +143 -0
- package/vendor/backtest-runner/lib/proxy.mjs +78 -0
- package/vendor/backtest-runner/lib/scenario.mjs +66 -0
- package/vendor/backtest-runner/lib/series.mjs +370 -0
- package/vendor/backtest-runner/lib/tape-loader.mjs +614 -0
- package/vendor/backtest-runner/lib/timeframes.mjs +55 -0
- package/vendor/backtest-runner/package.json +13 -0
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
import { abortable } from "./abortable.mjs";
|
|
2
|
+
import {
|
|
3
|
+
createDisabledTapeCache,
|
|
4
|
+
createFileTapeCache,
|
|
5
|
+
DEFAULT_TAPE_CACHE_DIR,
|
|
6
|
+
} from "./cache.mjs";
|
|
7
|
+
import {
|
|
8
|
+
formatCoverageSoftWarning,
|
|
9
|
+
isTapeDataUnavailableError,
|
|
10
|
+
TapeDataUnavailableError,
|
|
11
|
+
} from "./coverage.mjs";
|
|
12
|
+
import { encodeOhlcvColumns } from "./encode.mjs";
|
|
13
|
+
import {
|
|
14
|
+
resolveTapeExchange,
|
|
15
|
+
tapeExchangeRuntimeConfig,
|
|
16
|
+
} from "./exchanges.mjs";
|
|
17
|
+
import {
|
|
18
|
+
buildCcxtConstructorOptions,
|
|
19
|
+
ccxtExchangeClientCacheKey,
|
|
20
|
+
} from "./proxy.mjs";
|
|
21
|
+
import { loadSeriesWithCache, TIMEFRAME_MS } from "./series.mjs";
|
|
22
|
+
import {
|
|
23
|
+
ENGINE_BACKTEST_BASE_TIMEFRAME,
|
|
24
|
+
baseTimeframeStepMs,
|
|
25
|
+
resolvePlanBaseTimeframe,
|
|
26
|
+
} from "./timeframes.mjs";
|
|
27
|
+
|
|
28
|
+
const DAY_MS = 86_400_000;
|
|
29
|
+
const MARKETS_PROGRESS_END = 0.05;
|
|
30
|
+
const OHLCV_PROGRESS_END = 0.95;
|
|
31
|
+
const FUNDING_INTERVAL_TOLERANCE_MS = 5 * 60_000;
|
|
32
|
+
const CCXT_PRECISION_MODE_DECIMAL_PLACES = 2;
|
|
33
|
+
const CCXT_PRECISION_MODE_SIGNIFICANT_DIGITS = 3;
|
|
34
|
+
const CCXT_PRECISION_MODE_TICK_SIZE = 4;
|
|
35
|
+
const FUNDING_INTERVALS = [
|
|
36
|
+
{ interval: "1h", spacingMs: 3_600_000 },
|
|
37
|
+
{ interval: "2h", spacingMs: 7_200_000 },
|
|
38
|
+
{ interval: "4h", spacingMs: 14_400_000 },
|
|
39
|
+
{ interval: "8h", spacingMs: 28_800_000 },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const exchangePromises = new Map();
|
|
43
|
+
|
|
44
|
+
export function effectiveTapeEndMs(
|
|
45
|
+
requestedToMs,
|
|
46
|
+
nowMs = Date.now(),
|
|
47
|
+
baseTimeframe = ENGINE_BACKTEST_BASE_TIMEFRAME
|
|
48
|
+
) {
|
|
49
|
+
const baseStepMs = baseTimeframeStepMs(baseTimeframe);
|
|
50
|
+
return Math.min(requestedToMs, Math.floor(nowMs / baseStepMs) * baseStepMs);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function resolveEngineBacktestPrecisionMode(precisionMode, exchangeLabel) {
|
|
54
|
+
if (precisionMode === CCXT_PRECISION_MODE_DECIMAL_PLACES) {
|
|
55
|
+
return "DECIMAL_PLACES";
|
|
56
|
+
}
|
|
57
|
+
if (precisionMode === CCXT_PRECISION_MODE_TICK_SIZE) {
|
|
58
|
+
return "TICK_SIZE";
|
|
59
|
+
}
|
|
60
|
+
const modeLabel =
|
|
61
|
+
precisionMode === CCXT_PRECISION_MODE_SIGNIFICANT_DIGITS
|
|
62
|
+
? "SIGNIFICANT_DIGITS"
|
|
63
|
+
: String(precisionMode);
|
|
64
|
+
throw new Error(
|
|
65
|
+
`${exchangeLabel} 使用不支持的 CCXT precisionMode:${modeLabel}`
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function classifyTapeSymbolsForPreflight(symbols, markets, quoteAsset) {
|
|
70
|
+
const availableSymbols = [];
|
|
71
|
+
const missingSymbols = [];
|
|
72
|
+
for (const symbol of new Set(symbols)) {
|
|
73
|
+
if (isLinearSwapMarket(markets[symbol], quoteAsset)) {
|
|
74
|
+
availableSymbols.push(symbol);
|
|
75
|
+
} else {
|
|
76
|
+
missingSymbols.push(symbol);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { availableSymbols, missingSymbols };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function inferFundingIntervals(samples) {
|
|
83
|
+
if (samples.length < 2) {
|
|
84
|
+
return samples.map((sample) => ({ ...sample, interval: "8h" }));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return samples.map((sample, index) => {
|
|
88
|
+
const nextSample = samples[index + 1];
|
|
89
|
+
const previousSample = samples[index - 1];
|
|
90
|
+
const spacingMs = nextSample
|
|
91
|
+
? nextSample.timestamp - sample.timestamp
|
|
92
|
+
: previousSample
|
|
93
|
+
? sample.timestamp - previousSample.timestamp
|
|
94
|
+
: Number.NaN;
|
|
95
|
+
return {
|
|
96
|
+
...sample,
|
|
97
|
+
interval: fundingIntervalFromSpacing(spacingMs),
|
|
98
|
+
};
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function resolveTapeCache(options = {}) {
|
|
103
|
+
if (
|
|
104
|
+
options.cache === false ||
|
|
105
|
+
options.cache === "disable" ||
|
|
106
|
+
options.cache?.disabled === true
|
|
107
|
+
) {
|
|
108
|
+
return createDisabledTapeCache();
|
|
109
|
+
}
|
|
110
|
+
if (options.cache && typeof options.cache.read === "function") {
|
|
111
|
+
return options.cache;
|
|
112
|
+
}
|
|
113
|
+
if (options.cacheDir) {
|
|
114
|
+
return createFileTapeCache(options.cacheDir);
|
|
115
|
+
}
|
|
116
|
+
return createFileTapeCache(DEFAULT_TAPE_CACHE_DIR);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Load a closed-candle tape and columnar buffers.
|
|
121
|
+
* Inject `ohlcvFetcher` / `fundingFetcher` / `marketsLoader` to avoid ccxt.
|
|
122
|
+
*
|
|
123
|
+
* @param {import("../index.d.ts").TapeLoadRequest} request
|
|
124
|
+
* @param {import("../index.d.ts").TapeLoadOptions} [options]
|
|
125
|
+
*/
|
|
126
|
+
export async function loadTape(request, options = {}) {
|
|
127
|
+
request.signal?.throwIfAborted();
|
|
128
|
+
if (!request || request.symbols?.length === 0) {
|
|
129
|
+
throw new Error("回测至少需要一个 symbol");
|
|
130
|
+
}
|
|
131
|
+
if (request.toMs <= request.fromMs) {
|
|
132
|
+
throw new Error("回测区间结束时间必须晚于开始时间");
|
|
133
|
+
}
|
|
134
|
+
if ((request.auxiliaryDataRequirements?.length ?? 0) > 0) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`Unsupported backtest auxiliary data requirements: ${request.auxiliaryDataRequirements
|
|
137
|
+
.map((requirement) => requirement.kind)
|
|
138
|
+
.join(", ")}`
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const exchangeDefinition = resolveRequestExchange(request);
|
|
143
|
+
const onProgress = request.onProgress ?? options.onProgress;
|
|
144
|
+
const cache = resolveTapeCache(options);
|
|
145
|
+
const dataQualityMode = request.dataQualityMode ?? "strict";
|
|
146
|
+
const baseTimeframe = resolvePlanBaseTimeframe({
|
|
147
|
+
baseTimeframe: request.baseTimeframe,
|
|
148
|
+
timeframes: [
|
|
149
|
+
...request.timeframes,
|
|
150
|
+
...(request.seriesRequirements ?? []).map(
|
|
151
|
+
(requirement) => requirement.timeframe
|
|
152
|
+
),
|
|
153
|
+
],
|
|
154
|
+
});
|
|
155
|
+
const runtimeConfig = tapeExchangeRuntimeConfig(exchangeDefinition);
|
|
156
|
+
const timeframes = normalizeTimeframes(
|
|
157
|
+
[
|
|
158
|
+
...request.timeframes,
|
|
159
|
+
...(request.seriesRequirements ?? []).map(
|
|
160
|
+
(requirement) => requirement.timeframe
|
|
161
|
+
),
|
|
162
|
+
],
|
|
163
|
+
baseTimeframe,
|
|
164
|
+
runtimeConfig,
|
|
165
|
+
exchangeDefinition.label
|
|
166
|
+
);
|
|
167
|
+
const requirementWarmups = new Map(
|
|
168
|
+
(request.seriesRequirements ?? []).map((requirement) => [
|
|
169
|
+
`${requirement.symbol}\u0000${requirement.timeframe}`,
|
|
170
|
+
requirement.minWarmupCandles,
|
|
171
|
+
])
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
onProgress?.({
|
|
175
|
+
stage: "markets",
|
|
176
|
+
detail: `加载 ${exchangeDefinition.label} 市场元数据`,
|
|
177
|
+
fraction: 0,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const injected = usesInjectedRuntime(request);
|
|
181
|
+
if (injected) {
|
|
182
|
+
assertInjectedRuntime(request);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const exchange = injected
|
|
186
|
+
? createInjectedExchange(request)
|
|
187
|
+
: await abortable(
|
|
188
|
+
getTapeExchange(exchangeDefinition, options),
|
|
189
|
+
request.signal
|
|
190
|
+
);
|
|
191
|
+
const marketsSnapshot = await abortable(
|
|
192
|
+
exchange.loadMarkets(),
|
|
193
|
+
request.signal
|
|
194
|
+
);
|
|
195
|
+
const precisionMode = resolveEngineBacktestPrecisionMode(
|
|
196
|
+
exchange.precisionMode,
|
|
197
|
+
exchangeDefinition.label
|
|
198
|
+
);
|
|
199
|
+
const exchangeNowMs =
|
|
200
|
+
request.nowMs ??
|
|
201
|
+
options.nowMs ??
|
|
202
|
+
(await abortable(exchange.fetchTime(), request.signal));
|
|
203
|
+
const tapeToMs = effectiveTapeEndMs(
|
|
204
|
+
request.toMs,
|
|
205
|
+
exchangeNowMs,
|
|
206
|
+
baseTimeframe
|
|
207
|
+
);
|
|
208
|
+
const cacheUntilMs = Math.min(
|
|
209
|
+
tapeToMs,
|
|
210
|
+
Math.floor(exchangeNowMs / DAY_MS) * DAY_MS
|
|
211
|
+
);
|
|
212
|
+
if (tapeToMs <= request.fromMs) {
|
|
213
|
+
throw new Error("回测区间内还没有已收盘的 K 线");
|
|
214
|
+
}
|
|
215
|
+
request.signal?.throwIfAborted();
|
|
216
|
+
|
|
217
|
+
const markets = {};
|
|
218
|
+
for (const symbol of request.symbols) {
|
|
219
|
+
markets[symbol] = tapeMarketFromCcxt(
|
|
220
|
+
symbol,
|
|
221
|
+
exchange.markets?.[symbol] ?? marketsSnapshot?.[symbol],
|
|
222
|
+
exchangeDefinition.label,
|
|
223
|
+
precisionMode,
|
|
224
|
+
exchangeDefinition.quoteAsset
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
onProgress?.({
|
|
228
|
+
stage: "markets",
|
|
229
|
+
detail: `${exchangeDefinition.label} 市场元数据已加载`,
|
|
230
|
+
fraction: MARKETS_PROGRESS_END,
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const buffers = {};
|
|
234
|
+
const chartSeries = [];
|
|
235
|
+
const series = [];
|
|
236
|
+
const totalSeries = request.symbols.length * timeframes.length;
|
|
237
|
+
const dataIssues = [];
|
|
238
|
+
const coverageWarnings = [];
|
|
239
|
+
let seriesDone = 0;
|
|
240
|
+
let bufferSequence = 0;
|
|
241
|
+
for (const symbol of request.symbols) {
|
|
242
|
+
for (const timeframe of timeframes) {
|
|
243
|
+
request.signal?.throwIfAborted();
|
|
244
|
+
let loaded;
|
|
245
|
+
try {
|
|
246
|
+
loaded = await loadSeriesWithCache(
|
|
247
|
+
exchange,
|
|
248
|
+
exchangeDefinition,
|
|
249
|
+
runtimeConfig,
|
|
250
|
+
symbol,
|
|
251
|
+
timeframe,
|
|
252
|
+
exchange.markets?.[symbol] ?? marketsSnapshot?.[symbol],
|
|
253
|
+
request.fromMs,
|
|
254
|
+
tapeToMs,
|
|
255
|
+
requirementWarmups.get(`${symbol}\u0000${timeframe}`) ?? 0,
|
|
256
|
+
timeframe === baseTimeframe,
|
|
257
|
+
dataQualityMode,
|
|
258
|
+
(fraction) => {
|
|
259
|
+
onProgress?.({
|
|
260
|
+
stage: "ohlcv",
|
|
261
|
+
detail: `${symbol} ${timeframe}`,
|
|
262
|
+
fraction:
|
|
263
|
+
MARKETS_PROGRESS_END +
|
|
264
|
+
(OHLCV_PROGRESS_END - MARKETS_PROGRESS_END) *
|
|
265
|
+
((seriesDone + fraction) / totalSeries),
|
|
266
|
+
});
|
|
267
|
+
},
|
|
268
|
+
cacheUntilMs,
|
|
269
|
+
cache,
|
|
270
|
+
request.signal
|
|
271
|
+
);
|
|
272
|
+
} catch (error) {
|
|
273
|
+
request.signal?.throwIfAborted();
|
|
274
|
+
dataIssues.push(...toTapeDataIssues(error, symbol, timeframe));
|
|
275
|
+
seriesDone++;
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
if (loaded.softIssues.length > 0) {
|
|
279
|
+
coverageWarnings.push(
|
|
280
|
+
formatCoverageSoftWarning(loaded.softIssues, loaded.coverageRatio)
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
const { rows } = loaded;
|
|
284
|
+
const bufferKey = `k${bufferSequence++}`;
|
|
285
|
+
const buffer = encodeOhlcvColumns(rows);
|
|
286
|
+
buffers[bufferKey] = buffer;
|
|
287
|
+
if (timeframe === baseTimeframe) {
|
|
288
|
+
chartSeries.push({
|
|
289
|
+
symbol,
|
|
290
|
+
timeframe: baseTimeframe,
|
|
291
|
+
rows: rows.length,
|
|
292
|
+
buffer: buffer.slice(0),
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
series.push({ symbol, timeframe, buffer: bufferKey, rows: rows.length });
|
|
296
|
+
seriesDone++;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (dataIssues.length > 0) {
|
|
300
|
+
throw new TapeDataUnavailableError(dataIssues);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
let fundingRates;
|
|
304
|
+
if (request.needsFunding) {
|
|
305
|
+
fundingRates = {};
|
|
306
|
+
for (const symbol of request.symbols) {
|
|
307
|
+
request.signal?.throwIfAborted();
|
|
308
|
+
fundingRates[symbol] = await loadFundingHistory(
|
|
309
|
+
exchange,
|
|
310
|
+
exchangeDefinition,
|
|
311
|
+
runtimeConfig,
|
|
312
|
+
symbol,
|
|
313
|
+
request.fromMs,
|
|
314
|
+
tapeToMs,
|
|
315
|
+
request.signal
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
request.signal?.throwIfAborted();
|
|
320
|
+
onProgress?.({
|
|
321
|
+
stage: "validation",
|
|
322
|
+
detail: "行情数据校验完成",
|
|
323
|
+
fraction: 1,
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
return {
|
|
327
|
+
tape: {
|
|
328
|
+
from: new Date(request.fromMs).toISOString(),
|
|
329
|
+
to: new Date(tapeToMs).toISOString(),
|
|
330
|
+
baseTimeframe,
|
|
331
|
+
markets,
|
|
332
|
+
series,
|
|
333
|
+
...(fundingRates ? { fundingRates } : {}),
|
|
334
|
+
lowLiquiditySymbols: [],
|
|
335
|
+
},
|
|
336
|
+
buffers,
|
|
337
|
+
coverageWarnings,
|
|
338
|
+
chartData: {
|
|
339
|
+
exchangeId: exchangeDefinition.id,
|
|
340
|
+
series: chartSeries,
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function resolveRequestExchange(request) {
|
|
346
|
+
if (request.exchange) {
|
|
347
|
+
return resolveTapeExchange(request.exchange);
|
|
348
|
+
}
|
|
349
|
+
return resolveTapeExchange(request.exchangeId);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function usesInjectedRuntime(request) {
|
|
353
|
+
return Boolean(
|
|
354
|
+
request.ohlcvFetcher || request.fundingFetcher || request.marketsLoader
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function assertInjectedRuntime(request) {
|
|
359
|
+
if (!request.marketsLoader) {
|
|
360
|
+
throw new Error(
|
|
361
|
+
"loadTape requires marketsLoader when using injected fetchers (refusing to construct a live ccxt client)"
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
if (!request.ohlcvFetcher) {
|
|
365
|
+
throw new Error(
|
|
366
|
+
"loadTape requires ohlcvFetcher when using injected fetchers (refusing to construct a live ccxt client)"
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
if (request.needsFunding && !request.fundingFetcher) {
|
|
370
|
+
throw new Error(
|
|
371
|
+
"loadTape requires fundingFetcher when needsFunding is set with injected fetchers"
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function createInjectedExchange(request) {
|
|
377
|
+
let markets = {};
|
|
378
|
+
let precisionMode = CCXT_PRECISION_MODE_DECIMAL_PLACES;
|
|
379
|
+
let nowMs;
|
|
380
|
+
return {
|
|
381
|
+
get markets() {
|
|
382
|
+
return markets;
|
|
383
|
+
},
|
|
384
|
+
get precisionMode() {
|
|
385
|
+
return precisionMode;
|
|
386
|
+
},
|
|
387
|
+
async loadMarkets() {
|
|
388
|
+
const snapshot = await request.marketsLoader();
|
|
389
|
+
if (!snapshot || typeof snapshot !== "object" || !snapshot.markets) {
|
|
390
|
+
throw new Error("marketsLoader must return { markets }");
|
|
391
|
+
}
|
|
392
|
+
markets = snapshot.markets;
|
|
393
|
+
if (snapshot.precisionMode !== undefined) {
|
|
394
|
+
precisionMode = snapshot.precisionMode;
|
|
395
|
+
}
|
|
396
|
+
if (snapshot.nowMs !== undefined) {
|
|
397
|
+
nowMs = snapshot.nowMs;
|
|
398
|
+
}
|
|
399
|
+
return markets;
|
|
400
|
+
},
|
|
401
|
+
async fetchTime() {
|
|
402
|
+
return nowMs ?? Date.now();
|
|
403
|
+
},
|
|
404
|
+
async fetchOHLCV(symbol, timeframe, since, limit, params) {
|
|
405
|
+
return request.ohlcvFetcher(symbol, timeframe, since, limit, params);
|
|
406
|
+
},
|
|
407
|
+
async fetchFundingRateHistory(symbol, since, limit, params) {
|
|
408
|
+
if (!request.fundingFetcher) {
|
|
409
|
+
throw new Error("fundingFetcher is required when needsFunding is set");
|
|
410
|
+
}
|
|
411
|
+
return request.fundingFetcher(symbol, since, limit, params);
|
|
412
|
+
},
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function getTapeExchange(exchangeDefinition, options) {
|
|
417
|
+
const runtimeConfig = tapeExchangeRuntimeConfig(exchangeDefinition);
|
|
418
|
+
const constructorOptions = buildCcxtConstructorOptions(exchangeDefinition, {
|
|
419
|
+
...options,
|
|
420
|
+
runtimeConfig,
|
|
421
|
+
});
|
|
422
|
+
if (typeof options.createExchange === "function") {
|
|
423
|
+
const exchange = await options.createExchange(
|
|
424
|
+
exchangeDefinition,
|
|
425
|
+
constructorOptions
|
|
426
|
+
);
|
|
427
|
+
await exchange.loadMarkets();
|
|
428
|
+
return exchange;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const cacheKey = ccxtExchangeClientCacheKey(exchangeDefinition.id, options);
|
|
432
|
+
const cached = exchangePromises.get(cacheKey);
|
|
433
|
+
if (cached) {
|
|
434
|
+
return cached;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const pending = (async () => {
|
|
438
|
+
|
|
439
|
+
const ccxtModule = await import("ccxt");
|
|
440
|
+
const namespace = (ccxtModule.default ?? ccxtModule);
|
|
441
|
+
const ExchangeCtor = namespace[exchangeDefinition.ccxtId];
|
|
442
|
+
if (typeof ExchangeCtor !== "function") {
|
|
443
|
+
throw new Error(`ccxt 不包含 ${exchangeDefinition.ccxtId} 交易所实现`);
|
|
444
|
+
}
|
|
445
|
+
const exchange = new ExchangeCtor(constructorOptions);
|
|
446
|
+
await exchange.loadMarkets();
|
|
447
|
+
return exchange;
|
|
448
|
+
})().catch((error) => {
|
|
449
|
+
if (exchangePromises.get(cacheKey) === pending) {
|
|
450
|
+
exchangePromises.delete(cacheKey);
|
|
451
|
+
}
|
|
452
|
+
throw error;
|
|
453
|
+
});
|
|
454
|
+
exchangePromises.set(cacheKey, pending);
|
|
455
|
+
return pending;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function normalizeTimeframes(
|
|
459
|
+
timeframes,
|
|
460
|
+
baseTimeframe,
|
|
461
|
+
runtimeConfig,
|
|
462
|
+
exchangeLabel
|
|
463
|
+
) {
|
|
464
|
+
const set = new Set([baseTimeframe, ...timeframes]);
|
|
465
|
+
const unsupported = new Set(runtimeConfig.unsupportedTimeframes ?? []);
|
|
466
|
+
for (const timeframe of set) {
|
|
467
|
+
if (!TIMEFRAME_MS[timeframe]) {
|
|
468
|
+
throw new Error(`不支持的 timeframe:${timeframe}`);
|
|
469
|
+
}
|
|
470
|
+
if (unsupported.has(timeframe)) {
|
|
471
|
+
throw new Error(
|
|
472
|
+
`${exchangeLabel} 数据源不提供 ${timeframe} K 线,无法回测该周期的策略`
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return [...set].sort(
|
|
477
|
+
(left, right) => (TIMEFRAME_MS[left] ?? 0) - (TIMEFRAME_MS[right] ?? 0)
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function tapeMarketFromCcxt(
|
|
482
|
+
symbol,
|
|
483
|
+
market,
|
|
484
|
+
exchangeLabel,
|
|
485
|
+
precisionMode,
|
|
486
|
+
quoteAsset
|
|
487
|
+
) {
|
|
488
|
+
if (!market) {
|
|
489
|
+
throw new Error(`${exchangeLabel} 不存在市场 ${symbol}`);
|
|
490
|
+
}
|
|
491
|
+
if (!isLinearSwapMarket(market, quoteAsset)) {
|
|
492
|
+
throw new Error(
|
|
493
|
+
`${exchangeLabel} 市场 ${symbol} 不是可交易的 ${quoteAsset} 线性永续合约`
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
if (!market.id) {
|
|
497
|
+
throw new Error(`${exchangeLabel} 市场 ${symbol} 缺少交易所 id`);
|
|
498
|
+
}
|
|
499
|
+
return {
|
|
500
|
+
id: market.id,
|
|
501
|
+
symbol,
|
|
502
|
+
base: market.base,
|
|
503
|
+
quote: market.quote,
|
|
504
|
+
settle: market.settle,
|
|
505
|
+
contractSize: market.contractSize ?? 1,
|
|
506
|
+
linear: true,
|
|
507
|
+
precisionMode,
|
|
508
|
+
precisionAmount: market.precision?.amount,
|
|
509
|
+
precisionPrice: market.precision?.price,
|
|
510
|
+
amountMin: market.limits?.amount?.min,
|
|
511
|
+
amountMax: market.limits?.amount?.max,
|
|
512
|
+
priceMin: market.limits?.price?.min,
|
|
513
|
+
costMin: market.limits?.cost?.min,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function isLinearSwapMarket(market, quoteAsset) {
|
|
518
|
+
return Boolean(
|
|
519
|
+
market &&
|
|
520
|
+
market.active !== false &&
|
|
521
|
+
(market.type === "swap" || market.swap === true) &&
|
|
522
|
+
market.inverse !== true &&
|
|
523
|
+
(market.linear === true || market.settle === quoteAsset) &&
|
|
524
|
+
(market.quote === quoteAsset || market.settle === quoteAsset) &&
|
|
525
|
+
market.symbol
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async function loadFundingHistory(
|
|
530
|
+
exchange,
|
|
531
|
+
exchangeDefinition,
|
|
532
|
+
runtimeConfig,
|
|
533
|
+
symbol,
|
|
534
|
+
fromMs,
|
|
535
|
+
toMs,
|
|
536
|
+
signal
|
|
537
|
+
) {
|
|
538
|
+
signal?.throwIfAborted();
|
|
539
|
+
const sinceMs = fromMs - TIMEFRAME_MS["8h"];
|
|
540
|
+
const samples = [];
|
|
541
|
+
let cursor = sinceMs;
|
|
542
|
+
while (cursor < toMs) {
|
|
543
|
+
signal?.throwIfAborted();
|
|
544
|
+
const page = await abortable(
|
|
545
|
+
exchange.fetchFundingRateHistory(
|
|
546
|
+
symbol,
|
|
547
|
+
cursor,
|
|
548
|
+
runtimeConfig.fundingPageLimit,
|
|
549
|
+
{ ...runtimeConfig.requestParams }
|
|
550
|
+
),
|
|
551
|
+
signal
|
|
552
|
+
);
|
|
553
|
+
signal?.throwIfAborted();
|
|
554
|
+
if (page.length === 0) {
|
|
555
|
+
break;
|
|
556
|
+
}
|
|
557
|
+
let advanced = false;
|
|
558
|
+
for (const entry of page) {
|
|
559
|
+
const timestamp = entry.timestamp;
|
|
560
|
+
const rate = entry.fundingRate;
|
|
561
|
+
if (typeof timestamp !== "number" || typeof rate !== "number") {
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
if (timestamp >= toMs) {
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
if (
|
|
568
|
+
samples.length > 0 &&
|
|
569
|
+
timestamp <= samples[samples.length - 1].timestamp
|
|
570
|
+
) {
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
samples.push({ timestamp, rate });
|
|
574
|
+
advanced = true;
|
|
575
|
+
}
|
|
576
|
+
const lastTimestamp = page[page.length - 1]?.timestamp;
|
|
577
|
+
if (
|
|
578
|
+
typeof lastTimestamp !== "number" ||
|
|
579
|
+
lastTimestamp + 1 <= cursor ||
|
|
580
|
+
!advanced
|
|
581
|
+
) {
|
|
582
|
+
break;
|
|
583
|
+
}
|
|
584
|
+
cursor = lastTimestamp + 1;
|
|
585
|
+
}
|
|
586
|
+
if (samples.length === 0) {
|
|
587
|
+
throw new Error(
|
|
588
|
+
`${exchangeDefinition.label} 区间内没有 ${symbol} funding 历史数据(DCA funding filter 需要该数据)`
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
return inferFundingIntervals(samples);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function fundingIntervalFromSpacing(spacingMs) {
|
|
595
|
+
const match = FUNDING_INTERVALS.find(
|
|
596
|
+
(candidate) =>
|
|
597
|
+
Math.abs(candidate.spacingMs - spacingMs) <= FUNDING_INTERVAL_TOLERANCE_MS
|
|
598
|
+
);
|
|
599
|
+
return match?.interval ?? "8h";
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function toTapeDataIssues(error, symbol, timeframe) {
|
|
603
|
+
if (isTapeDataUnavailableError(error)) {
|
|
604
|
+
return [...error.issues];
|
|
605
|
+
}
|
|
606
|
+
return [
|
|
607
|
+
{
|
|
608
|
+
code: "load_failed",
|
|
609
|
+
symbol,
|
|
610
|
+
timeframe,
|
|
611
|
+
message: error instanceof Error ? error.message : String(error),
|
|
612
|
+
},
|
|
613
|
+
];
|
|
614
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export const ENGINE_BACKTEST_BASE_TIMEFRAME = "1m";
|
|
2
|
+
export const ENGINE_BACKTEST_WARMUP_CANDLES = 500;
|
|
3
|
+
|
|
4
|
+
/** Timeframes the tape loader can fetch natively. */
|
|
5
|
+
export const TIMEFRAME_MS = Object.freeze({
|
|
6
|
+
"1m": 60_000,
|
|
7
|
+
"3m": 180_000,
|
|
8
|
+
"5m": 300_000,
|
|
9
|
+
"15m": 900_000,
|
|
10
|
+
"30m": 1_800_000,
|
|
11
|
+
"1h": 3_600_000,
|
|
12
|
+
"2h": 7_200_000,
|
|
13
|
+
"4h": 14_400_000,
|
|
14
|
+
"6h": 21_600_000,
|
|
15
|
+
"8h": 28_800_000,
|
|
16
|
+
"12h": 43_200_000,
|
|
17
|
+
"1d": 86_400_000,
|
|
18
|
+
"3d": 259_200_000,
|
|
19
|
+
"1w": 604_800_000,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/** Plan resolution also accepts 1M; series fetch still rejects it. */
|
|
23
|
+
const PLAN_TIMEFRAME_MS = Object.freeze({
|
|
24
|
+
...TIMEFRAME_MS,
|
|
25
|
+
"1M": 2_592_000_000,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export function resolvePlanBaseTimeframe(input) {
|
|
29
|
+
const explicit = input.baseTimeframe?.trim();
|
|
30
|
+
if (explicit && PLAN_TIMEFRAME_MS[explicit]) {
|
|
31
|
+
return explicit;
|
|
32
|
+
}
|
|
33
|
+
const candidates = (input.timeframes ?? [])
|
|
34
|
+
.map((value) => value.trim())
|
|
35
|
+
.filter((value) => PLAN_TIMEFRAME_MS[value]);
|
|
36
|
+
if (candidates.length === 0) {
|
|
37
|
+
return ENGINE_BACKTEST_BASE_TIMEFRAME;
|
|
38
|
+
}
|
|
39
|
+
let best = candidates[0];
|
|
40
|
+
for (const timeframe of candidates) {
|
|
41
|
+
const bestMs = PLAN_TIMEFRAME_MS[best];
|
|
42
|
+
const nextMs = PLAN_TIMEFRAME_MS[timeframe];
|
|
43
|
+
if (nextMs < bestMs || (nextMs === bestMs && timeframe < best)) {
|
|
44
|
+
best = timeframe;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return best;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function baseTimeframeStepMs(baseTimeframe) {
|
|
51
|
+
return (
|
|
52
|
+
PLAN_TIMEFRAME_MS[baseTimeframe] ??
|
|
53
|
+
PLAN_TIMEFRAME_MS[ENGINE_BACKTEST_BASE_TIMEFRAME]
|
|
54
|
+
);
|
|
55
|
+
}
|