openbroker 1.0.33
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/CHANGELOG.md +94 -0
- package/README.md +160 -0
- package/SKILL.md +296 -0
- package/bin/cli.ts +170 -0
- package/bin/openbroker.js +24 -0
- package/config/example.env +48 -0
- package/package.json +79 -0
- package/scripts/core/client.ts +844 -0
- package/scripts/core/config.ts +92 -0
- package/scripts/core/types.ts +192 -0
- package/scripts/core/utils.ts +156 -0
- package/scripts/info/account.ts +117 -0
- package/scripts/info/all-markets.ts +223 -0
- package/scripts/info/funding.ts +133 -0
- package/scripts/info/markets.ts +151 -0
- package/scripts/info/positions.ts +88 -0
- package/scripts/info/search-markets.ts +230 -0
- package/scripts/info/spot.ts +192 -0
- package/scripts/operations/bracket.ts +285 -0
- package/scripts/operations/cancel.ts +124 -0
- package/scripts/operations/chase.ts +236 -0
- package/scripts/operations/limit-order.ts +160 -0
- package/scripts/operations/market-order.ts +167 -0
- package/scripts/operations/scale.ts +263 -0
- package/scripts/operations/set-tpsl.ts +302 -0
- package/scripts/operations/trigger-order.ts +201 -0
- package/scripts/operations/twap.ts +222 -0
- package/scripts/setup/approve-builder.ts +178 -0
- package/scripts/setup/onboard.ts +242 -0
- package/scripts/strategies/dca.ts +292 -0
- package/scripts/strategies/funding-arb.ts +319 -0
- package/scripts/strategies/grid.ts +397 -0
- package/scripts/strategies/mm-maker.ts +411 -0
- package/scripts/strategies/mm-spread.ts +402 -0
|
@@ -0,0 +1,844 @@
|
|
|
1
|
+
// Hyperliquid Client for Open Broker
|
|
2
|
+
// Uses @nktkas/hyperliquid SDK
|
|
3
|
+
|
|
4
|
+
import { HttpTransport, InfoClient, ExchangeClient } from '@nktkas/hyperliquid';
|
|
5
|
+
import { privateKeyToAccount, type PrivateKeyAccount } from 'viem/accounts';
|
|
6
|
+
import type {
|
|
7
|
+
OpenBrokerConfig,
|
|
8
|
+
BuilderInfo,
|
|
9
|
+
OrderResponse,
|
|
10
|
+
CancelResponse,
|
|
11
|
+
MetaAndAssetCtxs,
|
|
12
|
+
ClearinghouseState,
|
|
13
|
+
OpenOrder,
|
|
14
|
+
} from './types.js';
|
|
15
|
+
import { loadConfig, isMainnet } from './config.js';
|
|
16
|
+
import { roundPrice, roundSize } from './utils.js';
|
|
17
|
+
|
|
18
|
+
export class HyperliquidClient {
|
|
19
|
+
private config: OpenBrokerConfig;
|
|
20
|
+
private account: PrivateKeyAccount;
|
|
21
|
+
private transport: HttpTransport;
|
|
22
|
+
private info: InfoClient;
|
|
23
|
+
private exchange: ExchangeClient;
|
|
24
|
+
|
|
25
|
+
private meta: MetaAndAssetCtxs | null = null;
|
|
26
|
+
private assetMap: Map<string, number> = new Map();
|
|
27
|
+
private szDecimalsMap: Map<string, number> = new Map();
|
|
28
|
+
public verbose: boolean = false;
|
|
29
|
+
|
|
30
|
+
constructor(config?: OpenBrokerConfig) {
|
|
31
|
+
this.config = config ?? loadConfig();
|
|
32
|
+
this.account = privateKeyToAccount(this.config.privateKey);
|
|
33
|
+
this.verbose = process.env.VERBOSE === '1' || process.env.VERBOSE === 'true';
|
|
34
|
+
|
|
35
|
+
// Initialize SDK clients
|
|
36
|
+
this.transport = new HttpTransport({ isMainnet: isMainnet() });
|
|
37
|
+
this.info = new InfoClient({ transport: this.transport });
|
|
38
|
+
this.exchange = new ExchangeClient({
|
|
39
|
+
transport: this.transport,
|
|
40
|
+
wallet: this.account,
|
|
41
|
+
isMainnet: isMainnet(),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private log(...args: unknown[]) {
|
|
46
|
+
if (this.verbose) {
|
|
47
|
+
console.log('[DEBUG]', ...args);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The address we're trading on behalf of (may be different from wallet if using API wallet) */
|
|
52
|
+
get address(): string {
|
|
53
|
+
return this.config.accountAddress;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The address of the signing wallet (derived from private key) */
|
|
57
|
+
get walletAddress(): string {
|
|
58
|
+
return this.config.walletAddress;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Whether we're using an API wallet (signing wallet differs from trading account) */
|
|
62
|
+
get isApiWallet(): boolean {
|
|
63
|
+
return this.config.isApiWallet;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
get builderInfo(): BuilderInfo {
|
|
67
|
+
return {
|
|
68
|
+
b: this.config.builderAddress.toLowerCase(),
|
|
69
|
+
f: this.config.builderFee,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
get builderAddress(): string {
|
|
74
|
+
return this.config.builderAddress;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
get builderFeeBps(): number {
|
|
78
|
+
return this.config.builderFee / 10; // Convert from tenths of bps to bps
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ============ Market Data ============
|
|
82
|
+
|
|
83
|
+
async getMetaAndAssetCtxs(): Promise<MetaAndAssetCtxs> {
|
|
84
|
+
if (this.meta) return this.meta;
|
|
85
|
+
|
|
86
|
+
this.log('Fetching metaAndAssetCtxs...');
|
|
87
|
+
const response = await this.info.metaAndAssetCtxs();
|
|
88
|
+
this.log('metaAndAssetCtxs response:', JSON.stringify(response, null, 2).slice(0, 500) + '...');
|
|
89
|
+
|
|
90
|
+
this.meta = {
|
|
91
|
+
meta: { universe: response[0].universe },
|
|
92
|
+
assetCtxs: response[1],
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// Build lookup maps
|
|
96
|
+
this.meta.meta.universe.forEach((asset, index) => {
|
|
97
|
+
this.assetMap.set(asset.name, index);
|
|
98
|
+
this.szDecimalsMap.set(asset.name, asset.szDecimals);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
return this.meta;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async getAllMids(): Promise<Record<string, string>> {
|
|
105
|
+
this.log('Fetching allMids...');
|
|
106
|
+
const response = await this.info.allMids();
|
|
107
|
+
return response;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Get all perpetual DEXs (including HIP-3 builder-deployed markets)
|
|
112
|
+
* Returns array where index 0 is null (main dex), others are HIP-3 dexs
|
|
113
|
+
*/
|
|
114
|
+
async getPerpDexs(): Promise<Array<{
|
|
115
|
+
name: string;
|
|
116
|
+
fullName: string;
|
|
117
|
+
deployer: string;
|
|
118
|
+
} | null>> {
|
|
119
|
+
this.log('Fetching perpDexs...');
|
|
120
|
+
const baseUrl = isMainnet()
|
|
121
|
+
? 'https://api.hyperliquid.xyz'
|
|
122
|
+
: 'https://api.hyperliquid-testnet.xyz';
|
|
123
|
+
|
|
124
|
+
const response = await fetch(baseUrl + '/info', {
|
|
125
|
+
method: 'POST',
|
|
126
|
+
headers: { 'Content-Type': 'application/json' },
|
|
127
|
+
body: JSON.stringify({ type: 'perpDexs' }),
|
|
128
|
+
});
|
|
129
|
+
const data = await response.json();
|
|
130
|
+
this.log('perpDexs response:', JSON.stringify(data).slice(0, 500));
|
|
131
|
+
return data;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Get all perp markets including HIP-3 dexs
|
|
136
|
+
* Returns array of [meta, assetCtxs] for each dex
|
|
137
|
+
*/
|
|
138
|
+
async getAllPerpMetas(): Promise<Array<{
|
|
139
|
+
dexName: string | null;
|
|
140
|
+
meta: { universe: Array<{ name: string; szDecimals: number; maxLeverage: number; onlyIsolated?: boolean }> };
|
|
141
|
+
assetCtxs: Array<{
|
|
142
|
+
funding: string;
|
|
143
|
+
openInterest: string;
|
|
144
|
+
markPx: string;
|
|
145
|
+
midPx: string | null;
|
|
146
|
+
oraclePx: string;
|
|
147
|
+
prevDayPx: string;
|
|
148
|
+
dayNtlVlm: string;
|
|
149
|
+
}>;
|
|
150
|
+
}>> {
|
|
151
|
+
this.log('Fetching all perp markets...');
|
|
152
|
+
const baseUrl = isMainnet()
|
|
153
|
+
? 'https://api.hyperliquid.xyz'
|
|
154
|
+
: 'https://api.hyperliquid-testnet.xyz';
|
|
155
|
+
|
|
156
|
+
const results: Array<{
|
|
157
|
+
dexName: string | null;
|
|
158
|
+
meta: { universe: Array<{ name: string; szDecimals: number; maxLeverage: number; onlyIsolated?: boolean }> };
|
|
159
|
+
assetCtxs: Array<{
|
|
160
|
+
funding: string;
|
|
161
|
+
openInterest: string;
|
|
162
|
+
markPx: string;
|
|
163
|
+
midPx: string | null;
|
|
164
|
+
oraclePx: string;
|
|
165
|
+
prevDayPx: string;
|
|
166
|
+
dayNtlVlm: string;
|
|
167
|
+
}>;
|
|
168
|
+
}> = [];
|
|
169
|
+
|
|
170
|
+
// Get main dex data (no dex parameter)
|
|
171
|
+
const mainResponse = await fetch(baseUrl + '/info', {
|
|
172
|
+
method: 'POST',
|
|
173
|
+
headers: { 'Content-Type': 'application/json' },
|
|
174
|
+
body: JSON.stringify({ type: 'metaAndAssetCtxs' }),
|
|
175
|
+
});
|
|
176
|
+
const mainData = await mainResponse.json();
|
|
177
|
+
this.log('Main dex data fetched');
|
|
178
|
+
|
|
179
|
+
results.push({
|
|
180
|
+
dexName: null,
|
|
181
|
+
meta: { universe: mainData[0].universe },
|
|
182
|
+
assetCtxs: mainData[1],
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// Get HIP-3 dex names
|
|
186
|
+
const dexs = await this.getPerpDexs();
|
|
187
|
+
|
|
188
|
+
// Fetch each HIP-3 dex by name
|
|
189
|
+
for (let i = 1; i < dexs.length; i++) {
|
|
190
|
+
const dex = dexs[i];
|
|
191
|
+
if (!dex) continue;
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
const dexResponse = await fetch(baseUrl + '/info', {
|
|
195
|
+
method: 'POST',
|
|
196
|
+
headers: { 'Content-Type': 'application/json' },
|
|
197
|
+
body: JSON.stringify({ type: 'metaAndAssetCtxs', dex: dex.name }),
|
|
198
|
+
});
|
|
199
|
+
const dexData = await dexResponse.json();
|
|
200
|
+
|
|
201
|
+
if (dexData && dexData[0]?.universe) {
|
|
202
|
+
this.log(`Fetched HIP-3 dex: ${dex.name} with ${dexData[0].universe.length} markets`);
|
|
203
|
+
results.push({
|
|
204
|
+
dexName: dex.name,
|
|
205
|
+
meta: { universe: dexData[0].universe },
|
|
206
|
+
assetCtxs: dexData[1] || [],
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
} catch (e) {
|
|
210
|
+
this.log(`Failed to fetch HIP-3 dex ${dex.name}:`, e);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return results;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Get spot market metadata
|
|
219
|
+
*/
|
|
220
|
+
async getSpotMeta(): Promise<{
|
|
221
|
+
tokens: Array<{
|
|
222
|
+
name: string;
|
|
223
|
+
szDecimals: number;
|
|
224
|
+
weiDecimals: number;
|
|
225
|
+
index: number;
|
|
226
|
+
tokenId: string;
|
|
227
|
+
isCanonical: boolean;
|
|
228
|
+
fullName: string | null;
|
|
229
|
+
}>;
|
|
230
|
+
universe: Array<{
|
|
231
|
+
name: string;
|
|
232
|
+
tokens: [number, number];
|
|
233
|
+
index: number;
|
|
234
|
+
isCanonical: boolean;
|
|
235
|
+
}>;
|
|
236
|
+
}> {
|
|
237
|
+
this.log('Fetching spotMeta...');
|
|
238
|
+
const baseUrl = isMainnet()
|
|
239
|
+
? 'https://api.hyperliquid.xyz'
|
|
240
|
+
: 'https://api.hyperliquid-testnet.xyz';
|
|
241
|
+
|
|
242
|
+
const response = await fetch(baseUrl + '/info', {
|
|
243
|
+
method: 'POST',
|
|
244
|
+
headers: { 'Content-Type': 'application/json' },
|
|
245
|
+
body: JSON.stringify({ type: 'spotMeta' }),
|
|
246
|
+
});
|
|
247
|
+
const data = await response.json();
|
|
248
|
+
this.log('spotMeta response:', JSON.stringify(data).slice(0, 500));
|
|
249
|
+
return data;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Get spot metadata with asset contexts (prices, volumes)
|
|
254
|
+
*/
|
|
255
|
+
async getSpotMetaAndAssetCtxs(): Promise<{
|
|
256
|
+
meta: {
|
|
257
|
+
tokens: Array<{
|
|
258
|
+
name: string;
|
|
259
|
+
szDecimals: number;
|
|
260
|
+
weiDecimals: number;
|
|
261
|
+
index: number;
|
|
262
|
+
tokenId: string;
|
|
263
|
+
isCanonical: boolean;
|
|
264
|
+
}>;
|
|
265
|
+
universe: Array<{
|
|
266
|
+
name: string;
|
|
267
|
+
tokens: [number, number];
|
|
268
|
+
index: number;
|
|
269
|
+
isCanonical: boolean;
|
|
270
|
+
}>;
|
|
271
|
+
};
|
|
272
|
+
assetCtxs: Array<{
|
|
273
|
+
dayNtlVlm: string;
|
|
274
|
+
markPx: string;
|
|
275
|
+
midPx: string;
|
|
276
|
+
prevDayPx: string;
|
|
277
|
+
}>;
|
|
278
|
+
}> {
|
|
279
|
+
this.log('Fetching spotMetaAndAssetCtxs...');
|
|
280
|
+
const baseUrl = isMainnet()
|
|
281
|
+
? 'https://api.hyperliquid.xyz'
|
|
282
|
+
: 'https://api.hyperliquid-testnet.xyz';
|
|
283
|
+
|
|
284
|
+
const response = await fetch(baseUrl + '/info', {
|
|
285
|
+
method: 'POST',
|
|
286
|
+
headers: { 'Content-Type': 'application/json' },
|
|
287
|
+
body: JSON.stringify({ type: 'spotMetaAndAssetCtxs' }),
|
|
288
|
+
});
|
|
289
|
+
const data = await response.json();
|
|
290
|
+
this.log('spotMetaAndAssetCtxs response:', JSON.stringify(data).slice(0, 500));
|
|
291
|
+
return {
|
|
292
|
+
meta: data[0],
|
|
293
|
+
assetCtxs: data[1],
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Get user's spot token balances
|
|
299
|
+
*/
|
|
300
|
+
async getSpotBalances(user?: string): Promise<{
|
|
301
|
+
balances: Array<{
|
|
302
|
+
coin: string;
|
|
303
|
+
token: number;
|
|
304
|
+
hold: string;
|
|
305
|
+
total: string;
|
|
306
|
+
entryNtl: string;
|
|
307
|
+
}>;
|
|
308
|
+
}> {
|
|
309
|
+
this.log('Fetching spotClearinghouseState for:', user ?? this.address);
|
|
310
|
+
const baseUrl = isMainnet()
|
|
311
|
+
? 'https://api.hyperliquid.xyz'
|
|
312
|
+
: 'https://api.hyperliquid-testnet.xyz';
|
|
313
|
+
|
|
314
|
+
const response = await fetch(baseUrl + '/info', {
|
|
315
|
+
method: 'POST',
|
|
316
|
+
headers: { 'Content-Type': 'application/json' },
|
|
317
|
+
body: JSON.stringify({
|
|
318
|
+
type: 'spotClearinghouseState',
|
|
319
|
+
user: user ?? this.address,
|
|
320
|
+
}),
|
|
321
|
+
});
|
|
322
|
+
const data = await response.json();
|
|
323
|
+
this.log('spotClearinghouseState response:', JSON.stringify(data).slice(0, 500));
|
|
324
|
+
return data;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Get token details by token ID
|
|
329
|
+
*/
|
|
330
|
+
async getTokenDetails(tokenId: string): Promise<{
|
|
331
|
+
name: string;
|
|
332
|
+
maxSupply: string;
|
|
333
|
+
totalSupply: string;
|
|
334
|
+
circulatingSupply: string;
|
|
335
|
+
szDecimals: number;
|
|
336
|
+
weiDecimals: number;
|
|
337
|
+
midPx: string;
|
|
338
|
+
markPx: string;
|
|
339
|
+
prevDayPx: string;
|
|
340
|
+
deployer: string;
|
|
341
|
+
deployTime: string;
|
|
342
|
+
} | null> {
|
|
343
|
+
this.log('Fetching tokenDetails for:', tokenId);
|
|
344
|
+
const baseUrl = isMainnet()
|
|
345
|
+
? 'https://api.hyperliquid.xyz'
|
|
346
|
+
: 'https://api.hyperliquid-testnet.xyz';
|
|
347
|
+
|
|
348
|
+
try {
|
|
349
|
+
const response = await fetch(baseUrl + '/info', {
|
|
350
|
+
method: 'POST',
|
|
351
|
+
headers: { 'Content-Type': 'application/json' },
|
|
352
|
+
body: JSON.stringify({
|
|
353
|
+
type: 'tokenDetails',
|
|
354
|
+
tokenId,
|
|
355
|
+
}),
|
|
356
|
+
});
|
|
357
|
+
const data = await response.json();
|
|
358
|
+
this.log('tokenDetails response:', JSON.stringify(data).slice(0, 500));
|
|
359
|
+
return data;
|
|
360
|
+
} catch {
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Get predicted funding rates across venues
|
|
367
|
+
*/
|
|
368
|
+
async getPredictedFundings(): Promise<Array<[
|
|
369
|
+
string, // coin
|
|
370
|
+
Array<[string, { fundingRate: string; nextFundingTime: number }]> // venue funding rates
|
|
371
|
+
]>> {
|
|
372
|
+
this.log('Fetching predictedFundings...');
|
|
373
|
+
const baseUrl = isMainnet()
|
|
374
|
+
? 'https://api.hyperliquid.xyz'
|
|
375
|
+
: 'https://api.hyperliquid-testnet.xyz';
|
|
376
|
+
|
|
377
|
+
const response = await fetch(baseUrl + '/info', {
|
|
378
|
+
method: 'POST',
|
|
379
|
+
headers: { 'Content-Type': 'application/json' },
|
|
380
|
+
body: JSON.stringify({ type: 'predictedFundings' }),
|
|
381
|
+
});
|
|
382
|
+
const data = await response.json();
|
|
383
|
+
this.log('predictedFundings response length:', data?.length);
|
|
384
|
+
return data;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Get L2 order book for an asset
|
|
389
|
+
* Returns best bid/ask and depth
|
|
390
|
+
*/
|
|
391
|
+
async getL2Book(coin: string): Promise<{
|
|
392
|
+
bids: Array<{ px: string; sz: string; n: number }>;
|
|
393
|
+
asks: Array<{ px: string; sz: string; n: number }>;
|
|
394
|
+
bestBid: number;
|
|
395
|
+
bestAsk: number;
|
|
396
|
+
midPrice: number;
|
|
397
|
+
spread: number;
|
|
398
|
+
spreadBps: number;
|
|
399
|
+
}> {
|
|
400
|
+
this.log('Fetching l2Book for:', coin);
|
|
401
|
+
const response = await this.info.l2Book({ coin });
|
|
402
|
+
|
|
403
|
+
const bids = response.levels[0] as Array<{ px: string; sz: string; n: number }>;
|
|
404
|
+
const asks = response.levels[1] as Array<{ px: string; sz: string; n: number }>;
|
|
405
|
+
|
|
406
|
+
const bestBid = bids.length > 0 ? parseFloat(bids[0].px) : 0;
|
|
407
|
+
const bestAsk = asks.length > 0 ? parseFloat(asks[0].px) : 0;
|
|
408
|
+
const midPrice = (bestBid + bestAsk) / 2;
|
|
409
|
+
const spread = bestAsk - bestBid;
|
|
410
|
+
const spreadBps = midPrice > 0 ? (spread / midPrice) * 10000 : 0;
|
|
411
|
+
|
|
412
|
+
return {
|
|
413
|
+
bids,
|
|
414
|
+
asks,
|
|
415
|
+
bestBid,
|
|
416
|
+
bestAsk,
|
|
417
|
+
midPrice,
|
|
418
|
+
spread,
|
|
419
|
+
spreadBps,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
getAssetIndex(coin: string): number {
|
|
424
|
+
const index = this.assetMap.get(coin);
|
|
425
|
+
if (index === undefined) {
|
|
426
|
+
throw new Error(`Unknown asset: ${coin}. Available: ${Array.from(this.assetMap.keys()).slice(0, 10).join(', ')}...`);
|
|
427
|
+
}
|
|
428
|
+
return index;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
getSzDecimals(coin: string): number {
|
|
432
|
+
const decimals = this.szDecimalsMap.get(coin);
|
|
433
|
+
if (decimals === undefined) {
|
|
434
|
+
throw new Error(`Unknown asset: ${coin}`);
|
|
435
|
+
}
|
|
436
|
+
return decimals;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ============ Account Info ============
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Check if an address has sub-accounts (is a master account)
|
|
443
|
+
* Sub-accounts cannot approve builder fees - only master accounts can
|
|
444
|
+
*/
|
|
445
|
+
async getSubAccounts(user?: string): Promise<Array<{ subAccountUser: string; name: string }>> {
|
|
446
|
+
this.log('Fetching subAccounts for:', user ?? this.address);
|
|
447
|
+
try {
|
|
448
|
+
const response = await this.info.subAccounts({ user: user ?? this.address });
|
|
449
|
+
if (!response) return [];
|
|
450
|
+
// Response is an array of sub-account objects
|
|
451
|
+
return response.map((sub: { subAccountUser: string; name: string }) => ({
|
|
452
|
+
subAccountUser: sub.subAccountUser,
|
|
453
|
+
name: sub.name,
|
|
454
|
+
}));
|
|
455
|
+
} catch {
|
|
456
|
+
return [];
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Check the maximum builder fee approved for a user/builder pair
|
|
462
|
+
* Returns the max fee rate as a string (e.g., "0.1%") or null if not approved
|
|
463
|
+
*/
|
|
464
|
+
async getMaxBuilderFee(user?: string, builder?: string): Promise<string | null> {
|
|
465
|
+
// IMPORTANT: Hyperliquid API requires lowercase addresses
|
|
466
|
+
const targetUser = (user ?? this.address).toLowerCase();
|
|
467
|
+
const targetBuilder = (builder ?? this.config.builderAddress).toLowerCase();
|
|
468
|
+
|
|
469
|
+
this.log('Fetching maxBuilderFee for:', targetUser, 'builder:', targetBuilder);
|
|
470
|
+
|
|
471
|
+
try {
|
|
472
|
+
const baseUrl = isMainnet()
|
|
473
|
+
? 'https://api.hyperliquid.xyz'
|
|
474
|
+
: 'https://api.hyperliquid-testnet.xyz';
|
|
475
|
+
|
|
476
|
+
const response = await fetch(baseUrl + '/info', {
|
|
477
|
+
method: 'POST',
|
|
478
|
+
headers: { 'Content-Type': 'application/json' },
|
|
479
|
+
body: JSON.stringify({
|
|
480
|
+
type: 'maxBuilderFee',
|
|
481
|
+
user: targetUser,
|
|
482
|
+
builder: targetBuilder,
|
|
483
|
+
}),
|
|
484
|
+
});
|
|
485
|
+
const data = await response.json();
|
|
486
|
+
this.log('maxBuilderFee response:', data);
|
|
487
|
+
|
|
488
|
+
// API returns a number (fee in tenths of bps) or 0/null if not approved
|
|
489
|
+
// e.g., 100 = 10 bps = 0.1%
|
|
490
|
+
if (data !== null && data !== undefined && data !== 0) {
|
|
491
|
+
// Convert from tenths of bps to percentage string
|
|
492
|
+
const bps = Number(data) / 10;
|
|
493
|
+
const pct = bps / 100;
|
|
494
|
+
return `${pct}%`;
|
|
495
|
+
}
|
|
496
|
+
return null;
|
|
497
|
+
} catch (error) {
|
|
498
|
+
this.log('maxBuilderFee error:', error);
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Approve a builder fee for the open-broker builder
|
|
505
|
+
* IMPORTANT: This must be signed by a MAIN wallet, not an API wallet or sub-account
|
|
506
|
+
*
|
|
507
|
+
* @param maxFeeRate - Max fee rate to approve (e.g., "0.01%" for 1 bps)
|
|
508
|
+
* @param builder - Builder address (defaults to open-broker builder)
|
|
509
|
+
*/
|
|
510
|
+
async approveBuilderFee(
|
|
511
|
+
maxFeeRate: string = '0.1%',
|
|
512
|
+
builder?: string
|
|
513
|
+
): Promise<{ status: 'ok' | 'err'; response?: unknown }> {
|
|
514
|
+
const targetBuilder = builder ?? this.config.builderAddress;
|
|
515
|
+
|
|
516
|
+
this.log('Approving builder fee:', maxFeeRate, 'for builder:', targetBuilder);
|
|
517
|
+
|
|
518
|
+
// Check if using API wallet - this won't work
|
|
519
|
+
if (this.isApiWallet) {
|
|
520
|
+
return {
|
|
521
|
+
status: 'err',
|
|
522
|
+
response: 'Cannot approve builder fee with API wallet. Must use main wallet private key.',
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
try {
|
|
527
|
+
const response = await this.exchange.approveBuilderFee({
|
|
528
|
+
builder: targetBuilder as `0x${string}`,
|
|
529
|
+
maxFeeRate,
|
|
530
|
+
});
|
|
531
|
+
this.log('approveBuilderFee response:', response);
|
|
532
|
+
return { status: 'ok', response };
|
|
533
|
+
} catch (error) {
|
|
534
|
+
this.log('approveBuilderFee error:', error);
|
|
535
|
+
return {
|
|
536
|
+
status: 'err',
|
|
537
|
+
response: error instanceof Error ? error.message : String(error),
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
async getUserState(user?: string): Promise<ClearinghouseState> {
|
|
543
|
+
this.log('Fetching clearinghouseState for:', user ?? this.address);
|
|
544
|
+
const response = await this.info.clearinghouseState({ user: user ?? this.address });
|
|
545
|
+
return response as ClearinghouseState;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async getOpenOrders(user?: string): Promise<OpenOrder[]> {
|
|
549
|
+
this.log('Fetching openOrders for:', user ?? this.address);
|
|
550
|
+
const response = await this.info.openOrders({ user: user ?? this.address });
|
|
551
|
+
return response as OpenOrder[];
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// ============ Trading ============
|
|
555
|
+
|
|
556
|
+
async order(
|
|
557
|
+
coin: string,
|
|
558
|
+
isBuy: boolean,
|
|
559
|
+
size: number,
|
|
560
|
+
price: number,
|
|
561
|
+
orderType: { limit: { tif: 'Gtc' | 'Ioc' | 'Alo' } },
|
|
562
|
+
reduceOnly: boolean = false,
|
|
563
|
+
includeBuilder: boolean = true
|
|
564
|
+
): Promise<OrderResponse> {
|
|
565
|
+
await this.getMetaAndAssetCtxs();
|
|
566
|
+
|
|
567
|
+
const assetIndex = this.getAssetIndex(coin);
|
|
568
|
+
const szDecimals = this.getSzDecimals(coin);
|
|
569
|
+
|
|
570
|
+
const orderWire = {
|
|
571
|
+
a: assetIndex,
|
|
572
|
+
b: isBuy,
|
|
573
|
+
p: roundPrice(price, szDecimals),
|
|
574
|
+
s: roundSize(size, szDecimals),
|
|
575
|
+
r: reduceOnly,
|
|
576
|
+
t: orderType,
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
this.log('Placing order:', JSON.stringify(orderWire, null, 2));
|
|
580
|
+
|
|
581
|
+
const orderRequest: {
|
|
582
|
+
orders: typeof orderWire[];
|
|
583
|
+
grouping: 'na';
|
|
584
|
+
builder?: BuilderInfo;
|
|
585
|
+
} = {
|
|
586
|
+
orders: [orderWire],
|
|
587
|
+
grouping: 'na',
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
// Add builder fee if configured
|
|
591
|
+
if (includeBuilder && this.config.builderAddress !== '0x0000000000000000000000000000000000000000') {
|
|
592
|
+
orderRequest.builder = this.builderInfo;
|
|
593
|
+
this.log('Including builder fee:', this.builderInfo);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
try {
|
|
597
|
+
const response = await this.exchange.order(orderRequest);
|
|
598
|
+
this.log('Order response:', JSON.stringify(response, null, 2));
|
|
599
|
+
return response as unknown as OrderResponse;
|
|
600
|
+
} catch (error) {
|
|
601
|
+
this.log('Order error:', error);
|
|
602
|
+
// Return error in our format
|
|
603
|
+
return {
|
|
604
|
+
status: 'err',
|
|
605
|
+
response: error instanceof Error ? error.message : String(error),
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
async marketOrder(
|
|
611
|
+
coin: string,
|
|
612
|
+
isBuy: boolean,
|
|
613
|
+
size: number,
|
|
614
|
+
slippageBps?: number
|
|
615
|
+
): Promise<OrderResponse> {
|
|
616
|
+
await this.getMetaAndAssetCtxs();
|
|
617
|
+
|
|
618
|
+
// Get current mid price
|
|
619
|
+
const mids = await this.getAllMids();
|
|
620
|
+
const midPrice = parseFloat(mids[coin]);
|
|
621
|
+
if (!midPrice) {
|
|
622
|
+
throw new Error(`No mid price for ${coin}. Check if the asset exists.`);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Calculate slippage price
|
|
626
|
+
const slippage = (slippageBps ?? this.config.slippageBps) / 10000;
|
|
627
|
+
const limitPrice = isBuy
|
|
628
|
+
? midPrice * (1 + slippage)
|
|
629
|
+
: midPrice * (1 - slippage);
|
|
630
|
+
|
|
631
|
+
this.log(`Market order: ${coin} ${isBuy ? 'BUY' : 'SELL'} ${size} @ ${limitPrice} (mid: ${midPrice}, slippage: ${slippage * 100}%)`);
|
|
632
|
+
|
|
633
|
+
return this.order(
|
|
634
|
+
coin,
|
|
635
|
+
isBuy,
|
|
636
|
+
size,
|
|
637
|
+
limitPrice,
|
|
638
|
+
{ limit: { tif: 'Ioc' } },
|
|
639
|
+
false,
|
|
640
|
+
true
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
async limitOrder(
|
|
645
|
+
coin: string,
|
|
646
|
+
isBuy: boolean,
|
|
647
|
+
size: number,
|
|
648
|
+
price: number,
|
|
649
|
+
tif: 'Gtc' | 'Ioc' | 'Alo' = 'Gtc',
|
|
650
|
+
reduceOnly: boolean = false
|
|
651
|
+
): Promise<OrderResponse> {
|
|
652
|
+
return this.order(
|
|
653
|
+
coin,
|
|
654
|
+
isBuy,
|
|
655
|
+
size,
|
|
656
|
+
price,
|
|
657
|
+
{ limit: { tif } },
|
|
658
|
+
reduceOnly,
|
|
659
|
+
true
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Place a trigger order (stop loss or take profit)
|
|
665
|
+
* @param coin - Asset to trade
|
|
666
|
+
* @param isBuy - True for buy, false for sell
|
|
667
|
+
* @param size - Order size
|
|
668
|
+
* @param triggerPrice - Price at which the order triggers
|
|
669
|
+
* @param limitPrice - Limit price for the order (use triggerPrice for market-like execution)
|
|
670
|
+
* @param tpsl - 'tp' for take profit, 'sl' for stop loss
|
|
671
|
+
* @param reduceOnly - Whether order is reduce-only (should be true for TP/SL)
|
|
672
|
+
*/
|
|
673
|
+
async triggerOrder(
|
|
674
|
+
coin: string,
|
|
675
|
+
isBuy: boolean,
|
|
676
|
+
size: number,
|
|
677
|
+
triggerPrice: number,
|
|
678
|
+
limitPrice: number,
|
|
679
|
+
tpsl: 'tp' | 'sl',
|
|
680
|
+
reduceOnly: boolean = true
|
|
681
|
+
): Promise<OrderResponse> {
|
|
682
|
+
await this.getMetaAndAssetCtxs();
|
|
683
|
+
|
|
684
|
+
const assetIndex = this.getAssetIndex(coin);
|
|
685
|
+
const szDecimals = this.getSzDecimals(coin);
|
|
686
|
+
|
|
687
|
+
// For trigger orders, we use the trigger order type
|
|
688
|
+
// isMarket: false means it becomes a limit order at limitPrice when triggered
|
|
689
|
+
// For stop loss, we typically want some slippage protection
|
|
690
|
+
const orderWire = {
|
|
691
|
+
a: assetIndex,
|
|
692
|
+
b: isBuy,
|
|
693
|
+
p: roundPrice(limitPrice, szDecimals),
|
|
694
|
+
s: roundSize(size, szDecimals),
|
|
695
|
+
r: reduceOnly,
|
|
696
|
+
t: {
|
|
697
|
+
trigger: {
|
|
698
|
+
triggerPx: roundPrice(triggerPrice, szDecimals),
|
|
699
|
+
isMarket: false,
|
|
700
|
+
tpsl,
|
|
701
|
+
},
|
|
702
|
+
},
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
this.log('Placing trigger order:', JSON.stringify(orderWire, null, 2));
|
|
706
|
+
|
|
707
|
+
const orderRequest: {
|
|
708
|
+
orders: typeof orderWire[];
|
|
709
|
+
grouping: 'na';
|
|
710
|
+
builder?: BuilderInfo;
|
|
711
|
+
} = {
|
|
712
|
+
orders: [orderWire],
|
|
713
|
+
grouping: 'na',
|
|
714
|
+
};
|
|
715
|
+
|
|
716
|
+
// Add builder fee if configured
|
|
717
|
+
if (this.config.builderAddress !== '0x0000000000000000000000000000000000000000') {
|
|
718
|
+
orderRequest.builder = this.builderInfo;
|
|
719
|
+
this.log('Including builder fee:', this.builderInfo);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
try {
|
|
723
|
+
const response = await this.exchange.order(orderRequest);
|
|
724
|
+
this.log('Trigger order response:', JSON.stringify(response, null, 2));
|
|
725
|
+
return response as unknown as OrderResponse;
|
|
726
|
+
} catch (error) {
|
|
727
|
+
this.log('Trigger order error:', error);
|
|
728
|
+
return {
|
|
729
|
+
status: 'err',
|
|
730
|
+
response: error instanceof Error ? error.message : String(error),
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Place a stop loss order
|
|
737
|
+
*/
|
|
738
|
+
async stopLoss(
|
|
739
|
+
coin: string,
|
|
740
|
+
isBuy: boolean,
|
|
741
|
+
size: number,
|
|
742
|
+
triggerPrice: number,
|
|
743
|
+
slippageBps: number = 100 // 1% slippage for SL execution
|
|
744
|
+
): Promise<OrderResponse> {
|
|
745
|
+
// For stop loss, limit price should be worse than trigger to ensure fill
|
|
746
|
+
// Buy SL: limit above trigger, Sell SL: limit below trigger
|
|
747
|
+
const slippageMult = slippageBps / 10000;
|
|
748
|
+
const limitPrice = isBuy
|
|
749
|
+
? triggerPrice * (1 + slippageMult)
|
|
750
|
+
: triggerPrice * (1 - slippageMult);
|
|
751
|
+
|
|
752
|
+
return this.triggerOrder(coin, isBuy, size, triggerPrice, limitPrice, 'sl', true);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Place a take profit order
|
|
757
|
+
*/
|
|
758
|
+
async takeProfit(
|
|
759
|
+
coin: string,
|
|
760
|
+
isBuy: boolean,
|
|
761
|
+
size: number,
|
|
762
|
+
triggerPrice: number
|
|
763
|
+
): Promise<OrderResponse> {
|
|
764
|
+
// For take profit, we can use the same price as trigger (it's a favorable price)
|
|
765
|
+
return this.triggerOrder(coin, isBuy, size, triggerPrice, triggerPrice, 'tp', true);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
async cancel(coin: string, oid: number): Promise<CancelResponse> {
|
|
769
|
+
await this.getMetaAndAssetCtxs();
|
|
770
|
+
|
|
771
|
+
const assetIndex = this.getAssetIndex(coin);
|
|
772
|
+
|
|
773
|
+
this.log(`Cancelling order: ${coin} (asset ${assetIndex}) oid ${oid}`);
|
|
774
|
+
|
|
775
|
+
try {
|
|
776
|
+
const response = await this.exchange.cancel({
|
|
777
|
+
cancels: [{ a: assetIndex, o: oid }],
|
|
778
|
+
});
|
|
779
|
+
this.log('Cancel response:', JSON.stringify(response, null, 2));
|
|
780
|
+
return response as unknown as CancelResponse;
|
|
781
|
+
} catch (error) {
|
|
782
|
+
this.log('Cancel error:', error);
|
|
783
|
+
return {
|
|
784
|
+
status: 'err',
|
|
785
|
+
response: { type: 'cancel', data: { statuses: [error instanceof Error ? error.message : String(error)] } },
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
async cancelAll(coin?: string): Promise<CancelResponse[]> {
|
|
791
|
+
const orders = await this.getOpenOrders();
|
|
792
|
+
const results: CancelResponse[] = [];
|
|
793
|
+
|
|
794
|
+
for (const order of orders) {
|
|
795
|
+
if (coin && order.coin !== coin) continue;
|
|
796
|
+
const result = await this.cancel(order.coin, order.oid);
|
|
797
|
+
results.push(result);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
return results;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// ============ Leverage ============
|
|
804
|
+
|
|
805
|
+
async updateLeverage(
|
|
806
|
+
coin: string,
|
|
807
|
+
leverage: number,
|
|
808
|
+
isCross: boolean = true
|
|
809
|
+
): Promise<unknown> {
|
|
810
|
+
await this.getMetaAndAssetCtxs();
|
|
811
|
+
|
|
812
|
+
const assetIndex = this.getAssetIndex(coin);
|
|
813
|
+
|
|
814
|
+
this.log(`Updating leverage: ${coin} (asset ${assetIndex}) to ${leverage}x ${isCross ? 'cross' : 'isolated'}`);
|
|
815
|
+
|
|
816
|
+
try {
|
|
817
|
+
const response = await this.exchange.updateLeverage({
|
|
818
|
+
asset: assetIndex,
|
|
819
|
+
isCross,
|
|
820
|
+
leverage,
|
|
821
|
+
});
|
|
822
|
+
this.log('Leverage response:', JSON.stringify(response, null, 2));
|
|
823
|
+
return response;
|
|
824
|
+
} catch (error) {
|
|
825
|
+
this.log('Leverage error:', error);
|
|
826
|
+
throw error;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// Singleton instance
|
|
832
|
+
let clientInstance: HyperliquidClient | null = null;
|
|
833
|
+
|
|
834
|
+
export function getClient(config?: OpenBrokerConfig): HyperliquidClient {
|
|
835
|
+
if (!clientInstance) {
|
|
836
|
+
clientInstance = new HyperliquidClient(config);
|
|
837
|
+
}
|
|
838
|
+
return clientInstance;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// Reset client (useful for testing)
|
|
842
|
+
export function resetClient(): void {
|
|
843
|
+
clientInstance = null;
|
|
844
|
+
}
|