@alphafox/cli 0.3.7 → 0.3.9

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.
@@ -8,6 +8,8 @@ import type { ProfileConfig } from "../config/profiles";
8
8
  import { type StoredTokens } from "../keychain/store";
9
9
  /** Refresh when access token expires within this window. */
10
10
  export declare const ACCESS_TOKEN_REFRESH_SKEW_MS = 60000;
11
+ /** Drop a stale inter-process refresh lock after this long. */
12
+ export declare const REFRESH_LOCK_STALE_MS = 30000;
11
13
  export type RefreshOutcome = {
12
14
  readonly status: "refreshed";
13
15
  readonly tokens: StoredTokens;
@@ -40,5 +42,6 @@ export declare function refreshStoredTokensOrNull(profile: ProfileConfig, env?:
40
42
  readonly now?: number;
41
43
  readonly force?: boolean;
42
44
  }): Promise<StoredTokens | null>;
45
+ export declare function refreshLockFilePath(profile: string, env?: NodeJS.ProcessEnv): string;
43
46
  /** Test helper: clear in-flight map between cases. */
44
47
  export declare function clearRefreshInflightForTests(): void;
@@ -6,15 +6,21 @@
6
6
  * Outcomes are explicit: callers must not treat a failed refresh as a healthy session.
7
7
  */
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
- exports.ACCESS_TOKEN_REFRESH_SKEW_MS = void 0;
9
+ exports.REFRESH_LOCK_STALE_MS = exports.ACCESS_TOKEN_REFRESH_SKEW_MS = void 0;
10
10
  exports.accessTokenNeedsRefresh = accessTokenNeedsRefresh;
11
11
  exports.refreshStoredTokens = refreshStoredTokens;
12
12
  exports.refreshStoredTokensOrNull = refreshStoredTokensOrNull;
13
+ exports.refreshLockFilePath = refreshLockFilePath;
13
14
  exports.clearRefreshInflightForTests = clearRefreshInflightForTests;
15
+ const node_fs_1 = require("node:fs");
16
+ const node_path_1 = require("node:path");
17
+ const node_os_1 = require("node:os");
14
18
  const version_1 = require("../version");
15
19
  const store_1 = require("../keychain/store");
16
20
  /** Refresh when access token expires within this window. */
17
21
  exports.ACCESS_TOKEN_REFRESH_SKEW_MS = 60_000;
22
+ /** Drop a stale inter-process refresh lock after this long. */
23
+ exports.REFRESH_LOCK_STALE_MS = 30_000;
18
24
  /** In-flight refresh promises so concurrent API calls share one rotation. */
19
25
  const inflightByProfile = new Map();
20
26
  function accessTokenNeedsRefresh(tokens, now = Date.now()) {
@@ -36,20 +42,38 @@ async function refreshStoredTokens(profile, env = process.env, fetchImpl = fetch
36
42
  tokens: null,
37
43
  };
38
44
  }
39
- if (!options.force &&
40
- !accessTokenNeedsRefresh(existing, options.now ?? Date.now())) {
45
+ const now = options.now ?? Date.now();
46
+ if (!options.force && !accessTokenNeedsRefresh(existing, now)) {
41
47
  return { status: "unchanged", tokens: existing };
42
48
  }
43
- const key = profile.name;
44
- const pending = inflightByProfile.get(key);
45
- if (pending) {
46
- return pending;
47
- }
48
- const work = performRefresh(profile, existing, env, fetchImpl).finally(() => {
49
- inflightByProfile.delete(key);
49
+ return withRefreshLock(profile.name, env, async () => {
50
+ const latest = (0, store_1.loadTokens)(profile.name, env) ?? existing;
51
+ if (!latest?.refreshToken?.trim()) {
52
+ return {
53
+ status: "no_session",
54
+ reason: "no_refresh_token",
55
+ tokens: null,
56
+ };
57
+ }
58
+ const someoneElseRefreshed = latest.refreshToken !== existing.refreshToken ||
59
+ latest.expiresAt > existing.expiresAt;
60
+ if (someoneElseRefreshed && !accessTokenNeedsRefresh(latest, now)) {
61
+ return { status: "unchanged", tokens: latest };
62
+ }
63
+ if (!options.force && !accessTokenNeedsRefresh(latest, now)) {
64
+ return { status: "unchanged", tokens: latest };
65
+ }
66
+ const key = profile.name;
67
+ const pending = inflightByProfile.get(key);
68
+ if (pending) {
69
+ return pending;
70
+ }
71
+ const work = performRefresh(profile, latest, env, fetchImpl).finally(() => {
72
+ inflightByProfile.delete(key);
73
+ });
74
+ inflightByProfile.set(key, work);
75
+ return work;
50
76
  });
51
- inflightByProfile.set(key, work);
52
- return work;
53
77
  }
54
78
  /**
55
79
  * Convenience for callers that only need tokens on successful refresh/unchanged.
@@ -62,6 +86,68 @@ async function refreshStoredTokensOrNull(profile, env = process.env, fetchImpl =
62
86
  }
63
87
  return null;
64
88
  }
89
+ function refreshLockFilePath(profile, env = process.env) {
90
+ const base = env.ALPHAFOX_KEYCHAIN_DIR?.trim() ||
91
+ (0, node_path_1.join)((0, node_os_1.homedir)(), ".config", "alphafox", "keychain");
92
+ return (0, node_path_1.join)(base, `${profile}.refresh.lock`);
93
+ }
94
+ async function withRefreshLock(profile, env, work) {
95
+ const path = refreshLockFilePath(profile, env);
96
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(path), { recursive: true });
97
+ const started = Date.now();
98
+ while (true) {
99
+ try {
100
+ const fd = (0, node_fs_1.openSync)(path, node_fs_1.constants.O_CREAT | node_fs_1.constants.O_EXCL | node_fs_1.constants.O_WRONLY);
101
+ try {
102
+ (0, node_fs_1.writeFileSync)(fd, `${process.pid}\n${Date.now()}\n`);
103
+ }
104
+ finally {
105
+ (0, node_fs_1.closeSync)(fd);
106
+ }
107
+ try {
108
+ return await work();
109
+ }
110
+ finally {
111
+ try {
112
+ (0, node_fs_1.unlinkSync)(path);
113
+ }
114
+ catch {
115
+ // another process stole a stale lock
116
+ }
117
+ }
118
+ }
119
+ catch (err) {
120
+ const code = err.code;
121
+ if (code !== "EEXIST") {
122
+ throw err;
123
+ }
124
+ try {
125
+ if (Date.now() - (0, node_fs_1.statSync)(path).mtimeMs > exports.REFRESH_LOCK_STALE_MS) {
126
+ (0, node_fs_1.unlinkSync)(path);
127
+ continue;
128
+ }
129
+ }
130
+ catch {
131
+ // lock disappeared; retry acquire
132
+ }
133
+ if (Date.now() - started > exports.REFRESH_LOCK_STALE_MS + 5_000) {
134
+ try {
135
+ (0, node_fs_1.unlinkSync)(path);
136
+ }
137
+ catch {
138
+ // raced
139
+ }
140
+ continue;
141
+ }
142
+ await sleep(50);
143
+ }
144
+ }
145
+ }
146
+ function sleep(ms) {
147
+ return new Promise((resolve) => {
148
+ setTimeout(resolve, ms);
149
+ });
150
+ }
65
151
  async function performRefresh(profile, existing, env, fetchImpl) {
66
152
  const origin = profile.apiBaseUrl.replace(/\/$/, "").replace(/\/api\/v1$/, "");
67
153
  const url = `${origin}/api/auth/oauth/token`;
@@ -0,0 +1 @@
1
+ export declare function removeCacheRoot(directory: string, env?: NodeJS.ProcessEnv): void;
@@ -0,0 +1,12 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.removeCacheRoot = removeCacheRoot;
4
+ const node_fs_1 = require("node:fs");
5
+ const paths_1 = require("./paths");
6
+ function removeCacheRoot(directory, env = process.env) {
7
+ (0, paths_1.assertSafeCacheRoot)(directory, env);
8
+ if (!(0, node_fs_1.existsSync)(directory)) {
9
+ return;
10
+ }
11
+ (0, node_fs_1.rmSync)(directory, { recursive: true, force: true });
12
+ }
@@ -0,0 +1,7 @@
1
+ export interface DirectoryUsage {
2
+ readonly path: string;
3
+ readonly exists: boolean;
4
+ readonly bytes: number;
5
+ readonly files: number;
6
+ }
7
+ export declare function inspectDirectory(path: string): DirectoryUsage;
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.inspectDirectory = inspectDirectory;
4
+ const node_fs_1 = require("node:fs");
5
+ const node_path_1 = require("node:path");
6
+ function inspectDirectory(path) {
7
+ if (!(0, node_fs_1.existsSync)(path)) {
8
+ return { path, exists: false, bytes: 0, files: 0 };
9
+ }
10
+ const root = (0, node_fs_1.statSync)(path);
11
+ if (root.isFile()) {
12
+ return { path, exists: true, bytes: root.size, files: 1 };
13
+ }
14
+ let bytes = 0;
15
+ let files = 0;
16
+ const walk = (dir) => {
17
+ for (const entry of (0, node_fs_1.readdirSync)(dir, { withFileTypes: true })) {
18
+ const next = (0, node_path_1.join)(dir, entry.name);
19
+ if (entry.isDirectory()) {
20
+ walk(next);
21
+ continue;
22
+ }
23
+ if (entry.isFile()) {
24
+ files += 1;
25
+ bytes += (0, node_fs_1.statSync)(next).size;
26
+ }
27
+ }
28
+ };
29
+ walk(path);
30
+ return { path, exists: true, bytes, files };
31
+ }
@@ -0,0 +1,5 @@
1
+ /** Remind Agents to offer cleanup at or above this tape-cache size. */
2
+ export declare const TAPE_CACHE_REMIND_BYTES: number;
3
+ export declare function resolveTapeCacheDir(env?: NodeJS.ProcessEnv): string;
4
+ export declare function resolveRuntimeCacheRoot(env?: NodeJS.ProcessEnv): string;
5
+ export declare function assertSafeCacheRoot(directory: string, env?: NodeJS.ProcessEnv): void;
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TAPE_CACHE_REMIND_BYTES = void 0;
4
+ exports.resolveTapeCacheDir = resolveTapeCacheDir;
5
+ exports.resolveRuntimeCacheRoot = resolveRuntimeCacheRoot;
6
+ exports.assertSafeCacheRoot = assertSafeCacheRoot;
7
+ const node_os_1 = require("node:os");
8
+ const node_path_1 = require("node:path");
9
+ /** Remind Agents to offer cleanup at or above this tape-cache size. */
10
+ exports.TAPE_CACHE_REMIND_BYTES = 512 * 1024 * 1024;
11
+ function resolveTapeCacheDir(env = process.env) {
12
+ const override = env.ALPHAFOX_TAPE_CACHE_DIR?.trim();
13
+ if (override) {
14
+ return (0, node_path_1.resolve)(override);
15
+ }
16
+ return (0, node_path_1.resolve)((0, node_path_1.join)((0, node_os_1.homedir)(), ".alphafox", "cache", "engine-backtest"));
17
+ }
18
+ function resolveRuntimeCacheRoot(env = process.env) {
19
+ const override = env.ALPHAFOX_BACKTEST_RUNTIME_CACHE_DIR?.trim();
20
+ if (override) {
21
+ return (0, node_path_1.resolve)(override);
22
+ }
23
+ const xdg = env.XDG_CACHE_HOME?.trim();
24
+ return (0, node_path_1.resolve)((0, node_path_1.join)(xdg || (0, node_path_1.join)((0, node_os_1.homedir)(), ".cache"), "alphafox", "engine-backtest"));
25
+ }
26
+ function assertSafeCacheRoot(directory, env = process.env) {
27
+ const resolved = (0, node_path_1.resolve)(directory);
28
+ const tape = resolveTapeCacheDir(env);
29
+ const runtime = resolveRuntimeCacheRoot(env);
30
+ if (resolved === tape || resolved === runtime) {
31
+ return;
32
+ }
33
+ if ((0, node_path_1.basename)(resolved) === "engine-backtest") {
34
+ return;
35
+ }
36
+ throw Object.assign(new Error(`Refusing to touch cache directory ${resolved}`), {
37
+ type: "usage",
38
+ subtype: "cache_root_unsafe",
39
+ status: 400,
40
+ });
41
+ }
@@ -0,0 +1,7 @@
1
+ export interface CacheCliFlags {
2
+ readonly format: "json" | "jsonl" | "text";
3
+ readonly yes: boolean;
4
+ readonly dryRun: boolean;
5
+ readonly jq?: string;
6
+ }
7
+ export declare function cmdCache(args: string[], flags: CacheCliFlags, env?: NodeJS.ProcessEnv): Promise<number>;
@@ -0,0 +1,85 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.cmdCache = cmdCache;
4
+ const envelope_1 = require("../envelope");
5
+ const clean_1 = require("./clean");
6
+ const inspect_1 = require("./inspect");
7
+ const paths_1 = require("./paths");
8
+ async function cmdCache(args, flags, env = process.env) {
9
+ const sub = args[0];
10
+ if (sub === "status" || !sub || sub === "help" || sub === "--help" || sub === "-h") {
11
+ if (sub === "help" || sub === "--help" || sub === "-h") {
12
+ (0, envelope_1.writeSuccess)({
13
+ name: "cache",
14
+ usage: [
15
+ "alphafox cache status",
16
+ "alphafox cache clean [--tape|--runtime|--all] [--yes|--dry-run]",
17
+ ],
18
+ }, { format: flags.format, jq: flags.jq });
19
+ return 0;
20
+ }
21
+ const tape = (0, inspect_1.inspectDirectory)((0, paths_1.resolveTapeCacheDir)(env));
22
+ const runtime = (0, inspect_1.inspectDirectory)((0, paths_1.resolveRuntimeCacheRoot)(env));
23
+ (0, envelope_1.writeSuccess)({
24
+ tape: { ...tape, large: tape.bytes >= paths_1.TAPE_CACHE_REMIND_BYTES },
25
+ runtime,
26
+ remindAfterBytes: paths_1.TAPE_CACHE_REMIND_BYTES,
27
+ totalBytes: tape.bytes + runtime.bytes,
28
+ }, { format: flags.format, jq: flags.jq });
29
+ return 0;
30
+ }
31
+ if (sub === "clean") {
32
+ const rest = args.slice(1);
33
+ const all = rest.includes("--all");
34
+ const runtimeOnly = rest.includes("--runtime");
35
+ const tapeOnly = rest.includes("--tape") || (!all && !runtimeOnly);
36
+ const clearTape = all || tapeOnly;
37
+ const clearRuntime = all || runtimeOnly;
38
+ const tapePath = (0, paths_1.resolveTapeCacheDir)(env);
39
+ const runtimePath = (0, paths_1.resolveRuntimeCacheRoot)(env);
40
+ const tapeBefore = (0, inspect_1.inspectDirectory)(tapePath);
41
+ const runtimeBefore = (0, inspect_1.inspectDirectory)(runtimePath);
42
+ if (flags.dryRun) {
43
+ (0, envelope_1.writeSuccess)({
44
+ dryRun: true,
45
+ cleared: [
46
+ ...(clearTape ? ["tape"] : []),
47
+ ...(clearRuntime ? ["runtime"] : []),
48
+ ],
49
+ tape: tapeBefore,
50
+ runtime: runtimeBefore,
51
+ }, { format: flags.format, jq: flags.jq });
52
+ return 0;
53
+ }
54
+ if (!flags.yes) {
55
+ (0, envelope_1.writeError)({
56
+ type: "confirmation",
57
+ subtype: "yes_required",
58
+ message: "Cleaning local backtest cache requires --yes (or --dry-run).",
59
+ status: 400,
60
+ });
61
+ }
62
+ if (clearTape) {
63
+ (0, clean_1.removeCacheRoot)(tapePath, env);
64
+ }
65
+ if (clearRuntime) {
66
+ (0, clean_1.removeCacheRoot)(runtimePath, env);
67
+ }
68
+ (0, envelope_1.writeSuccess)({
69
+ dryRun: false,
70
+ cleared: [
71
+ ...(clearTape ? ["tape"] : []),
72
+ ...(clearRuntime ? ["runtime"] : []),
73
+ ],
74
+ tape: (0, inspect_1.inspectDirectory)(tapePath),
75
+ runtime: (0, inspect_1.inspectDirectory)(runtimePath),
76
+ bytesFreed: (clearTape ? tapeBefore.bytes : 0) +
77
+ (clearRuntime ? runtimeBefore.bytes : 0),
78
+ }, { format: flags.format, jq: flags.jq });
79
+ return 0;
80
+ }
81
+ (0, envelope_1.writeError)({
82
+ type: "usage",
83
+ message: "Usage: alphafox cache status|clean",
84
+ });
85
+ }
@@ -9,15 +9,17 @@ const allowlist_1 = require("../catalog/allowlist");
9
9
  const profiles_1 = require("../config/profiles");
10
10
  const envelope_1 = require("../envelope");
11
11
  const browser_login_1 = require("../auth/browser-login");
12
+ const refresh_1 = require("../auth/refresh");
12
13
  const client_1 = require("../http/client");
13
14
  const store_1 = require("../keychain/store");
14
15
  const confirmation_1 = require("../safety/confirmation");
15
16
  const version_1 = require("../version");
16
- const run_command_1 = require("../engine-backtest/run-command");
17
- const run_command_2 = require("../resolve-symbols/run-command");
18
- const run_command_3 = require("../skills/run-command");
17
+ const run_command_1 = require("../cache/run-command");
18
+ const run_command_2 = require("../engine-backtest/run-command");
19
+ const run_command_3 = require("../resolve-symbols/run-command");
20
+ const run_command_4 = require("../skills/run-command");
19
21
  const notify_1 = require("../update/notify");
20
- const run_command_4 = require("../update/run-command");
22
+ const run_command_5 = require("../update/run-command");
21
23
  const validate_body_1 = require("../catalog/validate-body");
22
24
  const types_1 = require("../install/types");
23
25
  const wizard_1 = require("../install/wizard");
@@ -101,6 +103,7 @@ async function runCli(argv, env = process.env) {
101
103
  "alphafox api METHOD PATH [--body JSON|--config @file]",
102
104
  "alphafox engine-backtest run --experiment <uuid> --definition <id> --config @file --exchange <id> --range FROM..TO --initial-equity N",
103
105
  "alphafox engine-backtest sweep --experiment <uuid> --definition <id> --config @file --axes @file --exchange <id> --range FROM..TO --initial-equity N --no-persist",
106
+ "alphafox cache status|clean [--tape|--runtime|--all]",
104
107
  "alphafox resolve-symbols <query...> [--exchange binance] [--asset-class equity_perp]",
105
108
  "alphafox <domain> <resource> <action> [flags]",
106
109
  ],
@@ -119,7 +122,8 @@ async function runCli(argv, env = process.env) {
119
122
  if (cmd !== "version" &&
120
123
  cmd !== "install" &&
121
124
  cmd !== "update" &&
122
- cmd !== "skills") {
125
+ cmd !== "skills" &&
126
+ cmd !== "cache") {
123
127
  assertCatalogCompatible();
124
128
  }
125
129
  switch (cmd) {
@@ -128,9 +132,11 @@ async function runCli(argv, env = process.env) {
128
132
  case "install":
129
133
  return await cmdInstall(args, flags, env);
130
134
  case "update":
131
- return await (0, run_command_4.cmdUpdate)(args, flags, env);
135
+ return await (0, run_command_5.cmdUpdate)(args, flags, env);
132
136
  case "skills":
133
- return await (0, run_command_3.cmdSkills)(args, flags, env);
137
+ return await (0, run_command_4.cmdSkills)(args, flags, env);
138
+ case "cache":
139
+ return await (0, run_command_1.cmdCache)(args, flags, env);
134
140
  case "doctor":
135
141
  return cmdDoctor(flags, env);
136
142
  case "whoami":
@@ -153,7 +159,7 @@ async function runCli(argv, env = process.env) {
153
159
  sub === "help" ||
154
160
  sub === "--help" ||
155
161
  sub === "-h") {
156
- return await (0, run_command_1.cmdEngineBacktest)(args, flags, env);
162
+ return await (0, run_command_2.cmdEngineBacktest)(args, flags, env);
157
163
  }
158
164
  // Hyphen built-in owns `run` and `sweep`. Underscore/hyphen catalog CRUD
159
165
  // (engine_backtest.experiments.*) still goes through the typed tree.
@@ -161,7 +167,7 @@ async function runCli(argv, env = process.env) {
161
167
  }
162
168
  case "resolve-symbols":
163
169
  case "resolve-symbol":
164
- return await (0, run_command_2.cmdResolveSymbols)(args, flags, env);
170
+ return await (0, run_command_3.cmdResolveSymbols)(args, flags, env);
165
171
  default:
166
172
  return await cmdTyped(cmd, args, flags, env);
167
173
  }
@@ -351,15 +357,30 @@ async function cmdAuth(args, flags, env) {
351
357
  });
352
358
  if (sub === "status") {
353
359
  const verify = args.includes("--verify");
354
- const tokens = (0, store_1.loadTokens)(profile.name, env);
360
+ let tokens = (0, store_1.loadTokens)(profile.name, env);
355
361
  if (!tokens) {
356
362
  (0, envelope_1.writeSuccess)({
357
363
  authenticated: false,
364
+ session: "none",
358
365
  profile: profile.name,
359
366
  verified: false,
367
+ refresh: "no_session",
368
+ accessTokenExpired: null,
369
+ hasRefreshToken: false,
360
370
  }, { format: flags.format, jq: flags.jq });
361
371
  return 0;
362
372
  }
373
+ let refresh = "skipped";
374
+ if ((0, refresh_1.accessTokenNeedsRefresh)(tokens)) {
375
+ const outcome = await (0, refresh_1.refreshStoredTokens)(profile, env);
376
+ refresh = outcome.status;
377
+ if (outcome.status === "refreshed" || outcome.status === "unchanged") {
378
+ tokens = outcome.tokens;
379
+ }
380
+ else {
381
+ tokens = (0, store_1.loadTokens)(profile.name, env) ?? tokens;
382
+ }
383
+ }
363
384
  let verified = null;
364
385
  let whoami = null;
365
386
  if (verify) {
@@ -370,9 +391,18 @@ async function cmdAuth(args, flags, env) {
370
391
  }, env);
371
392
  verified = res.status >= 200 && res.status < 300;
372
393
  whoami = verified ? res.json : { status: res.status, body: res.json };
394
+ tokens = (0, store_1.loadTokens)(profile.name, env) ?? tokens;
373
395
  }
396
+ const accessTokenExpired = tokens.expiresAt <= Date.now();
397
+ const hasRefreshToken = Boolean(tokens.refreshToken?.trim());
398
+ const session = !accessTokenExpired
399
+ ? "active"
400
+ : refresh === "failed"
401
+ ? "refresh_failed"
402
+ : "expired";
374
403
  (0, envelope_1.writeSuccess)({
375
- authenticated: true,
404
+ authenticated: session === "active",
405
+ session,
376
406
  profile: profile.name,
377
407
  environment: tokens.environment,
378
408
  issuer: tokens.issuer,
@@ -381,6 +411,9 @@ async function cmdAuth(args, flags, env) {
381
411
  scopes: tokens.scopes,
382
412
  accessTokenFingerprint: (0, store_1.tokenFingerprint)(tokens.accessToken),
383
413
  expiresAt: tokens.expiresAt,
414
+ accessTokenExpired,
415
+ hasRefreshToken,
416
+ refresh,
384
417
  verified,
385
418
  whoami,
386
419
  }, { format: flags.format, jq: flags.jq });
@@ -14,6 +14,7 @@ const load_config_1 = require("./load-config");
14
14
  const parse_args_1 = require("./parse-args");
15
15
  const sweep_command_1 = require("./sweep-command");
16
16
  const persist_1 = require("./persist");
17
+ const paths_1 = require("../cache/paths");
17
18
  const replay_timeframe_1 = require("./replay-timeframe");
18
19
  const resolve_packages_1 = require("./resolve-packages");
19
20
  var load_config_2 = require("./load-config");
@@ -316,6 +317,7 @@ async function executeEngineBacktestRun(args, flags, env = process.env, deps = {
316
317
  fromMs: args.range.fromMs,
317
318
  toMs: args.range.toMs,
318
319
  dataQualityMode: args.dataQualityMode,
320
+ cacheDir: (0, paths_1.resolveTapeCacheDir)(env),
319
321
  onProgress: (progress) => {
320
322
  emitProgress(flags, writeLine, progress.stage || "tape", progress.fraction, progress.detail);
321
323
  },
@@ -7,6 +7,7 @@ exports.executeEngineBacktestSweep = executeEngineBacktestSweep;
7
7
  const node_crypto_1 = require("node:crypto");
8
8
  const profiles_1 = require("../config/profiles");
9
9
  const client_1 = require("../http/client");
10
+ const paths_1 = require("../cache/paths");
10
11
  const store_1 = require("../keychain/store");
11
12
  const errors_1 = require("./errors");
12
13
  const load_config_1 = require("./load-config");
@@ -189,6 +190,7 @@ async function executeEngineBacktestSweep(args, flags, env = process.env, deps =
189
190
  fromMs: args.range.fromMs,
190
191
  toMs: args.range.toMs,
191
192
  dataQualityMode: args.dataQualityMode,
193
+ cacheDir: (0, paths_1.resolveTapeCacheDir)(env),
192
194
  onProgress: (progress) => {
193
195
  emitProgress(flags, writeLine, progress.stage || "tape", progress.fraction, progress.detail);
194
196
  },
@@ -238,6 +238,7 @@ export interface BacktestRunnerModule {
238
238
  readonly fromMs: number;
239
239
  readonly toMs: number;
240
240
  readonly dataQualityMode?: DataQualityMode;
241
+ readonly cacheDir?: string;
241
242
  readonly onProgress?: (progress: TapeLoadProgress) => void;
242
243
  }, options?: unknown): Promise<TapeLoadResult>;
243
244
  assembleScenario(input: {
@@ -1,141 +1,153 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "packageName": "@alphafox/cli",
4
- "packageVersion": "0.3.7",
4
+ "packageVersion": "0.3.9",
5
5
  "contractVersion": "2026-08-13",
6
- "bundleHash": "e12e1922538778707e60e931a247ecebb3e028cefeba295f4ca299d8d82d661b",
6
+ "bundleHash": "8693bb9b1b6f0c0bd70f75fe564a09ba829726f6e982d05c90a6595e8c36b715",
7
7
  "skills": [
8
8
  {
9
9
  "name": "alphafox",
10
- "version": "0.3.7",
10
+ "version": "0.3.9",
11
11
  "files": [
12
12
  {
13
13
  "path": "SKILL.md",
14
- "sha256": "2cd53898008075e68afd8ce955b63cf61f2971ef28f6c13c41853b1c5de967c8",
15
- "size": 3553
14
+ "sha256": "cb9f92b75cce3a03d4f2a9492b6ddd564541828bdb74acaf360cb3f2a0aa8447",
15
+ "size": 4405
16
16
  }
17
17
  ],
18
- "hash": "d687ecf8cc366e26dcb535bf31a30faceb037f021c818fa24997d144a22d3c20"
18
+ "hash": "92b330a3f902405235e2a049ce6c98a7cad705f8aeab6ba3c2062df33b222aec"
19
19
  },
20
20
  {
21
21
  "name": "alphafox-account",
22
- "version": "0.3.7",
22
+ "version": "0.3.9",
23
23
  "files": [
24
24
  {
25
25
  "path": "SKILL.md",
26
- "sha256": "fc5675dc44494227679994b761ca2e014d36acc207c68e3fb6d833912a7644ee",
26
+ "sha256": "4ea9c43c579ce7b4a4e5dd0e0ca10eeb793d19fc64e1cf8c4760b6d426524bf6",
27
27
  "size": 783
28
28
  }
29
29
  ],
30
- "hash": "bc1352e9d352f313f69f5257ba351137778c3ee1d8714d2d80fc39c0824c5cec"
30
+ "hash": "a94dc7e2e7a958394dbebba5b02e25c7114116b0b1a265be6b544e92f5e4ea65"
31
31
  },
32
32
  {
33
33
  "name": "alphafox-admin",
34
- "version": "0.3.7",
34
+ "version": "0.3.9",
35
35
  "files": [
36
36
  {
37
37
  "path": "SKILL.md",
38
- "sha256": "6594e6cbfd319a41ae3abf9bf4f8239bb42bc25e6eec295999fe39cd694b2b8b",
38
+ "sha256": "6c800c87dd98fc054508ffc179fce19f455cc2da762525accadf2b1fda68e647",
39
39
  "size": 787
40
40
  }
41
41
  ],
42
- "hash": "d6c5573444a020f376d51a73d44f39da9de8ea20bfd160f838b0de01bbb5e798"
42
+ "hash": "534d66e2614677fa7e40d3334f2e6992c632dccffc623c9dfcd963ae49eb66ac"
43
43
  },
44
44
  {
45
45
  "name": "alphafox-auth",
46
- "version": "0.3.7",
46
+ "version": "0.3.9",
47
47
  "files": [
48
48
  {
49
49
  "path": "SKILL.md",
50
- "sha256": "b7b2694cbcf6e784d89943950435cb34454309cfbe07e383cc028a435dfec77d",
51
- "size": 1919
50
+ "sha256": "a12ab3e613b26246ef6eb8d1289307c266d3c82861a8d6567856d63dff47cd6d",
51
+ "size": 2370
52
52
  }
53
53
  ],
54
- "hash": "aa7e2a39058660c4c803ed6e1bed94da0559aa943998336d6f0c83d8930a1d9d"
54
+ "hash": "e4b6b6f1dc76681e37c1a71ecaa10ab985b4dcc04ca5b2eca8bfcec367076a02"
55
+ },
56
+ {
57
+ "name": "alphafox-cache",
58
+ "version": "0.3.9",
59
+ "files": [
60
+ {
61
+ "path": "SKILL.md",
62
+ "sha256": "cceafb6d8d0017090eedb5be82dd13fe2c26963531c5e92f49d1ed7ba948fc5b",
63
+ "size": 1529
64
+ }
65
+ ],
66
+ "hash": "f858ee9c3e27c56035cede0f7a6929fc5128573a718762802fba67b7c9d6103a"
55
67
  },
56
68
  {
57
69
  "name": "alphafox-engine-backtest",
58
- "version": "0.3.7",
70
+ "version": "0.3.9",
59
71
  "files": [
60
72
  {
61
73
  "path": "SKILL.md",
62
- "sha256": "e09e938c9dd90d0be156b2d8bd83ba854c4d1a190a1b4aa575ca67db70184ebc",
63
- "size": 7584
74
+ "sha256": "8751aa7218862c44b4635d72a02a31752e400a67b41adec03aea77678a087c48",
75
+ "size": 7802
64
76
  }
65
77
  ],
66
- "hash": "91de2131ffde0b9fef16221d5589a8c430ae48ddfc4f2dc781d510b9d4c90532"
78
+ "hash": "2bab573fa07f5c5c1d83eb2e66d96d06abbff723e62f6b84a2de30a6ccf42339"
67
79
  },
68
80
  {
69
81
  "name": "alphafox-exchange",
70
- "version": "0.3.7",
82
+ "version": "0.3.9",
71
83
  "files": [
72
84
  {
73
85
  "path": "SKILL.md",
74
- "sha256": "53cba327c5507266b4dca7193fad67ba33c0c7bb745be41fd194425b9ef5928e",
86
+ "sha256": "416999214b3e99a9bbb3653286b2eefadbb7721da00762921e2c702de17f112a",
75
87
  "size": 743
76
88
  }
77
89
  ],
78
- "hash": "910f451fc7939926cfbeee27ee72f2aae86fcdd78f343e9f02c30df105ac6480"
90
+ "hash": "988bdad202ba2e522d5d025339ed354063ada77ae56b0d2616963e13df659d83"
79
91
  },
80
92
  {
81
93
  "name": "alphafox-market",
82
- "version": "0.3.7",
94
+ "version": "0.3.9",
83
95
  "files": [
84
96
  {
85
97
  "path": "SKILL.md",
86
- "sha256": "6589628478dc9c98c20479a2ffdded4014ce71b9c488dd386a606fef25a37d26",
98
+ "sha256": "5181ca01a222ae17c3e8feb711211c4150f59745b1559da5537a5b42ff4439b9",
87
99
  "size": 3078
88
100
  }
89
101
  ],
90
- "hash": "84d243b7c53c519cba34c8c487ba23eae60bde8440c4dcfff06e20efd63c07fa"
102
+ "hash": "29f11a29419e5443593255e07bc6055fe3d15e165ef5e442c550f56845dcb12d"
91
103
  },
92
104
  {
93
105
  "name": "alphafox-notification",
94
- "version": "0.3.7",
106
+ "version": "0.3.9",
95
107
  "files": [
96
108
  {
97
109
  "path": "SKILL.md",
98
- "sha256": "8b985ff7f33696eb11427147cd021d32654f525eea54f8098f368e8750a59381",
110
+ "sha256": "0f027ae12e13832a2d267bdf276a59882dc42396d45d31e6a43424d514209906",
99
111
  "size": 698
100
112
  }
101
113
  ],
102
- "hash": "1f316dc3fe251699b3f90102cbc16cb4fbd3a3c5c07b923297c075fe2e73a4b3"
114
+ "hash": "f8aa26444dd99e3374999086ab2956ce82156de2aab4c40a97a654082f899a29"
103
115
  },
104
116
  {
105
117
  "name": "alphafox-shared",
106
- "version": "0.3.7",
118
+ "version": "0.3.9",
107
119
  "files": [
108
120
  {
109
121
  "path": "SKILL.md",
110
- "sha256": "3aac1ab46a759a2c6748516936f886ec028e680bff7deb3ff1abc51af99ed18a",
111
- "size": 5163
122
+ "sha256": "896e71ca6da5f82b9c857486be8225a623a2e3a01be1a807db4d21d209c69a18",
123
+ "size": 5385
112
124
  }
113
125
  ],
114
- "hash": "72a7255ab23ff7c1adf41b1ff2e22834d1b58ee9e2ccea990c64f9277bf8b0a3"
126
+ "hash": "fc69c60e86b01d917f0a513d20643331d29ed2a7baa15577cd19f049b8f8b24a"
115
127
  },
116
128
  {
117
129
  "name": "alphafox-strategy",
118
- "version": "0.3.7",
130
+ "version": "0.3.9",
119
131
  "files": [
120
132
  {
121
133
  "path": "SKILL.md",
122
- "sha256": "1ef2a00dd1c74922a3d0248041813609fc58701e7857bb069cbed511b0c46168",
134
+ "sha256": "bbdba03e06c56e5fd35700071841e04a45c9b04d43727ba599bfbecf2fd77e1e",
123
135
  "size": 1826
124
136
  }
125
137
  ],
126
- "hash": "44c2bf74ff053977f3a8631115ebf868af7cff33856e0c6a4fe55080049c1ae1"
138
+ "hash": "fb5847c1d8d0229d7b0d3598dbb26e1104be250dfe3a62d1759354c3ecba3e3d"
127
139
  },
128
140
  {
129
141
  "name": "alphafox-trading",
130
- "version": "0.3.7",
142
+ "version": "0.3.9",
131
143
  "files": [
132
144
  {
133
145
  "path": "SKILL.md",
134
- "sha256": "db065fcc272e66d091cfe7469317d7911946f96094f475f64d34d201c392424c",
146
+ "sha256": "454102427cbb7ec119265c0c38adc9acd822881e23fad725b49f693c32ed19e3",
135
147
  "size": 3122
136
148
  }
137
149
  ],
138
- "hash": "af36dc51e47ca91cb4a2f5ca78e03e17f003ee47b5354cef42947f401fbe48c0"
150
+ "hash": "4768ca40fba0d940bf8425dc544f9501c9c5bca5a920fc942247eb7557ec9dcf"
139
151
  }
140
152
  ]
141
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.7";
3
+ export declare const CLI_VERSION = "0.3.9";
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.7";
6
+ exports.CLI_VERSION = "0.3.9";
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.7",
3
+ "version": "0.3.9",
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.7
4
+ version: 0.3.9
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.7
4
+ version: 0.3.9
5
5
  ---
6
6
 
7
7
  # Admin
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: alphafox
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. If a CLI command prints `[alphafox] update available`, ask the user「检测到新的版本,是否需要我帮你升级?」and only then run `alphafox update --format json --no-input`. 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.7
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. 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.9
5
5
  ---
6
6
 
7
7
  # AlphaFox
@@ -22,6 +22,7 @@ A **trader** is a running strategy instance (paper or live), not a person. Creat
22
22
  | Login, logout, whoami, profile, staging vs production | `alphafox-auth` |
23
23
  | Ticker / 标的 / 美股 / crypto / resolve a misspelled symbol | `alphafox-market` |
24
24
  | Engine WASM backtest, experiment, `engine-backtest run`, persist a local run | `alphafox-engine-backtest` |
25
+ | 清理回测缓存 / 历史 K 线占磁盘 / `alphafox cache` | `alphafox-cache` |
25
26
  | Strategy types / definitions / validate config (grid, dca, copy, …) | `alphafox-strategy` |
26
27
  | Create, list, start, or stop a running strategy (trader), including copy | `alphafox-trading` |
27
28
  | Exchange connectors | `alphafox-exchange` |
@@ -36,7 +37,7 @@ If several rows apply, load **all** of them (typical: `alphafox-shared` + `alpha
36
37
  The CLI may print this on **stderr** at most once every 24 hours:
37
38
 
38
39
  ```text
39
- [alphafox] update available: 0.3.6 -> 0.3.7. After the user confirms, run: alphafox update --format json --no-input,
40
+ [alphafox] update available: 0.3.8 -> 0.3.9. After the user confirms, run: alphafox update --format json --no-input,
40
41
  ```
41
42
 
42
43
  If you see that notice (or `updateAvailable: true` from `alphafox update --check`):
@@ -60,3 +61,13 @@ Do not install Skills from GitHub. Details and dry-run / check commands live in
60
61
  - Web `/api/v1/backtests` (`backtests.*`) is **not** a CLI surface. Do not call it via typed commands, `schema`, or `alphafox api`.
61
62
 
62
63
  Ambiguous “帮我回测” → `alphafox-engine-backtest`, after resolving symbols.
64
+
65
+ ## Large historical tape
66
+
67
+ `engine-backtest run|sweep` downloads closed OHLCV into the local tape cache. After a long-range or 1m backtest (or whenever the operator mentions disk / 缓存), read `alphafox-cache` and run `alphafox cache status --format json --no-input`.
68
+
69
+ If `data.tape.large` is true (tape ≥ `data.remindAfterBytes`):
70
+
71
+ 1. Ask the user: **回测下载的历史数据比较大,要不要我帮你清理本地缓存?**
72
+ 2. Wait for an explicit yes. Do not clean on your own.
73
+ 3. Follow `alphafox-cache` (`alphafox cache clean --dry-run`, then `--yes`).
@@ -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, and public operationIds only.
4
- version: 0.3.7
4
+ version: 0.3.9
5
5
  ---
6
6
 
7
7
  # AlphaFox shared Agent contract
@@ -67,9 +67,10 @@ alphafox auth login --no-wait --format json --no-input
67
67
  # show verification_uri / user_code to the human, then:
68
68
  alphafox auth login --device-code <device_code> --format json --no-input
69
69
  alphafox auth status --verify --format json --no-input
70
- alphafox whoami --format json --no-input
71
70
  ```
72
71
 
72
+ Access tokens last ~10 minutes; the CLI refreshes them. After idle, run **one** `auth status --verify` — not `whoami` in parallel. `session: active` means logged in. A past `expiresAt` is not logout. Re-login only when `session` is `none` or `refresh_failed`.
73
+
73
74
  Local browser: `alphafox auth login --browser --format json --no-input` (loopback 127.0.0.1). If the browser cannot open, copy `authorizeUrl` from the error; do not invent a Device Flow retry unless the operator is headless.
74
75
 
75
76
  Wrong environment / missing permission / missing `--yes`: stop. Do not retry with a different profile.
@@ -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.7
4
+ version: 0.3.9
5
5
  ---
6
6
 
7
7
  # Auth Skill
@@ -19,23 +19,26 @@ Always `--format json --no-input`. Never `--token`.
19
19
  1. `alphafox auth login --no-wait --format json --no-input`
20
20
  2. Present `verification_uri` / `user_code` to the human.
21
21
  3. After approval: `alphafox auth login --device-code <device_code> --format json --no-input`
22
- 4. `alphafox whoami --format json --no-input` / `alphafox auth status --verify --format json --no-input`
22
+ 4. `alphafox auth status --verify --format json --no-input` (do not also run `whoami` in parallel)
23
23
 
24
24
  ### Browser loopback (human, local machine)
25
25
 
26
26
  1. `alphafox auth login --browser --format json --no-input`
27
27
  2. CLI binds `127.0.0.1` and opens the system browser. Do not copy codes or verifiers.
28
- 3. After the localhost callback, `alphafox whoami` / `alphafox auth status --verify`
28
+ 3. After the localhost callback, `alphafox auth status --verify --format json --no-input`
29
29
  4. If the browser cannot open, the error includes a copyable `authorizeUrl`. Do not retry as Device Flow unless the human is headless.
30
30
 
31
31
  ### Status / logout
32
32
 
33
- - `alphafox auth status --verify --format json --no-input`
33
+ - `alphafox auth status --verify --format json --no-input` is enough. Do **not** also run `whoami` in parallel — concurrent refresh can kill the session.
34
+ - Access tokens last ~10 minutes. The CLI refreshes them automatically. A past `expiresAt` is **not** logout.
35
+ - Logged in: `session` is `active` (or `authenticated: true` after status). Re-login only when `session` is `none` or `refresh_failed`.
34
36
  - `alphafox auth logout --format json --no-input` (server revoke + local keychain clear). If `remoteRevoke` is `failed`, local tokens are still cleared but exit is non-zero — do not claim a full logout.
35
37
 
36
38
  ### Recovery
37
39
 
38
- - `401` / `expired_token`: re-run Device Flow or browser login. Do not reuse a token from another profile.
40
+ - `session: refresh_failed` / refresh grant `invalid_grant`: re-run Device Flow or browser login. Do not reuse a token from another profile.
41
+ - Do not treat a short idle or an expired access token as a missing login.
39
42
  - Cross-env: production tokens are rejected on staging/local. Switch `--profile` only with explicit operator intent.
40
43
 
41
44
  ## Safety
@@ -0,0 +1,38 @@
1
+ ---
2
+ name: alphafox-cache
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.9
5
+ ---
6
+
7
+ # Cache
8
+
9
+ Always `--format json --no-input`. Never `--token`. This is local disk only.
10
+
11
+ `engine-backtest run|sweep` writes closed OHLCV into the **tape** cache (`~/.alphafox/cache/engine-backtest`, or `ALPHAFOX_TAPE_CACHE_DIR`). The wasm / Node host lives under the **runtime** cache (`~/.cache/alphafox/engine-backtest/<hash>/`). Tape is the large historical download.
12
+
13
+ ## Status first
14
+
15
+ ```bash
16
+ alphafox cache status --format json --no-input
17
+ ```
18
+
19
+ Read `data.tape.bytes`, `data.tape.files`, `data.tape.large`, `data.remindAfterBytes`. `large` is true when tape bytes ≥ `remindAfterBytes` (512 MiB).
20
+
21
+ If `data.tape.large` is true, ask the user:
22
+
23
+ **回测下载的历史数据比较大,要不要我帮你清理本地缓存?**
24
+
25
+ Wait for an explicit yes. Do not clean on your own.
26
+
27
+ ## Clean
28
+
29
+ Default clean is **tape only** (historical bars). Runtime re-downloads on the next run; only add `--runtime` or `--all` when the operator asks.
30
+
31
+ ```bash
32
+ alphafox cache clean --dry-run --format json --no-input
33
+ alphafox cache clean --yes --format json --no-input
34
+ ```
35
+
36
+ `--yes` is required to delete. `--dry-run` reports what would be removed. After clean, `data.bytesFreed` is the space recovered.
37
+
38
+ Do not `rm` cache paths by hand. Do not delete `~/.config/alphafox` (config / skills state / keychain).
@@ -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.
4
- version: 0.3.7
4
+ version: 0.3.9
5
5
  ---
6
6
 
7
7
  # Engine Backtest
@@ -94,6 +94,7 @@ Owner isolation and 7-day expiry are enforced by the server. Applying a coordina
94
94
  2. `engine-backtest run` (reuse `--experiment` after the first create).
95
95
  3. Read `data.metrics` / `data.engineVersion` / `data.runId` / `data.experimentUrl`.
96
96
  4. Adjust parameters and run again. Do not invent a token flag if persist returns 401 — `alphafox auth login`.
97
+ 5. After a long-range or 1m run, follow `alphafox-cache`: `alphafox cache status`. If `data.tape.large` is true, ask **回测下载的历史数据比较大,要不要我帮你清理本地缓存?** and wait for yes.
97
98
 
98
99
  ## Safety
99
100
 
@@ -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.7
4
+ version: 0.3.9
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.7
4
+ version: 0.3.9
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.7
4
+ version: 0.3.9
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 (grid, dca, copy, …) and validate config. Creating a running strategy is creating a trader; use alphafox-trading for that.
4
- version: 0.3.7
4
+ version: 0.3.9
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.
4
- version: 0.3.7
4
+ version: 0.3.9
5
5
  ---
6
6
 
7
7
  # Running strategies (traders)
@@ -288,6 +288,8 @@ export interface TapeLoadRequest {
288
288
  readonly signal?: AbortSignal;
289
289
  readonly onProgress?: (progress: TapeLoadProgress) => void;
290
290
  readonly nowMs?: number;
291
+ readonly cacheDir?: string;
292
+ readonly seriesConcurrency?: number;
291
293
  readonly ohlcvFetcher?: TapeOhlcvFetcher;
292
294
  readonly fundingFetcher?: TapeFundingFetcher;
293
295
  readonly marketsLoader?: () => Promise<TapeMarketsSnapshot>;
@@ -302,6 +304,8 @@ export interface TapeProxyOptions {
302
304
  export interface TapeLoadOptions extends TapeProxyOptions {
303
305
  readonly cache?: FileTapeCache | false | "disable";
304
306
  readonly cacheDir?: string;
307
+ /** Independent series fetches; default 4, hard cap 8. Pagination stays serial. */
308
+ readonly seriesConcurrency?: number;
305
309
  readonly nowMs?: number;
306
310
  readonly onProgress?: (progress: TapeLoadProgress) => void;
307
311
  readonly createExchange?: (
@@ -336,6 +340,17 @@ export interface TapeLoadResult {
336
340
  };
337
341
  }
338
342
 
343
+ export const DEFAULT_TAPE_SERIES_CONCURRENCY: 4;
344
+ export const MAX_TAPE_SERIES_CONCURRENCY: 8;
345
+
346
+ export function resolveTapeSeriesConcurrency(value?: number): number;
347
+
348
+ export function mapWithConcurrency<T, R>(
349
+ items: readonly T[],
350
+ concurrency: number,
351
+ worker: (item: T, index: number) => Promise<R>
352
+ ): Promise<R[]>;
353
+
339
354
  export function loadTape(
340
355
  request: TapeLoadRequest,
341
356
  options?: TapeLoadOptions
@@ -50,8 +50,12 @@ export {
50
50
  loadSeriesWithCache,
51
51
  } from "./lib/series.mjs";
52
52
  export {
53
+ DEFAULT_TAPE_SERIES_CONCURRENCY,
54
+ MAX_TAPE_SERIES_CONCURRENCY,
53
55
  loadTape,
56
+ mapWithConcurrency,
54
57
  resolveTapeCache,
58
+ resolveTapeSeriesConcurrency,
55
59
  effectiveTapeEndMs,
56
60
  inferFundingIntervals,
57
61
  classifyTapeSymbolsForPreflight,
@@ -39,8 +39,47 @@ const FUNDING_INTERVALS = [
39
39
  { interval: "8h", spacingMs: 28_800_000 },
40
40
  ];
41
41
 
42
+ /** Independent symbol×timeframe (and funding) fetches. Pagination stays serial. */
43
+ export const DEFAULT_TAPE_SERIES_CONCURRENCY = 4;
44
+ export const MAX_TAPE_SERIES_CONCURRENCY = 8;
45
+
42
46
  const exchangePromises = new Map();
43
47
 
48
+ export function resolveTapeSeriesConcurrency(value) {
49
+ if (typeof value === "number" && Number.isFinite(value) && value >= 1) {
50
+ return Math.min(
51
+ MAX_TAPE_SERIES_CONCURRENCY,
52
+ Math.max(1, Math.floor(value))
53
+ );
54
+ }
55
+ return DEFAULT_TAPE_SERIES_CONCURRENCY;
56
+ }
57
+
58
+ export async function mapWithConcurrency(items, concurrency, worker) {
59
+ const list = [...items];
60
+ if (list.length === 0) {
61
+ return [];
62
+ }
63
+ const limit = Math.min(
64
+ list.length,
65
+ Math.max(1, Math.floor(Number(concurrency)) || 1)
66
+ );
67
+ const results = new Array(list.length);
68
+ let nextIndex = 0;
69
+ async function runWorker() {
70
+ while (true) {
71
+ const index = nextIndex;
72
+ nextIndex += 1;
73
+ if (index >= list.length) {
74
+ return;
75
+ }
76
+ results[index] = await worker(list[index], index);
77
+ }
78
+ }
79
+ await Promise.all(Array.from({ length: limit }, () => runWorker()));
80
+ return results;
81
+ }
82
+
44
83
  export function effectiveTapeEndMs(
45
84
  requestedToMs,
46
85
  nowMs = Date.now(),
@@ -141,7 +180,13 @@ export async function loadTape(request, options = {}) {
141
180
 
142
181
  const exchangeDefinition = resolveRequestExchange(request);
143
182
  const onProgress = request.onProgress ?? options.onProgress;
144
- const cache = resolveTapeCache(options);
183
+ const cache = resolveTapeCache({
184
+ ...options,
185
+ cacheDir: options.cacheDir ?? request.cacheDir,
186
+ });
187
+ const seriesConcurrency = resolveTapeSeriesConcurrency(
188
+ options.seriesConcurrency ?? request.seriesConcurrency
189
+ );
145
190
  const dataQualityMode = request.dataQualityMode ?? "strict";
146
191
  const baseTimeframe = resolvePlanBaseTimeframe({
147
192
  baseTimeframe: request.baseTimeframe,
@@ -233,17 +278,36 @@ export async function loadTape(request, options = {}) {
233
278
  const buffers = {};
234
279
  const chartSeries = [];
235
280
  const series = [];
236
- const totalSeries = request.symbols.length * timeframes.length;
281
+ const seriesJobs = request.symbols.flatMap((symbol) =>
282
+ timeframes.map((timeframe) => ({ symbol, timeframe }))
283
+ );
284
+ const totalSeries = seriesJobs.length;
237
285
  const dataIssues = [];
238
286
  const coverageWarnings = [];
239
- let seriesDone = 0;
240
- let bufferSequence = 0;
241
- for (const symbol of request.symbols) {
242
- for (const timeframe of timeframes) {
287
+ const seriesFractions = new Array(totalSeries).fill(0);
288
+ let lastOhlcvDetail = "";
289
+ const reportOhlcv = (index, fraction, detail) => {
290
+ seriesFractions[index] = fraction;
291
+ lastOhlcvDetail = detail;
292
+ const completed =
293
+ seriesFractions.reduce((sum, value) => sum + value, 0) / totalSeries;
294
+ onProgress?.({
295
+ stage: "ohlcv",
296
+ detail: lastOhlcvDetail,
297
+ fraction:
298
+ MARKETS_PROGRESS_END +
299
+ (OHLCV_PROGRESS_END - MARKETS_PROGRESS_END) * completed,
300
+ });
301
+ };
302
+ const loadedSeries = await mapWithConcurrency(
303
+ seriesJobs,
304
+ seriesConcurrency,
305
+ async (job, index) => {
243
306
  request.signal?.throwIfAborted();
244
- let loaded;
307
+ const { symbol, timeframe } = job;
308
+ const detail = `${symbol} ${timeframe}`;
245
309
  try {
246
- loaded = await loadSeriesWithCache(
310
+ const loaded = await loadSeriesWithCache(
247
311
  exchange,
248
312
  exchangeDefinition,
249
313
  runtimeConfig,
@@ -255,46 +319,51 @@ export async function loadTape(request, options = {}) {
255
319
  requirementWarmups.get(`${symbol}\u0000${timeframe}`) ?? 0,
256
320
  timeframe === baseTimeframe,
257
321
  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
- },
322
+ (fraction) => reportOhlcv(index, fraction, detail),
268
323
  cacheUntilMs,
269
324
  cache,
270
325
  request.signal
271
326
  );
327
+ reportOhlcv(index, 1, detail);
328
+ return { ok: true, job, loaded };
272
329
  } catch (error) {
273
330
  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
- });
331
+ reportOhlcv(index, 1, detail);
332
+ return {
333
+ ok: false,
334
+ issues: toTapeDataIssues(error, symbol, timeframe),
335
+ };
294
336
  }
295
- series.push({ symbol, timeframe, buffer: bufferKey, rows: rows.length });
296
- seriesDone++;
297
337
  }
338
+ );
339
+ let bufferSequence = 0;
340
+ for (const result of loadedSeries) {
341
+ if (!result.ok) {
342
+ dataIssues.push(...result.issues);
343
+ continue;
344
+ }
345
+ if (result.loaded.softIssues.length > 0) {
346
+ coverageWarnings.push(
347
+ formatCoverageSoftWarning(
348
+ result.loaded.softIssues,
349
+ result.loaded.coverageRatio
350
+ )
351
+ );
352
+ }
353
+ const { symbol, timeframe } = result.job;
354
+ const { rows } = result.loaded;
355
+ const bufferKey = `k${bufferSequence++}`;
356
+ const buffer = encodeOhlcvColumns(rows);
357
+ buffers[bufferKey] = buffer;
358
+ if (timeframe === baseTimeframe) {
359
+ chartSeries.push({
360
+ symbol,
361
+ timeframe: baseTimeframe,
362
+ rows: rows.length,
363
+ buffer: buffer.slice(0),
364
+ });
365
+ }
366
+ series.push({ symbol, timeframe, buffer: bufferKey, rows: rows.length });
298
367
  }
299
368
  if (dataIssues.length > 0) {
300
369
  throw new TapeDataUnavailableError(dataIssues);
@@ -302,19 +371,24 @@ export async function loadTape(request, options = {}) {
302
371
 
303
372
  let fundingRates;
304
373
  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
- }
374
+ const fundingEntries = await mapWithConcurrency(
375
+ request.symbols,
376
+ seriesConcurrency,
377
+ async (symbol) => {
378
+ request.signal?.throwIfAborted();
379
+ const samples = await loadFundingHistory(
380
+ exchange,
381
+ exchangeDefinition,
382
+ runtimeConfig,
383
+ symbol,
384
+ request.fromMs,
385
+ tapeToMs,
386
+ request.signal
387
+ );
388
+ return [symbol, samples];
389
+ }
390
+ );
391
+ fundingRates = Object.fromEntries(fundingEntries);
318
392
  }
319
393
  request.signal?.throwIfAborted();
320
394
  onProgress?.({