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
@@ -30,9 +30,23 @@ export const EXTENDED_KLINE_INTERVALS = {
30
30
  "12h": { wire: "PT12H", ms: 43_200_000 },
31
31
  "1d": { wire: "PT24H", ms: 86_400_000 },
32
32
  "1w": { wire: "P7D", ms: 604_800_000 },
33
+ // P30D answers calendar-month candles (live 2026-10-19: opens on the 1st, 00:00 UTC); ms is
34
+ // the nominal 30 d used only to size requests.
35
+ "1M": { wire: "P30D", ms: 2_592_000_000 },
33
36
  };
34
- /** Documented per-request maximum of the candles and funding-history endpoints. */
37
+ /** Documented per-request maximum of the candles and funding-history endpoints (accepted `limit`). */
35
38
  export const EXTENDED_MAX_RECORDS = 10_000;
39
+ /**
40
+ * Real server caps, measured live 2026-10-19 (BTC-USD; no pagination field, no error): candles
41
+ * return at most 2800 rows on every interval with that much history (PT1M … PT4H: 2800; PT8H 2445,
42
+ * PT12H 1631, PT24H 816, P7D 118, P30D 28 = the whole history since 2024-07), funding history at
43
+ * most 1000 (the newest). Both page backwards with `endTime` = before the oldest row and stop only
44
+ * on an empty page or at the window start — a short page is never taken as the end.
45
+ */
46
+ export const EXTENDED_CANDLES_PAGE_CAP = 2800;
47
+ export const EXTENDED_FUNDING_PAGE_CAP = 1000;
48
+ const EXTENDED_MAX_PAGES = 50;
49
+ export const EXTENDED_TRADES_CAP = 50;
36
50
  const DEC = /^-?\d+(\.\d+)?$/;
37
51
  function dec(v, what) {
38
52
  if (typeof v !== "string" || !DEC.test(v))
@@ -92,6 +106,10 @@ export class ExtendedPublicAdapter {
92
106
  return { bids: side(data.bid, "orderbook bid"), asks: side(data.ask, "orderbook ask") };
93
107
  }
94
108
  async getRecentTrades(symbol, limit = 50) {
109
+ // The endpoint takes no limit and answers the last 50 trades (live 2026-10-19, `limit=500` → 50).
110
+ if (!Number.isInteger(limit) || limit < 1 || limit > EXTENDED_TRADES_CAP) {
111
+ throw new PerpError("INVALID_PARAMS", `Extended: trades limit must be 1..${EXTENDED_TRADES_CAP} (got ${limit})`, { remediation: `Extended publishes only the last ${EXTENDED_TRADES_CAP} trades` });
112
+ }
95
113
  const m = await this.market(symbol);
96
114
  const body = await this.client.get(`/info/markets/${encodeURIComponent(m.name)}/trades`);
97
115
  if (!Array.isArray(body.data))
@@ -106,7 +124,10 @@ export class ExtendedPublicAdapter {
106
124
  // Newest first, like every other venue's tape.
107
125
  return trades.sort((a, b) => b.time - a.time).slice(0, limit);
108
126
  }
109
- /** Applied (settled) hourly rates, newest first — fraction per hour; no oracle price published. */
127
+ /**
128
+ * Applied (settled) hourly rates, newest first — fraction per hour; no oracle price published.
129
+ * Paged backwards in pages of EXTENDED_FUNDING_PAGE_CAP until `limit` rows or the window start.
130
+ */
110
131
  async getFundingHistory(symbol, limit = 10) {
111
132
  if (!Number.isInteger(limit) || limit < 1 || limit > EXTENDED_MAX_RECORDS) {
112
133
  throw new PerpError("INVALID_PARAMS", `Extended: funding history limit must be 1..${EXTENDED_MAX_RECORDS} (got ${limit})`);
@@ -115,20 +136,37 @@ export class ExtendedPublicAdapter {
115
136
  const end = Date.now();
116
137
  // One record per hour; two spare hours cover the in-progress hour and clock skew.
117
138
  const start = end - (limit + 2) * 3_600_000;
118
- const body = await this.client.get(`/info/${encodeURIComponent(m.name)}/funding`, [
119
- ["startTime", String(start)], ["endTime", String(end)], ["limit", String(limit)],
120
- ]);
121
- if (!Array.isArray(body.data))
122
- throw extendedShapeError(`funding history of ${m.name} is not a list`);
123
- return body.data
124
- .map(r => {
125
- const x = r;
126
- const rate = dec(x.f, "funding f");
127
- return { time: ms(x.T, "funding T"), rate, price: null, rateKind: "lastSettled", intervalHours: 1, annualizedPct: annualizeHourlyRate(Number(rate)) };
128
- })
129
- .sort((a, b) => b.time - a.time)
130
- .slice(0, limit);
139
+ const byTime = new Map();
140
+ let cursor = end;
141
+ for (let page = 0; byTime.size < limit; page++) {
142
+ if (page >= EXTENDED_MAX_PAGES)
143
+ throw new PerpError("EXCHANGE_ERROR", `Extended: funding history of ${m.name} did not end after ${EXTENDED_MAX_PAGES} pages`);
144
+ const want = Math.min(EXTENDED_FUNDING_PAGE_CAP, limit - byTime.size);
145
+ const body = await this.client.get(`/info/${encodeURIComponent(m.name)}/funding`, [
146
+ ["startTime", String(start)], ["endTime", String(cursor)], ["limit", String(want)],
147
+ ]);
148
+ if (!Array.isArray(body.data))
149
+ throw extendedShapeError(`funding history of ${m.name} is not a list`);
150
+ const rows = body.data.map(r => {
151
+ const x = r;
152
+ const rate = dec(x.f, "funding f");
153
+ return { time: ms(x.T, "funding T"), rate, price: null, rateKind: "lastSettled", intervalHours: 1, annualizedPct: annualizeHourlyRate(Number(rate)) };
154
+ });
155
+ for (const r of rows)
156
+ byTime.set(r.time, r);
157
+ // Only an empty page ends the window (a short page is not trusted as the end).
158
+ if (rows.length === 0)
159
+ break;
160
+ const oldest = Math.min(...rows.map(r => r.time));
161
+ if (oldest <= start)
162
+ break;
163
+ if (oldest >= cursor)
164
+ throw extendedShapeError(`funding history of ${m.name} did not move back past ${new Date(cursor).toISOString()}`);
165
+ cursor = oldest - 1;
166
+ }
167
+ return [...byTime.values()].sort((a, b) => b.time - a.time).slice(0, limit);
131
168
  }
169
+ /** Candles paged backwards from `endTime` in pages of EXTENDED_CANDLES_PAGE_CAP (newest first on the wire). */
132
170
  async getKlines(symbol, interval, startTime, endTime) {
133
171
  const iv = Object.prototype.hasOwnProperty.call(EXTENDED_KLINE_INTERVALS, interval) ? EXTENDED_KLINE_INTERVALS[interval] : undefined;
134
172
  if (iv === undefined) {
@@ -138,26 +176,50 @@ export class ExtendedPublicAdapter {
138
176
  }
139
177
  if (!(endTime > startTime))
140
178
  throw new PerpError("INVALID_PARAMS", "Extended: candle end time must be after the start time");
179
+ const bars = Math.ceil((endTime - startTime) / iv.ms) + 1;
180
+ const pages = Math.ceil(bars / EXTENDED_CANDLES_PAGE_CAP);
181
+ if (pages > EXTENDED_MAX_PAGES) {
182
+ throw new PerpError("INVALID_PARAMS", `Extended: ${interval} candles over this range need ${pages} requests (max ${EXTENDED_MAX_PAGES})`, {
183
+ remediation: "Narrow --start / --end or use a longer interval",
184
+ });
185
+ }
141
186
  const m = await this.market(symbol);
142
- const limit = Math.min(EXTENDED_MAX_RECORDS, Math.ceil((endTime - startTime) / iv.ms) + 1);
143
- const body = await this.client.get(`/info/candles/${encodeURIComponent(m.name)}/trades`, [
144
- ["interval", iv.wire], ["limit", String(limit)], ["endTime", String(endTime)],
145
- ]);
146
- if (!Array.isArray(body.data))
147
- throw extendedShapeError(`candles of ${m.name} is not a list`);
148
- return body.data
149
- .map(c => {
150
- const x = c;
151
- return {
152
- time: ms(x.T, "candle T"),
153
- open: dec(x.o, "candle o"), high: dec(x.h, "candle h"), low: dec(x.l, "candle l"), close: dec(x.c, "candle c"),
154
- volume: dec(x.v, "candle v"),
155
- quoteVolume: null,
156
- trades: null,
157
- };
158
- })
159
- .filter(k => k.time >= startTime && k.time <= endTime)
160
- .sort((a, b) => a.time - b.time);
187
+ const byTime = new Map();
188
+ // `endTime` is exclusive on the wire (a bar opening exactly at it is not returned): +1 ms makes
189
+ // `--end` inclusive like every other venue.
190
+ let cursor = endTime + 1;
191
+ for (let page = 0; page < EXTENDED_MAX_PAGES + 1; page++) {
192
+ const want = Math.min(EXTENDED_CANDLES_PAGE_CAP, Math.ceil((cursor - startTime) / iv.ms) + 1);
193
+ const body = await this.client.get(`/info/candles/${encodeURIComponent(m.name)}/trades`, [
194
+ ["interval", iv.wire], ["limit", String(want)], ["endTime", String(cursor)],
195
+ ]);
196
+ if (!Array.isArray(body.data))
197
+ throw extendedShapeError(`candles of ${m.name} is not a list`);
198
+ const rows = body.data.map(c => {
199
+ const x = c;
200
+ return {
201
+ time: ms(x.T, "candle T"),
202
+ open: dec(x.o, "candle o"), high: dec(x.h, "candle h"), low: dec(x.l, "candle l"), close: dec(x.c, "candle c"),
203
+ volume: dec(x.v, "candle v"),
204
+ quoteVolume: null,
205
+ trades: null,
206
+ };
207
+ });
208
+ for (const k of rows)
209
+ if (k.time >= startTime && k.time <= endTime)
210
+ byTime.set(k.time, k);
211
+ // Only an EMPTY page ends the history: a short page is not trusted as the end (the cap is
212
+ // per interval and was measured, not documented), it just costs one more request.
213
+ if (rows.length === 0)
214
+ break;
215
+ const oldest = Math.min(...rows.map(k => k.time));
216
+ if (oldest <= startTime)
217
+ break;
218
+ if (oldest >= cursor)
219
+ throw extendedShapeError(`candles of ${m.name} did not move back past ${new Date(cursor).toISOString()}`);
220
+ cursor = oldest - 1;
221
+ }
222
+ return [...byTime.values()].sort((a, b) => a.time - b.time);
161
223
  }
162
224
  // ── Account / trading: refused (market data only in this version) ──
163
225
  refuse(feature) {
@@ -42,6 +42,9 @@
42
42
  * - marketStats.nextFundingRate is (despite its name) the timestamp (ms) of the next application.
43
43
  * - rates are published ROUNDED TO 6 DECIMALS (resolution 0.000001 / h ≈ 0.876 % APR): the
44
44
  * 0.0000125 baseline reads "0.000013", and a rate below 0.0000005 / h reads "0.000000".
45
+ * There is no more precise public source (checked live 2026-10-19): /info/markets (407
46
+ * markets), /info/markets/<m>/stats and the applied history /info/<m>/funding (`f`, 48 h of
47
+ * ETH / SOL) all carry at most 6 decimals, so the adapter keeps this one read.
45
48
  * - `_24_5` RWA equities print 0 while their underlying is closed (TSLA_24_5-USD: 34 of 43
46
49
  * weekend prints were 0), so an hourly rate × 8760 overstates them — the venue is
47
50
  * fundingVerified false (venues/extended.ts).
@@ -42,6 +42,9 @@
42
42
  * - marketStats.nextFundingRate is (despite its name) the timestamp (ms) of the next application.
43
43
  * - rates are published ROUNDED TO 6 DECIMALS (resolution 0.000001 / h ≈ 0.876 % APR): the
44
44
  * 0.0000125 baseline reads "0.000013", and a rate below 0.0000005 / h reads "0.000000".
45
+ * There is no more precise public source (checked live 2026-10-19): /info/markets (407
46
+ * markets), /info/markets/<m>/stats and the applied history /info/<m>/funding (`f`, 48 h of
47
+ * ETH / SOL) all carry at most 6 decimals, so the adapter keeps this one read.
45
48
  * - `_24_5` RWA equities print 0 while their underlying is closed (TSLA_24_5-USD: 34 of 43
46
49
  * weekend prints were 0), so an hourly rate × 8760 overstates them — the venue is
47
50
  * fundingVerified false (venues/extended.ts).
@@ -301,7 +304,7 @@ export async function fetchExtendedMarketRows(client, deps = DEFAULT_DEPS) {
301
304
  return parseExtendedMarkets(body.data, deps.warn);
302
305
  }
303
306
  export async function fetchExtendedMarkets(net, deps = DEFAULT_DEPS) {
304
- return (await fetchExtendedMarketRows(extendedClient(net), deps)).map(r => r.market);
307
+ return (await fetchExtendedMarketRows(extendedClient(net), deps)).map(r => ({ ...r.market, status: r.meta.status }));
305
308
  }
306
309
  /** Funding rows: the current hour's accruing rate (fraction per hour); a market without one has no row. */
307
310
  export function toExtendedFundingRates(rows) {
@@ -418,7 +421,11 @@ export async function resolveExtendedMarketRemote(client, input, deps = DEFAULT_
418
421
  names = symbols.flatMap(extendedWireCandidates);
419
422
  }
420
423
  else {
421
- names = extendedWireCandidates(up);
424
+ // Wire names are case-sensitive (`kNOT-USD`): a mixed-case input's own casing first, then the
425
+ // upper-cased guess, then the venue's lower-case "k" (×1000) prefix — before the ~1 MB listing.
426
+ const kPrefixed = /^K[A-Z0-9]/.test(up) ? extendedWireCandidates(up).map(n => `k${n.slice(1)}`) : [];
427
+ const mixedCase = raw !== up && raw !== raw.toLowerCase();
428
+ names = [...new Set([...(mixedCase ? [`${raw}-USD`, `${raw}_24_5-USD`] : []), ...extendedWireCandidates(up), ...kPrefixed])];
422
429
  }
423
430
  for (const name of names)
424
431
  await read(name);
@@ -12,9 +12,23 @@ import type { Network } from "../venue-types.js";
12
12
  import { type GrvtVenueMarket } from "./public.js";
13
13
  /** perp-cli interval → GRVT CandlestickInterval (api-docs.grvt.io/schemas/candlestick_interval). */
14
14
  export declare const GRVT_KLINE_INTERVALS: Readonly<Record<string, string>>;
15
- /** Bars per kline page (the documented max) and pages read before stopping with a warning. */
15
+ /**
16
+ * Bars per kline page (the documented max) and the most pages one read may take. The `next`
17
+ * cursor runs dry after ~1000 bars (live 2026-10-19: page 2 had 2 rows and `next` ""), so pages
18
+ * walk `end_time` back to just before the oldest bar returned instead.
19
+ */
16
20
  export declare const GRVT_KLINE_PAGE = 1000;
17
- export declare const GRVT_KLINE_MAX_PAGES = 10;
21
+ /**
22
+ * Pages actually read before giving up. Real pages hold fewer than 1000 bars (minutes without a
23
+ * bar: 40 d of 1m took 58 pages for 57,583 bars), so the read budget is 25 % above the nominal
24
+ * GRVT_KLINE_MAX_PAGES. 75 requests stay well inside GRVT's 500 market-data requests / minute.
25
+ */
26
+ export declare const GRVT_KLINE_READ_PAGES = 75;
27
+ /** Measured live 2026-10-19: /trade answers at most 1000 rows (limit 1500 → 1000, no cursor). */
28
+ export declare const GRVT_TRADES_CAP = 1000;
29
+ /** Measured live 2026-10-19: /funding honours limit 10,000 in one answer (BTC). */
30
+ export declare const GRVT_FUNDING_CAP = 10000;
31
+ export declare const GRVT_KLINE_MAX_PAGES = 60;
18
32
  /** Order book depths GRVT serves (api_orderbook_levels_request). */
19
33
  export declare const GRVT_BOOK_DEPTHS: readonly [10, 50, 100, 500];
20
34
  /**
@@ -17,9 +17,31 @@ export const GRVT_KLINE_INTERVALS = {
17
17
  "1h": "CI_1_H", "2h": "CI_2_H", "4h": "CI_4_H", "6h": "CI_6_H", "8h": "CI_8_H", "12h": "CI_12_H",
18
18
  "1d": "CI_1_D", "3d": "CI_3_D", "5d": "CI_5_D", "1w": "CI_1_W", "2w": "CI_2_W", "3w": "CI_3_W", "4w": "CI_4_W",
19
19
  };
20
- /** Bars per kline page (the documented max) and pages read before stopping with a warning. */
20
+ /** Fixed-length intervals (ms): `start_time` is floored to their boundary (epoch-aligned, UTC). */
21
+ const GRVT_KLINE_FIXED_MS = {
22
+ "1m": 60_000, "3m": 180_000, "5m": 300_000, "15m": 900_000, "30m": 1_800_000, "1h": 3_600_000, "2h": 7_200_000,
23
+ "4h": 14_400_000, "6h": 21_600_000, "8h": 28_800_000, "12h": 43_200_000, "1d": 86_400_000,
24
+ };
25
+ const GRVT_KLINE_NOMINAL_MS = {
26
+ ...GRVT_KLINE_FIXED_MS, "3d": 259_200_000, "5d": 432_000_000, "1w": 604_800_000, "2w": 1_209_600_000, "3w": 1_814_400_000, "4w": 2_419_200_000,
27
+ };
28
+ /**
29
+ * Bars per kline page (the documented max) and the most pages one read may take. The `next`
30
+ * cursor runs dry after ~1000 bars (live 2026-10-19: page 2 had 2 rows and `next` ""), so pages
31
+ * walk `end_time` back to just before the oldest bar returned instead.
32
+ */
21
33
  export const GRVT_KLINE_PAGE = 1000;
22
- export const GRVT_KLINE_MAX_PAGES = 10;
34
+ /**
35
+ * Pages actually read before giving up. Real pages hold fewer than 1000 bars (minutes without a
36
+ * bar: 40 d of 1m took 58 pages for 57,583 bars), so the read budget is 25 % above the nominal
37
+ * GRVT_KLINE_MAX_PAGES. 75 requests stay well inside GRVT's 500 market-data requests / minute.
38
+ */
39
+ export const GRVT_KLINE_READ_PAGES = 75;
40
+ /** Measured live 2026-10-19: /trade answers at most 1000 rows (limit 1500 → 1000, no cursor). */
41
+ export const GRVT_TRADES_CAP = 1000;
42
+ /** Measured live 2026-10-19: /funding honours limit 10,000 in one answer (BTC). */
43
+ export const GRVT_FUNDING_CAP = 10_000;
44
+ export const GRVT_KLINE_MAX_PAGES = 60;
23
45
  const WARN = (msg) => { process.stderr.write(`${msg}\n`); };
24
46
  /** Order book depths GRVT serves (api_orderbook_levels_request). */
25
47
  export const GRVT_BOOK_DEPTHS = [10, 50, 100, 500];
@@ -91,8 +113,11 @@ export class GrvtPublicAdapter {
91
113
  }
92
114
  async getOrderbook(symbol, depth = 50) {
93
115
  const m = await this.market(symbol);
94
- // The smallest served depth that covers the request; above 500 levels, the deepest one (500).
95
- const d = GRVT_BOOK_DEPTHS.find(x => x >= depth) ?? 500;
116
+ // The smallest served depth that covers the request; above 500 → INVALID_PARAMS (never clamped).
117
+ const d = GRVT_BOOK_DEPTHS.find(x => x >= depth);
118
+ if (d === undefined || !Number.isInteger(depth) || depth < 1) {
119
+ throw new PerpError("INVALID_PARAMS", `GRVT: book depth must be 1..500 (got ${depth})`, { exchange: "grvt", remediation: "Pass --depth 1..500" });
120
+ }
96
121
  const res = await this.client.result("book", { instrument: m.instrument, depth: d });
97
122
  if (res === null || typeof res !== "object" || !Array.isArray(res.bids) || !Array.isArray(res.asks)) {
98
123
  throw grvtShapeError("book has no bids / asks arrays");
@@ -105,10 +130,16 @@ export class GrvtPublicAdapter {
105
130
  return { bids: side(res.bids.slice(0, depth), "bid"), asks: side(res.asks.slice(0, depth), "ask") };
106
131
  }
107
132
  async getRecentTrades(symbol, limit = 50) {
133
+ if (!Number.isInteger(limit) || limit < 1 || limit > GRVT_TRADES_CAP) {
134
+ throw new PerpError("INVALID_PARAMS", `GRVT: trades limit must be 1..${GRVT_TRADES_CAP} (got ${limit})`, {
135
+ remediation: `GRVT serves at most ${GRVT_TRADES_CAP} recent trades per request and no cursor for older ones`,
136
+ });
137
+ }
108
138
  const m = await this.market(symbol);
109
- const res = await this.client.result("trade", { instrument: m.instrument, limit: Math.min(Math.max(1, limit), 1000) });
139
+ const res = await this.client.result("trade", { instrument: m.instrument, limit });
110
140
  if (!Array.isArray(res))
111
141
  throw grvtShapeError("trade result is not an array");
142
+ // The venue answers oldest first; every venue's tape is newest first.
112
143
  return res.map(t => {
113
144
  const x = t;
114
145
  if (typeof x.is_taker_buyer !== "boolean")
@@ -121,15 +152,19 @@ export class GrvtPublicAdapter {
121
152
  size: dec(x.size, "trade size"),
122
153
  fee: null,
123
154
  };
124
- });
155
+ }).sort((a, b) => b.time - a.time);
125
156
  }
126
157
  /**
127
158
  * Settled funding (/full/v1/funding, one record per funding interval, newest first). `rate` is a
128
159
  * fraction per the record's `intervalHours` (published in percentage points, converted exactly).
129
160
  */
130
161
  async getFundingHistory(symbol, limit = 100) {
162
+ if (!Number.isInteger(limit) || limit < 1 || limit > GRVT_FUNDING_CAP) {
163
+ throw new PerpError("INVALID_PARAMS", `GRVT: funding history limit must be 1..${GRVT_FUNDING_CAP} (got ${limit})`);
164
+ }
131
165
  const m = await this.market(symbol);
132
- const res = await this.client.result("funding", { instrument: m.instrument, limit: Math.min(Math.max(1, limit), 1000) });
166
+ // Sent as asked (it was silently clamped to 1000; the venue serves 10,000 in one answer).
167
+ const res = await this.client.result("funding", { instrument: m.instrument, limit });
133
168
  if (!Array.isArray(res))
134
169
  throw grvtShapeError("funding result is not an array");
135
170
  return res.map(r => {
@@ -154,28 +189,63 @@ export class GrvtPublicAdapter {
154
189
  remediation: `Use one of: ${Object.keys(GRVT_KLINE_INTERVALS).join(", ")}`,
155
190
  });
156
191
  }
192
+ if (!(endTime > startTime))
193
+ throw new PerpError("INVALID_PARAMS", "GRVT: candle end time must be after the start time");
194
+ const fixed = GRVT_KLINE_FIXED_MS[interval];
195
+ // An off-grid start_time makes GRVT answer a truncated first bar labelled off the grid
196
+ // (live: 4h from 05:07 → a 06:00–08:00 bar), so the start is floored to the boundary.
197
+ const start = fixed === undefined ? startTime : Math.floor(startTime / fixed) * fixed;
198
+ const pagesNeeded = Math.ceil((endTime - start) / GRVT_KLINE_NOMINAL_MS[interval] / GRVT_KLINE_PAGE);
199
+ if (pagesNeeded > GRVT_KLINE_MAX_PAGES) {
200
+ throw new PerpError("INVALID_PARAMS", `GRVT: ${interval} candles over this range need ${pagesNeeded} requests (max ${GRVT_KLINE_MAX_PAGES})`, {
201
+ remediation: "Narrow --start / --end or use a longer interval",
202
+ });
203
+ }
157
204
  const m = await this.market(symbol);
158
- // Reverse-chronological pages of up to 1000 bars, followed by `next` until start_time is reached.
159
- const rows = [];
160
- let cursor = "";
161
- for (let page = 0;; page++) {
205
+ // Reverse-chronological pages of up to 1000 bars; the next page ends just before the oldest
206
+ // bar returned. Done when a page is empty or reaches `start` — never on the cursor alone.
207
+ const byOpen = new Map();
208
+ let end = endTime;
209
+ let oldestSeen = Infinity;
210
+ let exhausted = false;
211
+ for (let page = 0; page < GRVT_KLINE_READ_PAGES; page++) {
162
212
  const res = await this.client.post("kline", {
163
- instrument: m.instrument, interval: ci, type: "TRADE", start_time: msToNs(startTime), end_time: msToNs(endTime), limit: GRVT_KLINE_PAGE,
164
- ...(cursor !== "" ? { cursor } : {}),
213
+ instrument: m.instrument, interval: ci, type: "TRADE", start_time: msToNs(start), end_time: msToNs(end), limit: GRVT_KLINE_PAGE,
165
214
  });
166
215
  if (res === null || typeof res !== "object" || !Array.isArray(res.result))
167
216
  throw grvtShapeError("kline result is not an array");
168
- rows.push(...res.result);
169
- const next = typeof res.next === "string" ? res.next : "";
170
- if (res.result.length < GRVT_KLINE_PAGE || next === "")
171
- break;
172
- if (page + 1 >= GRVT_KLINE_MAX_PAGES) {
173
- WARN(`[grvt] kline ${m.instrument} ${interval}: stopped after ${GRVT_KLINE_MAX_PAGES * GRVT_KLINE_PAGE} bars — older bars in the window are not shown (narrow the range)`);
217
+ // A fixed-interval bar whose first minute had no trade is labelled at its first trade (live
218
+ // 2026-10-19: the 09:30–10:00 30m bar opened "09:31"); its open is the bucket boundary, and a
219
+ // page asked to end before that label answers the same bar again — it is not an older bar.
220
+ const inPage = res.result
221
+ .map(c => ({ c, t: fixed === undefined ? nsToMs(c.open_time, "kline open_time") : Math.floor(nsToMs(c.open_time, "kline open_time") / fixed) * fixed }))
222
+ .filter(x => page === 0 || x.t < oldestSeen);
223
+ // No bar older than the last page (an empty page, or only bars already read): history starts here.
224
+ if (inPage.length === 0) {
225
+ exhausted = true;
174
226
  break;
175
227
  }
176
- cursor = next;
228
+ let oldest = Infinity;
229
+ for (const { c, t } of inPage) {
230
+ byOpen.set(String(t), { ...c, open_time: `${t}000000` });
231
+ if (t < oldest)
232
+ oldest = t;
233
+ }
234
+ oldestSeen = oldest;
235
+ if (oldest <= start)
236
+ break;
237
+ end = oldest - 1;
238
+ }
239
+ // Ran out of the read budget before reaching `start`: the oldest bars would be missing — never
240
+ // returned as the whole range.
241
+ if (!exhausted && oldestSeen > start) {
242
+ throw new PerpError("EXCHANGE_ERROR", `GRVT: ${interval} candles reached only ${new Date(oldestSeen).toISOString()} after ${GRVT_KLINE_READ_PAGES} pages (requested from ${new Date(start).toISOString()})`, {
243
+ exchange: "grvt", reachedMs: oldestSeen, requestedFromMs: start,
244
+ remediation: `Narrow --start / --end (or use a longer interval); fetch older bars with --end ${oldestSeen - 1}`,
245
+ });
177
246
  }
178
- return rows.map(c => {
247
+ const rows = [...byOpen.values()];
248
+ const bars = rows.map(c => {
179
249
  const x = c;
180
250
  return {
181
251
  time: nsToMs(x.open_time, "kline open_time"),
@@ -183,8 +253,15 @@ export class GrvtPublicAdapter {
183
253
  volume: dec(x.volume_b, "kline volume_b"),
184
254
  quoteVolume: typeof x.volume_q === "string" && DEC.test(x.volume_q) ? x.volume_q : null,
185
255
  trades: typeof x.trades === "number" ? x.trades : null,
256
+ closeMs: nsToMs(x.close_time, "kline close_time"),
186
257
  };
187
258
  }).sort((a, b) => a.time - b.time);
259
+ // Calendar intervals (3d … 4w) are not floored: a first bar shorter than the interval is a
260
+ // truncated bar (live: the first 1w bar covered 4 days) and is left out. The newest bar may be
261
+ // in progress and is kept.
262
+ const nominal = GRVT_KLINE_NOMINAL_MS[interval];
263
+ const full = fixed === undefined && bars.length > 1 && bars[0].closeMs - bars[0].time + 1 < nominal * 0.99 ? bars.slice(1) : bars;
264
+ return full.map(({ closeMs: _c, ...k }) => k);
188
265
  }
189
266
  // ── Account / trading: refused (read-only in this version) ──
190
267
  refuse(feature) {
@@ -15,6 +15,8 @@
15
15
  */
16
16
  import type { OutcomeAdapter, OutcomeMarketInfo, OutcomeOrderbook, OutcomePosition, OutcomeView, OutcomeViewUnderlying } from "./outcome-interface.js";
17
17
  import type { HyperliquidAdapter } from "./hyperliquid.js";
18
+ /** Bid–ask width (probability points) above which a side's mid-implied probability is flagged. */
19
+ export declare const OUTCOME_WIDE_SPREAD = 0.2;
18
20
  export declare class HyperliquidOutcomeAdapter implements OutcomeAdapter {
19
21
  readonly name = "hyperliquid";
20
22
  private _hl;
@@ -22,6 +22,16 @@ const MIN_ORDER_QUOTE = 10;
22
22
  const MAX_SIDE = 9;
23
23
  /** Asset id offset spans `[100_000_000, 200_000_000)`, so encoding < 100M. */
24
24
  const MAX_ENCODING = 99_999_999;
25
+ /** A numeric description field; a malformed one is left unset (unknown) with a warning, never NaN. */
26
+ function finiteOrWarn(val, key, description) {
27
+ const n = val.trim() === "" ? NaN : Number(val);
28
+ if (Number.isFinite(n))
29
+ return n;
30
+ process.stderr.write(`[hyperliquid] outcome description ${key} "${val}" is not a number — target price unknown (${description.slice(0, 120)})\n`);
31
+ return undefined;
32
+ }
33
+ /** Bid–ask width (probability points) above which a side's mid-implied probability is flagged. */
34
+ export const OUTCOME_WIDE_SPREAD = 0.2;
25
35
  export class HyperliquidOutcomeAdapter {
26
36
  name = "hyperliquid";
27
37
  _hl;
@@ -76,9 +86,17 @@ export class HyperliquidOutcomeAdapter {
76
86
  else if (key === "expiry")
77
87
  out.expiryMs = HyperliquidOutcomeAdapter._parseExpiry(val);
78
88
  else if (key === "targetPrice")
79
- out.targetPrice = Number(val);
89
+ out.targetPrice = finiteOrWarn(val, key, description);
80
90
  else if (key === "period")
81
91
  out.period = val;
92
+ // Template outcomes (name "template:binaryPrice", live 2026-10-19):
93
+ // "perp:ZEC|priceDescription:…|seconds:90|threshold:1057|time:20261023-0800".
94
+ else if (key === "perp" && out.underlying === undefined)
95
+ out.underlying = val;
96
+ else if (key === "threshold" && out.targetPrice === undefined)
97
+ out.targetPrice = finiteOrWarn(val, key, description);
98
+ else if (key === "time" && out.expiryMs === undefined)
99
+ out.expiryMs = HyperliquidOutcomeAdapter._parseExpiry(val);
82
100
  }
83
101
  return out;
84
102
  }
@@ -315,6 +333,9 @@ export class HyperliquidOutcomeAdapter {
315
333
  });
316
334
  }
317
335
  const parsed = HyperliquidOutcomeAdapter.parseDescription(meta.description);
336
+ // A template binary-price outcome is a price binary (Yes = underlying ≥ threshold at `time`).
337
+ if (parsed.class === undefined && meta.name === "template:binaryPrice")
338
+ parsed.class = "priceBinary";
318
339
  // Fetch books for every side in parallel + allMids once for mids and
319
340
  // the underlying symbol's mark price.
320
341
  const allMidsPromise = this._infoPost({ type: "allMids" });
@@ -336,6 +357,13 @@ export class HyperliquidOutcomeAdapter {
336
357
  bestBid: trimmed.bestBid,
337
358
  bestAsk: trimmed.bestAsk,
338
359
  impliedProb: mid !== undefined ? Number(mid) : undefined,
360
+ // Template side names ("template:Yes") without the prefix; `name` stays as published.
361
+ label: spec.name.replace(/^template:/, ""),
362
+ // The mid of a book wider than OUTCOME_WIDE_SPREAD (e.g. 0.0257 / 0.9899) says little
363
+ // about the probability: flagged, never dropped.
364
+ wideSpread: trimmed.bestBid !== undefined && trimmed.bestAsk !== undefined
365
+ ? Number(trimmed.bestAsk) - Number(trimmed.bestBid) > OUTCOME_WIDE_SPREAD
366
+ : null,
339
367
  };
340
368
  });
341
369
  const midSum = HyperliquidOutcomeAdapter._computeMidSum(sides);
@@ -9,6 +9,14 @@
9
9
  import type { SpotAdapter, SpotMarketInfo, SpotBalance, SpotHoldingPrice, SpotHoldingPricesCapable } from "./spot-interface.js";
10
10
  import type { ResolvedSlippage } from "./slippage.js";
11
11
  import type { HyperliquidAdapter } from "./hyperliquid.js";
12
+ import { type HlAbstractionMode } from "./hyperliquid.js";
13
+ /**
14
+ * Spot balances from info `spotClearinghouseState` — the one mapper behind the keyed spot adapter
15
+ * and the keyless `account balance --address`. `shared` (already counted in the perp equity) follows
16
+ * the account mode: unified → the USDC row; portfolio margin → USDC and every eligible collateral row
17
+ * (the venue marks those with `ltv`); any other mode, or `null` (a HIP-3 dex scope / mode unknown) → none.
18
+ */
19
+ export declare function hlSpotBalanceRows(state: Record<string, unknown>, mode: HlAbstractionMode | null): SpotBalance[];
12
20
  export declare class HyperliquidSpotAdapter implements SpotAdapter, SpotHoldingPricesCapable {
13
21
  readonly name = "hyperliquid";
14
22
  private _hl;
@@ -16,6 +16,24 @@ import { invalidateCache } from "../cache.js";
16
16
  function hlRoundSize(size, szDecimals) {
17
17
  return floorSize(size, hlSizeStep(szDecimals));
18
18
  }
19
+ /**
20
+ * Spot balances from info `spotClearinghouseState` — the one mapper behind the keyed spot adapter
21
+ * and the keyless `account balance --address`. `shared` (already counted in the perp equity) follows
22
+ * the account mode: unified → the USDC row; portfolio margin → USDC and every eligible collateral row
23
+ * (the venue marks those with `ltv`); any other mode, or `null` (a HIP-3 dex scope / mode unknown) → none.
24
+ */
25
+ export function hlSpotBalanceRows(state, mode) {
26
+ const balances = (state?.balances ?? []);
27
+ return balances.map((b) => ({
28
+ token: String(b.coin ?? ""),
29
+ total: String(b.total ?? "0"),
30
+ available: String(Number(b.total ?? 0) - Number(b.hold ?? 0)),
31
+ held: String(b.hold ?? "0"),
32
+ entryNtl: b.entryNtl !== undefined ? String(b.entryNtl) : undefined,
33
+ shared: (mode === "unified" || mode === "portfolio") && String(b.coin ?? "") === "USDC"
34
+ || mode === "portfolio" && Object.prototype.hasOwnProperty.call(b, "ltv"),
35
+ }));
36
+ }
19
37
  export class HyperliquidSpotAdapter {
20
38
  name = "hyperliquid";
21
39
  _hl;
@@ -181,18 +199,8 @@ export class HyperliquidSpotAdapter {
181
199
  // in the funding-arb bot (and a misleading empty holdings table).
182
200
  // Reuse cached spot clearinghouse state (shared with getBalance() — saves 1 API call).
183
201
  const state = await this._hl._getSpotClearinghouseState({ fresh });
184
- const balances = (state?.balances ?? []);
185
- // Unified HL accounts: spot USDC IS the perp margin — mark it shared.
186
- // HIP-3 dex-scoped adapters report dex equity, which never contains spot USDC.
187
- const unified = this._hl.isUnifiedAccount === true && this._hl.dex === "";
188
- return balances.map((b) => ({
189
- token: String(b.coin ?? ""),
190
- total: String(b.total ?? "0"),
191
- available: String(Number(b.total ?? 0) - Number(b.hold ?? 0)),
192
- held: String(b.hold ?? "0"),
193
- entryNtl: b.entryNtl !== undefined ? String(b.entryNtl) : undefined,
194
- shared: unified && String(b.coin ?? "") === "USDC",
195
- }));
202
+ // HIP-3 dex-scoped adapters report dex equity, which never contains spot balances.
203
+ return hlSpotBalanceRows(state, this._hl.dex === "" ? this._hl.accountMode : null);
196
204
  }
197
205
  async spotMarketOrder(symbol, side, size, opts) {
198
206
  const what = `spot ${side} ${size} ${symbol}`;