openbroker 1.0.75 → 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.
package/SKILL.md CHANGED
@@ -4,8 +4,8 @@ description: Hyperliquid trading plugin with background position monitoring and
4
4
  license: MIT
5
5
  compatibility: Requires Node.js 22+, network access to api.hyperliquid.xyz
6
6
  homepage: https://www.npmjs.com/package/openbroker
7
- metadata: {"author": "monemetrics", "version": "1.0.75", "openclaw": {"requires": {"bins": ["openbroker"], "env": ["HYPERLIQUID_PRIVATE_KEY"]}, "primaryEnv": "HYPERLIQUID_PRIVATE_KEY", "install": [{"id": "node", "kind": "node", "package": "openbroker", "bins": ["openbroker"], "label": "Install openbroker (npm)"}]}}
8
- allowed-tools: ob_account ob_positions ob_funding ob_markets ob_search ob_spot ob_fills ob_orders ob_order_status ob_fees ob_candles ob_funding_history ob_trades ob_rate_limit ob_funding_scan ob_buy ob_sell ob_limit ob_trigger ob_tpsl ob_cancel ob_twap ob_twap_cancel ob_twap_status ob_bracket ob_chase ob_watcher_status ob_auto_run ob_auto_stop ob_auto_list Bash(openbroker:*)
7
+ metadata: {"author": "monemetrics", "version": "1.0.79", "openclaw": {"requires": {"bins": ["openbroker"], "env": ["HYPERLIQUID_PRIVATE_KEY"]}, "primaryEnv": "HYPERLIQUID_PRIVATE_KEY", "install": [{"id": "node", "kind": "node", "package": "openbroker", "bins": ["openbroker"], "label": "Install openbroker (npm)"}]}}
8
+ allowed-tools: ob_account ob_positions ob_funding ob_markets ob_search ob_spot ob_fills ob_orders ob_order_status ob_fees ob_candles ob_funding_history ob_trades ob_rate_limit ob_funding_scan ob_buy ob_sell ob_limit ob_trigger ob_tpsl ob_cancel ob_spot_buy ob_spot_sell ob_twap ob_twap_cancel ob_twap_status ob_bracket ob_chase ob_watcher_status ob_auto_run ob_auto_stop ob_auto_list Bash(openbroker:*)
9
9
  ---
10
10
 
11
11
  # Open Broker - Hyperliquid Trading CLI
package/bin/cli.ts CHANGED
@@ -47,6 +47,9 @@ const commands: Record<string, { script: string; description: string }> = {
47
47
  'scale': { script: 'operations/scale.ts', description: 'Scale in/out orders' },
48
48
  'bracket': { script: 'operations/bracket.ts', description: 'Bracket order (entry + TP + SL)' },
49
49
  'chase': { script: 'operations/chase.ts', description: 'Chase order with ALO' },
50
+ 'spot-buy': { script: 'operations/spot-order.ts', description: 'Spot buy order' },
51
+ 'spot-sell': { script: 'operations/spot-order.ts', description: 'Spot sell order' },
52
+ 'spot-order': { script: 'operations/spot-order.ts', description: 'Spot order (market or limit)' },
50
53
 
51
54
  // Automations
52
55
  'auto': { script: 'auto/cli.ts', description: 'Run/manage trading automations' },
@@ -88,6 +91,11 @@ Trading Commands:
88
91
  tpsl Set TP/SL on existing position
89
92
  cancel Cancel orders
90
93
 
94
+ Spot Trading:
95
+ spot-buy Spot buy order
96
+ spot-sell Spot sell order
97
+ spot-order Spot order (market or limit, specify --side)
98
+
91
99
  Advanced Execution:
92
100
  twap Native TWAP order (exchange-managed)
93
101
  twap-cancel Cancel a running TWAP order
@@ -167,6 +175,14 @@ function main() {
167
175
  runScript(commands['market'].script, ['--side', 'sell', ...commandArgs]);
168
176
  return;
169
177
  }
178
+ if (command === 'spot-buy') {
179
+ runScript(commands['spot-order'].script, ['--side', 'buy', ...commandArgs]);
180
+ return;
181
+ }
182
+ if (command === 'spot-sell') {
183
+ runScript(commands['spot-order'].script, ['--side', 'sell', ...commandArgs]);
184
+ return;
185
+ }
170
186
 
171
187
  // Handle version
172
188
  if (command === '--version' || command === '-v') {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "openbroker",
3
3
  "name": "OpenBroker — Hyperliquid Trading",
4
- "version": "1.0.75",
4
+ "version": "1.0.79",
5
5
  "description": "Trade on Hyperliquid DEX with background position monitoring",
6
6
  "configSchema": {
7
7
  "type": "object",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openbroker",
3
- "version": "1.0.75",
3
+ "version": "1.0.79",
4
4
  "description": "Hyperliquid trading CLI - execute orders, manage positions, and run trading strategies",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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(
@@ -5,6 +5,7 @@ import { WebSocketTransport, SubscriptionClient } from '@nktkas/hyperliquid';
5
5
  import type { ISubscription } from '@nktkas/hyperliquid';
6
6
  import type {
7
7
  AllMidsWsEvent,
8
+ L2BookWsEvent,
8
9
  OrderUpdatesWsEvent,
9
10
  UserFillsWsEvent,
10
11
  UserEventsWsEvent,
@@ -14,6 +15,15 @@ import { isMainnet } from './config.js';
14
15
  // ── Event types ────────────────────────────────────────────────────
15
16
 
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
+ };
17
27
  /** Mid prices for all assets updated */
18
28
  allMids: { mids: Record<string, string> };
19
29
  /** Order status changed (filled, canceled, rejected, etc.) */
@@ -174,6 +184,21 @@ export class WebSocketManager {
174
184
  return this.trackSub(sub);
175
185
  }
176
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
+
177
202
  // ── User subscriptions ────────────────────────────────────────
178
203
 
179
204
  /**
@@ -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
  });
@@ -127,14 +127,29 @@ async function main() {
127
127
 
128
128
  const markets: SpotMarket[] = [];
129
129
 
130
- for (let i = 0; i < spotData.meta.universe.length; i++) {
131
- const pair = spotData.meta.universe[i];
132
- const ctx = spotData.assetCtxs[i];
133
- if (!pair || !ctx) continue;
134
-
135
- // Filter by coin if specified
136
- if (args.coin && !pair.name.toUpperCase().includes(args.coin)) {
137
- continue;
130
+ // Build ctx map by coin name — the contexts array is NOT aligned with universe by index.
131
+ // Each context has a 'coin' field matching pair.name.
132
+ const ctxMap = new Map<string, { markPx: string; prevDayPx: string; dayNtlVlm: string; midPx: string }>();
133
+ for (const ctx of spotData.assetCtxs as Array<{ coin?: string; markPx: string; prevDayPx: string; dayNtlVlm: string; midPx: string }>) {
134
+ if (ctx.coin) ctxMap.set(ctx.coin, ctx);
135
+ }
136
+
137
+ // Build token name map for filtering by base token name
138
+ const tokenNameMap = new Map<number, string>();
139
+ for (const token of spotData.meta.tokens) {
140
+ tokenNameMap.set(token.index, token.name);
141
+ }
142
+
143
+ for (const pair of spotData.meta.universe) {
144
+ if (!pair) continue;
145
+ const ctx = ctxMap.get(pair.name);
146
+ if (!ctx) continue;
147
+
148
+ // Filter by coin — match pair name or base token name
149
+ if (args.coin) {
150
+ const baseTokenName = tokenNameMap.get(pair.tokens[0]) ?? '';
151
+ const searchable = `${pair.name} ${baseTokenName}`.toUpperCase();
152
+ if (!searchable.includes(args.coin)) continue;
138
153
  }
139
154
 
140
155
  markets.push({
@@ -0,0 +1,189 @@
1
+ #!/usr/bin/env npx tsx
2
+ // Execute a spot order on Hyperliquid
3
+
4
+ import { getClient } from '../core/client.js';
5
+ import { formatUsd, parseArgs, checkBuilderFeeApproval } from '../core/utils.js';
6
+
7
+ function printUsage() {
8
+ console.log(`
9
+ Open Broker - Spot Order
10
+ ========================
11
+
12
+ Buy or sell spot tokens on Hyperliquid.
13
+
14
+ Usage:
15
+ npx tsx scripts/operations/spot-order.ts --coin <COIN> --side <buy|sell> --size <SIZE>
16
+
17
+ Options:
18
+ --coin Base token to trade (e.g., PURR, HYPE)
19
+ --side Order side: buy or sell
20
+ --size Order size in base token units
21
+ --price Limit price (omit for market order)
22
+ --tif Time-in-force for limit orders: Gtc, Ioc, Alo (default: Gtc)
23
+ --slippage Slippage tolerance in bps for market orders (default: from config, usually 50 = 0.5%)
24
+ --dry Dry run - show order details without executing
25
+ --verbose Show full API request/response for debugging
26
+
27
+ Environment:
28
+ HYPERLIQUID_PRIVATE_KEY Your wallet private key (0x...)
29
+ HYPERLIQUID_NETWORK "mainnet" or "testnet" (default: mainnet)
30
+
31
+ Examples:
32
+ npx tsx scripts/operations/spot-order.ts --coin PURR --side buy --size 1000
33
+ npx tsx scripts/operations/spot-order.ts --coin HYPE --side sell --size 50 --price 25.50
34
+ npx tsx scripts/operations/spot-order.ts --coin PURR --side buy --size 500 --dry
35
+ `);
36
+ }
37
+
38
+ async function main() {
39
+ const args = parseArgs(process.argv.slice(2));
40
+
41
+ const coin = args.coin as string;
42
+ const side = args.side as string;
43
+ const size = parseFloat(args.size as string);
44
+ const price = args.price ? parseFloat(args.price as string) : undefined;
45
+ const tif = (args.tif as 'Gtc' | 'Ioc' | 'Alo') ?? 'Gtc';
46
+ const slippage = args.slippage ? parseInt(args.slippage as string) : undefined;
47
+ const dryRun = args.dry as boolean;
48
+
49
+ if (!coin || !side || isNaN(size)) {
50
+ printUsage();
51
+ process.exit(1);
52
+ }
53
+
54
+ if (side !== 'buy' && side !== 'sell') {
55
+ console.error('Error: --side must be "buy" or "sell"');
56
+ process.exit(1);
57
+ }
58
+
59
+ if (size <= 0) {
60
+ console.error('Error: --size must be positive');
61
+ process.exit(1);
62
+ }
63
+
64
+ const isBuy = side === 'buy';
65
+ const client = getClient();
66
+
67
+ if (args.verbose) {
68
+ client.verbose = true;
69
+ }
70
+
71
+ console.log('Open Broker - Spot Order');
72
+ console.log('========================\n');
73
+
74
+ await checkBuilderFeeApproval(client);
75
+
76
+ try {
77
+ // Load spot metadata to get the pair index, then use allMids for accurate price
78
+ const spotMeta = await client.getSpotMeta();
79
+ const tokenMap = new Map<number, string>();
80
+ for (const t of spotMeta.tokens) tokenMap.set(t.index, t.name);
81
+
82
+ // Find the USDC-quoted pair for this coin (prefer quote token 0 = USDC)
83
+ let pairName = '';
84
+ let spotCoinKey = '';
85
+ for (const pair of spotMeta.universe) {
86
+ const baseName = tokenMap.get(pair.tokens[0]) ?? '';
87
+ if (baseName.toUpperCase() !== coin.toUpperCase()) continue;
88
+ const quoteName = tokenMap.get(pair.tokens[1]) ?? 'USDC';
89
+ // Prefer USDC pair; if already found a USDC pair, skip non-USDC pairs
90
+ if (pairName && pair.tokens[1] !== 0) continue;
91
+ pairName = `${baseName}/${quoteName}`;
92
+ spotCoinKey = pair.name; // "@107" or "PURR/USDC"
93
+ if (pair.tokens[1] === 0) break; // USDC pair found, stop
94
+ }
95
+
96
+ if (!spotCoinKey) {
97
+ console.error(`Error: No spot market found for ${coin}`);
98
+ console.error('Use "openbroker spot" to see available spot markets.');
99
+ process.exit(1);
100
+ }
101
+
102
+ // Use allMids for live price (spotMetaAndAssetCtxs contexts can be misaligned)
103
+ const mids = await client.getAllMids();
104
+ const midPrice = parseFloat(mids[spotCoinKey] || '0');
105
+
106
+ if (!midPrice || midPrice === 0) {
107
+ console.error(`Error: No spot price for ${coin} (${spotCoinKey})`);
108
+ process.exit(1);
109
+ }
110
+
111
+ const isMarket = price === undefined;
112
+ const slippageBps = slippage ?? 50;
113
+ const displayPrice = isMarket
114
+ ? (isBuy ? midPrice * (1 + slippageBps / 10000) : midPrice * (1 - slippageBps / 10000))
115
+ : price;
116
+ const notional = midPrice * size;
117
+
118
+ console.log('Order Details');
119
+ console.log('-------------');
120
+ console.log(`Pair: ${pairName}`);
121
+ console.log(`Side: ${isBuy ? 'BUY' : 'SELL'}`);
122
+ console.log(`Size: ${size} ${coin}`);
123
+ console.log(`Mid Price: ${formatUsd(midPrice)}`);
124
+ if (isMarket) {
125
+ console.log(`Type: Market (IOC)`);
126
+ console.log(`Limit Price: ${formatUsd(displayPrice)} (${slippageBps} bps slippage)`);
127
+ } else {
128
+ console.log(`Type: Limit (${tif})`);
129
+ console.log(`Limit Price: ${formatUsd(price)}`);
130
+ }
131
+ console.log(`Notional: ~${formatUsd(notional)}`);
132
+ console.log(`Builder Fee: ${client.builderInfo.f / 10} bps`);
133
+
134
+ if (dryRun) {
135
+ console.log('\n🔍 Dry run - order not submitted');
136
+ return;
137
+ }
138
+
139
+ console.log('\nExecuting...');
140
+
141
+ const response = isMarket
142
+ ? await client.spotMarketOrder(coin, isBuy, size, slippage)
143
+ : await client.spotLimitOrder(coin, isBuy, size, price!, tif);
144
+
145
+ console.log('\nResult');
146
+ console.log('------');
147
+
148
+ if (args.verbose || process.env.VERBOSE) {
149
+ console.log('\nFull Response:');
150
+ console.log(JSON.stringify(response, null, 2));
151
+ }
152
+
153
+ if (response.status === 'ok' && response.response && typeof response.response === 'object') {
154
+ const statuses = response.response.data.statuses;
155
+ for (const status of statuses) {
156
+ if (status.filled) {
157
+ const fillSz = parseFloat(status.filled.totalSz);
158
+ const avgPx = parseFloat(status.filled.avgPx);
159
+ const fillNotional = fillSz * avgPx;
160
+
161
+ console.log(`✅ Filled`);
162
+ console.log(` Order ID: ${status.filled.oid}`);
163
+ console.log(` Size: ${fillSz} ${coin}`);
164
+ console.log(` Avg Price: ${formatUsd(avgPx)}`);
165
+ console.log(` Notional: ${formatUsd(fillNotional)}`);
166
+ } else if (status.resting) {
167
+ console.log(`⏳ Resting`);
168
+ console.log(` Order ID: ${status.resting.oid}`);
169
+ } else if (status.error) {
170
+ console.log(`❌ Error: ${status.error}`);
171
+ } else {
172
+ console.log(`⚠️ Unknown status:`);
173
+ console.log(JSON.stringify(status, null, 2));
174
+ }
175
+ }
176
+ } else if (response.status === 'err') {
177
+ console.log(`❌ API Error: ${response.response || JSON.stringify(response)}`);
178
+ } else {
179
+ console.log(`❌ Unexpected response:`);
180
+ console.log(JSON.stringify(response, null, 2));
181
+ }
182
+
183
+ } catch (error) {
184
+ console.error('Error executing spot order:', error);
185
+ process.exit(1);
186
+ }
187
+ }
188
+
189
+ main();
@@ -398,14 +398,27 @@ export function createTools(watcherOrCtx: PositionWatcher | null | ToolsContext)
398
398
  if (!typeFilter || typeFilter === 'spot') {
399
399
  try {
400
400
  const spotData = await client.getSpotMetaAndAssetCtxs();
401
- for (let i = 0; i < spotData.meta.universe.length; i++) {
402
- const pair = spotData.meta.universe[i];
403
- if (pair.name.toUpperCase().includes(query)) {
401
+ // Build ctx map by coin name — contexts are NOT aligned with universe by index
402
+ const ctxMap = new Map<string, Record<string, string>>();
403
+ for (const ctx of spotData.assetCtxs as Array<Record<string, string>>) {
404
+ if (ctx.coin) ctxMap.set(ctx.coin, ctx);
405
+ }
406
+ // Build token name map
407
+ const tMap = new Map<number, string>();
408
+ for (const t of spotData.meta.tokens) tMap.set(t.index, t.name);
409
+
410
+ for (const pair of spotData.meta.universe) {
411
+ const baseName = tMap.get(pair.tokens[0]) ?? '';
412
+ const quoteName = tMap.get(pair.tokens[1]) ?? '';
413
+ const searchable = `${pair.name} ${baseName} ${quoteName}`.toUpperCase();
414
+ if (searchable.includes(query)) {
415
+ const ctx = ctxMap.get(pair.name);
416
+ const displayName = baseName && quoteName ? `${baseName}/${quoteName}` : pair.name;
404
417
  results.push({
405
- coin: pair.name,
418
+ coin: displayName,
406
419
  type: 'spot',
407
- markPx: spotData.assetCtxs[i]?.markPx,
408
- dayVolume: spotData.assetCtxs[i]?.dayNtlVlm,
420
+ markPx: ctx?.markPx,
421
+ dayVolume: ctx?.dayNtlVlm,
409
422
  });
410
423
  }
411
424
  }
@@ -1193,6 +1206,113 @@ export function createTools(watcherOrCtx: PositionWatcher | null | ToolsContext)
1193
1206
  },
1194
1207
  },
1195
1208
 
1209
+ {
1210
+ name: 'ob_spot_buy',
1211
+ description: 'Buy spot tokens on Hyperliquid. Always use dry=true first to preview.',
1212
+ parameters: {
1213
+ type: 'object',
1214
+ properties: {
1215
+ coin: { type: 'string', description: 'Base token symbol (PURR, HYPE, etc.)' },
1216
+ size: { type: 'number', description: 'Order size in base token units' },
1217
+ price: { type: 'number', description: 'Limit price (omit for market order)' },
1218
+ tif: { type: 'string', description: 'Time-in-force for limit: Gtc, Ioc, Alo (default: Gtc)' },
1219
+ slippage: { type: 'number', description: 'Slippage tolerance in bps for market orders (default: 50)' },
1220
+ dry: { type: 'boolean', description: 'Preview without executing' },
1221
+ },
1222
+ required: ['coin', 'size'],
1223
+ },
1224
+ async execute(_id, params) {
1225
+ const { getClient } = await import('../core/client.js');
1226
+ const { formatUsd } = await import('../core/utils.js');
1227
+ const client = getClient();
1228
+
1229
+ if (client.isReadOnly) return error('Wallet not configured. Run "openbroker setup" first.');
1230
+
1231
+ const coin = (params.coin as string).toUpperCase();
1232
+ const size = params.size as number;
1233
+ const price = params.price as number | undefined;
1234
+ const isMarket = price === undefined;
1235
+
1236
+ if (params.dry) {
1237
+ // Use allMids for accurate spot price preview
1238
+ await client.getMetaAndAssetCtxs(); // ensure spot meta loaded
1239
+ const spotIdx = client.getSpotAssetIndex(coin);
1240
+ const mids = await client.getAllMids();
1241
+ const spotKey = spotIdx !== undefined ? (spotIdx === 10000 ? 'PURR/USDC' : `@${spotIdx - 10000}`) : '';
1242
+ const midPrice = parseFloat(mids[spotKey] || '0');
1243
+ return json({
1244
+ dryRun: true,
1245
+ action: 'spot_buy',
1246
+ coin,
1247
+ size,
1248
+ type: isMarket ? 'market' : 'limit',
1249
+ midPrice,
1250
+ price: price ?? midPrice,
1251
+ notional: formatUsd(midPrice * size),
1252
+ });
1253
+ }
1254
+
1255
+ const result = isMarket
1256
+ ? await client.spotMarketOrder(coin, true, size, params.slippage as number | undefined)
1257
+ : await client.spotLimitOrder(coin, true, size, price!, (params.tif as 'Gtc' | 'Ioc' | 'Alo') ?? 'Gtc');
1258
+
1259
+ return json({ action: 'spot_buy', coin, size, type: isMarket ? 'market' : 'limit', result });
1260
+ },
1261
+ },
1262
+
1263
+ {
1264
+ name: 'ob_spot_sell',
1265
+ description: 'Sell spot tokens on Hyperliquid. Always use dry=true first to preview.',
1266
+ parameters: {
1267
+ type: 'object',
1268
+ properties: {
1269
+ coin: { type: 'string', description: 'Base token symbol (PURR, HYPE, etc.)' },
1270
+ size: { type: 'number', description: 'Order size in base token units' },
1271
+ price: { type: 'number', description: 'Limit price (omit for market order)' },
1272
+ tif: { type: 'string', description: 'Time-in-force for limit: Gtc, Ioc, Alo (default: Gtc)' },
1273
+ slippage: { type: 'number', description: 'Slippage tolerance in bps for market orders (default: 50)' },
1274
+ dry: { type: 'boolean', description: 'Preview without executing' },
1275
+ },
1276
+ required: ['coin', 'size'],
1277
+ },
1278
+ async execute(_id, params) {
1279
+ const { getClient } = await import('../core/client.js');
1280
+ const { formatUsd } = await import('../core/utils.js');
1281
+ const client = getClient();
1282
+
1283
+ if (client.isReadOnly) return error('Wallet not configured. Run "openbroker setup" first.');
1284
+
1285
+ const coin = (params.coin as string).toUpperCase();
1286
+ const size = params.size as number;
1287
+ const price = params.price as number | undefined;
1288
+ const isMarket = price === undefined;
1289
+
1290
+ if (params.dry) {
1291
+ await client.getMetaAndAssetCtxs();
1292
+ const spotIdx = client.getSpotAssetIndex(coin);
1293
+ const mids = await client.getAllMids();
1294
+ const spotKey = spotIdx !== undefined ? (spotIdx === 10000 ? 'PURR/USDC' : `@${spotIdx - 10000}`) : '';
1295
+ const midPrice = parseFloat(mids[spotKey] || '0');
1296
+ return json({
1297
+ dryRun: true,
1298
+ action: 'spot_sell',
1299
+ coin,
1300
+ size,
1301
+ type: isMarket ? 'market' : 'limit',
1302
+ midPrice,
1303
+ price: price ?? midPrice,
1304
+ notional: formatUsd(midPrice * size),
1305
+ });
1306
+ }
1307
+
1308
+ const result = isMarket
1309
+ ? await client.spotMarketOrder(coin, false, size, params.slippage as number | undefined)
1310
+ : await client.spotLimitOrder(coin, false, size, price!, (params.tif as 'Gtc' | 'Ioc' | 'Alo') ?? 'Gtc');
1311
+
1312
+ return json({ action: 'spot_sell', coin, size, type: isMarket ? 'market' : 'limit', result });
1313
+ },
1314
+ },
1315
+
1196
1316
  {
1197
1317
  name: 'ob_cancel',
1198
1318
  description: 'Cancel open orders on Hyperliquid',