perp-cli 0.15.0 → 0.16.0

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 (154) hide show
  1. package/README.ja.md +243 -0
  2. package/README.ko.md +243 -0
  3. package/README.md +129 -535
  4. package/README.zh.md +243 -0
  5. package/dist/api/public/lighter.d.ts +5 -0
  6. package/dist/api/public/lighter.js +5 -0
  7. package/dist/api/public/pacifica.js +2 -2
  8. package/dist/arb/sizing.js +4 -2
  9. package/dist/arb/state.d.ts +6 -0
  10. package/dist/balance-by-address.d.ts +39 -0
  11. package/dist/balance-by-address.js +44 -0
  12. package/dist/bot/quote-rules.js +1 -1
  13. package/dist/bot/strategies/funding-arb-strategy.js +10 -3
  14. package/dist/bot/strategies/funding-arb-utils.d.ts +14 -2
  15. package/dist/bot/strategies/funding-arb-utils.js +48 -15
  16. package/dist/bot/strategies/spot-perp-arb-strategy.d.ts +1 -1
  17. package/dist/bot/strategies/spot-perp-arb-strategy.js +4 -2
  18. package/dist/bot/strategies/spot-perp-executor.js +2 -2
  19. package/dist/cli-program.js +1 -1
  20. package/dist/commands/account.js +168 -24
  21. package/dist/commands/arb/index.js +19 -77
  22. package/dist/commands/arb-auto.d.ts +24 -0
  23. package/dist/commands/arb-auto.js +155 -81
  24. package/dist/commands/arb.d.ts +8 -0
  25. package/dist/commands/arb.js +49 -23
  26. package/dist/commands/backtest.js +23 -21
  27. package/dist/commands/market.d.ts +48 -0
  28. package/dist/commands/market.js +241 -78
  29. package/dist/commands/options.d.ts +5 -0
  30. package/dist/commands/options.js +20 -3
  31. package/dist/commands/pacifica-earn.js +8 -21
  32. package/dist/commands/portfolio.d.ts +16 -0
  33. package/dist/commands/portfolio.js +51 -8
  34. package/dist/commands/trade.js +4 -4
  35. package/dist/cross-chain-margin.d.ts +3 -1
  36. package/dist/cross-chain-margin.js +4 -2
  37. package/dist/dashboard/server.js +2 -2
  38. package/dist/dashboard/ui.js +9 -6
  39. package/dist/errors.d.ts +5 -0
  40. package/dist/errors.js +10 -0
  41. package/dist/exchanges/address.d.ts +8 -0
  42. package/dist/exchanges/address.js +47 -0
  43. package/dist/exchanges/arcus/adapter.d.ts +10 -6
  44. package/dist/exchanges/arcus/adapter.js +33 -6
  45. package/dist/exchanges/arcus/public.js +3 -2
  46. package/dist/exchanges/aster.d.ts +27 -7
  47. package/dist/exchanges/aster.js +124 -42
  48. package/dist/exchanges/canonical-symbols.d.ts +7 -0
  49. package/dist/exchanges/canonical-symbols.js +20 -0
  50. package/dist/exchanges/capabilities.d.ts +2 -0
  51. package/dist/exchanges/capabilities.js +2 -1
  52. package/dist/exchanges/derive/options.d.ts +5 -0
  53. package/dist/exchanges/derive/options.js +20 -1
  54. package/dist/exchanges/extended/adapter.d.ts +16 -2
  55. package/dist/exchanges/extended/adapter.js +96 -34
  56. package/dist/exchanges/extended/public.d.ts +3 -0
  57. package/dist/exchanges/extended/public.js +9 -2
  58. package/dist/exchanges/grvt/adapter.d.ts +16 -2
  59. package/dist/exchanges/grvt/adapter.js +98 -21
  60. package/dist/exchanges/hyperliquid-outcome.d.ts +2 -0
  61. package/dist/exchanges/hyperliquid-outcome.js +29 -1
  62. package/dist/exchanges/hyperliquid-spot.d.ts +8 -0
  63. package/dist/exchanges/hyperliquid-spot.js +20 -12
  64. package/dist/exchanges/hyperliquid.d.ts +180 -13
  65. package/dist/exchanges/hyperliquid.js +487 -185
  66. package/dist/exchanges/interface.d.ts +64 -8
  67. package/dist/exchanges/interface.js +31 -0
  68. package/dist/exchanges/lighter-public.d.ts +66 -11
  69. package/dist/exchanges/lighter-public.js +249 -81
  70. package/dist/exchanges/lighter-spot.d.ts +7 -1
  71. package/dist/exchanges/lighter-spot.js +99 -90
  72. package/dist/exchanges/lighter.d.ts +70 -11
  73. package/dist/exchanges/lighter.js +285 -92
  74. package/dist/exchanges/ondo/adapter.d.ts +34 -8
  75. package/dist/exchanges/ondo/adapter.js +73 -16
  76. package/dist/exchanges/ondo/public.d.ts +27 -2
  77. package/dist/exchanges/ondo/public.js +63 -31
  78. package/dist/exchanges/outcome-interface.d.ts +4 -0
  79. package/dist/exchanges/pacifica-spot.d.ts +6 -0
  80. package/dist/exchanges/pacifica-spot.js +23 -15
  81. package/dist/exchanges/pacifica-vaults.d.ts +9 -0
  82. package/dist/exchanges/pacifica-vaults.js +14 -1
  83. package/dist/exchanges/pacifica.d.ts +77 -6
  84. package/dist/exchanges/pacifica.js +321 -130
  85. package/dist/exchanges/public-balances.d.ts +25 -0
  86. package/dist/exchanges/public-balances.js +158 -0
  87. package/dist/exchanges/public-positions.d.ts +43 -0
  88. package/dist/exchanges/public-positions.js +154 -0
  89. package/dist/exchanges/qfex/adapter.d.ts +52 -9
  90. package/dist/exchanges/qfex/adapter.js +74 -9
  91. package/dist/exchanges/qfex/public.d.ts +62 -9
  92. package/dist/exchanges/qfex/public.js +128 -22
  93. package/dist/exchanges/qfex/ws-transport.d.ts +1 -1
  94. package/dist/exchanges/risex/adapter.d.ts +19 -7
  95. package/dist/exchanges/risex/adapter.js +58 -12
  96. package/dist/exchanges/spot-factory.js +1 -1
  97. package/dist/exchanges/txflow/adapter.d.ts +14 -7
  98. package/dist/exchanges/txflow/adapter.js +51 -19
  99. package/dist/exchanges/txflow/client.js +3 -1
  100. package/dist/exchanges/txflow/public.d.ts +12 -1
  101. package/dist/exchanges/txflow/public.js +82 -11
  102. package/dist/exchanges/unknown-symbol.d.ts +6 -1
  103. package/dist/exchanges/unknown-symbol.js +7 -2
  104. package/dist/exchanges/variational/adapter.d.ts +2 -6
  105. package/dist/exchanges/venue-types.d.ts +46 -3
  106. package/dist/exchanges/venue-types.js +80 -9
  107. package/dist/exchanges/venue.d.ts +99 -3
  108. package/dist/exchanges/venue.js +72 -0
  109. package/dist/exchanges/venues/hyperliquid.js +9 -0
  110. package/dist/exchanges/venues/lighter-rh.js +13 -3
  111. package/dist/exchanges/venues/lighter.js +13 -3
  112. package/dist/exchanges/venues/pacifica.js +9 -0
  113. package/dist/exchanges/venues/risex.js +15 -0
  114. package/dist/funding/legacy-public.d.ts +5 -3
  115. package/dist/funding/legacy-public.js +33 -37
  116. package/dist/funding/rates.d.ts +54 -5
  117. package/dist/funding/rates.js +49 -13
  118. package/dist/liquidity.d.ts +3 -1
  119. package/dist/liquidity.js +8 -4
  120. package/dist/mcp-pacifica-earn.js +11 -5
  121. package/dist/mcp-server.js +104 -15
  122. package/dist/pacifica/client.d.ts +2 -1
  123. package/dist/pacifica/client.js +11 -0
  124. package/dist/positions-by-address.d.ts +34 -0
  125. package/dist/positions-by-address.js +46 -0
  126. package/dist/strategies/funding-arb.js +35 -62
  127. package/dist/utils.d.ts +11 -1
  128. package/dist/venue-health.js +1 -1
  129. package/dist/ws/arcus-market.d.ts +9 -0
  130. package/dist/ws/arcus-market.js +9 -0
  131. package/dist/ws/aster-market.d.ts +33 -0
  132. package/dist/ws/aster-market.js +30 -0
  133. package/dist/ws/index.d.ts +30 -9
  134. package/dist/ws/index.js +180 -15
  135. package/dist/ws/lighter-market.d.ts +14 -0
  136. package/dist/ws/lighter-market.js +13 -0
  137. package/dist/ws/node.d.ts +30 -9
  138. package/dist/ws/node.js +204 -16
  139. package/dist/ws/unclassified.d.ts +16 -0
  140. package/dist/ws/unclassified.js +43 -0
  141. package/package.json +15 -2
  142. package/skills/perp-cli/SKILL.md +97 -33
  143. package/skills/perp-cli/package.json +15 -2
  144. package/skills/perp-cli/references/agent-operations.md +15 -12
  145. package/skills/perp-cli/references/commands.md +67 -12
  146. package/skills/perp-cli/references/strategies.md +38 -3
  147. package/skills/perp-cli/references/ws-library.md +55 -24
  148. package/skills/perp-cli/scripts/arb-monitor.sh +13 -10
  149. package/skills/perp-cli/scripts/funding-analysis.sh +57 -29
  150. package/skills/perp-cli/scripts/preflight.sh +27 -19
  151. package/skills/perp-cli/scripts/spot-perp-scan.sh +58 -38
  152. package/skills/perp-cli/scripts/validate-arb.sh +56 -39
  153. package/dist/cli-spec.d.ts +0 -46
  154. package/dist/cli-spec.js +0 -81
@@ -0,0 +1,25 @@
1
+ import { type Network, type PublicBalanceResult, type PublicPositionsOptions } from "./venue.js";
2
+ import type { LighterVenueId } from "./venues/lighter-family.js";
3
+ /**
4
+ * Hyperliquid: info `clearinghouseState` (native perp dex), `userAbstraction` (the mode decides
5
+ * the equity source, exactly as the keyed getBalance) and `spotClearinghouseState` (spot rows;
6
+ * in unified / portfolio mode also the collateral), plus `meta.collateralToken` when the mode needs
7
+ * it. HIP-3: `clearinghouseState {dex}` for every dex of `perpDexs` (paced: hlDexFanOut); a dex
8
+ * balance is that dex's own perp clearinghouse, listed when its account value is non-zero. A dex
9
+ * (or the dex list) that fails is an `errors` entry ("hip3:<dex>") — unknown, never zero; the
10
+ * native, mode or spot read failing fails the venue.
11
+ */
12
+ export declare function hyperliquidBalanceByAddress(net: Network, address: string): Promise<PublicBalanceResult>;
13
+ /**
14
+ * Lighter / Lighter RH: `GET /account?by=l1_address` lists every account of the address; each
15
+ * account's balance (lighterBalanceOf) and spot assets (lighterSpotBalanceRows) are one
16
+ * `accounts` row. `accountIndex` narrows to one account (not one of the address's →
17
+ * INVALID_PARAMS); no account → empty `accounts` + note.
18
+ */
19
+ export declare function lighterBalanceByAddress(venue: LighterVenueId, net: Network, address: string, opts: PublicPositionsOptions): Promise<PublicBalanceResult>;
20
+ /**
21
+ * Pacifica: `GET /account?account=` (balance + spot_balances), `/positions?account=` and
22
+ * `/info/prices` — pacificaBalanceOf with the keyed extras (usdcBalance, borrowedUsdc,
23
+ * pendingInterest, availableToWithdraw). The address is validated as base58 32 bytes by the caller.
24
+ */
25
+ export declare function pacificaBalanceByAddress(net: Network, address: string): Promise<PublicBalanceResult>;
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Balances of ANY account by its public address, keyless (`perp account balance --address`, MCP
3
+ * get_balance_by_address). Wired into the registry through VenuePublicData.fetchBalanceByAddress;
4
+ * the balance comes from the same functions as the keyed adapters' getBalance (hlBalanceOf /
5
+ * lighterBalanceOf / pacificaBalanceOf) and the spot rows from the keyed spot mappers
6
+ * (hlSpotBalanceRows / lighterSpotBalanceRows / pacificaSpotBalanceRows). Read-only GETs / info
7
+ * POSTs; nothing is signed, no key is loaded.
8
+ */
9
+ import { classifyError, PerpError } from "../errors.js";
10
+ import { getVenue, networkConfig } from "./venue.js";
11
+ import { chooseLighterAccounts, hlDexFanOut, hlDexNamesOf, lighterAccountIndexOf } from "./public-positions.js";
12
+ function errorOf(e, venue) {
13
+ const s = classifyError(e, venue);
14
+ return { code: s.code, message: s.message };
15
+ }
16
+ /** A venue read failure as a typed error (the public HTTP helpers throw plain Errors on HTTP status). */
17
+ async function typed(venue, read) {
18
+ try {
19
+ return await read();
20
+ }
21
+ catch (e) {
22
+ if (e instanceof PerpError)
23
+ throw e;
24
+ const s = classifyError(e, venue);
25
+ throw new PerpError(s.code === "UNKNOWN" ? "EXCHANGE_ERROR" : s.code, s.message, { exchange: venue });
26
+ }
27
+ }
28
+ const EMPTY = { balance: null, spot: null, accounts: null, dexes: null, accountMode: null };
29
+ /** spotClearinghouseState `balances`, every row with a coin and numeric total / hold (unreadable → EXCHANGE_ERROR, never "0"). */
30
+ function hlSpotBalancesOf(state) {
31
+ const balances = state?.balances;
32
+ if (!Array.isArray(balances))
33
+ throw new PerpError("EXCHANGE_ERROR", "hyperliquid spotClearinghouseState: no balances array", { exchange: "hyperliquid" });
34
+ for (const b of balances) {
35
+ const num = (v) => typeof v === "string" && v !== "" && Number.isFinite(Number(v));
36
+ if (typeof b?.coin !== "string" || !num(b.total) || !num(b.hold)) {
37
+ throw new PerpError("EXCHANGE_ERROR", `hyperliquid spotClearinghouseState: unreadable balance row ${JSON.stringify(b).slice(0, 160)}`, { exchange: "hyperliquid" });
38
+ }
39
+ }
40
+ return balances;
41
+ }
42
+ /**
43
+ * Hyperliquid: info `clearinghouseState` (native perp dex), `userAbstraction` (the mode decides
44
+ * the equity source, exactly as the keyed getBalance) and `spotClearinghouseState` (spot rows;
45
+ * in unified / portfolio mode also the collateral), plus `meta.collateralToken` when the mode needs
46
+ * it. HIP-3: `clearinghouseState {dex}` for every dex of `perpDexs` (paced: hlDexFanOut); a dex
47
+ * balance is that dex's own perp clearinghouse, listed when its account value is non-zero. A dex
48
+ * (or the dex list) that fails is an `errors` entry ("hip3:<dex>") — unknown, never zero; the
49
+ * native, mode or spot read failing fails the venue.
50
+ */
51
+ export async function hyperliquidBalanceByAddress(net, address) {
52
+ const { legacyPublicJson } = await import("../funding/legacy-public.js");
53
+ const { legacyPublicBaseUrl } = await import("../api/public/urls.js");
54
+ const { hlAbstractionModeOf, hlBalanceOf, hlSpotUsdPricesOf } = await import("./hyperliquid.js");
55
+ const { hlSpotBalanceRows } = await import("./hyperliquid-spot.js");
56
+ const url = `${legacyPublicBaseUrl("hyperliquid", net)}/info`;
57
+ const post = (body, label) => legacyPublicJson("hyperliquid", url, label, {
58
+ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
59
+ });
60
+ const errors = {};
61
+ const [native, modeRaw, spotState, dexList] = await Promise.all([
62
+ typed("hyperliquid", () => post({ type: "clearinghouseState", user: address }, "info clearinghouseState")),
63
+ typed("hyperliquid", () => post({ type: "userAbstraction", user: address }, "info userAbstraction")),
64
+ typed("hyperliquid", () => post({ type: "spotClearinghouseState", user: address }, "info spotClearinghouseState")),
65
+ post({ type: "perpDexs" }, "info perpDexs").then(hlDexNamesOf, (e) => { errors.hip3 = errorOf(e, "hyperliquid"); return []; }),
66
+ ]);
67
+ const mode = hlAbstractionModeOf(modeRaw);
68
+ const balances = hlSpotBalancesOf(spotState);
69
+ let token = null;
70
+ const collateralToken = () => (token ??= typed("hyperliquid", () => post({ type: "meta" }, "info meta")).then((meta) => {
71
+ const idx = meta?.collateralToken;
72
+ if (typeof idx !== "number" || !Number.isInteger(idx) || idx < 0) {
73
+ throw new PerpError("EXCHANGE_ERROR", `Hyperliquid meta has no collateralToken (got ${JSON.stringify(idx)})`, { exchange: "hyperliquid" });
74
+ }
75
+ return idx;
76
+ }));
77
+ const nativeState = native;
78
+ const { balance, notes } = await hlBalanceOf(nativeState, mode, "", {
79
+ collateralToken,
80
+ spotCollateralRow: async () => {
81
+ const t = await collateralToken();
82
+ return { row: balances.find(b => Number(b.token) === t) ?? null, balances, token: t, portfolioMarginRatio: spotState.portfolioMarginRatio };
83
+ },
84
+ spotUsdPrices: async () => hlSpotUsdPricesOf(await typed("hyperliquid", () => post({ type: "spotMetaAndAssetCtxs" }, "info spotMetaAndAssetCtxs"))),
85
+ });
86
+ const unified = mode === "unified" || mode === "portfolio";
87
+ const out = notes.map(n => n.trim());
88
+ if (unified) {
89
+ const av = nativeState.marginSummary?.accountValue;
90
+ const wd = nativeState.withdrawable;
91
+ const perp = [typeof av === "string" ? `accountValue ${av}` : null, typeof wd === "string" ? `withdrawable ${wd}` : null].filter(x => x !== null).join(", ");
92
+ out.push(`account mode ${mode}: equity / available come from the spot clearinghouse (the collateral balance already moves with perp PnL), as in the keyed balance; the native perp clearinghouse${perp !== "" ? ` (${perp})` : ""} is a per-dex view and is not added`);
93
+ }
94
+ // HIP-3 dex balances: the dex's own perp clearinghouse (standard semantics — the mode-dependent
95
+ // spot collateral is the top-level balance, never repeated per dex).
96
+ const dexes = {};
97
+ const noSpotRead = () => { throw new PerpError("EXCHANGE_ERROR", "internal: a HIP-3 dex balance read asked for spot state", { exchange: "hyperliquid" }); };
98
+ const perDex = await hlDexFanOut(net, dexList, dex => post({ type: "clearinghouseState", user: address, dex }, `info clearinghouseState dex ${dex}`));
99
+ for (const [i, res] of perDex.entries()) {
100
+ const dex = dexList[i];
101
+ if (res.status === "rejected") {
102
+ errors[`hip3:${dex}`] = errorOf(res.reason, "hyperliquid");
103
+ continue;
104
+ }
105
+ try {
106
+ const b = (await hlBalanceOf(res.value, "standard", dex, { collateralToken: noSpotRead, spotCollateralRow: noSpotRead, spotUsdPrices: noSpotRead })).balance;
107
+ if (Number(b.equity) !== 0)
108
+ dexes[dex] = b;
109
+ }
110
+ catch (e) {
111
+ errors[`hip3:${dex}`] = errorOf(e, "hyperliquid");
112
+ }
113
+ }
114
+ return { balance, spot: hlSpotBalanceRows({ balances }, mode), accounts: null, dexes, accountMode: mode, errors, notes: out };
115
+ }
116
+ /**
117
+ * Lighter / Lighter RH: `GET /account?by=l1_address` lists every account of the address; each
118
+ * account's balance (lighterBalanceOf) and spot assets (lighterSpotBalanceRows) are one
119
+ * `accounts` row. `accountIndex` narrows to one account (not one of the address's →
120
+ * INVALID_PARAMS); no account → empty `accounts` + note.
121
+ */
122
+ export async function lighterBalanceByAddress(venue, net, address, opts) {
123
+ const { fetchLighterAccountsByL1 } = await import("./lighter-public.js");
124
+ const { lighterBalanceOf } = await import("./lighter.js");
125
+ const { lighterSpotBalanceRows } = await import("./lighter-spot.js");
126
+ const collateral = networkConfig(getVenue(venue), net).collateral[0];
127
+ if (collateral === undefined)
128
+ throw new PerpError("INVALID_PARAMS", `${venue} network config has no collateral`);
129
+ const accounts = await fetchLighterAccountsByL1(venue, net, address);
130
+ if (accounts === null || accounts.length === 0) {
131
+ return { ...EMPTY, accounts: [], errors: {}, notes: [`no ${venue === "lighter" ? "Lighter" : "Lighter RH"} account for this address`] };
132
+ }
133
+ const rows = chooseLighterAccounts(venue, accounts, opts.accountIndex).map(a => ({
134
+ accountIndex: lighterAccountIndexOf(a),
135
+ balance: lighterBalanceOf(a, venue, collateral),
136
+ spot: lighterSpotBalanceRows(a, venue, collateral),
137
+ }));
138
+ return { ...EMPTY, accounts: rows, errors: {}, notes: [] };
139
+ }
140
+ /**
141
+ * Pacifica: `GET /account?account=` (balance + spot_balances), `/positions?account=` and
142
+ * `/info/prices` — pacificaBalanceOf with the keyed extras (usdcBalance, borrowedUsdc,
143
+ * pendingInterest, availableToWithdraw). The address is validated as base58 32 bytes by the caller.
144
+ */
145
+ export async function pacificaBalanceByAddress(net, address) {
146
+ const { PacificaClient } = await import("../pacifica/client.js");
147
+ const { pacificaBalanceOf } = await import("./pacifica.js");
148
+ const { pacificaSpotBalanceRows } = await import("./pacifica-spot.js");
149
+ const client = new PacificaClient({ network: net });
150
+ const [info, positions, prices] = await typed("pacifica", () => Promise.all([client.getAccount(address), client.getPositions(address), client.getPrices()]));
151
+ return {
152
+ ...EMPTY,
153
+ balance: pacificaBalanceOf(info, positions, prices),
154
+ spot: pacificaSpotBalanceRows(info),
155
+ errors: {},
156
+ notes: [],
157
+ };
158
+ }
@@ -0,0 +1,43 @@
1
+ import { type Network, type PublicPositionsOptions, type PublicPositionsResult } from "./venue.js";
2
+ import type { LighterVenueId } from "./venues/lighter-family.js";
3
+ /** Requests of a HIP-3 per-dex fan-out in flight at once. */
4
+ export declare const HL_DEX_FANOUT_CONCURRENCY = 4;
5
+ /** Burst of the fan-out's token bucket, in HL weight units (clearinghouseState weighs 2 → 10 requests). */
6
+ export declare const HL_DEX_FANOUT_BURST = 20;
7
+ /**
8
+ * One `read` per HIP-3 dex, paced: at most HL_DEX_FANOUT_CONCURRENCY in flight, and every request
9
+ * takes its weight from a token bucket refilled at the venue's documented rate
10
+ * (hyperliquidVenue.rateLimit: 1200 weight / min = 20 / s; clearinghouseState weight 2) with a
11
+ * small burst (HL_DEX_FANOUT_BURST) instead of the full minute's budget. HL mainnet lists ~10
12
+ * dexes (no wait); testnet ~272, where an unpaced burst got HTTP 429 on 62 of 272 reads at
13
+ * concurrency 4 — paced, the fan-out takes ~25 s instead. A failed read stays that dex's
14
+ * (settled per dex); nothing is retried.
15
+ */
16
+ export declare function hlDexFanOut<T>(net: Network, dexes: readonly string[], read: (dex: string) => Promise<T>): Promise<PromiseSettledResult<T>[]>;
17
+ /** Hyperliquid `perpDexs` → HIP-3 dex names (the first entry, null, is the native dex). */
18
+ export declare function hlDexNamesOf(j: unknown): string[];
19
+ /** Account index of a Lighter account object. */
20
+ export declare function lighterAccountIndexOf(a: Record<string, unknown>): number;
21
+ /** The accounts of an address a read covers: every one, or the one of `accountIndex` (not one of the address's → INVALID_PARAMS). */
22
+ export declare function chooseLighterAccounts(venue: LighterVenueId, accounts: Array<Record<string, unknown>>, accountIndex: number | undefined): Array<Record<string, unknown>>;
23
+ /**
24
+ * Hyperliquid: info `clearinghouseState` {user} for the native perp dex plus {user, dex} for every
25
+ * HIP-3 dex of `perpDexs` (one request each, paced: hlDexFanOut). Native rows carry `dex: null`, HIP-3 rows their dex
26
+ * and the prefixed coin ("xyz:GOLD"), as the HIP-3 market rows. A dex (or the dex list) that
27
+ * fails is an `errors` entry ("hip3:<dex>") — its rows are unknown, never silently dropped; the
28
+ * native read failing fails the venue.
29
+ */
30
+ export declare function hyperliquidPositionsByAddress(net: Network, address: string): Promise<PublicPositionsResult>;
31
+ /**
32
+ * Lighter / Lighter RH: `GET /account?by=l1_address` lists every account of the address
33
+ * (sub-accounts); each account's rows are tagged with its `accountIndex`. The venue keeps a
34
+ * zero-size row for every market an account ever touched: those are dropped by the mapper.
35
+ * `accountIndex` narrows to one account (not one of the address's → INVALID_PARAMS).
36
+ */
37
+ export declare function lighterPositionsByAddress(venue: LighterVenueId, net: Network, address: string, opts: PublicPositionsOptions): Promise<PublicPositionsResult>;
38
+ /**
39
+ * Pacifica: `GET /positions?account=` marked with `/info/prices`; unresolved leverage from the
40
+ * account's public `/account/settings`, then the market max (`/info`). The address is validated
41
+ * as base58 32 bytes by the caller (an EVM address would be answered `success: true, []`).
42
+ */
43
+ export declare function pacificaPositionsByAddress(net: Network, address: string): Promise<PublicPositionsResult>;
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Open positions of ANY account by its public address, keyless (`perp account positions
3
+ * --address`, MCP get_positions_by_address). Wired into the registry through
4
+ * VenuePublicData.fetchPositionsByAddress (+ addressKind); the rows come from the same mappers as
5
+ * the keyed adapters' getPositions (hlPositionRows / lighterPositionRows / pacificaPositionRows),
6
+ * with plain symbols. Read-only GETs / info POSTs; nothing is signed, no key is loaded.
7
+ */
8
+ import { classifyError, PerpError } from "../errors.js";
9
+ import { getVenue } from "./venue.js";
10
+ import { acquireRate } from "./rate-limiter.js";
11
+ function errorOf(e, venue) {
12
+ const s = classifyError(e, venue);
13
+ return { code: s.code, message: s.message };
14
+ }
15
+ /** A venue read failure as a typed error (the public HTTP helpers throw plain Errors on HTTP status). */
16
+ async function typed(venue, read) {
17
+ try {
18
+ return await read();
19
+ }
20
+ catch (e) {
21
+ if (e instanceof PerpError)
22
+ throw e;
23
+ const s = classifyError(e, venue);
24
+ throw new PerpError(s.code === "UNKNOWN" ? "EXCHANGE_ERROR" : s.code, s.message, { exchange: venue });
25
+ }
26
+ }
27
+ /** Requests of a HIP-3 per-dex fan-out in flight at once. */
28
+ export const HL_DEX_FANOUT_CONCURRENCY = 4;
29
+ /** Burst of the fan-out's token bucket, in HL weight units (clearinghouseState weighs 2 → 10 requests). */
30
+ export const HL_DEX_FANOUT_BURST = 20;
31
+ /**
32
+ * One `read` per HIP-3 dex, paced: at most HL_DEX_FANOUT_CONCURRENCY in flight, and every request
33
+ * takes its weight from a token bucket refilled at the venue's documented rate
34
+ * (hyperliquidVenue.rateLimit: 1200 weight / min = 20 / s; clearinghouseState weight 2) with a
35
+ * small burst (HL_DEX_FANOUT_BURST) instead of the full minute's budget. HL mainnet lists ~10
36
+ * dexes (no wait); testnet ~272, where an unpaced burst got HTTP 429 on 62 of 272 reads at
37
+ * concurrency 4 — paced, the fan-out takes ~25 s instead. A failed read stays that dex's
38
+ * (settled per dex); nothing is retried.
39
+ */
40
+ export async function hlDexFanOut(net, dexes, read) {
41
+ const venuePolicy = getVenue("hyperliquid").rateLimit;
42
+ const policy = { capacity: HL_DEX_FANOUT_BURST, refillPerSec: venuePolicy.refillPerSec, weights: venuePolicy.weights };
43
+ const out = new Array(dexes.length);
44
+ let next = 0;
45
+ const worker = async () => {
46
+ while (next < dexes.length) {
47
+ const i = next++;
48
+ try {
49
+ await acquireRate(`hyperliquid:hip3-fanout:${net}`, policy, "clearinghouseState");
50
+ out[i] = { status: "fulfilled", value: await read(dexes[i]) };
51
+ }
52
+ catch (e) {
53
+ out[i] = { status: "rejected", reason: e };
54
+ }
55
+ }
56
+ };
57
+ await Promise.all(Array.from({ length: Math.min(HL_DEX_FANOUT_CONCURRENCY, dexes.length) }, worker));
58
+ return out;
59
+ }
60
+ /** Hyperliquid `perpDexs` → HIP-3 dex names (the first entry, null, is the native dex). */
61
+ export function hlDexNamesOf(j) {
62
+ if (!Array.isArray(j))
63
+ throw new PerpError("EXCHANGE_ERROR", "hyperliquid perpDexs: expected an array", { exchange: "hyperliquid" });
64
+ return j.flatMap(d => (d !== null && typeof d === "object" && typeof d.name === "string" ? [d.name] : []));
65
+ }
66
+ /** Account index of a Lighter account object. */
67
+ export function lighterAccountIndexOf(a) {
68
+ return Number(a.account_index ?? a.index);
69
+ }
70
+ /** The accounts of an address a read covers: every one, or the one of `accountIndex` (not one of the address's → INVALID_PARAMS). */
71
+ export function chooseLighterAccounts(venue, accounts, accountIndex) {
72
+ if (accountIndex === undefined)
73
+ return accounts;
74
+ const chosen = accounts.filter(a => lighterAccountIndexOf(a) === accountIndex);
75
+ if (chosen.length === 0) {
76
+ throw new PerpError("INVALID_PARAMS", `${venue}: account index ${accountIndex} is not an account of this address (accounts: ${accounts.map(lighterAccountIndexOf).join(", ")})`, {
77
+ exchange: venue, remediation: `Use one of: ${accounts.map(lighterAccountIndexOf).join(", ")} — or drop --account-index for every account`,
78
+ });
79
+ }
80
+ return chosen;
81
+ }
82
+ /**
83
+ * Hyperliquid: info `clearinghouseState` {user} for the native perp dex plus {user, dex} for every
84
+ * HIP-3 dex of `perpDexs` (one request each, paced: hlDexFanOut). Native rows carry `dex: null`, HIP-3 rows their dex
85
+ * and the prefixed coin ("xyz:GOLD"), as the HIP-3 market rows. A dex (or the dex list) that
86
+ * fails is an `errors` entry ("hip3:<dex>") — its rows are unknown, never silently dropped; the
87
+ * native read failing fails the venue.
88
+ */
89
+ export async function hyperliquidPositionsByAddress(net, address) {
90
+ const { legacyPublicJson } = await import("../funding/legacy-public.js");
91
+ const { legacyPublicBaseUrl } = await import("../api/public/urls.js");
92
+ const { hlPositionRows } = await import("./hyperliquid.js");
93
+ const url = `${legacyPublicBaseUrl("hyperliquid", net)}/info`;
94
+ const post = (body, label) => legacyPublicJson("hyperliquid", url, label, {
95
+ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
96
+ });
97
+ const errors = {};
98
+ const [native, dexList] = await Promise.all([
99
+ typed("hyperliquid", () => post({ type: "clearinghouseState", user: address }, "info clearinghouseState")),
100
+ post({ type: "perpDexs" }, "info perpDexs").then(hlDexNamesOf, (e) => { errors.hip3 = errorOf(e, "hyperliquid"); return []; }),
101
+ ]);
102
+ const positions = hlPositionRows(native, coin => coin).map(r => ({ ...r, dex: null }));
103
+ const perDex = await hlDexFanOut(net, dexList, dex => post({ type: "clearinghouseState", user: address, dex }, `info clearinghouseState dex ${dex}`));
104
+ perDex.forEach((res, i) => {
105
+ const dex = dexList[i];
106
+ if (res.status === "rejected") {
107
+ errors[`hip3:${dex}`] = errorOf(res.reason, "hyperliquid");
108
+ return;
109
+ }
110
+ try {
111
+ positions.push(...hlPositionRows(res.value, coin => (coin.includes(":") ? coin : `${dex}:${coin}`)).map(r => ({ ...r, dex })));
112
+ }
113
+ catch (e) {
114
+ errors[`hip3:${dex}`] = errorOf(e, "hyperliquid");
115
+ }
116
+ });
117
+ return { positions, errors, notes: [] };
118
+ }
119
+ /**
120
+ * Lighter / Lighter RH: `GET /account?by=l1_address` lists every account of the address
121
+ * (sub-accounts); each account's rows are tagged with its `accountIndex`. The venue keeps a
122
+ * zero-size row for every market an account ever touched: those are dropped by the mapper.
123
+ * `accountIndex` narrows to one account (not one of the address's → INVALID_PARAMS).
124
+ */
125
+ export async function lighterPositionsByAddress(venue, net, address, opts) {
126
+ const { fetchLighterAccountsByL1, fetchLighterPriceDecimals } = await import("./lighter-public.js");
127
+ const { lighterPositionRows } = await import("./lighter.js");
128
+ const accounts = await fetchLighterAccountsByL1(venue, net, address);
129
+ if (accounts === null || accounts.length === 0)
130
+ return { positions: [], errors: {}, notes: [`no ${venue === "lighter" ? "Lighter" : "Lighter RH"} account for this address`] };
131
+ const chosen = chooseLighterAccounts(venue, accounts, opts.accountIndex);
132
+ const open = (a) => (Array.isArray(a.positions) ? a.positions : []).some(p => Number(p.position) !== 0);
133
+ // Price decimals only when there is a mark to format (an account without positions costs one read).
134
+ const decimals = chosen.some(open) ? await fetchLighterPriceDecimals(venue, net) : new Map();
135
+ const positions = chosen.flatMap(a => lighterPositionRows(a.positions, venue, sym => decimals.get(sym.toUpperCase())).map(r => ({ ...r, accountIndex: lighterAccountIndexOf(a) })));
136
+ return { positions, errors: {}, notes: [] };
137
+ }
138
+ /**
139
+ * Pacifica: `GET /positions?account=` marked with `/info/prices`; unresolved leverage from the
140
+ * account's public `/account/settings`, then the market max (`/info`). The address is validated
141
+ * as base58 32 bytes by the caller (an EVM address would be answered `success: true, []`).
142
+ */
143
+ export async function pacificaPositionsByAddress(net, address) {
144
+ const { PacificaClient } = await import("../pacifica/client.js");
145
+ const { pacificaPositionRows } = await import("./pacifica.js");
146
+ const { legacyPublicData } = await import("../funding/legacy-public.js");
147
+ const client = new PacificaClient({ network: net });
148
+ const [rows, prices] = await typed("pacifica", () => Promise.all([client.getPositions(address), client.getPrices()]));
149
+ const positions = await pacificaPositionRows(rows, prices, {
150
+ accountSettings: () => client.getAccountSettings(address),
151
+ maxLeverage: async () => new Map((await legacyPublicData("pacifica").fetchMarkets(net)).map(m => [m.symbol, m.maxLeverage])),
152
+ });
153
+ return { positions, errors: {}, notes: [] };
154
+ }
@@ -41,7 +41,7 @@
41
41
  import { PerpError } from "../../errors.js";
42
42
  import type { WithdrawGate } from "../../settings.js";
43
43
  import { type BuilderFeeGate } from "../builder-fee.js";
44
- import type { ExchangeAdapter, ExchangeBalance, ExchangeFundingPayment, ExchangeKline, ExchangeMarketInfo, ExchangeOrder, ExchangePosition, ExchangeTrade, ExchangePublicTrade } from "../interface.js";
44
+ import type { ExchangeAdapter, ExchangeBalance, ExchangeFundingPayment, ExchangeKline, ExchangeMarketInfo, ExchangeOrder, ExchangePosition, ExchangeTrade, ExchangePublicTrade, FundingHistoryRow } from "../interface.js";
45
45
  import type { IocOutcome } from "../ioc-outcome.js";
46
46
  import type { ResolvedSlippage } from "../slippage.js";
47
47
  import type { TradingV2 } from "../trading-v2.js";
@@ -77,6 +77,22 @@ export declare function qfexClientOrderId(): string;
77
77
  * quantity) are quantized to the instrument tick / lot instead (fromWireNumber).
78
78
  */
79
79
  export declare function plainNumber(n: unknown, what: string): string;
80
+ /**
81
+ * A QFEX `market list` / `market info` row: the legacy shape plus listing state and quote currency.
82
+ * Prices are in `quoteAsset`; `volume24h` is USD (null for a non-USD quote — no FX rate published),
83
+ * `volume24hQuote` the venue's 24h notional in `quoteAsset`.
84
+ */
85
+ export type QfexMarketRow = ExchangeMarketInfo & {
86
+ tradable: boolean;
87
+ status: QfexListedStatus;
88
+ quoteAsset: string;
89
+ volume24hQuote: string | null;
90
+ };
91
+ /**
92
+ * Why a LISTED (ACTIVE) market is or is not tradable through perp-cli: "active" = tradable;
93
+ * "nonUsdQuote" = quoted in KRW / JPY / CNY / SEK / HKD — readable, never ordered (USD sizing).
94
+ */
95
+ export type QfexListedStatus = "active" | "nonUsdQuote";
80
96
  export interface QfexAddOrderWire {
81
97
  symbol: string;
82
98
  side: "BUY" | "SELL";
@@ -143,6 +159,12 @@ export declare class QfexAdapter implements ExchangeAdapter, TradingV2 {
143
159
  private session;
144
160
  private instrumentMap;
145
161
  private instrumentOf;
162
+ /**
163
+ * Instrument of a per-symbol read (book / funding / kline / info). Unknown → SYMBOL_NOT_FOUND; a
164
+ * listed market whose /refdata status is not ACTIVE (DELISTED, INACTIVE) → SYMBOL_NOT_FOUND with
165
+ * that status: it is not a live market. A non-USD ACTIVE market is readable (only orders refuse it).
166
+ */
167
+ private readableInstrument;
146
168
  /** Refuse a market perp-cli must not send orders to, and an order type / TIF the symbol does not list. */
147
169
  private requireOrderable;
148
170
  /** Mark of ONE symbol from a single-symbol MDS subscription; PRICE_STALE when absent or older than QFEX_MARK_MAX_AGE_MS. */
@@ -261,19 +283,40 @@ export declare class QfexAdapter implements ExchangeAdapter, TradingV2 {
261
283
  private settleUnknownStopCancel;
262
284
  private settledByHistory;
263
285
  applyLeverage(symbol: string, leverage: number, marginMode: "cross" | "isolated" | null): Promise<LeverageResult>;
264
- getMarkets(): Promise<ExchangeMarketInfo[]>;
286
+ /**
287
+ * Legacy rows plus `tradable` (ACTIVE and USD-quoted, qfexTradable), the /refdata `status`
288
+ * (ACTIVE / INACTIVE / DELISTED) and `quoteAsset` (prices of KRW / JPY / CNY / SEK / HKD markets
289
+ * are in that currency, not USD).
290
+ */
291
+ getMarkets(): Promise<QfexMarketRow[]>;
292
+ private marketRow;
293
+ /**
294
+ * `market info` of one ACTIVE market. fundingRate is the CURRENT /md/contracts `funding_rate`
295
+ * (the rate `market funding` / `arb scan --rates --include-unverified` show), labelled fundingVerified false: QFEX funding
296
+ * is unverified (Q4, display only). Market lists keep fundingRate null so no scan / bot reads it.
297
+ */
298
+ getMarketInfo(symbol: string): Promise<QfexMarketRow & {
299
+ fundingVerified: false;
300
+ fundingNote: string;
301
+ marketClosed: boolean | null;
302
+ }>;
303
+ /** `quoteAsset`: level prices are in the market's quote currency (KRW for SAMSUNG-KRW). */
265
304
  getOrderbook(symbol: string): Promise<{
266
305
  bids: [string, string][];
267
306
  asks: [string, string][];
307
+ quoteAsset: string;
268
308
  }>;
269
309
  getRecentTrades(): Promise<ExchangePublicTrade[]>;
270
- /** Hourly funding windows (GET /funding/{symbol}, intervalMinutes 60). `limit` = hours back (default 24). */
271
- getFundingHistory(symbol: string, limit?: number): Promise<{
272
- time: number;
273
- rate: string;
274
- price: string | null;
275
- }[]>;
276
- getKlines(symbol: string, interval: string, startTime: number, endTime: number): Promise<ExchangeKline[]>;
310
+ /**
311
+ * Hourly funding windows (GET /funding/{symbol}, intervalMinutes 60). `limit` = hours back (default 24).
312
+ * A window outside the market's funding hours (qfexFundingWindowClosed) is `rate: null,
313
+ * marketClosed: true` — the venue prints 0 there, which is "no funding", not a 0 rate.
314
+ */
315
+ getFundingHistory(symbol: string, limit?: number): Promise<FundingHistoryRow[]>;
316
+ /** Rows carry `quoteAsset`: OHLC prices are in the market's quote currency (KRW for SAMSUNG-KRW). */
317
+ getKlines(symbol: string, interval: string, startTime: number, endTime: number): Promise<Array<ExchangeKline & {
318
+ quoteAsset: string;
319
+ }>>;
277
320
  private userPositions;
278
321
  /** Equity is the venue's own figure (GET /user/subaccounts/equity) — never recomputed from balance fields. */
279
322
  getBalance(): Promise<ExchangeBalance>;
@@ -50,7 +50,7 @@ import { QFEX_BANKER_NETWORK, QFEX_ENDPOINTS, QFEX_WITHDRAW_DISABLED, QFEX_WITHD
50
50
  import { loadQfexCredentials, QFEX_IMPORT_REMEDIATION } from "./agent.js";
51
51
  import { QfexClient } from "./client.js";
52
52
  import { qfexStatusClass } from "./errors.js";
53
- import { fetchQfexInstruments, qfexPlainSymbol, qfexPublicData, qfexTradable, qfexVenueSymbol, } from "./public.js";
53
+ import { buildQfexFundingRates, buildQfexMarkets, fetchQfexInstruments, fetchQfexMarketsWithInstruments, qfexFundingWindowClosed, qfexIndexPrices, qfexListedStatus, qfexPlainSymbol, qfexPublicData, qfexTradable, qfexVenueSymbol, } from "./public.js";
54
54
  import { QFEX_AUTOMATION_VERIFIED, QFEX_MODIFY_KEEPS_CLIENT_ID, QFEX_REDUCE_ONLY_WIRE, QFEX_STOP_CLIENT_ID_ECHO, decimalsEqual, fromWireNumber, isZeroDecimal, qfexAutomationGate, qfexReduceOnlyGate, reduceOnlyWireValue, subDecimals, sumDecimals, toWireNumber, weightedAveragePrice, } from "./signing.js";
55
55
  import { QFEX_IDLE_CLOSE_MS, QFEX_MDS_TIMEOUT_MS, QFEX_TIMEOUTS, fetchQfexMarketStats, qfexPendingRemediation, qfexTradeSession, } from "./ws-transport.js";
56
56
  /** A mark price entry older than this (its own venue `time`) is not used as an order reference. */
@@ -283,6 +283,20 @@ export class QfexAdapter {
283
283
  }
284
284
  return i;
285
285
  }
286
+ /**
287
+ * Instrument of a per-symbol read (book / funding / kline / info). Unknown → SYMBOL_NOT_FOUND; a
288
+ * listed market whose /refdata status is not ACTIVE (DELISTED, INACTIVE) → SYMBOL_NOT_FOUND with
289
+ * that status: it is not a live market. A non-USD ACTIVE market is readable (only orders refuse it).
290
+ */
291
+ async readableInstrument(symbol) {
292
+ const i = await this.instrumentOf(symbol);
293
+ if (i.status !== "ACTIVE") {
294
+ throw new PerpError("SYMBOL_NOT_FOUND", `QFEX ${i.venueSymbol} is ${i.status.toLowerCase()} (status ${i.status}) — not a live market`, {
295
+ exchange: "qfex", status: i.status, remediation: "List tradable markets with: perp market list -e qfex",
296
+ });
297
+ }
298
+ return i;
299
+ }
286
300
  /** Refuse a market perp-cli must not send orders to, and an order type / TIF the symbol does not list. */
287
301
  requireOrderable(i, orderType, tif) {
288
302
  if (i.status !== "ACTIVE") {
@@ -1111,26 +1125,70 @@ export class QfexAdapter {
1111
1125
  return { venue: "qfex", symbol: i.symbol, leverage: raw.leverage, marginMode: "cross", venueRaw: raw };
1112
1126
  }
1113
1127
  // ── Legacy ExchangeAdapter: market data ──
1128
+ /**
1129
+ * Legacy rows plus `tradable` (ACTIVE and USD-quoted, qfexTradable), the /refdata `status`
1130
+ * (ACTIVE / INACTIVE / DELISTED) and `quoteAsset` (prices of KRW / JPY / CNY / SEK / HKD markets
1131
+ * are in that currency, not USD).
1132
+ */
1114
1133
  async getMarkets() {
1115
- return (await this.getVenueMarkets()).map(toLegacyMarketInfo);
1134
+ const { markets, instruments, volumeNotional } = await fetchQfexMarketsWithInstruments(this.network);
1135
+ const bySymbol = new Map(instruments.map(i => [i.venueSymbol, i]));
1136
+ return markets.map(m => this.marketRow(m, bySymbol.get(m.venueSymbol), volumeNotional));
1116
1137
  }
1138
+ marketRow(m, i, volumeNotional) {
1139
+ if (i === undefined)
1140
+ throw new PerpError("EXCHANGE_ERROR", `QFEX ${m.venueSymbol} is missing from /refdata`, { exchange: "qfex" });
1141
+ return {
1142
+ ...toLegacyMarketInfo(m), tradable: m.tradable, status: qfexListedStatus(i), quoteAsset: i.quoteAsset,
1143
+ volume24hQuote: volumeNotional.get(i.venueSymbol) ?? null,
1144
+ };
1145
+ }
1146
+ /**
1147
+ * `market info` of one ACTIVE market. fundingRate is the CURRENT /md/contracts `funding_rate`
1148
+ * (the rate `market funding` / `arb scan --rates --include-unverified` show), labelled fundingVerified false: QFEX funding
1149
+ * is unverified (Q4, display only). Market lists keep fundingRate null so no scan / bot reads it.
1150
+ */
1151
+ async getMarketInfo(symbol) {
1152
+ const i = await this.readableInstrument(symbol);
1153
+ const snap = await fetchQfexMarketStats(QFEX_ENDPOINTS[this.network].mdsWs, i.venueSymbol, QFEX_MDS_TIMEOUT_MS);
1154
+ const contracts = await this.client.publicGet("/md/contracts", null);
1155
+ const { markets } = buildQfexMarkets([i], snap, Date.now(), qfexIndexPrices(listOf(contracts.data, "/md/contracts data")));
1156
+ if (markets.length === 0)
1157
+ throw new PerpError("PRICE_STALE", `QFEX ${i.venueSymbol}: no mark price in the market_stats snapshot`, { exchange: "qfex" });
1158
+ const { rates } = buildQfexFundingRates([i], listOf(contracts.data, "/md/contracts data").filter(c => c.ticker_id === i.venueSymbol), snap.marks, Date.now());
1159
+ return {
1160
+ ...this.marketRow(markets[0], i, snap.volumeNotional),
1161
+ fundingRate: rates.length === 0 ? null : rates[0].rate,
1162
+ // true = the window in progress is outside the market's funding hours (fundingRate null).
1163
+ marketClosed: rates.length === 0 ? null : rates[0].marketClosed === true,
1164
+ fundingVerified: false,
1165
+ fundingNote: "QFEX funding rate UNVERIFIED (not confirmed against settled payments) — display only, never a basis for trading",
1166
+ };
1167
+ }
1168
+ /** `quoteAsset`: level prices are in the market's quote currency (KRW for SAMSUNG-KRW). */
1117
1169
  async getOrderbook(symbol) {
1118
1170
  // Resolved against refdata first: an unknown symbol is SYMBOL_NOT_FOUND, not the venue's
1119
1171
  // HTTP 404 surfacing as a retryable EXCHANGE_ERROR.
1120
- const venueSymbol = (await this.instrumentOf(symbol)).venueSymbol;
1172
+ const instrument = await this.readableInstrument(symbol);
1173
+ const venueSymbol = instrument.venueSymbol;
1121
1174
  const book = await this.client.publicGet(`/md/orderbook/${encodeURIComponent(venueSymbol)}`, null);
1122
1175
  // The venue lists empty price levels (quantity "0"); they are not part of the book.
1123
1176
  const side = (v, what) => listOf(v, what)
1124
1177
  .filter(l => Array.isArray(l) && typeof l[0] === "string" && typeof l[1] === "string" && Number(l[1]) > 0)
1125
1178
  .map(l => [l[0], l[1]]);
1126
- return { bids: side(book.bids, "orderbook bids"), asks: side(book.asks, "orderbook asks") };
1179
+ return { bids: side(book.bids, "orderbook bids"), asks: side(book.asks, "orderbook asks"), quoteAsset: instrument.quoteAsset };
1127
1180
  }
1128
1181
  async getRecentTrades() {
1129
1182
  throw unsupported("qfex", "recentTrades", "the documented public trade endpoint (GET /public/trades) answers 404", "Use the order book: perp market book <symbol> -e qfex");
1130
1183
  }
1131
- /** Hourly funding windows (GET /funding/{symbol}, intervalMinutes 60). `limit` = hours back (default 24). */
1184
+ /**
1185
+ * Hourly funding windows (GET /funding/{symbol}, intervalMinutes 60). `limit` = hours back (default 24).
1186
+ * A window outside the market's funding hours (qfexFundingWindowClosed) is `rate: null,
1187
+ * marketClosed: true` — the venue prints 0 there, which is "no funding", not a 0 rate.
1188
+ */
1132
1189
  async getFundingHistory(symbol, limit) {
1133
- const venueSymbol = (await this.instrumentOf(symbol)).venueSymbol;
1190
+ const instrument = await this.readableInstrument(symbol);
1191
+ const venueSymbol = instrument.venueSymbol;
1134
1192
  const hours = limit === undefined ? 24 : limit;
1135
1193
  if (!Number.isInteger(hours) || hours < 1)
1136
1194
  throw new PerpError("INVALID_PARAMS", `funding history limit must be a positive integer of hours (got ${hours})`);
@@ -1145,16 +1203,20 @@ export class QfexAdapter {
1145
1203
  const time = typeof r.windowStart === "string" ? Date.parse(r.windowStart) : NaN;
1146
1204
  if (!Number.isFinite(time))
1147
1205
  throw new PerpError("EXCHANGE_ERROR", `QFEX funding history: invalid windowStart ${JSON.stringify(r.windowStart)}`);
1148
- out.push({ time, rate: plainNumber(r.rate, "funding rate"), price: null });
1206
+ const rate = plainNumber(r.rate, "funding rate");
1207
+ const marketClosed = qfexFundingWindowClosed(instrument.marketHours, time, Number(rate));
1208
+ out.push({ time, rate: marketClosed ? null : rate, price: null, intervalHours: 1, rateKind: marketClosed ? null : "lastSettled", marketClosed });
1149
1209
  }
1150
1210
  return out.sort((a, b) => a.time - b.time);
1151
1211
  }
1212
+ /** Rows carry `quoteAsset`: OHLC prices are in the market's quote currency (KRW for SAMSUNG-KRW). */
1152
1213
  async getKlines(symbol, interval, startTime, endTime) {
1153
1214
  const resolution = KLINE_RESOLUTION[interval.trim()];
1154
1215
  if (resolution === undefined) {
1155
1216
  throw new PerpError("INVALID_PARAMS", `Unsupported kline interval "${interval}" on QFEX`, { remediation: `Use one of: ${Object.keys(KLINE_RESOLUTION).join(", ")}` });
1156
1217
  }
1157
- const venueSymbol = (await this.instrumentOf(symbol)).venueSymbol;
1218
+ const instrument = await this.readableInstrument(symbol);
1219
+ const venueSymbol = instrument.venueSymbol;
1158
1220
  const body = await this.client.publicGet(`/candles/${encodeURIComponent(venueSymbol)}`, {
1159
1221
  resolution, fromISO: new Date(startTime).toISOString(), toISO: new Date(endTime).toISOString(),
1160
1222
  });
@@ -1162,7 +1224,10 @@ export class QfexAdapter {
1162
1224
  const time = Date.parse(c.startedAt);
1163
1225
  if (!Number.isFinite(time))
1164
1226
  throw new PerpError("EXCHANGE_ERROR", `QFEX candles: invalid startedAt "${c.startedAt}"`);
1165
- return { time, open: c.open, high: c.high, low: c.low, close: c.close, ...qfexCandleVolumes(c), trades: null };
1227
+ const v = qfexCandleVolumes(c);
1228
+ // `usdVolume` is Σ price × qty in the QUOTE currency (live 2026-10-19: SAMSUNG-KRW 1m candles
1229
+ // equal the KRW trade tape exactly); quoteVolume is USD → null for a non-USD quote.
1230
+ return { time, open: c.open, high: c.high, low: c.low, close: c.close, ...v, quoteVolume: instrument.quoteAsset === "USD" ? v.quoteVolume : null, trades: null, quoteAsset: instrument.quoteAsset };
1166
1231
  }).sort((a, b) => a.time - b.time);
1167
1232
  }
1168
1233
  // ── Legacy ExchangeAdapter: account ──