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
package/dist/config.js CHANGED
@@ -3,7 +3,27 @@ import bs58 from "bs58";
3
3
  import chalk from "chalk";
4
4
  import { resolve } from "path";
5
5
  import { existsSync, readFileSync } from "fs";
6
- export async function loadPrivateKey(exchange, pkOverride) {
6
+ /**
7
+ * Try to load a private key without throwing if none is found.
8
+ * Returns null if no key is configured.
9
+ */
10
+ export async function tryLoadPrivateKey(exchange, pkOverride, walletName) {
11
+ try {
12
+ return await loadPrivateKey(exchange, pkOverride, walletName);
13
+ }
14
+ catch {
15
+ return null;
16
+ }
17
+ }
18
+ export async function loadPrivateKey(exchange, pkOverride, walletName) {
19
+ // 0. Wallet name override (--wallet flag)
20
+ if (walletName) {
21
+ const { getWalletKeyByName } = await import("./commands/wallet.js");
22
+ const key = getWalletKeyByName(walletName);
23
+ if (!key)
24
+ throw new Error(`Wallet "${walletName}" not found. Run: perp wallet list`);
25
+ return key;
26
+ }
7
27
  // 1. CLI flag
8
28
  if (pkOverride)
9
29
  return pkOverride;
@@ -569,7 +569,7 @@ class LighterFeed extends ExchangeWsFeed {
569
569
  liquidationPrice: String(p.liquidation_price || "N/A"),
570
570
  unrealizedPnl: String(p.unrealized_pnl ?? "0"),
571
571
  leverage: Number(p.initial_margin_fraction ?? 0) > 0
572
- ? Math.round(100 / Number(p.initial_margin_fraction))
572
+ ? Math.round(10000 / Number(p.initial_margin_fraction))
573
573
  : 1,
574
574
  };
575
575
  });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Equity Tracker — time-series equity snapshots for PnL analysis.
3
+ *
4
+ * Stores snapshots in ~/.perp/equity-history/YYYY-MM.jsonl (monthly files).
5
+ * Computes daily PnL, Sharpe ratio, max drawdown, win/loss stats.
6
+ */
7
+ export interface EquitySnapshot {
8
+ ts: string;
9
+ exchange: string;
10
+ equity: number;
11
+ available: number;
12
+ marginUsed: number;
13
+ unrealizedPnl: number;
14
+ positionCount: number;
15
+ }
16
+ export interface DailyPnl {
17
+ date: string;
18
+ exchange: string;
19
+ startEquity: number;
20
+ endEquity: number;
21
+ pnl: number;
22
+ pnlPct: number;
23
+ }
24
+ export interface PnlMetrics {
25
+ totalReturn: number;
26
+ totalReturnPct: number;
27
+ dailyReturns: DailyPnl[];
28
+ sharpeRatio: number;
29
+ maxDrawdown: number;
30
+ maxDrawdownPct: number;
31
+ winDays: number;
32
+ lossDays: number;
33
+ winRate: number;
34
+ peakEquity: number;
35
+ currentDrawdown: number;
36
+ currentDrawdownPct: number;
37
+ bestDay: DailyPnl | null;
38
+ worstDay: DailyPnl | null;
39
+ avgDailyPnl: number;
40
+ period: {
41
+ from: string;
42
+ to: string;
43
+ days: number;
44
+ };
45
+ }
46
+ export declare function saveEquitySnapshot(snap: EquitySnapshot): void;
47
+ export declare function readEquityHistory(opts?: {
48
+ exchange?: string;
49
+ since?: Date;
50
+ until?: Date;
51
+ }): EquitySnapshot[];
52
+ /** Group snapshots by date, take first and last per day for daily PnL. */
53
+ export declare function computeDailyPnl(snapshots: EquitySnapshot[], exchange?: string): DailyPnl[];
54
+ /** Compute comprehensive PnL metrics from equity snapshots. */
55
+ export declare function computePnlMetrics(snapshots: EquitySnapshot[], exchange?: string): PnlMetrics;
56
+ /** Compute weekly PnL from daily PnL data. */
57
+ export declare function aggregateWeekly(daily: DailyPnl[]): DailyPnl[];
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Equity Tracker — time-series equity snapshots for PnL analysis.
3
+ *
4
+ * Stores snapshots in ~/.perp/equity-history/YYYY-MM.jsonl (monthly files).
5
+ * Computes daily PnL, Sharpe ratio, max drawdown, win/loss stats.
6
+ */
7
+ import { homedir } from "os";
8
+ import { join } from "path";
9
+ import { mkdirSync, appendFileSync, existsSync, readdirSync, readFileSync } from "fs";
10
+ const BASE_DIR = join(homedir(), ".perp", "equity-history");
11
+ // ── Storage ──
12
+ function getFilePath(date) {
13
+ const ym = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
14
+ return join(BASE_DIR, `${ym}.jsonl`);
15
+ }
16
+ export function saveEquitySnapshot(snap) {
17
+ mkdirSync(BASE_DIR, { recursive: true });
18
+ const file = getFilePath(new Date(snap.ts));
19
+ appendFileSync(file, JSON.stringify(snap) + "\n");
20
+ }
21
+ export function readEquityHistory(opts) {
22
+ if (!existsSync(BASE_DIR))
23
+ return [];
24
+ const files = readdirSync(BASE_DIR)
25
+ .filter(f => f.endsWith(".jsonl"))
26
+ .sort();
27
+ const results = [];
28
+ for (const file of files) {
29
+ // Skip files clearly before range by filename (YYYY-MM.jsonl)
30
+ if (opts?.since) {
31
+ const ym = file.replace(".jsonl", "");
32
+ const fileMonth = new Date(ym + "-01");
33
+ const sinceMonth = new Date(opts.since.getFullYear(), opts.since.getMonth(), 1);
34
+ if (fileMonth < sinceMonth)
35
+ continue;
36
+ }
37
+ const content = readFileSync(join(BASE_DIR, file), "utf-8");
38
+ for (const line of content.split("\n")) {
39
+ if (!line.trim())
40
+ continue;
41
+ try {
42
+ const snap = JSON.parse(line);
43
+ if (opts?.exchange && snap.exchange !== opts.exchange)
44
+ continue;
45
+ const ts = new Date(snap.ts);
46
+ if (opts?.since && ts < opts.since)
47
+ continue;
48
+ if (opts?.until && ts > opts.until)
49
+ continue;
50
+ results.push(snap);
51
+ }
52
+ catch { /* skip malformed lines */ }
53
+ }
54
+ }
55
+ return results;
56
+ }
57
+ // ── Analysis ──
58
+ /** Group snapshots by date, take first and last per day for daily PnL. */
59
+ export function computeDailyPnl(snapshots, exchange) {
60
+ // If exchange filter provided, apply it
61
+ const filtered = exchange
62
+ ? snapshots.filter(s => s.exchange === exchange)
63
+ : snapshots;
64
+ // Aggregate across exchanges if no specific exchange requested
65
+ const byTimestamp = new Map();
66
+ for (const s of filtered) {
67
+ const key = s.ts;
68
+ byTimestamp.set(key, (byTimestamp.get(key) ?? 0) + s.equity);
69
+ }
70
+ // Group by date
71
+ const byDate = new Map();
72
+ for (const s of filtered) {
73
+ const date = s.ts.slice(0, 10); // YYYY-MM-DD
74
+ const eq = exchange ? s.equity : (byTimestamp.get(s.ts) ?? s.equity);
75
+ const existing = byDate.get(date);
76
+ if (!existing) {
77
+ byDate.set(date, { first: { ts: s.ts, eq }, last: { ts: s.ts, eq } });
78
+ }
79
+ else {
80
+ if (s.ts < existing.first.ts)
81
+ existing.first = { ts: s.ts, eq };
82
+ if (s.ts > existing.last.ts)
83
+ existing.last = { ts: s.ts, eq };
84
+ }
85
+ }
86
+ const daily = [];
87
+ const dates = [...byDate.keys()].sort();
88
+ for (let i = 0; i < dates.length; i++) {
89
+ const date = dates[i];
90
+ const day = byDate.get(date);
91
+ // Use previous day's end as start, or this day's first if no prev
92
+ const startEquity = i > 0 ? byDate.get(dates[i - 1]).last.eq : day.first.eq;
93
+ const endEquity = day.last.eq;
94
+ const pnl = endEquity - startEquity;
95
+ const pnlPct = startEquity > 0 ? (pnl / startEquity) * 100 : 0;
96
+ daily.push({
97
+ date,
98
+ exchange: exchange ?? "all",
99
+ startEquity,
100
+ endEquity,
101
+ pnl,
102
+ pnlPct,
103
+ });
104
+ }
105
+ return daily;
106
+ }
107
+ /** Compute comprehensive PnL metrics from equity snapshots. */
108
+ export function computePnlMetrics(snapshots, exchange) {
109
+ const daily = computeDailyPnl(snapshots, exchange);
110
+ if (daily.length === 0) {
111
+ return {
112
+ totalReturn: 0, totalReturnPct: 0, dailyReturns: [],
113
+ sharpeRatio: 0, maxDrawdown: 0, maxDrawdownPct: 0,
114
+ winDays: 0, lossDays: 0, winRate: 0,
115
+ peakEquity: 0, currentDrawdown: 0, currentDrawdownPct: 0,
116
+ bestDay: null, worstDay: null, avgDailyPnl: 0,
117
+ period: { from: "", to: "", days: 0 },
118
+ };
119
+ }
120
+ const firstEquity = daily[0].startEquity;
121
+ const lastEquity = daily[daily.length - 1].endEquity;
122
+ const totalReturn = lastEquity - firstEquity;
123
+ const totalReturnPct = firstEquity > 0 ? (totalReturn / firstEquity) * 100 : 0;
124
+ // Win/loss stats
125
+ const winDays = daily.filter(d => d.pnl > 0).length;
126
+ const lossDays = daily.filter(d => d.pnl < 0).length;
127
+ const winRate = daily.length > 0 ? (winDays / daily.length) * 100 : 0;
128
+ // Best/worst day
129
+ const bestDay = daily.reduce((a, b) => (b.pnl > a.pnl ? b : a), daily[0]);
130
+ const worstDay = daily.reduce((a, b) => (b.pnl < a.pnl ? b : a), daily[0]);
131
+ // Average daily PnL
132
+ const avgDailyPnl = daily.reduce((sum, d) => sum + d.pnl, 0) / daily.length;
133
+ // Sharpe ratio (annualized, assuming 365 trading days for crypto)
134
+ const dailyReturns = daily.map(d => d.pnlPct / 100);
135
+ const meanReturn = dailyReturns.reduce((s, r) => s + r, 0) / dailyReturns.length;
136
+ const variance = dailyReturns.reduce((s, r) => s + (r - meanReturn) ** 2, 0) / dailyReturns.length;
137
+ const stdDev = Math.sqrt(variance);
138
+ const sharpeRatio = stdDev > 0 ? (meanReturn / stdDev) * Math.sqrt(365) : 0;
139
+ // Max drawdown
140
+ let peakEquity = 0;
141
+ let maxDrawdown = 0;
142
+ let maxDrawdownPct = 0;
143
+ for (const d of daily) {
144
+ if (d.endEquity > peakEquity)
145
+ peakEquity = d.endEquity;
146
+ const dd = peakEquity - d.endEquity;
147
+ const ddPct = peakEquity > 0 ? (dd / peakEquity) * 100 : 0;
148
+ if (dd > maxDrawdown) {
149
+ maxDrawdown = dd;
150
+ maxDrawdownPct = ddPct;
151
+ }
152
+ }
153
+ const currentDrawdown = peakEquity - lastEquity;
154
+ const currentDrawdownPct = peakEquity > 0 ? (currentDrawdown / peakEquity) * 100 : 0;
155
+ return {
156
+ totalReturn,
157
+ totalReturnPct,
158
+ dailyReturns: daily,
159
+ sharpeRatio,
160
+ maxDrawdown,
161
+ maxDrawdownPct,
162
+ winDays,
163
+ lossDays,
164
+ winRate,
165
+ peakEquity,
166
+ currentDrawdown,
167
+ currentDrawdownPct,
168
+ bestDay,
169
+ worstDay,
170
+ avgDailyPnl,
171
+ period: {
172
+ from: daily[0].date,
173
+ to: daily[daily.length - 1].date,
174
+ days: daily.length,
175
+ },
176
+ };
177
+ }
178
+ /** Compute weekly PnL from daily PnL data. */
179
+ export function aggregateWeekly(daily) {
180
+ if (daily.length === 0)
181
+ return [];
182
+ const weeks = new Map();
183
+ for (const d of daily) {
184
+ // ISO week start (Monday)
185
+ const dt = new Date(d.date);
186
+ const day = dt.getDay();
187
+ const monday = new Date(dt);
188
+ monday.setDate(dt.getDate() - ((day + 6) % 7));
189
+ const weekKey = monday.toISOString().slice(0, 10);
190
+ const existing = weeks.get(weekKey);
191
+ if (!existing) {
192
+ weeks.set(weekKey, { start: d, end: d });
193
+ }
194
+ else {
195
+ if (d.date < existing.start.date)
196
+ existing.start = d;
197
+ if (d.date > existing.end.date)
198
+ existing.end = d;
199
+ }
200
+ }
201
+ return [...weeks.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([weekStart, { start, end }]) => ({
202
+ date: weekStart,
203
+ exchange: start.exchange,
204
+ startEquity: start.startEquity,
205
+ endEquity: end.endEquity,
206
+ pnl: end.endEquity - start.startEquity,
207
+ pnlPct: start.startEquity > 0 ? ((end.endEquity - start.startEquity) / start.startEquity) * 100 : 0,
208
+ }));
209
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Hyperliquid Spot Adapter.
3
+ *
4
+ * Uses the same exchange.placeOrder() endpoint as perps — the only difference
5
+ * is the asset index: spot assets use `10000 + spotIndex`.
6
+ *
7
+ * Composes with HyperliquidAdapter for signing and API access.
8
+ */
9
+ import type { SpotAdapter, SpotMarketInfo, SpotBalance } from "./spot-interface.js";
10
+ import type { HyperliquidAdapter } from "./hyperliquid.js";
11
+ export declare class HyperliquidSpotAdapter implements SpotAdapter {
12
+ readonly name = "hyperliquid";
13
+ private _hl;
14
+ private _spotAssetMap;
15
+ private _spotAssetReverse;
16
+ private _spotDecimals;
17
+ private _initialized;
18
+ private _cachedMetaCtx;
19
+ constructor(hlAdapter: HyperliquidAdapter);
20
+ /** Load spot metadata from the Hyperliquid spot API */
21
+ init(): Promise<void>;
22
+ private _loadSpotMeta;
23
+ getSpotMarkets(): Promise<SpotMarketInfo[]>;
24
+ getSpotOrderbook(symbol: string): Promise<{
25
+ bids: [string, string][];
26
+ asks: [string, string][];
27
+ }>;
28
+ getSpotBalances(): Promise<SpotBalance[]>;
29
+ spotMarketOrder(symbol: string, side: "buy" | "sell", size: string): Promise<unknown>;
30
+ spotLimitOrder(symbol: string, side: "buy" | "sell", price: string, size: string, opts?: {
31
+ tif?: string;
32
+ }): Promise<unknown>;
33
+ spotCancelOrder(symbol: string, orderId: string): Promise<unknown>;
34
+ /**
35
+ * Transfer USDC between perp and spot accounts.
36
+ * HL uses separate balances — spot buys require USDC in the spot account.
37
+ * Uses SDK's transferBetweenSpotAndPerp (userSignedAction, not L1 action).
38
+ */
39
+ transferUsdcToSpot(amount: number): Promise<unknown>;
40
+ transferUsdcToPerp(amount: number): Promise<unknown>;
41
+ /** Get the spot asset index (without the 10000 offset) */
42
+ getSpotIndex(base: string): number | undefined;
43
+ /** Get spot size decimals for a base token */
44
+ getSpotDecimals(base: string): {
45
+ size: number;
46
+ price: number;
47
+ };
48
+ /**
49
+ * Resolve a symbol to its actual spot base token and index.
50
+ * Handles U-token mapping: "BTC" → "UBTC" (Unit protocol bridged token).
51
+ * Throws if no mapping found.
52
+ */
53
+ private _resolveBase;
54
+ /**
55
+ * Get the perp symbol for a spot token.
56
+ * e.g., "UBTC" → "BTC", "ETH" → "ETH"
57
+ */
58
+ getUnderlying(spotBase: string): string;
59
+ /**
60
+ * Validate that an order actually filled.
61
+ * HL API returns status "ok" even for 0-fill IoC orders.
62
+ * Response shape: { status: "ok", response: { type: "order", data: { statuses: [...] } } }
63
+ * Each status is one of: { filled: { totalSz, avgPx, oid } }, { resting: { oid } }, { error: "msg" }
64
+ */
65
+ private _validateOrderFill;
66
+ private _rawPlaceSpotOrder;
67
+ private _signAndSendAction;
68
+ private _infoPost;
69
+ }
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Hyperliquid Spot Adapter.
3
+ *
4
+ * Uses the same exchange.placeOrder() endpoint as perps — the only difference
5
+ * is the asset index: spot assets use `10000 + spotIndex`.
6
+ *
7
+ * Composes with HyperliquidAdapter for signing and API access.
8
+ */
9
+ import { PERP_TO_SPOT_MAP, SPOT_PERP_TOKEN_MAP } from "./spot-interface.js";
10
+ /** HL official price rounding: 5 significant figures, max 8 decimals for spot */
11
+ function hlRoundPrice(px, szDecimals) {
12
+ const MAX_DECIMALS = 8; // spot uses 8 (perp uses 6)
13
+ if (px > 100_000)
14
+ return String(Math.round(px));
15
+ const sig5 = Number(px.toPrecision(5));
16
+ const priceDec = Math.max(0, MAX_DECIMALS - szDecimals);
17
+ return Number(sig5.toFixed(priceDec)).toString();
18
+ }
19
+ /** HL official size rounding */
20
+ function hlRoundSize(sz, szDecimals) {
21
+ return sz.toFixed(szDecimals);
22
+ }
23
+ export class HyperliquidSpotAdapter {
24
+ name = "hyperliquid";
25
+ _hl;
26
+ _spotAssetMap = new Map(); // "ETH" → spotIndex (not +10000)
27
+ _spotAssetReverse = new Map(); // spotIndex → "ETH"
28
+ _spotDecimals = new Map();
29
+ _initialized = false;
30
+ _cachedMetaCtx = null;
31
+ constructor(hlAdapter) {
32
+ this._hl = hlAdapter;
33
+ }
34
+ /** Load spot metadata from the Hyperliquid spot API */
35
+ async init() {
36
+ if (this._initialized)
37
+ return;
38
+ await this._loadSpotMeta();
39
+ this._initialized = true;
40
+ }
41
+ async _loadSpotMeta() {
42
+ try {
43
+ // Single API call — spotMetaAndAssetCtxs includes all data from spotMeta
44
+ const metaCtx = await this._infoPost({ type: "spotMetaAndAssetCtxs" });
45
+ if (!metaCtx?.[0]?.universe)
46
+ return;
47
+ this._cachedMetaCtx = metaCtx;
48
+ const meta = metaCtx[0];
49
+ // Token index → name map
50
+ const tokenNames = new Map();
51
+ for (const t of meta.tokens ?? []) {
52
+ tokenNames.set(t.index, t.name);
53
+ }
54
+ // Find USDC token index for filtering
55
+ const usdcTokenIndex = meta.tokens?.find(t => t.name === "USDC")?.index ?? 0;
56
+ for (const u of meta.universe) {
57
+ const baseToken = tokenNames.get(u.tokens[0]) ?? "";
58
+ if (!baseToken)
59
+ continue;
60
+ const key = baseToken.toUpperCase();
61
+ const isUsdcPair = u.tokens[1] === usdcTokenIndex;
62
+ if (this._spotAssetMap.has(key) && !isUsdcPair)
63
+ continue;
64
+ this._spotAssetMap.set(key, u.index);
65
+ this._spotAssetReverse.set(u.index, key);
66
+ this._spotDecimals.set(key, {
67
+ size: u.szDecimals ?? 2,
68
+ price: 6,
69
+ });
70
+ }
71
+ }
72
+ catch (e) {
73
+ console.error("[hl-spot] Failed to load spot meta:", e instanceof Error ? e.message : e);
74
+ }
75
+ }
76
+ async getSpotMarkets() {
77
+ await this.init();
78
+ try {
79
+ // Reuse cached metaCtx from init, or fetch fresh if cache expired
80
+ const metaCtx = this._cachedMetaCtx
81
+ ?? await this._infoPost({ type: "spotMetaAndAssetCtxs" });
82
+ if (!metaCtx?.[0]?.universe)
83
+ return [];
84
+ const tokenNames = new Map();
85
+ for (const t of metaCtx[0].tokens ?? []) {
86
+ tokenNames.set(t.index, t.name);
87
+ }
88
+ const ctxs = metaCtx[1] ?? [];
89
+ const markets = [];
90
+ for (let i = 0; i < metaCtx[0].universe.length; i++) {
91
+ const u = metaCtx[0].universe[i];
92
+ const ctx = ctxs[i] ?? {};
93
+ const baseToken = tokenNames.get(u.tokens[0]) ?? "";
94
+ const quoteToken = tokenNames.get(u.tokens[1]) ?? "USDC";
95
+ if (!baseToken)
96
+ continue;
97
+ markets.push({
98
+ symbol: `${baseToken}/USDC`,
99
+ baseToken,
100
+ quoteToken,
101
+ markPrice: String(ctx.markPx ?? ctx.midPx ?? "0"),
102
+ volume24h: String(ctx.dayNtlVlm ?? "0"),
103
+ sizeDecimals: u.szDecimals ?? 2,
104
+ priceDecimals: 6,
105
+ });
106
+ }
107
+ return markets;
108
+ }
109
+ catch {
110
+ return [];
111
+ }
112
+ }
113
+ async getSpotOrderbook(symbol) {
114
+ await this.init();
115
+ const { spotIndex } = this._resolveBase(symbol);
116
+ // HL spot L2 book uses the universe name, which is like "ETH/USDC" internally
117
+ // But the API takes coin=<name>, where name is from spotMeta universe
118
+ const book = await this._infoPost({
119
+ type: "l2Book",
120
+ coin: `@${spotIndex}`,
121
+ });
122
+ const levels = book?.levels ?? [[], []];
123
+ return {
124
+ bids: (levels[0] ?? []).map((l) => [
125
+ String(l.px ?? "0"),
126
+ String(l.sz ?? "0"),
127
+ ]),
128
+ asks: (levels[1] ?? []).map((l) => [
129
+ String(l.px ?? "0"),
130
+ String(l.sz ?? "0"),
131
+ ]),
132
+ };
133
+ }
134
+ async getSpotBalances() {
135
+ try {
136
+ const state = await this._hl.client.info.spot.getSpotClearinghouseState(this._hl.address);
137
+ const balances = state?.balances ?? [];
138
+ return balances.map((b) => ({
139
+ token: String(b.coin ?? ""),
140
+ total: String(b.total ?? "0"),
141
+ available: String(Number(b.total ?? 0) - Number(b.hold ?? 0)),
142
+ held: String(b.hold ?? "0"),
143
+ }));
144
+ }
145
+ catch {
146
+ return [];
147
+ }
148
+ }
149
+ async spotMarketOrder(symbol, side, size) {
150
+ await this.init();
151
+ const { base, spotIndex } = this._resolveBase(symbol);
152
+ const assetIndex = 10000 + spotIndex;
153
+ // Get mid price for slippage calculation
154
+ const book = await this.getSpotOrderbook(symbol);
155
+ const bestAsk = book.asks.length > 0 ? Number(book.asks[0][0]) : 0;
156
+ const bestBid = book.bids.length > 0 ? Number(book.bids[0][0]) : 0;
157
+ const midPrice = (bestAsk + bestBid) / 2;
158
+ if (midPrice <= 0)
159
+ throw new Error(`Cannot get price for spot ${base}`);
160
+ // 5% slippage for market orders
161
+ const slippage = 0.05;
162
+ const isBuy = side === "buy";
163
+ const slippagePrice = isBuy ? midPrice * (1 + slippage) : midPrice * (1 - slippage);
164
+ const dec = this._spotDecimals.get(base);
165
+ const szDec = dec?.size ?? 2;
166
+ const result = await this._rawPlaceSpotOrder({
167
+ assetIndex,
168
+ isBuy,
169
+ price: hlRoundPrice(slippagePrice, szDec),
170
+ size: hlRoundSize(Number(size), szDec),
171
+ orderType: { limit: { tif: "Ioc" } },
172
+ });
173
+ // Validate fill — HL returns status "ok" even for 0-fill IoC orders
174
+ this._validateOrderFill(result, base, side, size);
175
+ return result;
176
+ }
177
+ async spotLimitOrder(symbol, side, price, size, opts) {
178
+ await this.init();
179
+ const { base, spotIndex } = this._resolveBase(symbol);
180
+ const assetIndex = 10000 + spotIndex;
181
+ const rawTif = opts?.tif ?? "Gtc";
182
+ const tifMap = { IOC: "Ioc", GTC: "Gtc", ALO: "Alo" };
183
+ const tif = tifMap[rawTif.toUpperCase()] ?? rawTif;
184
+ const dec = this._spotDecimals.get(base);
185
+ const szDec = dec?.size ?? 2;
186
+ return this._rawPlaceSpotOrder({
187
+ assetIndex,
188
+ isBuy: side === "buy",
189
+ price: hlRoundPrice(Number(price), szDec),
190
+ size: hlRoundSize(Number(size), szDec),
191
+ orderType: { limit: { tif } },
192
+ });
193
+ }
194
+ async spotCancelOrder(symbol, orderId) {
195
+ await this.init();
196
+ const { spotIndex } = this._resolveBase(symbol);
197
+ const assetIndex = 10000 + spotIndex;
198
+ return this._signAndSendAction({
199
+ type: "cancel",
200
+ cancels: [{ a: assetIndex, o: parseInt(orderId) }],
201
+ });
202
+ }
203
+ /**
204
+ * Transfer USDC between perp and spot accounts.
205
+ * HL uses separate balances — spot buys require USDC in the spot account.
206
+ * Uses SDK's transferBetweenSpotAndPerp (userSignedAction, not L1 action).
207
+ */
208
+ async transferUsdcToSpot(amount) {
209
+ return this._hl.client.exchange.transferBetweenSpotAndPerp(amount, false);
210
+ }
211
+ async transferUsdcToPerp(amount) {
212
+ return this._hl.client.exchange.transferBetweenSpotAndPerp(amount, true);
213
+ }
214
+ /** Get the spot asset index (without the 10000 offset) */
215
+ getSpotIndex(base) {
216
+ return this._spotAssetMap.get(base.toUpperCase());
217
+ }
218
+ /** Get spot size decimals for a base token */
219
+ getSpotDecimals(base) {
220
+ return this._spotDecimals.get(base.toUpperCase()) ?? { size: 2, price: 6 };
221
+ }
222
+ /**
223
+ * Resolve a symbol to its actual spot base token and index.
224
+ * Handles U-token mapping: "BTC" → "UBTC" (Unit protocol bridged token).
225
+ * Throws if no mapping found.
226
+ */
227
+ _resolveBase(symbol) {
228
+ const raw = symbol.split("/")[0].toUpperCase();
229
+ // 1. Direct match (e.g., "ETH" → ETH spot, or "UBTC" → UBTC spot)
230
+ const directIndex = this._spotAssetMap.get(raw);
231
+ if (directIndex !== undefined)
232
+ return { base: raw, spotIndex: directIndex };
233
+ // 2. U-token mapping: "BTC" → "UBTC" (perp name → spot token name)
234
+ const uToken = PERP_TO_SPOT_MAP[raw];
235
+ if (uToken) {
236
+ const mappedIndex = this._spotAssetMap.get(uToken);
237
+ if (mappedIndex !== undefined)
238
+ return { base: uToken, spotIndex: mappedIndex };
239
+ }
240
+ throw new Error(`Unknown HL spot asset: ${raw} (no direct match or U-token mapping)`);
241
+ }
242
+ /**
243
+ * Get the perp symbol for a spot token.
244
+ * e.g., "UBTC" → "BTC", "ETH" → "ETH"
245
+ */
246
+ getUnderlying(spotBase) {
247
+ const upper = spotBase.toUpperCase();
248
+ return SPOT_PERP_TOKEN_MAP[upper] ?? upper;
249
+ }
250
+ // ── Private helpers ──
251
+ /**
252
+ * Validate that an order actually filled.
253
+ * HL API returns status "ok" even for 0-fill IoC orders.
254
+ * Response shape: { status: "ok", response: { type: "order", data: { statuses: [...] } } }
255
+ * Each status is one of: { filled: { totalSz, avgPx, oid } }, { resting: { oid } }, { error: "msg" }
256
+ */
257
+ _validateOrderFill(result, base, side, requestedSize) {
258
+ const r = result;
259
+ const statuses = r?.response?.data?.statuses;
260
+ if (!statuses || statuses.length === 0) {
261
+ throw new Error(`Spot ${side} ${base}: no order status in response`);
262
+ }
263
+ const st = statuses[0];
264
+ if (st.error) {
265
+ throw new Error(`Spot ${side} ${base}: ${st.error}`);
266
+ }
267
+ const filled = st.filled;
268
+ if (!filled || Number(filled.totalSz ?? 0) === 0) {
269
+ // Resting or 0-fill → for IoC market orders this means no fill
270
+ throw new Error(`Spot ${side} ${base}: order not filled (0 of ${requestedSize} ${base}). Check price/liquidity.`);
271
+ }
272
+ }
273
+ async _rawPlaceSpotOrder(opts) {
274
+ const trimZeros = (s) => {
275
+ if (!s.includes("."))
276
+ return s;
277
+ const n = s.replace(/\.?0+$/, "");
278
+ return n === "-0" ? "0" : n;
279
+ };
280
+ const orderWire = {
281
+ a: opts.assetIndex,
282
+ b: opts.isBuy,
283
+ p: trimZeros(opts.price),
284
+ s: trimZeros(opts.size),
285
+ r: false,
286
+ t: opts.orderType,
287
+ };
288
+ const action = {
289
+ type: "order",
290
+ orders: [orderWire],
291
+ grouping: "na",
292
+ };
293
+ return this._signAndSendAction(action);
294
+ }
295
+ async _signAndSendAction(action) {
296
+ return this._hl.exchangeAction(action);
297
+ }
298
+ async _infoPost(body) {
299
+ const baseUrl = this._hl.isTestnet
300
+ ? "https://api.hyperliquid-testnet.xyz"
301
+ : "https://api.hyperliquid.xyz";
302
+ const res = await fetch(`${baseUrl}/info`, {
303
+ method: "POST",
304
+ headers: { "Content-Type": "application/json" },
305
+ body: JSON.stringify(body),
306
+ });
307
+ return res.json();
308
+ }
309
+ }