perp-cli 0.4.2 → 0.4.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +46 -5
  2. package/dist/arb/reconcile.d.ts +28 -0
  3. package/dist/arb/reconcile.js +162 -0
  4. package/dist/arb/sizing.d.ts +12 -1
  5. package/dist/arb/sizing.js +38 -5
  6. package/dist/arb/state.d.ts +6 -0
  7. package/dist/cli-spec.d.ts +0 -4
  8. package/dist/cli-spec.js +8 -1
  9. package/dist/commands/account.d.ts +1 -1
  10. package/dist/commands/account.js +300 -63
  11. package/dist/commands/agent.js +16 -9
  12. package/dist/commands/arb/index.js +243 -49
  13. package/dist/commands/arb-auto.js +696 -9
  14. package/dist/commands/arb.d.ts +2 -1
  15. package/dist/commands/arb.js +535 -131
  16. package/dist/commands/bot.js +207 -0
  17. package/dist/commands/dex.js +7 -1
  18. package/dist/commands/funding.d.ts +2 -1
  19. package/dist/commands/funding.js +211 -4
  20. package/dist/commands/funds.d.ts +4 -0
  21. package/dist/commands/funds.js +741 -0
  22. package/dist/commands/history.d.ts +2 -1
  23. package/dist/commands/history.js +508 -5
  24. package/dist/commands/manage.js +6 -4
  25. package/dist/commands/market.js +35 -2
  26. package/dist/commands/multileg.d.ts +16 -0
  27. package/dist/commands/multileg.js +174 -0
  28. package/dist/commands/pnl.d.ts +12 -0
  29. package/dist/commands/pnl.js +243 -0
  30. package/dist/commands/risk.d.ts +2 -0
  31. package/dist/commands/risk.js +56 -0
  32. package/dist/commands/settings.js +147 -0
  33. package/dist/commands/stream.js +132 -55
  34. package/dist/commands/trade.d.ts +1 -1
  35. package/dist/commands/trade.js +270 -267
  36. package/dist/commands/wallet.d.ts +4 -0
  37. package/dist/commands/wallet.js +11 -1
  38. package/dist/config.d.ts +6 -1
  39. package/dist/config.js +21 -1
  40. package/dist/dashboard/ws-feeds.js +1 -1
  41. package/dist/equity-tracker.d.ts +57 -0
  42. package/dist/equity-tracker.js +209 -0
  43. package/dist/exchanges/hyperliquid-spot.d.ts +69 -0
  44. package/dist/exchanges/hyperliquid-spot.js +309 -0
  45. package/dist/exchanges/hyperliquid.d.ts +26 -5
  46. package/dist/exchanges/hyperliquid.js +171 -73
  47. package/dist/exchanges/interface.d.ts +1 -1
  48. package/dist/exchanges/lighter-spot.d.ts +55 -0
  49. package/dist/exchanges/lighter-spot.js +415 -0
  50. package/dist/exchanges/lighter.d.ts +10 -2
  51. package/dist/exchanges/lighter.js +210 -56
  52. package/dist/exchanges/pacifica.d.ts +11 -4
  53. package/dist/exchanges/pacifica.js +64 -37
  54. package/dist/exchanges/spot-interface.d.ts +45 -0
  55. package/dist/exchanges/spot-interface.js +22 -0
  56. package/dist/execution-log.d.ts +2 -2
  57. package/dist/funding/rates.d.ts +36 -0
  58. package/dist/funding/rates.js +146 -0
  59. package/dist/index.js +126 -161
  60. package/dist/liquidity.d.ts +12 -0
  61. package/dist/liquidity.js +43 -0
  62. package/dist/mcp-server.js +43 -96
  63. package/dist/signer/evm-local.d.ts +12 -0
  64. package/dist/signer/evm-local.js +19 -0
  65. package/dist/signer/index.d.ts +3 -0
  66. package/dist/signer/index.js +2 -0
  67. package/dist/signer/interface.d.ts +31 -0
  68. package/dist/signer/interface.js +7 -0
  69. package/dist/signer/solana-local.d.ts +13 -0
  70. package/dist/signer/solana-local.js +19 -0
  71. package/dist/smart-order.js +14 -3
  72. package/dist/strategies/split-order.d.ts +49 -0
  73. package/dist/strategies/split-order.js +138 -0
  74. package/dist/utils.js +7 -1
  75. package/dist/ws/market-feed.d.ts +74 -0
  76. package/dist/ws/market-feed.js +227 -0
  77. package/package.json +1 -1
  78. package/skills/perp-cli/SKILL.md +80 -4
  79. package/skills/perp-cli/references/agent-operations.md +19 -9
  80. package/skills/perp-cli/references/commands.md +54 -32
  81. package/skills/perp-cli/references/strategies.md +27 -17
  82. package/skills/perp-cli/scripts/arb-monitor.sh +74 -0
  83. package/skills/perp-cli/scripts/funding-analysis.sh +85 -0
  84. package/skills/perp-cli/scripts/preflight.sh +98 -0
  85. package/skills/perp-cli/scripts/spot-perp-scan.sh +83 -0
  86. package/skills/perp-cli/scripts/validate-arb.sh +112 -0
  87. package/skills/perp-cli/templates/arb-scan.sh +0 -15
  88. package/skills/perp-cli/templates/open-position.sh +0 -38
  89. package/skills/perp-cli/templates/wallet-setup.sh +0 -17
@@ -1,22 +1,37 @@
1
- import nacl from "tweetnacl";
2
1
  import { PacificaClient } from "../pacifica/index.js";
3
- function makeSignMessage(keypair) {
4
- return async (message) => {
5
- return nacl.sign.detached(message, keypair.secretKey);
6
- };
7
- }
2
+ import { LocalSolanaSigner } from "../signer/solana-local.js";
8
3
  export class PacificaAdapter {
9
4
  name = "pacifica";
10
5
  client;
11
- keypair;
6
+ _solanaSigner;
7
+ _hasRealKey;
12
8
  account;
13
9
  signMessage;
14
10
  // In-memory caches removed — using file-based cache (src/cache.ts) for cross-process dedup
15
- constructor(keypair, network = "mainnet", builderCode) {
16
- this.keypair = keypair;
17
- this.account = keypair.publicKey.toBase58();
11
+ constructor(keypair, network = "mainnet", builderCode, hasRealKey = true) {
12
+ this._solanaSigner = new LocalSolanaSigner(keypair);
13
+ this._hasRealKey = hasRealKey;
14
+ this.account = this._solanaSigner.getPublicKeyBase58();
18
15
  this.client = new PacificaClient({ network, builderCode });
19
- this.signMessage = makeSignMessage(keypair);
16
+ this.signMessage = (msg) => this._solanaSigner.signMessage(msg);
17
+ }
18
+ ensureSigner() {
19
+ if (!this._hasRealKey) {
20
+ throw new Error("No private key configured. Run: perp init");
21
+ }
22
+ }
23
+ /** Inject an external Solana signer. */
24
+ setSigner(signer) {
25
+ this._solanaSigner = signer;
26
+ this.account = signer.getPublicKeyBase58();
27
+ this.signMessage = (msg) => this._solanaSigner.signMessage(msg);
28
+ }
29
+ /** Access the underlying Keypair (only available with LocalSolanaSigner). */
30
+ get keypair() {
31
+ if (this._solanaSigner instanceof LocalSolanaSigner) {
32
+ return this._solanaSigner._keypair;
33
+ }
34
+ throw new Error("keypair not available with external signer");
20
35
  }
21
36
  async _getPrices() {
22
37
  const { fetchAndCache, TTL_ACCOUNT } = await import("../cache.js");
@@ -62,24 +77,15 @@ export class PacificaAdapter {
62
77
  };
63
78
  }
64
79
  async getBalance() {
65
- const [info, positions, prices] = await Promise.all([
80
+ if (!this._hasRealKey)
81
+ throw new Error("No private key configured — account data unavailable. Run: perp init");
82
+ const [info, positions] = await Promise.all([
66
83
  this.client.getAccount(this.account),
67
84
  this._getPositions(),
68
- this._getPrices(),
69
85
  ]);
70
86
  const raw = info;
71
- const priceMap = new Map(prices.map((p) => [p.symbol, Number(p.mark)]));
72
- // Sum actual unrealized PnL from positions (mark vs entry)
73
- let totalPnl = 0;
74
- for (const p of positions) {
75
- const mark = priceMap.get(p.symbol) ?? 0;
76
- const entry = Number(p.entry_price);
77
- const amount = Number(p.amount);
78
- const side = p.side === "bid" ? "long" : "short";
79
- totalPnl += side === "long"
80
- ? (mark - entry) * amount
81
- : (entry - mark) * amount;
82
- }
87
+ // Use API-provided unrealized PnL from positions
88
+ const totalPnl = positions.reduce((sum, p) => sum + Number(p.unrealized_pnl ?? 0), 0);
83
89
  return {
84
90
  equity: info.account_equity,
85
91
  available: info.available_to_spend,
@@ -88,6 +94,8 @@ export class PacificaAdapter {
88
94
  };
89
95
  }
90
96
  async getPositions() {
97
+ if (!this._hasRealKey)
98
+ throw new Error("No private key configured — account data unavailable. Run: perp init");
91
99
  const [positions, prices] = await Promise.all([
92
100
  this._getPositions(),
93
101
  this._getPrices(),
@@ -104,14 +112,8 @@ export class PacificaAdapter {
104
112
  // Settings API may not be available
105
113
  }
106
114
  return positions.map((p) => {
107
- const mark = priceMap.get(p.symbol)?.mark ?? "0";
108
- const entryPrice = Number(p.entry_price);
109
- const amount = Number(p.amount);
110
- const markNum = Number(mark);
115
+ const mark = priceMap.get(p.symbol)?.mark ?? p.mark_price ?? "0";
111
116
  const side = p.side === "bid" ? "long" : "short";
112
- const pnl = side === "long"
113
- ? (markNum - entryPrice) * amount
114
- : (entryPrice - markNum) * amount;
115
117
  return {
116
118
  symbol: p.symbol,
117
119
  side: side,
@@ -119,12 +121,14 @@ export class PacificaAdapter {
119
121
  entryPrice: String(p.entry_price),
120
122
  markPrice: mark,
121
123
  liquidationPrice: String(p.liquidation_price ?? "N/A"),
122
- unrealizedPnl: pnl.toFixed(4),
123
- leverage: levMap.get(String(p.symbol)) ?? 1,
124
+ unrealizedPnl: String(p.unrealized_pnl ?? "0"),
125
+ leverage: p.leverage ?? levMap.get(String(p.symbol)) ?? 1,
124
126
  };
125
127
  });
126
128
  }
127
129
  async getOpenOrders() {
130
+ if (!this._hasRealKey)
131
+ throw new Error("No private key configured — account data unavailable. Run: perp init");
128
132
  const orders = await this.client.getOrders(this.account);
129
133
  return orders.map((o) => {
130
134
  const raw = o;
@@ -141,21 +145,37 @@ export class PacificaAdapter {
141
145
  });
142
146
  }
143
147
  async marketOrder(symbol, side, size) {
148
+ this.ensureSigner();
144
149
  return this.client.createMarketOrder({ symbol, amount: size, side: side === "buy" ? "bid" : "ask", reduce_only: false, slippage_percent: "1" }, this.account, this.signMessage);
145
150
  }
146
151
  async limitOrder(symbol, side, price, size, opts) {
152
+ this.ensureSigner();
147
153
  return this.client.createLimitOrder({ symbol, price, amount: size, side: side === "buy" ? "bid" : "ask", reduce_only: opts?.reduceOnly ?? false, tif: (opts?.tif ?? "GTC") }, this.account, this.signMessage);
148
154
  }
149
155
  async cancelOrder(symbol, orderId) {
156
+ this.ensureSigner();
150
157
  return this.client.cancelOrder({ symbol, order_id: Number(orderId) }, this.account, this.signMessage);
151
158
  }
152
- async cancelAllOrders(_symbol) {
159
+ async cancelAllOrders(symbol) {
160
+ this.ensureSigner();
161
+ if (symbol) {
162
+ // Cancel only orders for this symbol: fetch open orders, filter, cancel individually
163
+ const orders = await this.getOpenOrders();
164
+ const filtered = orders.filter(o => o.symbol.toUpperCase() === symbol.toUpperCase());
165
+ const results = [];
166
+ for (const o of filtered) {
167
+ results.push(await this.cancelOrder(o.symbol, o.orderId));
168
+ }
169
+ return results;
170
+ }
153
171
  return this.client.cancelAllOrders({ all_symbols: true, exclude_reduce_only: false }, this.account, this.signMessage);
154
172
  }
155
173
  async editOrder(symbol, orderId, price, size) {
174
+ this.ensureSigner();
156
175
  return this.client.editOrder({ symbol, order_id: Number(orderId), price, amount: size }, this.account, this.signMessage);
157
176
  }
158
177
  async setLeverage(symbol, leverage, marginMode = "cross") {
178
+ this.ensureSigner();
159
179
  await this.client.updateLeverage({ symbol, leverage }, this.account, this.signMessage);
160
180
  if (marginMode === "isolated") {
161
181
  await this.client.updateMarginMode({ symbol, is_isolated: true }, this.account, this.signMessage);
@@ -163,6 +183,7 @@ export class PacificaAdapter {
163
183
  return { symbol, leverage, marginMode };
164
184
  }
165
185
  async stopOrder(symbol, side, size, triggerPrice, opts) {
186
+ this.ensureSigner();
166
187
  return this.client.createStopOrder({
167
188
  symbol,
168
189
  side: side === "buy" ? "bid" : "ask",
@@ -173,12 +194,12 @@ export class PacificaAdapter {
173
194
  async getRecentTrades(symbol, _limit = 20) {
174
195
  const trades = await this.client.getTrades(symbol);
175
196
  return trades.slice(0, _limit).map((t) => ({
176
- time: new Date(t.created_at).getTime(),
197
+ time: Number(t.created_at ?? 0),
177
198
  symbol,
178
199
  side: t.side === "bid" ? "buy" : "sell",
179
200
  price: t.price,
180
201
  size: t.amount,
181
- fee: "0",
202
+ fee: String(t.fee ?? "0"),
182
203
  }));
183
204
  }
184
205
  async getFundingHistory(symbol, limit = 10) {
@@ -205,6 +226,8 @@ export class PacificaAdapter {
205
226
  }));
206
227
  }
207
228
  async getOrderHistory(limit = 30) {
229
+ if (!this._hasRealKey)
230
+ throw new Error("No private key configured — account data unavailable. Run: perp init");
208
231
  const result = await this.client.getOrderHistory(this.account);
209
232
  const data = (result.data ?? result.orders ?? []);
210
233
  if (!Array.isArray(data))
@@ -221,6 +244,8 @@ export class PacificaAdapter {
221
244
  }));
222
245
  }
223
246
  async getTradeHistory(limit = 30) {
247
+ if (!this._hasRealKey)
248
+ throw new Error("No private key configured — account data unavailable. Run: perp init");
224
249
  const raw = await this.client.getTradeHistory(this.account);
225
250
  const trades = (raw.data ?? raw);
226
251
  if (!Array.isArray(trades))
@@ -235,6 +260,8 @@ export class PacificaAdapter {
235
260
  }));
236
261
  }
237
262
  async getFundingPayments(limit = 30) {
263
+ if (!this._hasRealKey)
264
+ throw new Error("No private key configured — account data unavailable. Run: perp init");
238
265
  const raw = await this.client.getFundingAccountHistory(this.account);
239
266
  const history = (raw.data ?? raw);
240
267
  if (!Array.isArray(history))
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Spot trading interface for exchanges that support both spot and perp.
3
+ * Separate from ExchangeAdapter because spot uses balance-based tracking
4
+ * (not position-based) and has no leverage, liquidation, or funding concepts.
5
+ *
6
+ * Supported: Hyperliquid, Lighter (Pacifica is perp-only)
7
+ */
8
+ export interface SpotMarketInfo {
9
+ symbol: string;
10
+ baseToken: string;
11
+ quoteToken: string;
12
+ markPrice: string;
13
+ volume24h: string;
14
+ sizeDecimals: number;
15
+ priceDecimals: number;
16
+ }
17
+ export interface SpotBalance {
18
+ token: string;
19
+ total: string;
20
+ available: string;
21
+ held: string;
22
+ }
23
+ export interface SpotAdapter {
24
+ readonly name: string;
25
+ getSpotMarkets(): Promise<SpotMarketInfo[]>;
26
+ getSpotOrderbook(symbol: string): Promise<{
27
+ bids: [string, string][];
28
+ asks: [string, string][];
29
+ }>;
30
+ getSpotBalances(): Promise<SpotBalance[]>;
31
+ spotMarketOrder(symbol: string, side: "buy" | "sell", size: string): Promise<unknown>;
32
+ spotLimitOrder(symbol: string, side: "buy" | "sell", price: string, size: string, opts?: {
33
+ tif?: string;
34
+ }): Promise<unknown>;
35
+ spotCancelOrder(symbol: string, orderId: string): Promise<unknown>;
36
+ }
37
+ /**
38
+ * U-token (Unit protocol) → underlying perp symbol mapping.
39
+ * These are bridged tokens on HyperEVM with the same price as the underlying (~0.1% deviation).
40
+ */
41
+ export declare const SPOT_PERP_TOKEN_MAP: Record<string, string>;
42
+ /** Reverse: perp symbol → U-token spot name on HL */
43
+ export declare const PERP_TO_SPOT_MAP: Record<string, string>;
44
+ /** Maximum price deviation (%) between spot and perp to be considered same underlying */
45
+ export declare const MAX_PRICE_DEVIATION_PCT = 5;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Spot trading interface for exchanges that support both spot and perp.
3
+ * Separate from ExchangeAdapter because spot uses balance-based tracking
4
+ * (not position-based) and has no leverage, liquidation, or funding concepts.
5
+ *
6
+ * Supported: Hyperliquid, Lighter (Pacifica is perp-only)
7
+ */
8
+ // ── Token mapping & price verification ──
9
+ /**
10
+ * U-token (Unit protocol) → underlying perp symbol mapping.
11
+ * These are bridged tokens on HyperEVM with the same price as the underlying (~0.1% deviation).
12
+ */
13
+ export const SPOT_PERP_TOKEN_MAP = {
14
+ UBTC: "BTC",
15
+ UETH: "ETH",
16
+ USOL: "SOL",
17
+ UFART: "FARTCOIN",
18
+ };
19
+ /** Reverse: perp symbol → U-token spot name on HL */
20
+ export const PERP_TO_SPOT_MAP = Object.fromEntries(Object.entries(SPOT_PERP_TOKEN_MAP).map(([k, v]) => [v, k]));
21
+ /** Maximum price deviation (%) between spot and perp to be considered same underlying */
22
+ export const MAX_PRICE_DEVIATION_PCT = 5;
@@ -1,14 +1,14 @@
1
1
  export interface ExecutionRecord {
2
2
  id: string;
3
3
  timestamp: string;
4
- type: "market_order" | "limit_order" | "cancel" | "stop_order" | "edit_order" | "rebalance" | "arb_entry" | "arb_close" | "bridge";
4
+ type: "market_order" | "limit_order" | "cancel" | "stop_order" | "edit_order" | "rebalance" | "arb_entry" | "arb_close" | "bridge" | "multi_leg" | "multi_leg_rollback" | "split_order";
5
5
  exchange: string;
6
6
  symbol: string;
7
7
  side: string;
8
8
  size: string;
9
9
  price?: string;
10
10
  notional?: number;
11
- status: "success" | "failed" | "simulated";
11
+ status: "success" | "failed" | "simulated" | "unverified";
12
12
  error?: string;
13
13
  meta?: Record<string, unknown>;
14
14
  dryRun: boolean;
@@ -43,3 +43,39 @@ export declare function fetchAllFundingRates(opts?: {
43
43
  * Fetch rates for a single symbol across all exchanges.
44
44
  */
45
45
  export declare function fetchSymbolFundingRates(symbol: string): Promise<SymbolFundingComparison | null>;
46
+ export interface SpotPerpSpread {
47
+ symbol: string;
48
+ /** Perp exchange with the highest absolute funding rate */
49
+ perpExchange: string;
50
+ /** Spot exchange(s) available for the hedge leg */
51
+ spotExchanges: string[];
52
+ /** Perp funding rate (raw) */
53
+ perpFundingRate: number;
54
+ /** Perp funding hourly rate */
55
+ perpHourlyRate: number;
56
+ /** Annualized spread % (spot funding = 0, so spread = |perp annual rate|) */
57
+ annualSpreadPct: number;
58
+ /** Best mark price */
59
+ bestMarkPrice: number;
60
+ /** Direction: "long-spot-short-perp" when perp funding > 0, else "sell-spot-long-perp" */
61
+ direction: "long-spot-short-perp" | "sell-spot-long-perp";
62
+ /** Estimated hourly income for $1000 notional */
63
+ estHourlyIncomeUsd: number;
64
+ }
65
+ /**
66
+ * Fetch spot+perp funding rate spreads.
67
+ * Since spot has 0 funding cost, the spread is simply |perp funding rate|.
68
+ * Only includes symbols available on at least one spot exchange (HL or LT).
69
+ *
70
+ * Key safety checks:
71
+ * - U-token mapping: UBTC→BTC, UETH→ETH, USOL→SOL, UFART→FARTCOIN
72
+ * - Price validation: spot mid price must be within 5% of perp mark price
73
+ * (filters out same-ticker-different-token issues like HIP-1 TRUMP ≠ perp TRUMP)
74
+ */
75
+ export declare function fetchSpotPerpSpreads(opts?: {
76
+ symbols?: string[];
77
+ minSpread?: number;
78
+ }): Promise<{
79
+ timestamp: string;
80
+ spreads: SpotPerpSpread[];
81
+ }>;
@@ -8,6 +8,7 @@
8
8
  import { toHourlyRate, annualizeRate, computeAnnualSpread, estimateHourlyFunding } from "./normalize.js";
9
9
  import { fetchPacificaPrices, fetchHyperliquidMeta, fetchLighterOrderBookDetails, fetchLighterFundingRates as fetchLtFundingRates, } from "../shared-api.js";
10
10
  import { getHistoricalAverages } from "./history.js";
11
+ import { SPOT_PERP_TOKEN_MAP, MAX_PRICE_DEVIATION_PCT } from "../exchanges/spot-interface.js";
11
12
  // ── Default top symbols to track ──
12
13
  export const TOP_SYMBOLS = [
13
14
  "BTC", "ETH", "SOL", "DOGE", "SUI", "AVAX", "LINK", "ARB",
@@ -170,3 +171,148 @@ export async function fetchSymbolFundingRates(symbol) {
170
171
  const snapshot = await fetchAllFundingRates({ symbols: [symbol.toUpperCase()] });
171
172
  return snapshot.symbols[0] ?? null;
172
173
  }
174
+ /**
175
+ * Fetch spot+perp funding rate spreads.
176
+ * Since spot has 0 funding cost, the spread is simply |perp funding rate|.
177
+ * Only includes symbols available on at least one spot exchange (HL or LT).
178
+ *
179
+ * Key safety checks:
180
+ * - U-token mapping: UBTC→BTC, UETH→ETH, USOL→SOL, UFART→FARTCOIN
181
+ * - Price validation: spot mid price must be within 5% of perp mark price
182
+ * (filters out same-ticker-different-token issues like HIP-1 TRUMP ≠ perp TRUMP)
183
+ */
184
+ export async function fetchSpotPerpSpreads(opts) {
185
+ // Fetch all perp funding rates
186
+ const [hlRates, ltRates, pacRates] = await Promise.all([
187
+ fetchHyperliquidRates(),
188
+ fetchLighterRates(),
189
+ fetchPacificaRates(),
190
+ ]);
191
+ // Build actual spot availability maps + spot prices for cross-validation
192
+ // hlSpotByPerp: perpSymbol → { spotToken, exchange }
193
+ const hlSpotByPerp = new Map(); // perpSymbol → spotTokenName
194
+ const hlSpotPrices = new Map(); // perpSymbol → spot mid price
195
+ const ltSpotSymbols = new Set();
196
+ const ltSpotPrices = new Map(); // symbol → spot mid price
197
+ try {
198
+ // HL: spotMetaAndAssetCtxs → universe + prices in one call
199
+ const hlMetaCtx = await fetch("https://api.hyperliquid.xyz/info", {
200
+ method: "POST", headers: { "Content-Type": "application/json" },
201
+ body: JSON.stringify({ type: "spotMetaAndAssetCtxs" }),
202
+ }).then(r => r.json());
203
+ const meta = hlMetaCtx[0];
204
+ const ctxs = hlMetaCtx[1] ?? [];
205
+ const tokenNames = new Map();
206
+ for (const t of meta.tokens ?? [])
207
+ tokenNames.set(t.index, t.name);
208
+ const usdcIdx = meta.tokens?.find(t => t.name === "USDC")?.index ?? 0;
209
+ // We need to match universe entries to ctxs by index.
210
+ // API returns ctxs in same order as universe entries.
211
+ for (let i = 0; i < (meta.universe ?? []).length; i++) {
212
+ const u = meta.universe[i];
213
+ if (u.tokens[1] !== usdcIdx)
214
+ continue; // only /USDC pairs
215
+ const base = tokenNames.get(u.tokens[0])?.toUpperCase();
216
+ if (!base)
217
+ continue;
218
+ const ctx = ctxs[i];
219
+ const spotPrice = Number(ctx?.midPx ?? ctx?.markPx ?? 0);
220
+ // Map to perp symbol: UBTC→BTC, or direct if no mapping
221
+ const perpSymbol = SPOT_PERP_TOKEN_MAP[base] ?? base;
222
+ // Only keep the best-priced entry per perp symbol (prefer higher-volume)
223
+ if (!hlSpotByPerp.has(perpSymbol) || spotPrice > 0) {
224
+ hlSpotByPerp.set(perpSymbol, base);
225
+ if (spotPrice > 0)
226
+ hlSpotPrices.set(perpSymbol, spotPrice);
227
+ }
228
+ }
229
+ }
230
+ catch { /* non-critical */ }
231
+ try {
232
+ // LT: explorer API → symbols with "/"
233
+ const ltMarkets = await fetch("https://explorer.elliot.ai/api/markets")
234
+ .then(r => r.json());
235
+ for (const m of ltMarkets) {
236
+ if (m.symbol.includes("/")) {
237
+ ltSpotSymbols.add(m.symbol.split("/")[0].toUpperCase());
238
+ }
239
+ }
240
+ // Fetch LT spot prices from orderbook for price validation
241
+ for (const sym of ltSpotSymbols) {
242
+ try {
243
+ const book = await fetch(`https://mainnet.zklighter.elliot.ai/api/v1/orderBookOrders?market_id=${
244
+ // We don't have market_id here, skip detailed price fetch for LT
245
+ // LT tokens (ETH, LINK, etc.) are direct names, low risk of mismatch
246
+ ""}`);
247
+ // Skip — LT uses direct token names, not indices
248
+ }
249
+ catch { /* skip */ }
250
+ }
251
+ }
252
+ catch { /* non-critical */ }
253
+ // Merge all perp rates by symbol
254
+ const perpRateMap = new Map();
255
+ for (const r of [...hlRates, ...ltRates, ...pacRates]) {
256
+ if (!r.symbol)
257
+ continue;
258
+ if (opts?.symbols && !opts.symbols.includes(r.symbol.toUpperCase()))
259
+ continue;
260
+ const key = r.symbol.toUpperCase();
261
+ if (!perpRateMap.has(key))
262
+ perpRateMap.set(key, []);
263
+ perpRateMap.get(key).push(r);
264
+ }
265
+ const spreads = [];
266
+ const minSpread = opts?.minSpread ?? 0;
267
+ for (const [symbol, rates] of perpRateMap) {
268
+ rates.sort((a, b) => Math.abs(b.hourlyRate) - Math.abs(a.hourlyRate));
269
+ const bestPerp = rates[0];
270
+ // Spot exchanges: only include if symbol has a verified spot market
271
+ const uniqueSpot = [];
272
+ // HL spot: check U-token mapped availability
273
+ if (hlSpotByPerp.has(symbol)) {
274
+ // Price cross-validation: spot price must be close to perp mark price
275
+ const spotPrice = hlSpotPrices.get(symbol);
276
+ const perpPrice = bestPerp.markPrice;
277
+ if (spotPrice && perpPrice > 0) {
278
+ const deviation = Math.abs(spotPrice - perpPrice) / perpPrice * 100;
279
+ if (deviation <= MAX_PRICE_DEVIATION_PCT) {
280
+ uniqueSpot.push("hyperliquid");
281
+ }
282
+ // else: same ticker, different token (e.g., HIP-1 TRUMP ≠ perp TRUMP) → skip
283
+ }
284
+ else if (spotPrice === undefined) {
285
+ // No price data (empty orderbook) → skip, can't verify
286
+ }
287
+ }
288
+ // LT spot: direct token names, low mismatch risk
289
+ if (ltSpotSymbols.has(symbol))
290
+ uniqueSpot.push("lighter");
291
+ if (uniqueSpot.length === 0)
292
+ continue;
293
+ const annualSpreadPct = Math.abs(bestPerp.annualizedPct);
294
+ if (annualSpreadPct < minSpread)
295
+ continue;
296
+ const direction = bestPerp.hourlyRate > 0
297
+ ? "long-spot-short-perp"
298
+ : "sell-spot-long-perp";
299
+ const notional = 1000;
300
+ const estHourlyIncomeUsd = Math.abs(bestPerp.hourlyRate) * notional;
301
+ spreads.push({
302
+ symbol,
303
+ perpExchange: bestPerp.exchange,
304
+ spotExchanges: uniqueSpot,
305
+ perpFundingRate: bestPerp.fundingRate,
306
+ perpHourlyRate: bestPerp.hourlyRate,
307
+ annualSpreadPct,
308
+ bestMarkPrice: bestPerp.markPrice,
309
+ direction,
310
+ estHourlyIncomeUsd,
311
+ });
312
+ }
313
+ spreads.sort((a, b) => b.annualSpreadPct - a.annualSpreadPct);
314
+ return {
315
+ timestamp: new Date().toISOString(),
316
+ spreads,
317
+ };
318
+ }