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
@@ -1,12 +1,14 @@
1
+ import { assertCandleInterval } from "./interface.js";
2
+ import { isLighterMarketLive } from "../ws/lighter-market.js";
1
3
  import { alignProtectionPrice, floorSize, formatComputedPrice, mulDecimal, slippagePrice, toUnitsExact, unitsToString } from "./precision.js";
2
4
  import { unsupported } from "./trading-v2.js";
3
5
  import { LocalEvmSigner } from "../signer/index.js";
4
6
  import { isExpired } from "../agent-wallet/expiry.js";
5
- import { PerpError, classifyError, extractErrorMessage, orderNotSent, orderOutcomeUnknown, sendFailure, unknownActionRemediation, parsePositiveDecimal, DEFINITE_ORDER_FAILURE_CODES } from "../errors.js";
7
+ import { PerpError, classifyError, extractErrorMessage, orderNotSent, orderOutcomeUnknown, sendFailure, unknownActionRemediation, parsePositiveDecimal, DEFINITE_ORDER_FAILURE_CODES, noAccountKeyError } from "../errors.js";
6
8
  import { parseFiniteVenueNumber, venueRateField } from "../utils/numeric.js";
7
9
  import { lighterFamilyNetworks, LIGHTER_RH_RATE_LIMIT, LIGHTER_RH_RESERVED_KEY_INDEXES, LIGHTER_GTT_EXPIRY_MS, LIGHTER_CLIENT_ORDER_INDEX_MAX, LIGHTER_REFERENCE_PRICE_SOURCE, } from "./venues/lighter-family.js";
8
10
  import { acquireRate, rateLimitedError } from "./rate-limiter.js";
9
- import { lighterWafChallenge } from "./lighter-public.js";
11
+ import { fetchLighterFundingStats, lighterFundingStatsWarning, LIGHTER_BOOK_ORDERS_LIMIT, lighterBook, lighterWafChallenge } from "./lighter-public.js";
10
12
  import { fetchAndCache, invalidateCache, setCached, withCache, TTL_ACCOUNT } from "../cache.js";
11
13
  import { LighterWasmSigner } from "./lighter-signer.js";
12
14
  /** Lighter trigger orderType enum (matches WASM signer). */
@@ -35,6 +37,29 @@ export function classifyTriggerOrderType(side, triggerPrice, markPrice, isMarket
35
37
  }
36
38
  /** Lighter order time-in-force enum (lighter-go / SDK `TimeInForce`): 0 IOC, 1 GTT, 2 post-only. */
37
39
  export const LIGHTER_TIF = { IOC: 0, GTT: 1, POST_ONLY: 2 };
40
+ /**
41
+ * One GET /api/v1/fundings row → a funding history row (fraction per 1 h, signed).
42
+ * `rate` is percent per hour, unsigned; `direction` "long" = longs pay (positive), "short" = shorts pay.
43
+ */
44
+ export function lighterFundingRow(venue, f) {
45
+ const x = f;
46
+ if (typeof x.timestamp !== "number" || !Number.isFinite(x.timestamp) || x.timestamp <= 0) {
47
+ throw new PerpError("EXCHANGE_ERROR", `${venue} /fundings: row without a numeric timestamp: ${JSON.stringify(f).slice(0, 160)}`, { exchange: venue });
48
+ }
49
+ if (typeof x.rate !== "string" || !/^\d+(\.\d+)?$/.test(x.rate)) {
50
+ throw new PerpError("EXCHANGE_ERROR", `${venue} /fundings: row without an unsigned decimal rate: ${JSON.stringify(f).slice(0, 160)}`, { exchange: venue });
51
+ }
52
+ if (x.direction !== "long" && x.direction !== "short") {
53
+ throw new PerpError("EXCHANGE_ERROR", `${venue} /fundings: unknown direction ${JSON.stringify(x.direction)}`, { exchange: venue });
54
+ }
55
+ return {
56
+ time: x.timestamp * 1000,
57
+ rate: mulDecimal(x.rate, x.direction === "long" ? "0.01" : "-0.01"),
58
+ price: null,
59
+ intervalHours: 1,
60
+ rateKind: "lastSettled",
61
+ };
62
+ }
38
63
  /** Decimal step for `decimals` digits ("1" for 0, "0.01" for 2). */
39
64
  function lighterStep(decimals) {
40
65
  return decimals <= 0 ? "1" : `0.${"0".repeat(decimals - 1)}1`;
@@ -369,6 +394,65 @@ export function lighterRowCreatedMs(row) {
369
394
  * leverage = floor(10000 / bps), which returns the integer leverage that was set. margin_mode:
370
395
  * 0 cross, 1 isolated (the values updateLeverage signs). Anything else throws — never a guess.
371
396
  */
397
+ /**
398
+ * Balance of one Lighter-family account object (GET /account) — the one implementation behind
399
+ * LighterAdapter.getBalance() and the keyless `account balance --address`. Equity includes the
400
+ * spot collateral asset (USDC on Lighter, USDG on Lighter RH; separate from perp collateral).
401
+ */
402
+ export function lighterBalanceOf(acct, venue, collateralSymbol) {
403
+ const totalAsset = parseFiniteVenueNumber(acct.total_asset_value, "total_asset_value", "lighter");
404
+ const available = parseFiniteVenueNumber(acct.available_balance, "available_balance", "lighter");
405
+ const collateral = parseFiniteVenueNumber(acct.collateral, "collateral", "lighter");
406
+ const unrealizedPnl = acct.positions?.reduce((sum, p) => sum + parseFiniteVenueNumber(p.unrealized_pnl, "position.unrealized_pnl", "lighter"), 0) ?? 0;
407
+ // Include spot collateral balance (USDC on Lighter, USDG on Lighter RH; separate from perp collateral)
408
+ // An account without that asset holds none of it (0); an unreadable assets list / balance throws.
409
+ let spotUsdc = 0;
410
+ if (!Array.isArray(acct.assets)) {
411
+ throw new PerpError("EXCHANGE_ERROR", `${venue} account has no assets list — spot ${collateralSymbol} balance unknown`, { exchange: venue });
412
+ }
413
+ const usdcAsset = acct.assets.find(a => a?.symbol === collateralSymbol);
414
+ if (usdcAsset !== undefined)
415
+ spotUsdc = parseFiniteVenueNumber(usdcAsset.balance, `assets.${collateralSymbol}.balance`, "lighter");
416
+ return {
417
+ equity: String(totalAsset + spotUsdc),
418
+ available: String(available),
419
+ marginUsed: String(Math.max(0, collateral - available)),
420
+ unrealizedPnl: String(unrealizedPnl),
421
+ };
422
+ }
423
+ /**
424
+ * Open positions of one Lighter account (`GET /account` `accounts[].positions[]`): the venue lists
425
+ * every market the account ever traded or configured, so rows with `position` 0 are dropped. The
426
+ * ONE mapper of the keyed `getPositions` and the public-address read; `priceDecimalsOf` formats
427
+ * the computed mark (position_value / size) to the market's price decimals when known.
428
+ */
429
+ export function lighterPositionRows(positions, venue, priceDecimalsOf) {
430
+ return (positions ?? [])
431
+ .filter((p) => parseFiniteVenueNumber(p.position, "position", "lighter") !== 0)
432
+ .map((p) => {
433
+ const posSize = parseFiniteVenueNumber(p.position, "position", "lighter");
434
+ const positionValue = parseFiniteVenueNumber(p.position_value, "position_value", "lighter");
435
+ return {
436
+ symbol: String(p.symbol || `Market-${p.market_id}`),
437
+ side: (Number(p.sign) > 0 ? "long" : "short"),
438
+ size: String(Math.abs(posSize)),
439
+ entryPrice: String(p.avg_entry_price || "0"),
440
+ markPrice: (() => {
441
+ if (posSize === 0)
442
+ return "0";
443
+ const rawMark = positionValue / Math.abs(posSize);
444
+ const priceDec = priceDecimalsOf(String(p.symbol || ""));
445
+ return priceDec !== undefined ? rawMark.toFixed(priceDec) : formatComputedPrice(rawMark);
446
+ })(),
447
+ // The venue reports "0" when the position has no liquidation price (e.g. cross margin
448
+ // well covered): that is "none", not a price.
449
+ liquidationPrice: Number(p.liquidation_price) > 0 ? String(p.liquidation_price) : null,
450
+ unrealizedPnl: String(p.unrealized_pnl || "0"),
451
+ // The leverage CONFIGURED for the market (what `trade leverage` set) — not notional / equity.
452
+ ...lighterPositionSettings(p, venue),
453
+ };
454
+ });
455
+ }
372
456
  export function lighterPositionSettings(p, venue) {
373
457
  const pct = typeof p.initial_margin_fraction === "string" && /^\d+(\.\d+)?$/.test(p.initial_margin_fraction) ? Number(p.initial_margin_fraction) : NaN;
374
458
  const bps = Math.round(pct * 100);
@@ -409,6 +493,17 @@ export function lighterSelectAccount(accounts, accountIndex, venue) {
409
493
  * window is unknown (SYMBOL_NOT_FOUND) until the entry expires.
410
494
  */
411
495
  export const LIGHTER_MARKET_LIST_TTL_MS = 5 * 60_000;
496
+ /** GET /api/v1/candles resolutions, live-verified 2026-10-19 on lighter + lighter-rh mainnet and lighter testnet (3m / 2h / 6h / 8h / 1w / 1M / 2m: code 20001 "invalid param"). */
497
+ export const LIGHTER_CANDLE_INTERVALS = {
498
+ "1m": 60_000, "5m": 300_000, "15m": 900_000, "30m": 1_800_000,
499
+ "1h": 3_600_000, "4h": 14_400_000, "12h": 43_200_000, "1d": 86_400_000,
500
+ };
501
+ /** "Returns at most 500 candles per call" (apidocs.lighter.xyz/reference/candles). */
502
+ export const LIGHTER_CANDLES_PER_CALL = 500;
503
+ /** Max `limit` of GET /api/v1/recentTrades. */
504
+ export const LIGHTER_RECENT_TRADES_MAX = 100;
505
+ /** Requests one `market kline` may send (the venue allows 60 / minute on standard accounts). */
506
+ export const LIGHTER_KLINE_MAX_PAGES = 10;
412
507
  /** A candle volume field: a finite number / numeric string → its string form; absent or non-numeric → null (never "" / "undefined"). */
413
508
  function candleNumber(v) {
414
509
  if (typeof v === "number" && Number.isFinite(v))
@@ -442,6 +537,8 @@ export class LighterAdapter {
442
537
  _address;
443
538
  _marketMap = new Map(); // symbol → marketIndex
444
539
  _marketDecimals = new Map(); // symbol → decimals
540
+ /** symbol → live (isLighterMarketLive). Inactive markets stay in _marketMap for reads and closes. */
541
+ _marketLive = new Map();
445
542
  _evmKey;
446
543
  _apiKey;
447
544
  _accountIndexInit;
@@ -842,7 +939,8 @@ export class LighterAdapter {
842
939
  * the 60 / minute budget.
843
940
  */
844
941
  async _refreshMarketMap() {
845
- const perps = await withCache(`mkt:${this.venueId}:${this.networkName}:orderBooks`, LIGHTER_MARKET_LIST_TTL_MS, async () => {
942
+ // v2: rows carry `live` (entries cached before it lack the field).
943
+ const perps = await withCache(`mkt:${this.venueId}:${this.networkName}:orderBooks:v2`, LIGHTER_MARKET_LIST_TTL_MS, async () => {
846
944
  const res = await this.restGet("/orderBooks", {});
847
945
  if (!Array.isArray(res.order_books) || res.order_books.length === 0) {
848
946
  throw new PerpError("EXCHANGE_ERROR", `${this.name} orderBooks returned no order_books list — the market list is unknown`, {
@@ -859,15 +957,29 @@ export class LighterAdapter {
859
957
  if (typeof d.symbol !== "string" || !Number.isInteger(d.market_id) || !Number.isInteger(d.supported_size_decimals) || !Number.isInteger(d.supported_price_decimals)) {
860
958
  throw new PerpError("EXCHANGE_ERROR", `${this.name} orderBooks: perp market row without symbol / market_id / decimals: ${JSON.stringify(d).slice(0, 160)}`, { exchange: this.name });
861
959
  }
862
- rows.push({ symbol: d.symbol.toUpperCase(), marketId: d.market_id, size: d.supported_size_decimals, price: d.supported_price_decimals });
960
+ rows.push({ symbol: d.symbol.toUpperCase(), marketId: d.market_id, size: d.supported_size_decimals, price: d.supported_price_decimals, live: isLighterMarketLive(d) });
863
961
  }
864
962
  return rows;
865
963
  });
866
964
  for (const m of perps) {
867
965
  this._marketMap.set(m.symbol, m.marketId);
868
966
  this._marketDecimals.set(m.symbol, { size: m.size, price: m.price });
967
+ (this._marketLive ??= new Map()).set(m.symbol, m.live);
869
968
  }
870
969
  }
970
+ /**
971
+ * An order that can open or add exposure (not reduce-only) on a market that is not live
972
+ * (`status` inactive / `is_frozen`) is refused before signing. Reduce-only orders and closes are
973
+ * still sent, so a position on a market that went inactive can be closed.
974
+ */
975
+ assertOpenable(symbol, reduceOnly) {
976
+ if (reduceOnly || this._marketLive?.get(symbol.toUpperCase()) !== false)
977
+ return;
978
+ throw new PerpError("ORDER_REJECTED", `${this.name} market ${symbol.toUpperCase()} is inactive (status not "active" or frozen) — only reduce-only orders and closes are sent; nothing was signed`, {
979
+ exchange: this.name, marketInactive: true,
980
+ remediation: `Close or reduce with --reduce-only; list live markets: perp -e ${this.name} market list`,
981
+ });
982
+ }
871
983
  getMarketIndex(symbol) {
872
984
  const idx = this._marketMap.get(symbol.toUpperCase());
873
985
  if (idx === undefined)
@@ -1204,28 +1316,44 @@ export class LighterAdapter {
1204
1316
  }
1205
1317
  return { baseAmount, priceTicks };
1206
1318
  }
1207
- async getMarkets() {
1319
+ /** Settled / predicted hourly funding per market_id (WS market_stats snapshot, shared with the public directory). */
1320
+ fundingStats() {
1321
+ return fetchLighterFundingStats(this.venueId, this.networkName);
1322
+ }
1323
+ /** Why the last listing's fundingRate is null (the settled-rate snapshot failed), else null. */
1324
+ fundingWarning() {
1325
+ return lighterFundingStatsWarning(this.venueId, this.networkName);
1326
+ }
1327
+ async getMarkets(opts) {
1208
1328
  if (this._marketsCache && Date.now() - this._marketsCacheTime < LighterAdapter.CACHE_TTL)
1209
1329
  return this._marketsCache;
1330
+ const withFunding = opts?.funding !== false;
1210
1331
  const markets = [];
1211
1332
  // Ensure _marketDecimals is populated (has supported_size_decimals per symbol)
1212
1333
  await this.ensureMarketMap();
1213
1334
  // Fetch order book details and funding rates in parallel
1214
1335
  const [obRes, fundingRates] = await Promise.all([
1215
1336
  this.restGet("/orderBookDetails", {}),
1216
- this.getFundingRates(),
1337
+ // Settled + predicted hourly rates (WS market_stats snapshot — the public directory and
1338
+ // perp-ws read the same source). Unreadable → fundingRate null (unknown), reason printed.
1339
+ !withFunding ? Promise.resolve(new Map()) : this.fundingStats().catch((e) => {
1340
+ console.error(`[${this.name}] settled funding rate unavailable (fundingRate null): ${e instanceof Error ? e.message : String(e)}`);
1341
+ return new Map();
1342
+ }),
1217
1343
  ]);
1218
1344
  // An absent list is a failed fetch, never "no markets" (SSOT Rule #2).
1219
1345
  if (!Array.isArray(obRes.order_book_details)) {
1220
1346
  throw new PerpError("EXCHANGE_ERROR", `${this.name} orderBookDetails: missing order_book_details array`, { exchange: this.name });
1221
1347
  }
1222
1348
  const isPriceStr = (v) => typeof v === "string" && v.length > 0 && Number(v) > 0;
1223
- const perps = obRes.order_book_details.filter(d => d.market_type === "perp");
1349
+ // Live perps only (status "active", not frozen): the one rule shared with the WS directory and
1350
+ // lighter-public.ts. Inactive markets (26 on mainnet, 2026-10-19) were listed as live.
1351
+ const perps = obRes.order_book_details.filter(d => d.market_type === "perp" && isLighterMarketLive(d));
1224
1352
  const unpriced = [];
1225
1353
  for (const d of perps) {
1226
1354
  const imf = d.min_initial_margin_fraction ?? d.default_initial_margin_fraction ?? 500;
1227
1355
  const maxLev = imf > 0 ? Math.floor(10000 / imf) : 50;
1228
- const fr = fundingRates.get(d.symbol);
1356
+ const fr = fundingRates.get(d.market_id);
1229
1357
  const dec = this._marketDecimals.get(d.symbol.toUpperCase());
1230
1358
  // markPrice = the venue's MARK price: orderBookDetails `mark_price` (the source the public
1231
1359
  // data path lighter-public.ts uses; /funding-rates carries no mark price). last_trade_price
@@ -1238,13 +1366,20 @@ export class LighterAdapter {
1238
1366
  symbol: d.symbol,
1239
1367
  markPrice: d.mark_price,
1240
1368
  indexPrice: isPriceStr(d.index_price) ? d.index_price : null,
1369
+ // Last settled hourly rate (market_stats funding_rate, = the newest /fundings print, = the
1370
+ // perp-ws `rate`), a fraction per 1 h; the running hour's predicted rate is additive.
1241
1371
  fundingRate: fr?.rate ?? null,
1372
+ fundingRateKind: fr?.rate != null ? "lastSettled" : null,
1373
+ predictedFundingRate: fr?.predictedRate ?? null,
1242
1374
  volume24h: d.daily_quote_token_volume != null ? String(d.daily_quote_token_volume) : null,
1375
+ // BASE units, one side (WS market_stats open_interest is the same quantity in USD).
1243
1376
  openInterest: d.open_interest != null ? String(d.open_interest) : null,
1244
1377
  maxLeverage: maxLev,
1245
1378
  sizeDecimals: dec ? dec.size : null,
1246
- stepSize: null,
1247
- fundingHours: null,
1379
+ // The lot grid of the size decimals (the public directory's stepSize, lighter-public.ts).
1380
+ stepSize: dec ? (dec.size === 0 ? "1" : `0.${"0".repeat(dec.size - 1)}1`) : null,
1381
+ // `fundingRate` is per 1 h (hourly settlement).
1382
+ fundingHours: 1,
1248
1383
  marginTiers: null,
1249
1384
  });
1250
1385
  }
@@ -1256,8 +1391,11 @@ export class LighterAdapter {
1256
1391
  exchange: this.name, remediation: "Retry later; the venue market data is incomplete",
1257
1392
  });
1258
1393
  }
1259
- this._marketsCache = markets;
1260
- this._marketsCacheTime = Date.now();
1394
+ // A marks-only listing (no funding read) is not cached as the full one.
1395
+ if (withFunding) {
1396
+ this._marketsCache = markets;
1397
+ this._marketsCacheTime = Date.now();
1398
+ }
1261
1399
  return markets;
1262
1400
  }
1263
1401
  get accountCacheKey() {
@@ -1291,27 +1429,22 @@ export class LighterAdapter {
1291
1429
  * Book from GET /orderBookOrders?market_id=<id> on this venue's host (Lighter RH: its own host
1292
1430
  * and market ids, e.g. RH testnet TSLA = 112). Rule #2: an unknown symbol (SYMBOL_NOT_FOUND) or
1293
1431
  * a failed read throws — it is never reported as an empty book. An empty `bids`/`asks` is the
1294
- * venue's real answer (no liquidity).
1432
+ * venue's real answer (no liquidity). The endpoint lists individual ORDERS (up to 250 per side,
1433
+ * the documented max); they are aggregated into price levels (lighterBookLevels), as the WS
1434
+ * `order_book` channel publishes them.
1295
1435
  */
1296
- async getOrderbook(symbol) {
1436
+ async getOrderbook(symbol, depth) {
1297
1437
  await this.ensureMarketMap();
1298
1438
  const marketId = this.getMarketIndex(symbol);
1299
1439
  const res = await this.restGet("/orderBookOrders", {
1300
1440
  market_id: String(marketId),
1301
- limit: "50",
1441
+ limit: String(LIGHTER_BOOK_ORDERS_LIMIT),
1302
1442
  });
1303
- const level = (l) => {
1304
- const size = l.remaining_base_amount ?? l.size;
1305
- if (typeof l.price !== "string" || typeof size !== "string") {
1306
- throw new PerpError("EXCHANGE_ERROR", `${this.name} orderBookOrders ${symbol}: level without price / size`, { exchange: this.name });
1307
- }
1308
- return [l.price, size];
1309
- };
1310
- return { bids: (res.bids ?? []).map(level), asks: (res.asks ?? []).map(level) };
1443
+ return lighterBook(this.name, res, symbol, depth);
1311
1444
  }
1312
1445
  async getBalance() {
1313
1446
  if (!this._address)
1314
- throw new Error("No private key configured — account data unavailable. Run: perp setup");
1447
+ throw noAccountKeyError(this.name);
1315
1448
  const acct = await this.fetchAccount();
1316
1449
  // Rule #2: no account is not a zero balance — say so (like getMarketSettings).
1317
1450
  if (!acct) {
@@ -1320,58 +1453,15 @@ export class LighterAdapter {
1320
1453
  remediation: `A ${this.name} account is created by the first deposit; check the address / account index (perp -e ${this.name} wallet show) or deposit first`,
1321
1454
  });
1322
1455
  }
1323
- const totalAsset = parseFiniteVenueNumber(acct.total_asset_value, "total_asset_value", "lighter");
1324
- const available = parseFiniteVenueNumber(acct.available_balance, "available_balance", "lighter");
1325
- const collateral = parseFiniteVenueNumber(acct.collateral, "collateral", "lighter");
1326
- const unrealizedPnl = acct.positions?.reduce((sum, p) => sum + parseFiniteVenueNumber(p.unrealized_pnl, "position.unrealized_pnl", "lighter"), 0) ?? 0;
1327
- // Include spot collateral balance (USDC on Lighter, USDG on Lighter RH; separate from perp collateral)
1328
- // An account without that asset holds none of it (0); an unreadable assets list / balance throws.
1329
- let spotUsdc = 0;
1330
- const collateralSymbol = this.collateralSymbol;
1331
- if (!Array.isArray(acct.assets)) {
1332
- throw new PerpError("EXCHANGE_ERROR", `${this.name} account has no assets list — spot ${collateralSymbol} balance unknown`, { exchange: this.name });
1333
- }
1334
- const usdcAsset = acct.assets.find(a => a?.symbol === collateralSymbol);
1335
- if (usdcAsset !== undefined)
1336
- spotUsdc = parseFiniteVenueNumber(usdcAsset.balance, `assets.${collateralSymbol}.balance`, "lighter");
1337
- return {
1338
- equity: String(totalAsset + spotUsdc),
1339
- available: String(available),
1340
- marginUsed: String(Math.max(0, collateral - available)),
1341
- unrealizedPnl: String(unrealizedPnl),
1342
- };
1456
+ return lighterBalanceOf(acct, this.name, this.collateralSymbol);
1343
1457
  }
1344
1458
  async getPositions() {
1345
1459
  if (!this._address)
1346
- throw new Error("No private key configured — account data unavailable. Run: perp setup");
1460
+ throw noAccountKeyError(this.name);
1347
1461
  const acct = await this.fetchAccount();
1348
1462
  if (!acct)
1349
1463
  return [];
1350
- return (acct.positions ?? [])
1351
- .filter((p) => parseFiniteVenueNumber(p.position, "position", "lighter") !== 0)
1352
- .map((p) => {
1353
- const posSize = parseFiniteVenueNumber(p.position, "position", "lighter");
1354
- const positionValue = parseFiniteVenueNumber(p.position_value, "position_value", "lighter");
1355
- return {
1356
- symbol: String(p.symbol || `Market-${p.market_id}`),
1357
- side: (Number(p.sign) > 0 ? "long" : "short"),
1358
- size: String(Math.abs(posSize)),
1359
- entryPrice: String(p.avg_entry_price || "0"),
1360
- markPrice: (() => {
1361
- if (posSize === 0)
1362
- return "0";
1363
- const rawMark = positionValue / Math.abs(posSize);
1364
- const priceDec = this._marketDecimals.get(String(p.symbol || "").toUpperCase())?.price;
1365
- return priceDec !== undefined ? rawMark.toFixed(priceDec) : formatComputedPrice(rawMark);
1366
- })(),
1367
- // The venue reports "0" when the position has no liquidation price (e.g. cross margin
1368
- // well covered): that is "none", not a price.
1369
- liquidationPrice: Number(p.liquidation_price) > 0 ? String(p.liquidation_price) : null,
1370
- unrealizedPnl: String(p.unrealized_pnl || "0"),
1371
- // The leverage CONFIGURED for the market (what `trade leverage` set) — not notional / equity.
1372
- ...lighterPositionSettings(p, this.name),
1373
- };
1374
- });
1464
+ return lighterPositionRows(acct.positions, this.name, (sym) => this._marketDecimals.get(sym.toUpperCase())?.price);
1375
1465
  }
1376
1466
  /**
1377
1467
  * Per-market leverage / margin mode the venue exposes: every `positions[]` entry of the
@@ -1380,7 +1470,7 @@ export class LighterAdapter {
1380
1470
  */
1381
1471
  async getMarketSettings() {
1382
1472
  if (!this._address)
1383
- throw new Error("No private key configured — account data unavailable. Run: perp setup");
1473
+ throw noAccountKeyError(this.name);
1384
1474
  const acct = await this.fetchAccount();
1385
1475
  if (acct === null)
1386
1476
  throw new PerpError("EXCHANGE_ERROR", `${this.name}: no account found for ${this._address}`, { exchange: this.name });
@@ -1402,7 +1492,7 @@ export class LighterAdapter {
1402
1492
  }
1403
1493
  async getOpenOrders() {
1404
1494
  if (!this._address)
1405
- throw new Error("No private key configured — account data unavailable. Run: perp setup");
1495
+ throw noAccountKeyError(this.name);
1406
1496
  if (this._accountIndex < 0 || this._readOnly)
1407
1497
  return [];
1408
1498
  // ONE request for every perp market. Docs (apidocs.lighter.xyz/reference/accountactiveorders):
@@ -1474,8 +1564,9 @@ export class LighterAdapter {
1474
1564
  try {
1475
1565
  this.ensureSigner();
1476
1566
  await this.ensureMarketMap();
1477
- const nonce = await this.getNextNonce();
1478
1567
  const marketIndex = this.getMarketIndex(symbol);
1568
+ this.assertOpenable(symbol, opts.reduceOnly);
1569
+ const nonce = await this.getNextNonce();
1479
1570
  ({ baseAmount } = this.toTicks(symbol, size, "0"));
1480
1571
  const reference = await this.getReferencePrice(symbol);
1481
1572
  const p = this.protectionFor(symbol, side, reference, opts.slippage, LIGHTER_REFERENCE_PRICE_SOURCE);
@@ -1538,8 +1629,9 @@ export class LighterAdapter {
1538
1629
  throw unsupported(this.name, tif === "FOK" ? "fok" : "timeInForce", `time-in-force ${opts?.tif} is not offered`, "Use --tif GTC or IOC");
1539
1630
  this.ensureSigner();
1540
1631
  await this.ensureMarketMap();
1541
- const nonce = await this.getNextNonce();
1542
1632
  const marketIndex = this.getMarketIndex(symbol);
1633
+ this.assertOpenable(symbol, opts?.reduceOnly === true);
1634
+ const nonce = await this.getNextNonce();
1543
1635
  const ticks = this.toTicks(symbol, size, price);
1544
1636
  sentBase = ticks.baseAmount;
1545
1637
  isIoc = timeInForce === LIGHTER_TIF.IOC;
@@ -1703,6 +1795,7 @@ export class LighterAdapter {
1703
1795
  // The order must be an open order of this market, and not a trigger order: a modify is
1704
1796
  // signed with triggerPrice 0, which would turn a stop / take-profit into a plain limit.
1705
1797
  existing = await this.activeOrderRow(symbol, marketIndex, orderId, "modified");
1798
+ this.assertOpenable(symbol, existing.reduce_only === true || existing.reduce_only === 1);
1706
1799
  const type = String(existing.type ?? "").toLowerCase();
1707
1800
  if (/stop|take|trigger/.test(type) || Number(existing.trigger_price ?? 0) > 0) {
1708
1801
  throw unsupported(this.name, "amend", "trigger orders cannot be modified", "cancel and re-place");
@@ -1872,8 +1965,9 @@ export class LighterAdapter {
1872
1965
  }
1873
1966
  this.ensureSigner();
1874
1967
  await this.ensureMarketMap();
1875
- const nonce = await this.getNextNonce();
1876
1968
  const marketIndex = this.getMarketIndex(symbol);
1969
+ this.assertOpenable(symbol, opts?.reduceOnly === true);
1970
+ const nonce = await this.getNextNonce();
1877
1971
  const { baseAmount } = this.toTicks(symbol, size, "0");
1878
1972
  const { priceTicks: triggerTicks } = this.toTicks(symbol, "0", triggerPrice);
1879
1973
  const triggerNum = parseFloat(triggerPrice);
@@ -2008,6 +2102,12 @@ export class LighterAdapter {
2008
2102
  return this.updateLeverage(symbol, leverage, marginMode);
2009
2103
  }
2010
2104
  async getRecentTrades(symbol, limit = 20) {
2105
+ // /recentTrades `limit`: 1..100 (apidocs.lighter.xyz/reference/recenttrades; 101 → code 20001).
2106
+ if (!Number.isInteger(limit) || limit < 1 || limit > LIGHTER_RECENT_TRADES_MAX) {
2107
+ throw new PerpError("INVALID_PARAMS", `${this.name} trades limit must be 1..${LIGHTER_RECENT_TRADES_MAX} (got ${limit})`, {
2108
+ exchange: this.name, remediation: `${this.name} serves at most the last ${LIGHTER_RECENT_TRADES_MAX} trades`,
2109
+ });
2110
+ }
2011
2111
  await this.ensureMarketMap();
2012
2112
  const marketId = this.getMarketIndex(symbol);
2013
2113
  const res = await this.restGet("/recentTrades", { market_id: String(marketId), limit: String(limit) });
@@ -2025,23 +2125,115 @@ export class LighterAdapter {
2025
2125
  fee: null,
2026
2126
  }));
2027
2127
  }
2128
+ /**
2129
+ * Settled funding prints: GET /api/v1/fundings (apidocs.lighter.xyz/reference/fundings;
2130
+ * `count_back` rows from `start_timestamp`, max 750). Each row is one hourly settlement:
2131
+ * `rate` is PERCENT per 1 h, unsigned, with `direction` "long" (longs pay → positive) or
2132
+ * "short" (shorts pay → negative). Live 2026-10-19 BTC: rate "0.0012", value "0.99315" =
2133
+ * mark 82,763 × 0.000012, i.e. value / mark = rate / 100. NOT the /funding-rates unit (8 h).
2134
+ */
2028
2135
  async getFundingHistory(symbol, limit = 10) {
2136
+ if (!Number.isInteger(limit) || limit < 1 || limit > 750) {
2137
+ throw new PerpError("INVALID_PARAMS", `${this.name} funding history limit must be 1..750 (got ${limit})`, { exchange: this.name });
2138
+ }
2029
2139
  await this.ensureMarketMap();
2030
- this.getMarketIndex(symbol); // unlisted market: SYMBOL_NOT_FOUND, not an empty history
2031
- const rates = await this.getFundingRates();
2032
- const r = rates.get(symbol.toUpperCase());
2033
- if (!r)
2034
- return [];
2035
- // Lighter funding-rates is current only, not historical
2036
- return [{ time: Date.now(), rate: r.rate, price: r.markPrice }];
2140
+ const marketId = this.getMarketIndex(symbol); // unlisted market: SYMBOL_NOT_FOUND, not an empty history
2141
+ const end = Math.floor(Date.now() / 1000);
2142
+ const res = await this.restGet("/fundings", {
2143
+ market_id: String(marketId),
2144
+ resolution: "1h",
2145
+ // One spare hour covers the settlement in progress.
2146
+ start_timestamp: String(end - (limit + 1) * 3600),
2147
+ end_timestamp: String(end),
2148
+ count_back: String(limit),
2149
+ });
2150
+ if (!Array.isArray(res.fundings)) {
2151
+ throw new PerpError("EXCHANGE_ERROR", `${this.name} /fundings: missing fundings array (${JSON.stringify(res).slice(0, 120)})`, { exchange: this.name });
2152
+ }
2153
+ return res.fundings.map((f) => lighterFundingRow(this.name, f)).sort((a, b) => a.time - b.time).slice(-limit);
2037
2154
  }
2155
+ /**
2156
+ * Candles opening in [startTime, endTime] (ms). GET /api/v1/candles answers the
2157
+ * max(count_back, bars in the window) newest bars ending at `end_timestamp`, at most 500
2158
+ * (live 2026-10-19, BTC 1h: a 96-bar window with count_back 1 / 96 → those 96 bars; with
2159
+ * count_back 500 → 500 bars, 404 of them before start_timestamp; a future window → past bars).
2160
+ * So count_back is the window's bar count (≤ 500), rows outside the window are dropped, and a
2161
+ * longer window is paged backwards in 500-bar chunks by moving end_timestamp. `end_timestamp` is
2162
+ * exclusive at second resolution (live: end = a bar's open excludes it, open + 1 s includes it),
2163
+ * so end + 1 s is sent for an inclusive --end. Each answer's `r`
2164
+ * must be the requested resolution. Intervals outside LIGHTER_CANDLE_INTERVALS and windows over
2165
+ * LIGHTER_KLINE_MAX_PAGES pages are refused with INVALID_PARAMS before any request.
2166
+ */
2038
2167
  async getKlines(symbol, interval, startTime, endTime) {
2039
- const res = await this.getCandles(symbol, interval, startTime, endTime);
2040
- // /candles returns the array under key `c` (top keys: code, r, c) — `res.candles`
2041
- // is always undefined, which silently rendered every Lighter kline set empty.
2042
- const candles = (res.c ?? res.candles ?? []);
2168
+ const ivMs = LIGHTER_CANDLE_INTERVALS[interval];
2169
+ if (ivMs === undefined) {
2170
+ throw new PerpError("INVALID_PARAMS", `${this.name} kline interval ${JSON.stringify(interval)} is not served (supported: ${Object.keys(LIGHTER_CANDLE_INTERVALS).join(", ")})`, {
2171
+ exchange: this.name, remediation: `Use one of: ${Object.keys(LIGHTER_CANDLE_INTERVALS).join(", ")}`,
2172
+ });
2173
+ }
2174
+ if (!Number.isFinite(startTime) || !Number.isFinite(endTime) || startTime > endTime) {
2175
+ throw new PerpError("INVALID_PARAMS", `${this.name} kline window must be start ≤ end (unix ms; got ${startTime}..${endTime})`, { exchange: this.name });
2176
+ }
2177
+ // Bar opens in [start, end] (bars open on multiples of the interval since the epoch).
2178
+ const barsIn = (from, to) => Math.floor(to / ivMs) - Math.ceil(from / ivMs) + 1;
2179
+ const bars = barsIn(startTime, endTime);
2180
+ if (bars > LIGHTER_CANDLES_PER_CALL * LIGHTER_KLINE_MAX_PAGES) {
2181
+ throw new PerpError("INVALID_PARAMS", `${this.name} kline window spans ${bars} ${interval} bars; at most ${LIGHTER_CANDLES_PER_CALL * LIGHTER_KLINE_MAX_PAGES} are read (${LIGHTER_KLINE_MAX_PAGES} requests of ${LIGHTER_CANDLES_PER_CALL})`, {
2182
+ exchange: this.name, remediation: "Narrow --start / --end or use a longer interval",
2183
+ });
2184
+ }
2185
+ if (bars <= 0) {
2186
+ // No bar opens in the window: nothing to ask for (count_back 0 is never sent); the symbol is still checked.
2187
+ await this.ensureMarketMap();
2188
+ this.getMarketIndex(symbol);
2189
+ return [];
2190
+ }
2191
+ const byTime = new Map();
2192
+ let cursorEnd = endTime;
2193
+ let prevEarliest = Infinity;
2194
+ let covered = false;
2195
+ for (let page = 0; page < LIGHTER_KLINE_MAX_PAGES; page++) {
2196
+ const left = barsIn(startTime, cursorEnd);
2197
+ if (left <= 0) {
2198
+ covered = true;
2199
+ break;
2200
+ } // every bar open in the window is read
2201
+ const want = Math.min(LIGHTER_CANDLES_PER_CALL, left);
2202
+ const res = await this.getCandles(symbol, interval, startTime, cursorEnd + 1000, want);
2203
+ assertCandleInterval(this.name, interval, res.r);
2204
+ // /candles returns the array under key `c` (top keys: code, r, c).
2205
+ if (!Array.isArray(res.c)) {
2206
+ throw new PerpError("EXCHANGE_ERROR", `${this.name} /candles: no candle list (${JSON.stringify(res).slice(0, 120)})`, { exchange: this.name });
2207
+ }
2208
+ const rows = res.c;
2209
+ let earliest = Infinity;
2210
+ for (const c of rows) {
2211
+ const t = Number(c.t);
2212
+ if (!Number.isFinite(t))
2213
+ throw new PerpError("EXCHANGE_ERROR", `${this.name} /candles: row without an open time: ${JSON.stringify(c).slice(0, 120)}`, { exchange: this.name });
2214
+ if (t < earliest)
2215
+ earliest = t;
2216
+ if (t >= startTime && t <= endTime)
2217
+ byTime.set(t, c);
2218
+ }
2219
+ // Done: the window start is covered, or the venue has no older bars (a short answer).
2220
+ if (rows.length === 0 || earliest <= startTime || rows.length < want) {
2221
+ covered = true;
2222
+ break;
2223
+ }
2224
+ if (earliest >= prevEarliest) {
2225
+ throw new PerpError("EXCHANGE_ERROR", `${this.name} /candles: paging made no progress (oldest bar ${earliest} not before the previous page's ${prevEarliest})`, { exchange: this.name });
2226
+ }
2227
+ prevEarliest = earliest;
2228
+ cursorEnd = earliest - 1000; // sent as `earliest`: the bars strictly before the oldest one read
2229
+ }
2230
+ if (!covered) {
2231
+ // Every page was full and the window start is still not reached: never a silently short window.
2232
+ throw new PerpError("EXCHANGE_ERROR", `${this.name} /candles: ${LIGHTER_KLINE_MAX_PAGES} full pages did not reach the window start ${startTime} (oldest ${prevEarliest})`, { exchange: this.name });
2233
+ }
2234
+ const candles = [...byTime.values()].sort((a, b) => Number(a.t) - Number(b.t));
2043
2235
  return candles.map((c) => ({
2044
- time: Number(c.start_timestamp ?? c.t ?? 0),
2236
+ time: Number(c.t),
2045
2237
  open: String(c.open ?? c.o ?? "0"),
2046
2238
  high: String(c.high ?? c.h ?? "0"),
2047
2239
  low: String(c.low ?? c.l ?? "0"),
@@ -2109,7 +2301,7 @@ export class LighterAdapter {
2109
2301
  */
2110
2302
  async getOrderHistory(limit = 30) {
2111
2303
  if (!this._address)
2112
- throw new Error("No private key configured — account data unavailable. Run: perp setup");
2304
+ throw noAccountKeyError(this.name);
2113
2305
  const orders = await this.historyRows("/accountInactiveOrders", "orders", { market_type: "perp" }, limit, true);
2114
2306
  // Every field is required: a missing price / size / status / type is an unreadable row, never "0" / "done" / "limit".
2115
2307
  const req = (o, k) => lighterRowField(o, k, this.name, "order history");
@@ -2139,7 +2331,7 @@ export class LighterAdapter {
2139
2331
  */
2140
2332
  async getTradeHistory(limit = 30) {
2141
2333
  if (!this._address)
2142
- throw new Error("No private key configured — account data unavailable. Run: perp setup");
2334
+ throw noAccountKeyError(this.name);
2143
2335
  const trades = await this.historyRows("/trades", "trades", { sort_by: "timestamp", market_type: "perp" }, limit, false);
2144
2336
  return trades.map((t) => {
2145
2337
  const sold = t.ask_account_id === this._accountIndex;
@@ -2174,7 +2366,7 @@ export class LighterAdapter {
2174
2366
  */
2175
2367
  async getFundingPayments(limit = 200) {
2176
2368
  if (!this._address)
2177
- throw new Error("No private key configured — account data unavailable. Run: perp setup");
2369
+ throw noAccountKeyError(this.name);
2178
2370
  const items = await this.historyRows("/positionFunding", "position_fundings", { side: "all" }, limit, true);
2179
2371
  return items.map((f) => {
2180
2372
  if (typeof f.timestamp !== "number" || typeof f.change !== "string") {
@@ -2184,8 +2376,9 @@ export class LighterAdapter {
2184
2376
  });
2185
2377
  }
2186
2378
  /**
2187
- * Get funding rates for all markets.
2188
- * API: GET /api/v1/funding-rates
2379
+ * GET /api/v1/funding-rates: the venue's PREDICTED rate × 8, a fraction per 8 h — not the venue
2380
+ * basis (1 h; market rows carry the settled hourly rate, fundingStats). Not used for funding
2381
+ * comparisons; normalize with 8 h if read.
2189
2382
  */
2190
2383
  async getFundingRates() {
2191
2384
  const map = new Map();