openbroker 1.0.73 → 1.0.79

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.
@@ -37,6 +37,12 @@ export class HyperliquidClient {
37
37
  private hip3MaxLeverageMap: Map<string, number> = new Map();
38
38
  /** Cached account abstraction mode: 'standard' | 'unified' | 'portfolio' | 'dexAbstraction' */
39
39
  private accountMode: string | null = null;
40
+ /** Spot asset index map: coin name → 10000 + spotMeta.universe[i].index */
41
+ private spotAssetMap: Map<string, number> = new Map();
42
+ /** Spot szDecimals map: coin name → base token szDecimals */
43
+ private spotSzDecimalsMap: Map<string, number> = new Map();
44
+ /** Whether spot metadata has been loaded */
45
+ private spotMetaLoaded: boolean = false;
40
46
  public verbose: boolean = false;
41
47
 
42
48
  constructor(config?: OpenBrokerConfig) {
@@ -412,6 +418,68 @@ export class HyperliquidClient {
412
418
  };
413
419
  }
414
420
 
421
+ /**
422
+ * Load spot metadata into lookup maps.
423
+ * Spot asset index for orders = 10000 + universe[i].index
424
+ * Uses the base token's szDecimals for size rounding.
425
+ */
426
+ private async loadSpotMeta(): Promise<void> {
427
+ if (this.spotMetaLoaded) return;
428
+
429
+ try {
430
+ const spotData = await this.getSpotMeta();
431
+ // Build token lookup for szDecimals
432
+ const tokenMap = new Map<number, { name: string; szDecimals: number }>();
433
+ for (const token of spotData.tokens) {
434
+ tokenMap.set(token.index, { name: token.name, szDecimals: token.szDecimals });
435
+ }
436
+
437
+ for (const pair of spotData.universe) {
438
+ // pair.name is the market name (e.g., "PURR/USDC", "@107")
439
+ // pair.tokens = [baseTokenIndex, quoteTokenIndex]
440
+ // pair.index is the spot universe index
441
+ const baseToken = tokenMap.get(pair.tokens[0]);
442
+ if (!baseToken) continue;
443
+
444
+ const spotAssetIndex = 10000 + pair.index;
445
+ const quoteTokenIdx = pair.tokens[1];
446
+
447
+ // A token can appear in multiple pairs (e.g., HYPE/USDC, HYPE/USDE, HYPE/USDH).
448
+ // Prefer the USDC pair (quote token index 0) for the primary mapping.
449
+ const existing = this.spotAssetMap.get(baseToken.name);
450
+ if (existing !== undefined && quoteTokenIdx !== 0) {
451
+ // Already have a mapping — skip non-USDC pairs
452
+ continue;
453
+ }
454
+
455
+ this.spotAssetMap.set(baseToken.name, spotAssetIndex);
456
+ this.spotSzDecimalsMap.set(baseToken.name, baseToken.szDecimals);
457
+
458
+ this.log(`Spot: ${baseToken.name} → asset ${spotAssetIndex} (szDecimals: ${baseToken.szDecimals})`);
459
+ }
460
+
461
+ this.spotMetaLoaded = true;
462
+ this.log(`Loaded ${this.spotAssetMap.size} spot markets`);
463
+ } catch (e) {
464
+ this.log('Failed to load spot metadata:', e);
465
+ }
466
+ }
467
+
468
+ /** Get the spot asset index for a coin, or undefined if not a spot asset */
469
+ getSpotAssetIndex(coin: string): number | undefined {
470
+ return this.spotAssetMap.get(coin);
471
+ }
472
+
473
+ /** Get spot szDecimals for a coin */
474
+ getSpotSzDecimals(coin: string): number | undefined {
475
+ return this.spotSzDecimalsMap.get(coin);
476
+ }
477
+
478
+ /** Get all loaded spot asset names */
479
+ getSpotAssetNames(): string[] {
480
+ return Array.from(this.spotAssetMap.keys());
481
+ }
482
+
415
483
  /**
416
484
  * Get user's spot token balances
417
485
  */
@@ -1598,6 +1666,183 @@ export class HyperliquidClient {
1598
1666
  return results;
1599
1667
  }
1600
1668
 
1669
+ // ============ Spot Trading ============
1670
+
1671
+ /**
1672
+ * Place a spot order.
1673
+ * Uses the same exchange.order() endpoint but with spot asset indices (10000 + spotIndex).
1674
+ * Spot orders have no leverage, no reduce-only, and builder fee max is 1000 (vs 100 for perps).
1675
+ *
1676
+ * @param coin - Base token symbol (e.g. "PURR", "HYPE")
1677
+ * @param isBuy - True to buy base token, false to sell
1678
+ * @param size - Size in base token units
1679
+ * @param price - Limit price in quote token (usually USDC)
1680
+ * @param orderType - Order type with time-in-force
1681
+ * @param includeBuilder - Whether to include builder fee (default: true)
1682
+ */
1683
+ async spotOrder(
1684
+ coin: string,
1685
+ isBuy: boolean,
1686
+ size: number,
1687
+ price: number,
1688
+ orderType: { limit: { tif: 'Gtc' | 'Ioc' | 'Alo' } },
1689
+ includeBuilder: boolean = true,
1690
+ ): Promise<OrderResponse> {
1691
+ this.requireTrading();
1692
+ await this.loadSpotMeta();
1693
+
1694
+ const assetIndex = this.spotAssetMap.get(coin);
1695
+ if (assetIndex === undefined) {
1696
+ throw new Error(
1697
+ `Unknown spot asset: ${coin}. Available: ${Array.from(this.spotAssetMap.keys()).slice(0, 15).join(', ')}...\n` +
1698
+ `Use "openbroker spot" to see all spot markets.`
1699
+ );
1700
+ }
1701
+
1702
+ const szDecimals = this.spotSzDecimalsMap.get(coin)!;
1703
+
1704
+ const orderWire = {
1705
+ a: assetIndex,
1706
+ b: isBuy,
1707
+ p: roundPrice(price, szDecimals, true),
1708
+ s: roundSize(size, szDecimals),
1709
+ r: false, // reduce-only not applicable for spot
1710
+ t: orderType,
1711
+ };
1712
+
1713
+ this.log('Placing spot order:', JSON.stringify(orderWire, null, 2));
1714
+
1715
+ const orderRequest: {
1716
+ orders: typeof orderWire[];
1717
+ grouping: 'na';
1718
+ builder?: BuilderInfo;
1719
+ } = {
1720
+ orders: [orderWire],
1721
+ grouping: 'na',
1722
+ };
1723
+
1724
+ // Add builder fee if configured (spot max is 1000 vs 100 for perps)
1725
+ if (includeBuilder && this.config.builderAddress !== '0x0000000000000000000000000000000000000000') {
1726
+ orderRequest.builder = this.builderInfo;
1727
+ this.log('Including builder fee:', this.builderInfo);
1728
+ }
1729
+
1730
+ try {
1731
+ const response = await this.exchange.order(orderRequest);
1732
+ this.log('Spot order response:', JSON.stringify(response, null, 2));
1733
+ return response as unknown as OrderResponse;
1734
+ } catch (error) {
1735
+ this.log('Spot order error:', error);
1736
+ return {
1737
+ status: 'err',
1738
+ response: error instanceof Error ? error.message : String(error),
1739
+ };
1740
+ }
1741
+ }
1742
+
1743
+ /**
1744
+ * Place a spot market order (IOC at slippage price).
1745
+ * @param coin - Base token symbol (e.g. "PURR", "HYPE")
1746
+ * @param isBuy - True to buy, false to sell
1747
+ * @param size - Size in base token units
1748
+ * @param slippageBps - Slippage tolerance in basis points (default: config value)
1749
+ */
1750
+ async spotMarketOrder(
1751
+ coin: string,
1752
+ isBuy: boolean,
1753
+ size: number,
1754
+ slippageBps?: number,
1755
+ ): Promise<OrderResponse> {
1756
+ await this.loadSpotMeta();
1757
+
1758
+ // Get the spot pair name (@index or PURR/USDC) for allMids lookup
1759
+ const assetIndex = this.spotAssetMap.get(coin);
1760
+ if (assetIndex === undefined) {
1761
+ throw new Error(`Unknown spot asset: ${coin}. Use "openbroker spot" to see available markets.`);
1762
+ }
1763
+ const spotPairIndex = assetIndex - 10000;
1764
+ // Canonical PURR/USDC is index 0, everything else uses @index
1765
+ const spotCoinKey = spotPairIndex === 0 ? 'PURR/USDC' : `@${spotPairIndex}`;
1766
+
1767
+ // Use allMids for accurate live prices (spotMetaAndAssetCtxs contexts can be misaligned)
1768
+ const mids = await this.getAllMids();
1769
+ const midStr = mids[spotCoinKey];
1770
+ const midPrice = midStr ? parseFloat(midStr) : 0;
1771
+
1772
+ if (!midPrice || midPrice === 0) {
1773
+ throw new Error(`No spot price for ${coin} (${spotCoinKey}). Check if the spot market exists with "openbroker spot --coin ${coin}".`);
1774
+ }
1775
+
1776
+ // Calculate slippage price
1777
+ const slippage = (slippageBps ?? this.config.slippageBps) / 10000;
1778
+ const limitPrice = isBuy
1779
+ ? midPrice * (1 + slippage)
1780
+ : midPrice * (1 - slippage);
1781
+
1782
+ this.log(`Spot market order: ${coin} ${isBuy ? 'BUY' : 'SELL'} ${size} @ ${limitPrice} (mid: ${midPrice}, slippage: ${slippage * 100}%)`);
1783
+
1784
+ return this.spotOrder(
1785
+ coin,
1786
+ isBuy,
1787
+ size,
1788
+ limitPrice,
1789
+ { limit: { tif: 'Ioc' } },
1790
+ );
1791
+ }
1792
+
1793
+ /**
1794
+ * Place a spot limit order.
1795
+ * @param coin - Base token symbol (e.g. "PURR", "HYPE")
1796
+ * @param isBuy - True to buy, false to sell
1797
+ * @param size - Size in base token units
1798
+ * @param price - Limit price in quote token (usually USDC)
1799
+ * @param tif - Time-in-force (default: Gtc)
1800
+ */
1801
+ async spotLimitOrder(
1802
+ coin: string,
1803
+ isBuy: boolean,
1804
+ size: number,
1805
+ price: number,
1806
+ tif: 'Gtc' | 'Ioc' | 'Alo' = 'Gtc',
1807
+ ): Promise<OrderResponse> {
1808
+ return this.spotOrder(
1809
+ coin,
1810
+ isBuy,
1811
+ size,
1812
+ price,
1813
+ { limit: { tif } },
1814
+ );
1815
+ }
1816
+
1817
+ /**
1818
+ * Cancel a spot order by coin and order ID.
1819
+ */
1820
+ async spotCancel(coin: string, oid: number): Promise<CancelResponse> {
1821
+ this.requireTrading();
1822
+ await this.loadSpotMeta();
1823
+
1824
+ const assetIndex = this.spotAssetMap.get(coin);
1825
+ if (assetIndex === undefined) {
1826
+ throw new Error(`Unknown spot asset: ${coin}`);
1827
+ }
1828
+
1829
+ this.log(`Cancelling spot order: ${coin} (asset ${assetIndex}) oid ${oid}`);
1830
+
1831
+ try {
1832
+ const response = await this.exchange.cancel({
1833
+ cancels: [{ a: assetIndex, o: oid }],
1834
+ });
1835
+ this.log('Spot cancel response:', JSON.stringify(response, null, 2));
1836
+ return response as unknown as CancelResponse;
1837
+ } catch (error) {
1838
+ this.log('Spot cancel error:', error);
1839
+ return {
1840
+ status: 'err',
1841
+ response: { type: 'cancel', data: { statuses: [error instanceof Error ? error.message : String(error)] } },
1842
+ };
1843
+ }
1844
+ }
1845
+
1601
1846
  // ============ Leverage ============
1602
1847
 
1603
1848
  async updateLeverage(
@@ -0,0 +1,308 @@
1
+ // WebSocket Manager for Hyperliquid real-time data
2
+ // Wraps @nktkas/hyperliquid SubscriptionClient with event-driven API
3
+
4
+ import { WebSocketTransport, SubscriptionClient } from '@nktkas/hyperliquid';
5
+ import type { ISubscription } from '@nktkas/hyperliquid';
6
+ import type {
7
+ AllMidsWsEvent,
8
+ L2BookWsEvent,
9
+ OrderUpdatesWsEvent,
10
+ UserFillsWsEvent,
11
+ UserEventsWsEvent,
12
+ } from '@nktkas/hyperliquid';
13
+ import { isMainnet } from './config.js';
14
+
15
+ // ── Event types ────────────────────────────────────────────────────
16
+
17
+ export interface WsEventMap {
18
+ /** L2 order book snapshot for a specific coin */
19
+ l2Book: {
20
+ coin: string;
21
+ time: number;
22
+ levels: [
23
+ Array<{ px: string; sz: string; n: number }>,
24
+ Array<{ px: string; sz: string; n: number }>,
25
+ ];
26
+ };
27
+ /** Mid prices for all assets updated */
28
+ allMids: { mids: Record<string, string> };
29
+ /** Order status changed (filled, canceled, rejected, etc.) */
30
+ orderUpdate: {
31
+ order: {
32
+ coin: string;
33
+ side: 'B' | 'A';
34
+ limitPx: string;
35
+ sz: string;
36
+ oid: number;
37
+ timestamp: number;
38
+ origSz: string;
39
+ cloid?: string;
40
+ reduceOnly?: boolean;
41
+ };
42
+ status: string;
43
+ statusTimestamp: number;
44
+ };
45
+ /** Trade fill received */
46
+ userFill: {
47
+ coin: string;
48
+ px: string;
49
+ sz: string;
50
+ side: 'B' | 'A';
51
+ time: number;
52
+ closedPnl: string;
53
+ fee: string;
54
+ oid: number;
55
+ crossed: boolean;
56
+ };
57
+ /** User event (fills, funding, liquidation, non-user cancels) */
58
+ userEvent: UserEventsWsEvent;
59
+ /** WebSocket connected */
60
+ connected: undefined;
61
+ /** WebSocket disconnected */
62
+ disconnected: { reason?: string };
63
+ /** WebSocket error */
64
+ error: { error: Error };
65
+ }
66
+
67
+ export type WsEventType = keyof WsEventMap;
68
+ export type WsEventHandler<E extends WsEventType> = (data: WsEventMap[E]) => void;
69
+
70
+ // ── Manager ────────────────────────────────────────────────────────
71
+
72
+ export class WebSocketManager {
73
+ private transport: WebSocketTransport | null = null;
74
+ private client: SubscriptionClient | null = null;
75
+ private subscriptions: ISubscription[] = [];
76
+ private handlers = new Map<WsEventType, Set<Function>>();
77
+ private _connected = false;
78
+ private verbose: boolean;
79
+
80
+ constructor(verbose = false) {
81
+ this.verbose = verbose;
82
+ }
83
+
84
+ private log(...args: unknown[]) {
85
+ if (this.verbose) console.log('[WS]', ...args);
86
+ }
87
+
88
+ get connected(): boolean {
89
+ return this._connected;
90
+ }
91
+
92
+ // ── Connection management ──────────────────────────────────────
93
+
94
+ async connect(): Promise<void> {
95
+ if (this.transport) return; // already connected
96
+
97
+ this.transport = new WebSocketTransport({
98
+ isTestnet: !isMainnet(),
99
+ resubscribe: true, // auto-resubscribe on reconnect
100
+ });
101
+
102
+ this.client = new SubscriptionClient({ transport: this.transport });
103
+
104
+ await this.transport.ready();
105
+ this._connected = true;
106
+ this.emit('connected', undefined);
107
+ this.log('Connected to', isMainnet() ? 'mainnet' : 'testnet');
108
+ }
109
+
110
+ async close(): Promise<void> {
111
+ for (const sub of this.subscriptions) {
112
+ try { await sub.unsubscribe(); } catch { /* ignore */ }
113
+ }
114
+ this.subscriptions = [];
115
+
116
+ if (this.transport) {
117
+ try { await this.transport.close(); } catch { /* ignore */ }
118
+ this.transport = null;
119
+ this.client = null;
120
+ }
121
+
122
+ this._connected = false;
123
+ this.emit('disconnected', { reason: 'manual close' });
124
+ this.log('Closed');
125
+ }
126
+
127
+ // ── Event system ───────────────────────────────────────────────
128
+
129
+ on<E extends WsEventType>(event: E, handler: WsEventHandler<E>): void {
130
+ if (!this.handlers.has(event)) {
131
+ this.handlers.set(event, new Set());
132
+ }
133
+ this.handlers.get(event)!.add(handler);
134
+ }
135
+
136
+ off<E extends WsEventType>(event: E, handler: WsEventHandler<E>): void {
137
+ this.handlers.get(event)?.delete(handler);
138
+ }
139
+
140
+ private emit<E extends WsEventType>(event: E, data: WsEventMap[E]): void {
141
+ const set = this.handlers.get(event);
142
+ if (!set) return;
143
+ for (const handler of set) {
144
+ try {
145
+ handler(data);
146
+ } catch (err) {
147
+ this.log('Handler error:', err instanceof Error ? err.message : String(err));
148
+ this.emit('error' as E, { error: err instanceof Error ? err : new Error(String(err)) } as WsEventMap[E]);
149
+ }
150
+ }
151
+ }
152
+
153
+ removeAllListeners(): void {
154
+ this.handlers.clear();
155
+ }
156
+
157
+ // ── Subscription helpers ───────────────────────────────────────
158
+
159
+ private ensureClient(): SubscriptionClient {
160
+ if (!this.client) throw new Error('WebSocket not connected. Call connect() first.');
161
+ return this.client;
162
+ }
163
+
164
+ private trackSub(sub: ISubscription): ISubscription {
165
+ this.subscriptions.push(sub);
166
+ sub.failureSignal.addEventListener('abort', () => {
167
+ this.log('Subscription failed, removing from tracked list');
168
+ const idx = this.subscriptions.indexOf(sub);
169
+ if (idx >= 0) this.subscriptions.splice(idx, 1);
170
+ });
171
+ return sub;
172
+ }
173
+
174
+ // ── Market data subscriptions ──────────────────────────────────
175
+
176
+ /**
177
+ * Subscribe to mid prices for all assets. Fires on every price update.
178
+ */
179
+ async subscribeAllMids(): Promise<ISubscription> {
180
+ const client = this.ensureClient();
181
+ const sub = await client.allMids((data: AllMidsWsEvent) => {
182
+ this.emit('allMids', { mids: data.mids });
183
+ });
184
+ return this.trackSub(sub);
185
+ }
186
+
187
+ /**
188
+ * Subscribe to L2 order book snapshots for a specific coin.
189
+ */
190
+ async subscribeL2Book(coin: string): Promise<ISubscription> {
191
+ const client = this.ensureClient();
192
+ const sub = await client.l2Book({ coin }, (data: L2BookWsEvent) => {
193
+ this.emit('l2Book', {
194
+ coin: data.coin,
195
+ time: data.time,
196
+ levels: data.levels,
197
+ });
198
+ });
199
+ return this.trackSub(sub);
200
+ }
201
+
202
+ // ── User subscriptions ────────────────────────────────────────
203
+
204
+ /**
205
+ * Subscribe to order lifecycle events (fill, cancel, reject, etc.).
206
+ * This is the most important subscription for trading automations.
207
+ */
208
+ async subscribeOrderUpdates(user: `0x${string}`): Promise<ISubscription> {
209
+ const client = this.ensureClient();
210
+ const sub = await client.orderUpdates({ user }, (data: OrderUpdatesWsEvent) => {
211
+ for (const update of data) {
212
+ this.emit('orderUpdate', {
213
+ order: {
214
+ coin: update.order.coin,
215
+ side: update.order.side,
216
+ limitPx: update.order.limitPx,
217
+ sz: update.order.sz,
218
+ oid: update.order.oid,
219
+ timestamp: update.order.timestamp,
220
+ origSz: update.order.origSz,
221
+ cloid: update.order.cloid,
222
+ reduceOnly: update.order.reduceOnly,
223
+ },
224
+ status: update.status,
225
+ statusTimestamp: update.statusTimestamp,
226
+ });
227
+ }
228
+ });
229
+ return this.trackSub(sub);
230
+ }
231
+
232
+ /**
233
+ * Subscribe to trade fills for a user.
234
+ */
235
+ async subscribeUserFills(user: `0x${string}`): Promise<ISubscription> {
236
+ const client = this.ensureClient();
237
+ const sub = await client.userFills({ user }, (data: UserFillsWsEvent) => {
238
+ if (data.isSnapshot) return; // skip initial snapshot
239
+ for (const fill of data.fills) {
240
+ this.emit('userFill', {
241
+ coin: fill.coin,
242
+ px: fill.px,
243
+ sz: fill.sz,
244
+ side: fill.side,
245
+ time: fill.time,
246
+ closedPnl: fill.closedPnl,
247
+ fee: fill.fee,
248
+ oid: fill.oid,
249
+ crossed: fill.crossed,
250
+ });
251
+ }
252
+ });
253
+ return this.trackSub(sub);
254
+ }
255
+
256
+ /**
257
+ * Subscribe to all user events (fills, funding, liquidations, non-user cancels).
258
+ * This is the only way to get liquidation alerts.
259
+ */
260
+ async subscribeUserEvents(user: `0x${string}`): Promise<ISubscription> {
261
+ const client = this.ensureClient();
262
+ const sub = await client.userEvents({ user }, (data: UserEventsWsEvent) => {
263
+ this.emit('userEvent', data);
264
+ });
265
+ return this.trackSub(sub);
266
+ }
267
+
268
+ // ── Convenience: subscribe to all relevant feeds for an automation ──
269
+
270
+ /**
271
+ * Start all subscriptions needed for the automation runtime:
272
+ * - allMids (price feed)
273
+ * - orderUpdates (order lifecycle)
274
+ * - userFills (trade fills)
275
+ * - userEvents (liquidations, funding payments, system cancels)
276
+ */
277
+ async subscribeAll(user: `0x${string}`): Promise<void> {
278
+ await this.connect();
279
+ this.log('Subscribing to all feeds for', user);
280
+
281
+ await Promise.all([
282
+ this.subscribeAllMids(),
283
+ this.subscribeOrderUpdates(user),
284
+ this.subscribeUserFills(user),
285
+ this.subscribeUserEvents(user),
286
+ ]);
287
+
288
+ this.log('All subscriptions active');
289
+ }
290
+ }
291
+
292
+ // ── Singleton ──────────────────────────────────────────────────────
293
+
294
+ let wsInstance: WebSocketManager | null = null;
295
+
296
+ export function getWebSocket(verbose = false): WebSocketManager {
297
+ if (!wsInstance) {
298
+ wsInstance = new WebSocketManager(verbose);
299
+ }
300
+ return wsInstance;
301
+ }
302
+
303
+ export function resetWebSocket(): void {
304
+ if (wsInstance) {
305
+ wsInstance.close().catch(() => {});
306
+ wsInstance = null;
307
+ }
308
+ }
@@ -52,6 +52,11 @@ async function main() {
52
52
 
53
53
  const totalPnl = positions.reduce((sum, p) => sum + p.unrealizedPnl, 0);
54
54
 
55
+ // Fetch spot balances
56
+ const userParam = isOtherAccount ? lookupAddress : undefined;
57
+ const spotState = await client.getSpotBalances(userParam);
58
+ const spotBalances = (spotState?.balances ?? []).filter(b => parseFloat(b.total) > 0);
59
+
55
60
  // JSON output
56
61
  if (jsonOutput) {
57
62
  const result: Record<string, unknown> = {
@@ -68,6 +73,12 @@ async function main() {
68
73
  marginRatio: totalMarginUsed > 0 && accountValue > 0 ? totalMarginUsed / accountValue : 0,
69
74
  totalUnrealizedPnl: totalPnl,
70
75
  positions,
76
+ spotBalances: spotBalances.map(b => ({
77
+ coin: b.coin,
78
+ total: b.total,
79
+ hold: b.hold,
80
+ entryNtl: b.entryNtl,
81
+ })),
71
82
  };
72
83
 
73
84
  if (args.orders) {
@@ -166,6 +177,23 @@ async function main() {
166
177
  console.log(`Total Unrealized PnL: ${formatUsd(totalPnl)}`);
167
178
  }
168
179
 
180
+ // Show spot balances
181
+ if (spotBalances.length > 0) {
182
+ console.log('\nSpot Balances');
183
+ console.log('-------------');
184
+ console.log('Token | Total | Hold | Entry Value');
185
+ console.log('-------------|--------------------|--------------------|------------');
186
+
187
+ for (const b of spotBalances) {
188
+ const total = parseFloat(b.total);
189
+ const hold = parseFloat(b.hold);
190
+ const entry = parseFloat(b.entryNtl);
191
+ console.log(
192
+ `${b.coin.padEnd(12)} | ${total.toFixed(6).padStart(18)} | ${hold.toFixed(6).padStart(18)} | ${formatUsd(entry)}`
193
+ );
194
+ }
195
+ }
196
+
169
197
  // Show open orders if requested
170
198
  if (args.orders) {
171
199
  console.log('\nOpen Orders');
@@ -73,9 +73,9 @@ async function main() {
73
73
 
74
74
  console.log(
75
75
  time.padEnd(20) +
76
- formatPercent(rate * 100, 6).padEnd(16) +
77
- formatPercent(annualized * 100).padEnd(14) +
78
- formatPercent(premium * 100, 4)
76
+ formatPercent(rate, 6).padEnd(16) +
77
+ formatPercent(annualized).padEnd(14) +
78
+ formatPercent(premium, 4)
79
79
  );
80
80
  }
81
81
 
@@ -85,8 +85,8 @@ async function main() {
85
85
 
86
86
  console.log('─'.repeat(60));
87
87
  console.log(`Samples: ${history.length}`);
88
- console.log(`Avg Hourly Rate: ${formatPercent(avgRate * 100, 6)}`);
89
- console.log(`Avg Annualized: ${formatPercent(avgAnnualized * 100)}`);
88
+ console.log(`Avg Hourly Rate: ${formatPercent(avgRate, 6)}`);
89
+ console.log(`Avg Annualized: ${formatPercent(avgAnnualized)}`);
90
90
 
91
91
  if (avgRate > 0) {
92
92
  console.log('Longs pay shorts');
@@ -157,16 +157,40 @@ async function main() {
157
157
  if (args.type === 'all' || args.type === 'spot') {
158
158
  try {
159
159
  const spotData = await client.getSpotMetaAndAssetCtxs();
160
- for (let i = 0; i < spotData.meta.universe.length; i++) {
161
- const pair = spotData.meta.universe[i];
162
- const ctx = spotData.assetCtxs[i];
163
- if (!pair || !ctx) continue;
164
160
 
165
- if (pair.name.toUpperCase().includes(query)) {
161
+ // Build token index → name lookup for matching by base token name
162
+ const tokenNameMap = new Map<number, string>();
163
+ for (const token of spotData.meta.tokens) {
164
+ tokenNameMap.set(token.index, token.name);
165
+ }
166
+
167
+ // Build ctx map by coin name (contexts have a 'coin' field that matches pair.name).
168
+ // The contexts array can be longer than universe and is NOT aligned by index.
169
+ const ctxMap = new Map<string, (typeof spotData.assetCtxs)[number]>();
170
+ for (const ctx of spotData.assetCtxs) {
171
+ if ((ctx as Record<string, unknown>).coin) {
172
+ ctxMap.set((ctx as Record<string, unknown>).coin as string, ctx);
173
+ }
174
+ }
175
+
176
+ for (const pair of spotData.meta.universe) {
177
+ if (!pair) continue;
178
+ const ctx = ctxMap.get(pair.name);
179
+ if (!ctx) continue;
180
+
181
+ // Match against pair name, base token name, and quote token name
182
+ const baseTokenName = tokenNameMap.get(pair.tokens[0]) ?? '';
183
+ const quoteTokenName = tokenNameMap.get(pair.tokens[1]) ?? '';
184
+ const searchable = `${pair.name} ${baseTokenName} ${quoteTokenName}`.toUpperCase();
185
+
186
+ if (searchable.includes(query)) {
187
+ const displayName = baseTokenName && quoteTokenName
188
+ ? `${baseTokenName}/${quoteTokenName}`
189
+ : pair.name;
166
190
  results.push({
167
191
  type: 'spot',
168
192
  provider: 'Spot',
169
- coin: pair.name,
193
+ coin: displayName,
170
194
  price: ctx.markPx,
171
195
  volume24h: parseFloat(ctx.dayNtlVlm || '0'),
172
196
  });