@alphafox/cli 0.3.21 → 0.3.23

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 (36) hide show
  1. package/dist/auth/refresh.js +3 -4
  2. package/dist/commands/run.js +2 -2
  3. package/dist/engine-backtest/fetch-runtime.d.ts +4 -0
  4. package/dist/engine-backtest/fetch-runtime.js +103 -17
  5. package/dist/engine-backtest/parse-args.js +10 -2
  6. package/dist/engine-backtest/run-command.js +4 -1
  7. package/dist/engine-backtest/sweep-command.js +2 -2
  8. package/dist/engine-backtest/types.d.ts +5 -1
  9. package/dist/http/client.js +3 -3
  10. package/dist/skills-manifest.json +45 -45
  11. package/dist/version.d.ts +1 -1
  12. package/dist/version.js +1 -1
  13. package/docs/agents/domain.md +3 -49
  14. package/docs/agents/issue-tracker.md +5 -41
  15. package/docs/agents/triage-labels.md +2 -14
  16. package/package.json +1 -1
  17. package/skills/account/SKILL.md +1 -1
  18. package/skills/admin/SKILL.md +1 -1
  19. package/skills/alphafox/SKILL.md +6 -28
  20. package/skills/alphafox-shared/SKILL.md +6 -6
  21. package/skills/auth/SKILL.md +1 -1
  22. package/skills/cache/SKILL.md +3 -3
  23. package/skills/engine-backtest/SKILL.md +3 -3
  24. package/skills/exchange/SKILL.md +1 -1
  25. package/skills/market/SKILL.md +2 -2
  26. package/skills/notification/SKILL.md +1 -1
  27. package/skills/strategy/SKILL.md +6 -4
  28. package/skills/trading/SKILL.md +2 -2
  29. package/vendor/backtest-runner/README.md +2 -0
  30. package/vendor/backtest-runner/index.d.ts +8 -2
  31. package/vendor/backtest-runner/index.mjs +5 -2
  32. package/vendor/backtest-runner/lib/series.mjs +5 -171
  33. package/vendor/backtest-runner/lib/tape-loader-concurrency.mjs +72 -0
  34. package/vendor/backtest-runner/lib/tape-loader-range-plan.mjs +77 -0
  35. package/vendor/backtest-runner/lib/tape-loader-range.mjs +222 -0
  36. package/vendor/backtest-runner/lib/tape-loader.mjs +13 -41
@@ -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,