@alphafox/cli 0.3.6 → 0.3.8

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`;
@@ -1,5 +1,7 @@
1
1
  /**
2
- * Chat-attached `/api/v1/backtests` jobs are not a CLI surface.
2
+ * Not a CLI surface:
3
+ * - Web `/api/v1/backtests` jobs (`backtests` / `backtests.*`)
4
+ * - Chat product (`chats` / `chats.*`, `chat_summaries` / `chat_summaries.*`)
3
5
  * Local Engine WASM (`engine-backtest run`) and `engine_backtest.*` stay.
4
6
  * Match `backtests` / `backtests.*` only — never `engine_backtest.*`.
5
7
  */
@@ -2,10 +2,17 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.isOmittedCatalogOperation = isOmittedCatalogOperation;
4
4
  /**
5
- * Chat-attached `/api/v1/backtests` jobs are not a CLI surface.
5
+ * Not a CLI surface:
6
+ * - Web `/api/v1/backtests` jobs (`backtests` / `backtests.*`)
7
+ * - Chat product (`chats` / `chats.*`, `chat_summaries` / `chat_summaries.*`)
6
8
  * Local Engine WASM (`engine-backtest run`) and `engine_backtest.*` stay.
7
9
  * Match `backtests` / `backtests.*` only — never `engine_backtest.*`.
8
10
  */
9
11
  function isOmittedCatalogOperation(operationId) {
10
- return operationId === "backtests" || operationId.startsWith("backtests.");
12
+ return (operationId === "backtests" ||
13
+ operationId.startsWith("backtests.") ||
14
+ operationId === "chats" ||
15
+ operationId.startsWith("chats.") ||
16
+ operationId === "chat_summaries" ||
17
+ operationId.startsWith("chat_summaries."));
11
18
  }
@@ -9,6 +9,7 @@ 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");
@@ -101,7 +102,7 @@ async function runCli(argv, env = process.env) {
101
102
  "alphafox api METHOD PATH [--body JSON|--config @file]",
102
103
  "alphafox engine-backtest run --experiment <uuid> --definition <id> --config @file --exchange <id> --range FROM..TO --initial-equity N",
103
104
  "alphafox engine-backtest sweep --experiment <uuid> --definition <id> --config @file --axes @file --exchange <id> --range FROM..TO --initial-equity N --no-persist",
104
- "alphafox resolve-symbols <query...> [--exchange binance]",
105
+ "alphafox resolve-symbols <query...> [--exchange binance] [--asset-class equity_perp]",
105
106
  "alphafox <domain> <resource> <action> [flags]",
106
107
  ],
107
108
  }, { format: flags.format, jq: flags.jq });
@@ -351,15 +352,30 @@ async function cmdAuth(args, flags, env) {
351
352
  });
352
353
  if (sub === "status") {
353
354
  const verify = args.includes("--verify");
354
- const tokens = (0, store_1.loadTokens)(profile.name, env);
355
+ let tokens = (0, store_1.loadTokens)(profile.name, env);
355
356
  if (!tokens) {
356
357
  (0, envelope_1.writeSuccess)({
357
358
  authenticated: false,
359
+ session: "none",
358
360
  profile: profile.name,
359
361
  verified: false,
362
+ refresh: "no_session",
363
+ accessTokenExpired: null,
364
+ hasRefreshToken: false,
360
365
  }, { format: flags.format, jq: flags.jq });
361
366
  return 0;
362
367
  }
368
+ let refresh = "skipped";
369
+ if ((0, refresh_1.accessTokenNeedsRefresh)(tokens)) {
370
+ const outcome = await (0, refresh_1.refreshStoredTokens)(profile, env);
371
+ refresh = outcome.status;
372
+ if (outcome.status === "refreshed" || outcome.status === "unchanged") {
373
+ tokens = outcome.tokens;
374
+ }
375
+ else {
376
+ tokens = (0, store_1.loadTokens)(profile.name, env) ?? tokens;
377
+ }
378
+ }
363
379
  let verified = null;
364
380
  let whoami = null;
365
381
  if (verify) {
@@ -370,9 +386,18 @@ async function cmdAuth(args, flags, env) {
370
386
  }, env);
371
387
  verified = res.status >= 200 && res.status < 300;
372
388
  whoami = verified ? res.json : { status: res.status, body: res.json };
389
+ tokens = (0, store_1.loadTokens)(profile.name, env) ?? tokens;
373
390
  }
391
+ const accessTokenExpired = tokens.expiresAt <= Date.now();
392
+ const hasRefreshToken = Boolean(tokens.refreshToken?.trim());
393
+ const session = !accessTokenExpired
394
+ ? "active"
395
+ : refresh === "failed"
396
+ ? "refresh_failed"
397
+ : "expired";
374
398
  (0, envelope_1.writeSuccess)({
375
- authenticated: true,
399
+ authenticated: session === "active",
400
+ session,
376
401
  profile: profile.name,
377
402
  environment: tokens.environment,
378
403
  issuer: tokens.issuer,
@@ -381,6 +406,9 @@ async function cmdAuth(args, flags, env) {
381
406
  scopes: tokens.scopes,
382
407
  accessTokenFingerprint: (0, store_1.tokenFingerprint)(tokens.accessToken),
383
408
  expiresAt: tokens.expiresAt,
409
+ accessTokenExpired,
410
+ hasRefreshToken,
411
+ refresh,
384
412
  verified,
385
413
  whoami,
386
414
  }, { format: flags.format, jq: flags.jq });
@@ -1,3 +1,9 @@
1
+ import type { SymbolMetadata } from "./types";
1
2
  export declare const MARKET_SYMBOLS_PATH = "/api/v1/market/symbols";
2
3
  export declare function marketSymbolsPath(exchangeId: string): string;
4
+ export interface MarketCatalogPayload {
5
+ readonly symbols: string[];
6
+ readonly symbolMetadata: Readonly<Record<string, SymbolMetadata>>;
7
+ }
3
8
  export declare function extractCatalogSymbols(json: unknown): string[];
9
+ export declare function extractMarketCatalog(json: unknown): MarketCatalogPayload;
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.MARKET_SYMBOLS_PATH = void 0;
4
4
  exports.marketSymbolsPath = marketSymbolsPath;
5
5
  exports.extractCatalogSymbols = extractCatalogSymbols;
6
+ exports.extractMarketCatalog = extractMarketCatalog;
6
7
  const errors_1 = require("./errors");
7
8
  exports.MARKET_SYMBOLS_PATH = "/api/v1/market/symbols";
8
9
  function marketSymbolsPath(exchangeId) {
@@ -10,6 +11,9 @@ function marketSymbolsPath(exchangeId) {
10
11
  return `${exports.MARKET_SYMBOLS_PATH}?${query}`;
11
12
  }
12
13
  function extractCatalogSymbols(json) {
14
+ return extractMarketCatalog(json).symbols;
15
+ }
16
+ function extractMarketCatalog(json) {
13
17
  const root = asRecord(json);
14
18
  const payload = asRecord(root?.data) ?? root;
15
19
  const symbols = payload?.symbols;
@@ -40,8 +44,47 @@ function extractCatalogSymbols(json) {
40
44
  details: json,
41
45
  });
42
46
  }
47
+ return {
48
+ symbols: out,
49
+ symbolMetadata: extractSymbolMetadata(payload?.symbolMetadata),
50
+ };
51
+ }
52
+ function extractSymbolMetadata(value) {
53
+ const rec = asRecord(value);
54
+ if (!rec)
55
+ return {};
56
+ const out = {};
57
+ for (const [rawKey, rawMeta] of Object.entries(rec)) {
58
+ const key = rawKey.trim();
59
+ const meta = parseSymbolMetadata(rawMeta);
60
+ if (!key || !meta)
61
+ continue;
62
+ out[key] = meta;
63
+ }
43
64
  return out;
44
65
  }
66
+ function parseSymbolMetadata(value) {
67
+ const rec = asRecord(value);
68
+ if (!rec)
69
+ return undefined;
70
+ const meta = {};
71
+ if (typeof rec.isTradFiRwa === "boolean") {
72
+ meta.isTradFiRwa = rec.isTradFiRwa;
73
+ }
74
+ if (typeof rec.assetClass === "string" && rec.assetClass.trim()) {
75
+ meta.assetClass = rec.assetClass.trim();
76
+ }
77
+ if (isFiniteNumber(rec.minAmount))
78
+ meta.minAmount = rec.minAmount;
79
+ if (isFiniteNumber(rec.minCost))
80
+ meta.minCost = rec.minCost;
81
+ if (isFiniteNumber(rec.contractSize))
82
+ meta.contractSize = rec.contractSize;
83
+ return Object.keys(meta).length > 0 ? meta : undefined;
84
+ }
85
+ function isFiniteNumber(value) {
86
+ return typeof value === "number" && Number.isFinite(value);
87
+ }
45
88
  function asRecord(value) {
46
89
  if (!value || typeof value !== "object" || Array.isArray(value)) {
47
90
  return undefined;
@@ -42,12 +42,15 @@ for (const exchange of exports.RESOLVE_SYMBOLS_EXCHANGES) {
42
42
  EXCHANGE_BY_ALIAS.set(alias.toLowerCase(), exchange);
43
43
  }
44
44
  }
45
+ const PASSTHROUGH_EXCHANGE_ID = /^[a-z][a-z0-9_]{1,63}$/;
45
46
  function resolveSymbolsExchangeId(raw) {
46
47
  const key = raw.trim().toLowerCase();
47
48
  const exchange = EXCHANGE_BY_ALIAS.get(key);
48
- if (!exchange) {
49
- const allowed = exports.RESOLVE_SYMBOLS_EXCHANGES.map((item) => item.aliases[0]).join("|");
50
- throw new Error(`--exchange must be ${allowed} (got ${raw.trim() || "<empty>"})`);
49
+ if (exchange)
50
+ return exchange;
51
+ if (PASSTHROUGH_EXCHANGE_ID.test(key)) {
52
+ return { id: key, label: raw.trim(), aliases: [key] };
51
53
  }
52
- return exchange;
54
+ const allowed = exports.RESOLVE_SYMBOLS_EXCHANGES.map((item) => item.aliases[0]).join("|");
55
+ throw new Error(`--exchange must be ${allowed} or a market.symbols.list catalog id (got ${raw.trim() || "<empty>"})`);
53
56
  }
@@ -1,6 +1,7 @@
1
- import type { CatalogSymbol, ResolveSymbolsQueryResult } from "./types";
1
+ import type { CatalogSymbol, ResolveAssetClassFilter, ResolveSymbolsQueryResult, SymbolMetadata } from "./types";
2
2
  export declare function normalizeSymbolSearchKey(value: string): string;
3
3
  export declare function parseCatalogSymbol(symbol: string): CatalogSymbol | null;
4
- export declare function indexCatalogSymbols(symbols: readonly string[]): readonly CatalogSymbol[];
5
- export declare function resolveQueryAgainstCatalog(query: string, catalog: readonly CatalogSymbol[], limit?: number): ResolveSymbolsQueryResult;
4
+ export declare function indexCatalogSymbols(symbols: readonly string[], metadata?: Readonly<Record<string, SymbolMetadata>>): readonly CatalogSymbol[];
5
+ export declare function catalogMatchesAssetClass(item: CatalogSymbol, assetClass: ResolveAssetClassFilter): boolean;
6
+ export declare function resolveQueryAgainstCatalog(query: string, catalog: readonly CatalogSymbol[], limit?: number, assetClass?: ResolveAssetClassFilter): ResolveSymbolsQueryResult;
6
7
  export declare function levenshtein(left: string, right: string): number;
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.normalizeSymbolSearchKey = normalizeSymbolSearchKey;
4
4
  exports.parseCatalogSymbol = parseCatalogSymbol;
5
5
  exports.indexCatalogSymbols = indexCatalogSymbols;
6
+ exports.catalogMatchesAssetClass = catalogMatchesAssetClass;
6
7
  exports.resolveQueryAgainstCatalog = resolveQueryAgainstCatalog;
7
8
  exports.levenshtein = levenshtein;
8
9
  const LINEAR_PERP_PATTERN = /^([\p{L}\p{N}]+(?:-[\p{L}\p{N}]+)*)\/(USDT|USDC|USD):\2$/u;
@@ -69,7 +70,7 @@ function parseCatalogSymbol(symbol) {
69
70
  compactKey: normalizeSymbolSearchKey(upper),
70
71
  };
71
72
  }
72
- function indexCatalogSymbols(symbols) {
73
+ function indexCatalogSymbols(symbols, metadata = {}) {
73
74
  const seen = new Set();
74
75
  const indexed = [];
75
76
  for (const raw of symbols) {
@@ -79,64 +80,59 @@ function indexCatalogSymbols(symbols) {
79
80
  if (seen.has(parsed.symbol))
80
81
  continue;
81
82
  seen.add(parsed.symbol);
82
- indexed.push(parsed);
83
+ indexed.push({
84
+ ...parsed,
85
+ metadata: metadata[raw] ?? metadata[parsed.symbol],
86
+ });
83
87
  }
84
88
  return indexed;
85
89
  }
86
- function resolveQueryAgainstCatalog(query, catalog, limit = 8) {
90
+ function catalogMatchesAssetClass(item, assetClass) {
91
+ if (assetClass === "all")
92
+ return true;
93
+ const cls = item.metadata?.assetClass;
94
+ const tradfi = item.metadata?.isTradFiRwa === true;
95
+ if (assetClass === "equity_perp")
96
+ return cls === "equity_perp";
97
+ if (assetClass === "rwa_perp")
98
+ return cls === "rwa_perp";
99
+ return !tradfi && cls !== "equity_perp" && cls !== "rwa_perp";
100
+ }
101
+ function resolveQueryAgainstCatalog(query, catalog, limit = 8, assetClass = "all") {
87
102
  const trimmed = query.trim();
88
- const matches = rankMatches(trimmed, catalog);
103
+ const scoped = catalog.filter((item) => catalogMatchesAssetClass(item, assetClass));
104
+ const matches = rankMatches(trimmed, scoped);
89
105
  const exact = matches.filter((item) => EXACT_REASONS.has(item.reason));
90
106
  const close = matches.filter((item) => !EXACT_REASONS.has(item.reason));
91
107
  const capped = (items) => items.slice(0, Math.max(1, limit));
92
108
  if (exact.length === 1) {
93
- return {
94
- query: trimmed,
95
- status: "exact",
96
- resolved: exact[0].symbol,
97
- needsConfirmation: false,
98
- matches: exact,
99
- matchCount: exact.length,
100
- };
109
+ return result("exact", exact[0].symbol, false, exact, exact.length);
101
110
  }
102
111
  if (exact.length > 1) {
103
- return {
104
- query: trimmed,
105
- status: "ambiguous",
106
- resolved: null,
107
- needsConfirmation: true,
108
- matches: capped(exact),
109
- matchCount: exact.length,
110
- };
112
+ return result("ambiguous", null, true, capped(exact), exact.length);
111
113
  }
112
114
  if (close.length === 1) {
113
- return {
114
- query: trimmed,
115
- status: "close",
116
- resolved: close[0].symbol,
117
- needsConfirmation: true,
118
- matches: close,
119
- matchCount: 1,
120
- };
115
+ return result("close", close[0].symbol, true, close, 1);
121
116
  }
122
117
  if (close.length > 1) {
118
+ return result("ambiguous", null, true, capped(close), close.length);
119
+ }
120
+ return result("none", null, false, [], 0);
121
+ function result(status, resolved, needsConfirmation, matches, matchCount) {
122
+ const chosen = resolved
123
+ ? matches.find((item) => item.symbol === resolved)
124
+ : undefined;
123
125
  return {
124
126
  query: trimmed,
125
- status: "ambiguous",
126
- resolved: null,
127
- needsConfirmation: true,
128
- matches: capped(close),
129
- matchCount: close.length,
127
+ status,
128
+ resolved,
129
+ assetClass: chosen?.assetClass ?? null,
130
+ isTradFiRwa: chosen?.isTradFiRwa ?? false,
131
+ needsConfirmation,
132
+ matches,
133
+ matchCount,
130
134
  };
131
135
  }
132
- return {
133
- query: trimmed,
134
- status: "none",
135
- resolved: null,
136
- needsConfirmation: false,
137
- matches: [],
138
- matchCount: 0,
139
- };
140
136
  }
141
137
  function rankMatches(query, catalog) {
142
138
  const canonicalQuery = query.trim().toUpperCase();
@@ -161,21 +157,21 @@ function rankMatches(query, catalog) {
161
157
  }
162
158
  function rankCatalogSymbol(item, canonicalQuery, queryKey, queryBaseKey) {
163
159
  if (item.symbol === canonicalQuery) {
164
- return match(item.symbol, "exact_canonical", 100);
160
+ return match(item, "exact_canonical", 100);
165
161
  }
166
162
  if (item.pair === canonicalQuery) {
167
- return match(item.symbol, "exact_pair", 96);
163
+ return match(item, "exact_pair", 96);
168
164
  }
169
165
  if (item.compactKey === queryKey || item.searchKey === queryKey) {
170
- return match(item.symbol, "exact_compact", 98);
166
+ return match(item, "exact_compact", 98);
171
167
  }
172
168
  if (item.baseKey === queryKey || item.baseKey === queryBaseKey) {
173
- return match(item.symbol, "exact_base", 95);
169
+ return match(item, "exact_base", 95);
174
170
  }
175
171
  let best = null;
176
172
  const consider = (reason, score) => {
177
173
  if (!best || score > best.score) {
178
- best = match(item.symbol, reason, score);
174
+ best = match(item, reason, score);
179
175
  }
180
176
  };
181
177
  if (queryBaseKey.length >= 2 && item.baseKey.startsWith(queryBaseKey)) {
@@ -219,8 +215,14 @@ function isCloseDistance(left, right, distance) {
219
215
  return distance === 1;
220
216
  return distance <= 2;
221
217
  }
222
- function match(symbol, reason, score) {
223
- return { symbol, reason, score };
218
+ function match(item, reason, score) {
219
+ return {
220
+ symbol: item.symbol,
221
+ reason,
222
+ score,
223
+ assetClass: item.metadata?.assetClass ?? null,
224
+ isTradFiRwa: item.metadata?.isTradFiRwa === true,
225
+ };
224
226
  }
225
227
  function clampScore(score) {
226
228
  return Math.max(1, Math.min(94, score));
@@ -1,5 +1,6 @@
1
1
  import type { ResolveSymbolsRunArgs } from "./types";
2
2
  export declare const RESOLVE_SYMBOLS_DEFAULT_LIMIT = 8;
3
3
  export declare const RESOLVE_SYMBOLS_MAX_LIMIT = 25;
4
+ export declare const RESOLVE_SYMBOLS_ASSET_CLASSES: readonly ["all", "equity_perp", "rwa_perp", "crypto"];
4
5
  export declare const RESOLVE_SYMBOLS_USAGE: string[];
5
6
  export declare function parseResolveSymbolsArgs(args: readonly string[]): ResolveSymbolsRunArgs;
@@ -1,14 +1,21 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.RESOLVE_SYMBOLS_USAGE = exports.RESOLVE_SYMBOLS_MAX_LIMIT = exports.RESOLVE_SYMBOLS_DEFAULT_LIMIT = void 0;
3
+ exports.RESOLVE_SYMBOLS_USAGE = exports.RESOLVE_SYMBOLS_ASSET_CLASSES = exports.RESOLVE_SYMBOLS_MAX_LIMIT = exports.RESOLVE_SYMBOLS_DEFAULT_LIMIT = void 0;
4
4
  exports.parseResolveSymbolsArgs = parseResolveSymbolsArgs;
5
5
  const errors_1 = require("./errors");
6
6
  const exchanges_1 = require("./exchanges");
7
7
  exports.RESOLVE_SYMBOLS_DEFAULT_LIMIT = 8;
8
8
  exports.RESOLVE_SYMBOLS_MAX_LIMIT = 25;
9
+ exports.RESOLVE_SYMBOLS_ASSET_CLASSES = [
10
+ "all",
11
+ "equity_perp",
12
+ "rwa_perp",
13
+ "crypto",
14
+ ];
9
15
  exports.RESOLVE_SYMBOLS_USAGE = [
10
- "alphafox resolve-symbols <query...> [--exchange binance] [--limit 8]",
16
+ "alphafox resolve-symbols <query...> [--exchange binance] [--asset-class all] [--limit 8]",
11
17
  "alphafox resolve-symbols --query BTC --query ETH --exchange okx",
18
+ "alphafox resolve-symbols NVDA AAPL TSLA --exchange binance --asset-class equity_perp",
12
19
  ];
13
20
  function usage(message, subtype = "invalid_args") {
14
21
  throw new errors_1.ResolveSymbolsError({
@@ -30,6 +37,7 @@ function parseResolveSymbolsArgs(args) {
30
37
  const queries = [];
31
38
  let exchange = exchanges_1.RESOLVE_SYMBOLS_DEFAULT_EXCHANGE;
32
39
  let limit = exports.RESOLVE_SYMBOLS_DEFAULT_LIMIT;
40
+ let assetClass = "all";
33
41
  let help = false;
34
42
  for (let i = 0; i < args.length; i += 1) {
35
43
  const a = args[i];
@@ -59,6 +67,14 @@ function parseResolveSymbolsArgs(args) {
59
67
  case "--exchange":
60
68
  exchange = read();
61
69
  break;
70
+ case "--asset-class": {
71
+ const value = read().trim().toLowerCase();
72
+ if (!exports.RESOLVE_SYMBOLS_ASSET_CLASSES.includes(value)) {
73
+ usage(`--asset-class must be ${exports.RESOLVE_SYMBOLS_ASSET_CLASSES.join("|")}`, "invalid_asset_class");
74
+ }
75
+ assetClass = value;
76
+ break;
77
+ }
62
78
  case "--limit": {
63
79
  const n = Number(read());
64
80
  if (!Number.isInteger(n) || n <= 0 || n > exports.RESOLVE_SYMBOLS_MAX_LIMIT) {
@@ -72,7 +88,7 @@ function parseResolveSymbolsArgs(args) {
72
88
  }
73
89
  }
74
90
  if (help) {
75
- return { help: true, queries: [], exchange, limit };
91
+ return { help: true, queries: [], exchange, limit, assetClass };
76
92
  }
77
93
  const normalizedQueries = queries.map((item) => item.trim()).filter(Boolean);
78
94
  if (normalizedQueries.length === 0) {
@@ -90,6 +106,7 @@ function parseResolveSymbolsArgs(args) {
90
106
  queries: uniqueQueries(normalizedQueries),
91
107
  exchange: resolvedExchange,
92
108
  limit,
109
+ assetClass,
93
110
  };
94
111
  }
95
112
  function uniqueQueries(queries) {
@@ -41,10 +41,12 @@ function resolveSymbolsHelpData() {
41
41
  name: "resolve-symbols",
42
42
  usage: parse_args_1.RESOLVE_SYMBOLS_USAGE,
43
43
  notes: [
44
- "Resolves user-mentioned tickers against the public linear-perp catalog from market.symbols.list",
45
- "Default --exchange is binance (binance_perp_usdt). Aliases: binance|okx|bybit|bitget|hyperliquid|aster",
46
- "Do not guess CCXT symbols. Exact match may be used directly; a single close match needs user confirmation; multiple close matches must be chosen by the user",
47
- "Catalog CRUD remains alphafox market symbols list --exchange <id>",
44
+ "Resolves user-mentioned tickers against market.symbols.list for the chosen catalog",
45
+ "Default --exchange is binance (binance_perp_usdt). Built-in aliases: binance|okx|bybit|bitget|hyperliquid|aster",
46
+ "US stock perps live in the same catalog (NVDA/USDT:USDT) and are tagged symbolMetadata.assetClass=equity_perp / isTradFiRwa",
47
+ "For 美股 use --asset-class equity_perp. For gold/silver RWAs use rwa_perp. Crypto perps are untagged or --asset-class crypto",
48
+ "Each match includes assetClass and isTradFiRwa. Do not treat an equity_perp as a crypto coin",
49
+ "Catalog dump remains alphafox market symbols list --exchange <id>",
48
50
  ],
49
51
  };
50
52
  }
@@ -77,12 +79,22 @@ async function executeResolveSymbols(args, flags, env = process.env, deps = {})
77
79
  details: res.json,
78
80
  });
79
81
  }
80
- const catalog = (0, match_1.indexCatalogSymbols)((0, catalog_1.extractCatalogSymbols)(res.json));
82
+ const payload = (0, catalog_1.extractMarketCatalog)(res.json);
83
+ const catalog = (0, match_1.indexCatalogSymbols)(payload.symbols, payload.symbolMetadata);
84
+ if (args.assetClass !== "all" &&
85
+ !catalog.some((item) => item.metadata?.assetClass)) {
86
+ throw new errors_1.ResolveSymbolsError({
87
+ type: "runtime",
88
+ subtype: "metadata_missing",
89
+ message: "market.symbols.list did not include symbolMetadata; cannot apply --asset-class",
90
+ hint: "Retry without --asset-class, or use a catalog that returns symbolMetadata.assetClass.",
91
+ });
92
+ }
81
93
  return {
82
94
  exchange: exchange.id,
83
95
  exchangeLabel: exchange.label,
84
96
  catalogSize: catalog.length,
85
- queries: args.queries.map((query) => (0, match_1.resolveQueryAgainstCatalog)(query, catalog, args.limit)),
97
+ queries: args.queries.map((query) => (0, match_1.resolveQueryAgainstCatalog)(query, catalog, args.limit, args.assetClass)),
86
98
  };
87
99
  }
88
100
  async function cmdResolveSymbols(args, flags, env = process.env, deps = {}) {
@@ -119,6 +131,7 @@ async function cmdResolveSymbols(args, flags, env = process.env, deps = {}) {
119
131
  path: (0, catalog_1.marketSymbolsPath)(exchange.id),
120
132
  exchange: exchange.id,
121
133
  exchangeLabel: exchange.label,
134
+ assetClass: parsed.assetClass,
122
135
  queries: parsed.queries,
123
136
  }, { format: flags.format, jq: flags.jq });
124
137
  return 0;