openbroker 1.0.89 → 1.1.0

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.
@@ -1,1686 +0,0 @@
1
- // Agent Tools for the OpenBroker OpenClaw plugin
2
- // Uses direct imports of core modules instead of shelling out to CLI
3
-
4
- import type { PluginTool } from './types.js';
5
- import type { PositionWatcher } from './watcher.js';
6
- import { normalizeCoin } from '../core/utils.js';
7
-
8
- /** Helper to wrap a result as OpenClaw tool response */
9
- function json(payload: unknown) {
10
- return {
11
- content: [{ type: 'text' as const, text: JSON.stringify(payload, null, 2) }],
12
- details: payload,
13
- };
14
- }
15
-
16
- /** Helper to wrap an error */
17
- function error(message: string) {
18
- return json({ error: message });
19
- }
20
-
21
- export interface ToolsContext {
22
- watcher: PositionWatcher | null;
23
- gatewayPort?: number;
24
- hooksToken?: string;
25
- }
26
-
27
- export function createTools(watcherOrCtx: PositionWatcher | null | ToolsContext): PluginTool[] {
28
- // Support both old signature (watcher only) and new (full context)
29
- const ctx: ToolsContext = watcherOrCtx !== null && typeof watcherOrCtx === 'object' && 'watcher' in watcherOrCtx
30
- ? watcherOrCtx
31
- : { watcher: watcherOrCtx };
32
- const { watcher, gatewayPort, hooksToken } = ctx;
33
- return [
34
- // ── Info Tools ──────────────────────────────────────────────
35
-
36
- {
37
- name: 'ob_account',
38
- description: 'View Hyperliquid account balance, equity, margin, and optionally open orders',
39
- parameters: {
40
- type: 'object',
41
- properties: {
42
- orders: { type: 'boolean', description: 'Include open orders in output' },
43
- },
44
- },
45
- async execute(_id, params) {
46
- const { getClient } = await import('../core/client.js');
47
- const client = getClient();
48
- const state = await client.getUserStateAll();
49
- const accountMode = await client.getAccountMode();
50
-
51
- const margin = state.crossMarginSummary;
52
- const accountValue = parseFloat(margin.accountValue);
53
- const totalMarginUsed = parseFloat(margin.totalMarginUsed);
54
-
55
- const result: Record<string, unknown> = {
56
- address: client.address,
57
- signingWallet: client.walletAddress,
58
- walletType: client.isApiWallet ? 'api' : 'main',
59
- accountMode,
60
- equity: margin.accountValue,
61
- totalNtlPos: margin.totalNtlPos,
62
- totalMarginUsed: margin.totalMarginUsed,
63
- withdrawable: margin.withdrawable,
64
- marginRatio: totalMarginUsed > 0 && accountValue > 0 ? totalMarginUsed / accountValue : 0,
65
- positions: state.assetPositions
66
- .filter(ap => parseFloat(ap.position.szi) !== 0)
67
- .map(ap => ({
68
- coin: ap.position.coin,
69
- side: parseFloat(ap.position.szi) > 0 ? 'long' : 'short',
70
- size: ap.position.szi,
71
- entryPrice: ap.position.entryPx,
72
- positionValue: ap.position.positionValue,
73
- unrealizedPnl: ap.position.unrealizedPnl,
74
- liquidationPx: ap.position.liquidationPx,
75
- leverage: ap.position.leverage,
76
- })),
77
- };
78
-
79
- if (params.orders) {
80
- const orders = await client.getOpenOrders();
81
- result.openOrders = orders.map(o => ({
82
- coin: o.coin,
83
- oid: o.oid,
84
- side: o.side === 'B' ? 'buy' : 'sell',
85
- size: o.sz,
86
- price: o.limitPx,
87
- orderType: o.orderType,
88
- timestamp: o.timestamp,
89
- }));
90
- }
91
-
92
- // Warn if likely misconfigured API wallet (querying the API wallet address instead of master)
93
- if (!client.isApiWallet && accountValue === 0 && state.assetPositions.filter(ap => parseFloat(ap.position.szi) !== 0).length === 0) {
94
- result.warning = 'No positions and $0 equity. If using an API wallet, ensure HYPERLIQUID_ACCOUNT_ADDRESS is set to the master account address in ~/.openbroker/.env or plugin config.';
95
- }
96
-
97
- return json(result);
98
- },
99
- },
100
-
101
- {
102
- name: 'ob_positions',
103
- description: 'View open positions with entry price, mark price, PnL, liquidation price, and leverage',
104
- parameters: {
105
- type: 'object',
106
- properties: {
107
- coin: { type: 'string', description: 'Filter by coin symbol (e.g. ETH, BTC)' },
108
- },
109
- },
110
- async execute(_id, params) {
111
- const { getClient } = await import('../core/client.js');
112
- const client = getClient();
113
- const [state, mids, fundingHistory] = await Promise.all([
114
- client.getUserStateAll(),
115
- client.getAllMids(),
116
- client.getUserFunding(),
117
- ]);
118
-
119
- // Sum cumulative funding per coin
120
- const fundingByCoin = new Map<string, number>();
121
- for (const entry of fundingHistory) {
122
- const coin = entry.delta.coin;
123
- const usdc = parseFloat(entry.delta.usdc);
124
- fundingByCoin.set(coin, (fundingByCoin.get(coin) ?? 0) + usdc);
125
- }
126
-
127
- let positions = state.assetPositions
128
- .filter(ap => parseFloat(ap.position.szi) !== 0)
129
- .map(ap => {
130
- const pos = ap.position;
131
- const markPx = parseFloat(mids[pos.coin] || '0');
132
- const liqPx = pos.liquidationPx ? parseFloat(pos.liquidationPx) : null;
133
- return {
134
- coin: pos.coin,
135
- side: parseFloat(pos.szi) > 0 ? 'long' : 'short',
136
- size: pos.szi,
137
- entryPrice: pos.entryPx,
138
- markPrice: markPx,
139
- notional: Math.abs(parseFloat(pos.positionValue)),
140
- unrealizedPnl: parseFloat(pos.unrealizedPnl),
141
- returnOnEquity: parseFloat(pos.returnOnEquity),
142
- cumulativeFunding: fundingByCoin.get(pos.coin) ?? 0,
143
- marginUsed: parseFloat(pos.marginUsed),
144
- leverage: `${pos.leverage.value}x`,
145
- leverageType: pos.leverage.type,
146
- liquidationPrice: liqPx,
147
- liquidationDistance: liqPx && markPx ? Math.abs((markPx - liqPx) / markPx) : null,
148
- maxLeverage: pos.maxLeverage,
149
- };
150
- });
151
-
152
- if (params.coin) {
153
- const coin = normalizeCoin(params.coin as string);
154
- positions = positions.filter(p => p.coin === coin);
155
- }
156
-
157
- return json({ address: client.address, positions });
158
- },
159
- },
160
-
161
- {
162
- name: 'ob_funding',
163
- description: 'View funding rates for Hyperliquid perpetuals, sorted by annualized rate',
164
- parameters: {
165
- type: 'object',
166
- properties: {
167
- coin: { type: 'string', description: 'Filter by coin symbol' },
168
- top: { type: 'number', description: 'Show top N results (default: 20)' },
169
- },
170
- },
171
- async execute(_id, params) {
172
- const { getClient } = await import('../core/client.js');
173
- const { annualizeFundingRate } = await import('../core/utils.js');
174
- const client = getClient();
175
-
176
- const filterCoin = params.coin ? normalizeCoin(params.coin as string) : undefined;
177
- const includeHip3 = !filterCoin || filterCoin.includes(':');
178
-
179
- let results: Array<{ coin: string; fundingRate: number; annualizedRate: number; openInterest: string; markPx: string; type: string }> = [];
180
-
181
- // Main dex funding from meta
182
- try {
183
- const { meta, assetCtxs } = await client.getMetaAndAssetCtxs();
184
- for (let i = 0; i < meta.universe.length; i++) {
185
- const asset = meta.universe[i];
186
- const ctx = assetCtxs[i];
187
- if (!ctx) continue;
188
- if (filterCoin && asset.name !== filterCoin) continue;
189
-
190
- const rate = parseFloat(ctx.funding);
191
- results.push({
192
- coin: asset.name,
193
- fundingRate: rate,
194
- annualizedRate: annualizeFundingRate(rate),
195
- openInterest: ctx.openInterest,
196
- markPx: ctx.markPx,
197
- type: 'perp',
198
- });
199
- }
200
- } catch { /* skip */ }
201
-
202
- // HIP-3 dex funding
203
- if (includeHip3) {
204
- try {
205
- const allPerps = await client.getAllPerpMetas();
206
- for (let dexIdx = 1; dexIdx < allPerps.length; dexIdx++) {
207
- const dexData = allPerps[dexIdx];
208
- if (!dexData?.meta?.universe) continue;
209
-
210
- for (let i = 0; i < dexData.meta.universe.length; i++) {
211
- const asset = dexData.meta.universe[i];
212
- const ctx = dexData.assetCtxs[i];
213
- if (!asset || !ctx) continue;
214
- if (filterCoin && asset.name !== filterCoin) continue;
215
-
216
- const rate = parseFloat(ctx.funding);
217
- results.push({
218
- coin: asset.name,
219
- fundingRate: rate,
220
- annualizedRate: annualizeFundingRate(rate),
221
- openInterest: ctx.openInterest,
222
- markPx: ctx.markPx,
223
- type: 'hip3',
224
- });
225
- }
226
- }
227
- } catch { /* skip */ }
228
- }
229
-
230
- results.sort((a, b) => Math.abs(b.annualizedRate) - Math.abs(a.annualizedRate));
231
-
232
- const top = (params.top as number) || 20;
233
- results = results.slice(0, top);
234
-
235
- return json({ fundings: results });
236
- },
237
- },
238
-
239
- {
240
- name: 'ob_markets',
241
- description: 'View market data for Hyperliquid perpetuals (price, volume, open interest). Includes HIP-3 markets.',
242
- parameters: {
243
- type: 'object',
244
- properties: {
245
- coin: { type: 'string', description: 'Filter by coin symbol' },
246
- top: { type: 'number', description: 'Show top N results (default: 30)' },
247
- },
248
- },
249
- async execute(_id, params) {
250
- const { getClient } = await import('../core/client.js');
251
- const client = getClient();
252
- const { meta, assetCtxs } = await client.getMetaAndAssetCtxs();
253
-
254
- const filterCoin = params.coin ? normalizeCoin(params.coin as string) : undefined;
255
- const includeHip3 = !filterCoin || filterCoin.includes(':');
256
-
257
- interface MarketEntry { coin: string; type: string; markPx: number; oraclePx: number; change24h: number; dayVolume: number; openInterest: number; maxLeverage: number; szDecimals: number; funding: string }
258
-
259
- const markets: MarketEntry[] = [];
260
-
261
- // Main dex
262
- for (let i = 0; i < meta.universe.length; i++) {
263
- const asset = meta.universe[i];
264
- const ctx = assetCtxs[i];
265
- if (!ctx) continue;
266
- if (filterCoin && asset.name !== filterCoin) continue;
267
-
268
- const markPx = parseFloat(ctx.markPx);
269
- const prevDayPx = parseFloat(ctx.prevDayPx);
270
- markets.push({
271
- coin: asset.name,
272
- type: 'perp',
273
- markPx,
274
- oraclePx: parseFloat(ctx.oraclePx),
275
- change24h: prevDayPx > 0 ? (markPx - prevDayPx) / prevDayPx : 0,
276
- dayVolume: parseFloat(ctx.dayNtlVlm),
277
- openInterest: parseFloat(ctx.openInterest),
278
- maxLeverage: asset.maxLeverage,
279
- szDecimals: asset.szDecimals,
280
- funding: ctx.funding,
281
- });
282
- }
283
-
284
- // HIP-3 dexes
285
- if (includeHip3) {
286
- try {
287
- const allPerps = await client.getAllPerpMetas();
288
- for (let dexIdx = 1; dexIdx < allPerps.length; dexIdx++) {
289
- const dexData = allPerps[dexIdx];
290
- if (!dexData?.meta?.universe) continue;
291
- for (let i = 0; i < dexData.meta.universe.length; i++) {
292
- const asset = dexData.meta.universe[i];
293
- const ctx = dexData.assetCtxs[i];
294
- if (!asset || !ctx) continue;
295
- if (filterCoin && asset.name !== filterCoin) continue;
296
-
297
- const markPx = parseFloat(ctx.markPx);
298
- const prevDayPx = parseFloat(ctx.prevDayPx);
299
- markets.push({
300
- coin: asset.name,
301
- type: 'hip3',
302
- markPx,
303
- oraclePx: parseFloat(ctx.oraclePx),
304
- change24h: prevDayPx > 0 ? (markPx - prevDayPx) / prevDayPx : 0,
305
- dayVolume: parseFloat(ctx.dayNtlVlm),
306
- openInterest: parseFloat(ctx.openInterest),
307
- maxLeverage: asset.maxLeverage,
308
- szDecimals: asset.szDecimals,
309
- funding: ctx.funding,
310
- });
311
- }
312
- }
313
- } catch { /* skip */ }
314
- }
315
-
316
- // Sort by volume (matches CLI behavior)
317
- markets.sort((a, b) => b.dayVolume - a.dayVolume);
318
-
319
- const top = (params.top as number) || 30;
320
- const result = filterCoin ? markets : markets.slice(0, top);
321
-
322
- return json({ markets: result });
323
- },
324
- },
325
-
326
- {
327
- name: 'ob_search',
328
- description: 'Search for assets across all Hyperliquid market providers (perps, HIP-3, spot)',
329
- parameters: {
330
- type: 'object',
331
- properties: {
332
- query: { type: 'string', description: 'Search query (e.g. GOLD, BTC, ETH)' },
333
- type: { type: 'string', enum: ['perp', 'hip3', 'spot', 'all'], description: 'Filter by market type: perp, hip3, spot, or all (default: all)' },
334
- },
335
- required: ['query'],
336
- },
337
- async execute(_id, params) {
338
- const { getClient } = await import('../core/client.js');
339
- const client = getClient();
340
- const query = (params.query as string).toUpperCase();
341
- // Normalize type filter: lowercase, strip hyphens, treat "all" as no filter
342
- const rawType = params.type ? String(params.type).toLowerCase().replace(/-/g, '') : undefined;
343
- const typeFilter = rawType === 'all' ? undefined : rawType;
344
-
345
- const results: Array<Record<string, unknown>> = [];
346
- const errors: string[] = [];
347
-
348
- // Search main perps
349
- if (!typeFilter || typeFilter === 'perp') {
350
- try {
351
- const { meta, assetCtxs } = await client.getMetaAndAssetCtxs();
352
- for (let i = 0; i < meta.universe.length; i++) {
353
- const asset = meta.universe[i];
354
- if (asset.name.toUpperCase().includes(query)) {
355
- results.push({
356
- coin: asset.name,
357
- type: 'perp',
358
- markPx: assetCtxs[i]?.markPx,
359
- funding: assetCtxs[i]?.funding,
360
- dayVolume: assetCtxs[i]?.dayNtlVlm,
361
- openInterest: assetCtxs[i]?.openInterest,
362
- maxLeverage: asset.maxLeverage,
363
- });
364
- }
365
- }
366
- } catch (e) { errors.push(`perp: ${e instanceof Error ? e.message : String(e)}`); }
367
- }
368
-
369
- // Search HIP-3 perps (always included unless filtering to perp-only or spot-only)
370
- if (!typeFilter || typeFilter === 'hip3') {
371
- try {
372
- const allPerps = await client.getAllPerpMetas();
373
- for (let dexIdx = 1; dexIdx < allPerps.length; dexIdx++) {
374
- const dexData = allPerps[dexIdx];
375
- if (!dexData?.meta?.universe) continue;
376
- for (let i = 0; i < dexData.meta.universe.length; i++) {
377
- const asset = dexData.meta.universe[i];
378
- const ctx = dexData.assetCtxs[i];
379
- if (!asset) continue;
380
- if (asset.name.toUpperCase().includes(query)) {
381
- results.push({
382
- coin: asset.name,
383
- type: 'hip3',
384
- dex: dexData.dexName,
385
- markPx: ctx?.markPx,
386
- funding: ctx?.funding,
387
- dayVolume: ctx?.dayNtlVlm,
388
- openInterest: ctx?.openInterest,
389
- maxLeverage: asset.maxLeverage,
390
- });
391
- }
392
- }
393
- }
394
- } catch (e) { errors.push(`hip3: ${e instanceof Error ? e.message : String(e)}`); }
395
- }
396
-
397
- // Search spot
398
- if (!typeFilter || typeFilter === 'spot') {
399
- try {
400
- const spotData = await client.getSpotMetaAndAssetCtxs();
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;
417
- results.push({
418
- coin: displayName,
419
- type: 'spot',
420
- markPx: ctx?.markPx,
421
- dayVolume: ctx?.dayNtlVlm,
422
- });
423
- }
424
- }
425
- } catch (e) { errors.push(`spot: ${e instanceof Error ? e.message : String(e)}`); }
426
- }
427
-
428
- const response: Record<string, unknown> = { query, results };
429
- if (errors.length > 0) response.errors = errors;
430
- return json(response);
431
- },
432
- },
433
-
434
- {
435
- name: 'ob_spot',
436
- description: 'View spot markets, balances, and token info on Hyperliquid',
437
- parameters: {
438
- type: 'object',
439
- properties: {
440
- coin: { type: 'string', description: 'Filter by coin symbol' },
441
- balances: { type: 'boolean', description: 'Show your spot token balances' },
442
- top: { type: 'number', description: 'Show top N results' },
443
- },
444
- },
445
- async execute(_id, params) {
446
- const { getClient } = await import('../core/client.js');
447
- const client = getClient();
448
-
449
- if (params.balances) {
450
- const balances = await client.getSpotBalances();
451
- return json({ address: client.address, balances });
452
- }
453
-
454
- const spotData = await client.getSpotMetaAndAssetCtxs();
455
- return json({ spotData });
456
- },
457
- },
458
-
459
- {
460
- name: 'ob_fills',
461
- description: 'View trade fill history with prices, fees, and realized PnL',
462
- parameters: {
463
- type: 'object',
464
- properties: {
465
- coin: { type: 'string', description: 'Filter by coin symbol (e.g. ETH, BTC)' },
466
- side: { type: 'string', enum: ['buy', 'sell'], description: 'Filter by side' },
467
- top: { type: 'number', description: 'Number of recent fills to return (default: 20)' },
468
- },
469
- },
470
- async execute(_id, params) {
471
- const { getClient } = await import('../core/client.js');
472
- const client = getClient();
473
- let fills = await client.getUserFills();
474
-
475
- if (params.coin) {
476
- const coin = normalizeCoin(params.coin as string);
477
- fills = fills.filter(f => f.coin === coin);
478
- }
479
- if (params.side) {
480
- const sideCode = (params.side as string) === 'buy' ? 'B' : 'A';
481
- fills = fills.filter(f => f.side === sideCode);
482
- }
483
-
484
- fills.sort((a, b) => b.time - a.time);
485
- const top = (params.top as number) || 20;
486
- fills = fills.slice(0, top);
487
-
488
- const totalFees = fills.reduce((s, f) => s + parseFloat(f.fee), 0);
489
- const totalPnl = fills.reduce((s, f) => s + parseFloat(f.closedPnl), 0);
490
-
491
- return json({
492
- address: client.address,
493
- fills: fills.map(f => ({
494
- coin: f.coin,
495
- side: f.side === 'B' ? 'buy' : 'sell',
496
- size: f.sz,
497
- price: f.px,
498
- fee: f.fee,
499
- closedPnl: f.closedPnl,
500
- time: f.time,
501
- oid: f.oid,
502
- crossed: f.crossed,
503
- })),
504
- totalFees: String(totalFees),
505
- totalClosedPnl: String(totalPnl),
506
- });
507
- },
508
- },
509
-
510
- {
511
- name: 'ob_orders',
512
- description: 'View order history with status (filled, canceled, open, etc.). Use open_only to get currently open orders.',
513
- parameters: {
514
- type: 'object',
515
- properties: {
516
- coin: { type: 'string', description: 'Filter by coin symbol' },
517
- status: { type: 'string', description: 'Filter by status (filled, canceled, open, etc.)' },
518
- open_only: { type: 'boolean', description: 'If true, return only currently open orders (uses dedicated endpoint)' },
519
- top: { type: 'number', description: 'Number of recent orders (default: 20)' },
520
- },
521
- },
522
- async execute(_id, params) {
523
- const { getClient } = await import('../core/client.js');
524
- const client = getClient();
525
- const top = (params.top as number) || 20;
526
-
527
- if (params.open_only) {
528
- let openOrders = await client.getOpenOrders();
529
-
530
- if (params.coin) {
531
- const coin = normalizeCoin(params.coin as string);
532
- openOrders = openOrders.filter(o => o.coin === coin);
533
- }
534
-
535
- openOrders.sort((a, b) => b.timestamp - a.timestamp);
536
- openOrders = openOrders.slice(0, top);
537
-
538
- return json({
539
- address: client.address,
540
- orders: openOrders.map(o => ({
541
- coin: o.coin,
542
- side: o.side === 'B' ? 'buy' : 'sell',
543
- size: o.sz,
544
- origSize: o.origSz,
545
- price: o.limitPx,
546
- orderType: o.orderType,
547
- oid: o.oid,
548
- status: 'open',
549
- timestamp: o.timestamp,
550
- })),
551
- });
552
- }
553
-
554
- let orders = await client.getHistoricalOrders();
555
-
556
- if (params.coin) {
557
- const coin = normalizeCoin(params.coin as string);
558
- orders = orders.filter(o => o.order.coin === coin);
559
- }
560
- if (params.status) {
561
- const s = (params.status as string).toLowerCase();
562
- orders = orders.filter(o => o.status.toLowerCase().includes(s));
563
- }
564
-
565
- orders.sort((a, b) => b.order.timestamp - a.order.timestamp);
566
- orders = orders.slice(0, top);
567
-
568
- return json({
569
- address: client.address,
570
- orders: orders.map(e => ({
571
- coin: e.order.coin,
572
- side: e.order.side === 'B' ? 'buy' : 'sell',
573
- size: e.order.sz,
574
- origSize: e.order.origSz,
575
- price: e.order.limitPx,
576
- orderType: e.order.orderType,
577
- tif: e.order.tif,
578
- oid: e.order.oid,
579
- status: e.status,
580
- timestamp: e.order.timestamp,
581
- statusTimestamp: e.statusTimestamp,
582
- reduceOnly: e.order.reduceOnly,
583
- isTrigger: e.order.isTrigger,
584
- triggerPx: e.order.triggerPx,
585
- })),
586
- });
587
- },
588
- },
589
-
590
- {
591
- name: 'ob_order_status',
592
- description: 'Check the status of a specific order by order ID',
593
- parameters: {
594
- type: 'object',
595
- properties: {
596
- oid: { type: 'number', description: 'Order ID to check' },
597
- },
598
- required: ['oid'],
599
- },
600
- async execute(_id, params) {
601
- const { getClient } = await import('../core/client.js');
602
- const client = getClient();
603
- const result = await client.getOrderStatus(params.oid as number);
604
-
605
- if (result.status === 'unknownOid') {
606
- return json({ found: false, oid: params.oid });
607
- }
608
-
609
- if (result.order) {
610
- const o = result.order.order;
611
- return json({
612
- found: true,
613
- coin: o.coin,
614
- side: o.side === 'B' ? 'buy' : 'sell',
615
- size: o.sz,
616
- origSize: o.origSz,
617
- price: o.limitPx,
618
- orderType: o.orderType,
619
- tif: o.tif,
620
- oid: o.oid,
621
- status: result.order.status,
622
- timestamp: o.timestamp,
623
- statusTimestamp: result.order.statusTimestamp,
624
- reduceOnly: o.reduceOnly,
625
- isTrigger: o.isTrigger,
626
- triggerPx: o.triggerPx,
627
- });
628
- }
629
-
630
- return json(result);
631
- },
632
- },
633
-
634
- {
635
- name: 'ob_fees',
636
- description: 'View fee schedule, tier, maker/taker rates, and recent daily trading volumes',
637
- parameters: {
638
- type: 'object',
639
- properties: {},
640
- },
641
- async execute() {
642
- const { getClient } = await import('../core/client.js');
643
- const client = getClient();
644
- const fees = await client.getUserFees();
645
-
646
- return json({
647
- address: client.address,
648
- perpTakerRate: fees.userCrossRate,
649
- perpMakerRate: fees.userAddRate,
650
- spotTakerRate: fees.userSpotCrossRate,
651
- spotMakerRate: fees.userSpotAddRate,
652
- referralDiscount: fees.activeReferralDiscount,
653
- stakingDiscount: fees.activeStakingDiscount,
654
- recentVolume: fees.dailyUserVlm?.slice(-7),
655
- });
656
- },
657
- },
658
-
659
- {
660
- name: 'ob_candles',
661
- description: 'Get OHLCV candle data for an asset',
662
- parameters: {
663
- type: 'object',
664
- properties: {
665
- coin: { type: 'string', description: 'Asset symbol (e.g. ETH, BTC)' },
666
- interval: { type: 'string', description: 'Candle interval: 1m, 5m, 15m, 1h, 4h, 1d, etc. (default: 1h)' },
667
- bars: { type: 'number', description: 'Number of bars to fetch (default: 24)' },
668
- },
669
- required: ['coin'],
670
- },
671
- async execute(_id, params) {
672
- const { getClient } = await import('../core/client.js');
673
- const client = getClient();
674
-
675
- const coin = normalizeCoin(params.coin as string);
676
- const interval = (params.interval as string) || '1h';
677
- const bars = (params.bars as number) || 24;
678
-
679
- const intervalMs: Record<string, number> = {
680
- '1m': 60_000, '3m': 180_000, '5m': 300_000, '15m': 900_000, '30m': 1_800_000,
681
- '1h': 3_600_000, '2h': 7_200_000, '4h': 14_400_000, '8h': 28_800_000, '12h': 43_200_000,
682
- '1d': 86_400_000, '3d': 259_200_000, '1w': 604_800_000, '1M': 2_592_000_000,
683
- };
684
-
685
- // Load metadata for HIP-3 coin resolution
686
- await client.getMetaAndAssetCtxs();
687
- const now = Date.now();
688
- const startTime = now - (bars * (intervalMs[interval] || 3_600_000));
689
- const candles = await client.getCandleSnapshot(coin, interval, startTime);
690
-
691
- return json({
692
- coin,
693
- interval,
694
- candles: candles.map(c => ({
695
- time: c.t,
696
- open: c.o,
697
- high: c.h,
698
- low: c.l,
699
- close: c.c,
700
- volume: c.v,
701
- trades: c.n,
702
- })),
703
- });
704
- },
705
- },
706
-
707
- {
708
- name: 'ob_funding_history',
709
- description: 'Get historical funding rates for an asset over a time period',
710
- parameters: {
711
- type: 'object',
712
- properties: {
713
- coin: { type: 'string', description: 'Asset symbol (e.g. ETH, BTC)' },
714
- hours: { type: 'number', description: 'Hours of history (default: 24)' },
715
- },
716
- required: ['coin'],
717
- },
718
- async execute(_id, params) {
719
- const { getClient } = await import('../core/client.js');
720
- const { annualizeFundingRate } = await import('../core/utils.js');
721
- const client = getClient();
722
-
723
- const coin = normalizeCoin(params.coin as string);
724
- const hours = (params.hours as number) || 24;
725
- const startTime = Date.now() - (hours * 3_600_000);
726
-
727
- // Load metadata for HIP-3 coin resolution
728
- await client.getMetaAndAssetCtxs();
729
- const history = await client.getFundingHistory(coin, startTime);
730
-
731
- const rates = history.map(e => parseFloat(e.fundingRate));
732
- const avgRate = rates.length > 0 ? rates.reduce((a, b) => a + b, 0) / rates.length : 0;
733
-
734
- return json({
735
- coin,
736
- hours,
737
- samples: history.length,
738
- avgHourlyRate: String(avgRate),
739
- avgAnnualizedRate: String(annualizeFundingRate(avgRate)),
740
- history: history.map(e => ({
741
- time: e.time,
742
- fundingRate: e.fundingRate,
743
- premium: e.premium,
744
- })),
745
- });
746
- },
747
- },
748
-
749
- {
750
- name: 'ob_trades',
751
- description: 'Get recent trades (tape/time & sales) for an asset',
752
- parameters: {
753
- type: 'object',
754
- properties: {
755
- coin: { type: 'string', description: 'Asset symbol (e.g. ETH, BTC)' },
756
- top: { type: 'number', description: 'Number of trades (default: 30)' },
757
- },
758
- required: ['coin'],
759
- },
760
- async execute(_id, params) {
761
- const { getClient } = await import('../core/client.js');
762
- const client = getClient();
763
-
764
- const coin = normalizeCoin(params.coin as string);
765
- // Load metadata for HIP-3 coin resolution
766
- await client.getMetaAndAssetCtxs();
767
- let trades = await client.getRecentTrades(coin);
768
-
769
- trades.sort((a, b) => b.time - a.time);
770
- const top = (params.top as number) || 30;
771
- trades = trades.slice(0, top);
772
-
773
- let buyVol = 0;
774
- let sellVol = 0;
775
- for (const t of trades) {
776
- const ntl = parseFloat(t.px) * parseFloat(t.sz);
777
- if (t.side === 'B') buyVol += ntl;
778
- else sellVol += ntl;
779
- }
780
-
781
- return json({
782
- coin,
783
- trades: trades.map(t => ({
784
- side: t.side === 'B' ? 'buy' : 'sell',
785
- size: t.sz,
786
- price: t.px,
787
- time: t.time,
788
- })),
789
- totalVolume: String(buyVol + sellVol),
790
- buyVolume: String(buyVol),
791
- sellVolume: String(sellVol),
792
- });
793
- },
794
- },
795
-
796
- {
797
- name: 'ob_rate_limit',
798
- description: 'Check API rate limit usage, capacity, and cumulative trading volume',
799
- parameters: {
800
- type: 'object',
801
- properties: {},
802
- },
803
- async execute() {
804
- const { getClient } = await import('../core/client.js');
805
- const client = getClient();
806
- const rl = await client.getUserRateLimit();
807
-
808
- return json({
809
- address: client.address,
810
- requestsUsed: rl.nRequestsUsed,
811
- requestsCap: rl.nRequestsCap,
812
- requestsSurplus: rl.nRequestsSurplus,
813
- usagePercent: rl.nRequestsCap > 0 ? (rl.nRequestsUsed / rl.nRequestsCap * 100).toFixed(1) + '%' : '0%',
814
- cumulativeVolume: rl.cumVlm,
815
- });
816
- },
817
- },
818
-
819
- {
820
- name: 'ob_funding_scan',
821
- description: 'Scan funding rates across all dexes (main + HIP-3) for arbitrage opportunities',
822
- parameters: {
823
- type: 'object',
824
- properties: {
825
- threshold: { type: 'number', description: 'Min annualized funding rate % to show (default: 25)' },
826
- mainOnly: { type: 'boolean', description: 'Only scan main perps' },
827
- hip3Only: { type: 'boolean', description: 'Only scan HIP-3 perps' },
828
- top: { type: 'number', description: 'Number of results (default: 30)' },
829
- pairs: { type: 'boolean', description: 'Show opposing funding pairs' },
830
- },
831
- },
832
- async execute(_id, params) {
833
- const { getClient } = await import('../core/client.js');
834
- const { annualizeFundingRate } = await import('../core/utils.js');
835
- const client = getClient();
836
-
837
- const threshold = (params.threshold as number) ?? 25;
838
- const mainOnly = params.mainOnly as boolean ?? false;
839
- const hip3Only = params.hip3Only as boolean ?? false;
840
- const topN = (params.top as number) ?? 30;
841
-
842
- const allPerps = await client.getAllPerpMetas();
843
- const results: Array<Record<string, unknown>> = [];
844
-
845
- for (const dexData of allPerps) {
846
- const isMain = !dexData.dexName;
847
- if (mainOnly && !isMain) continue;
848
- if (hip3Only && isMain) continue;
849
-
850
- for (let i = 0; i < dexData.meta.universe.length; i++) {
851
- const asset = dexData.meta.universe[i];
852
- const ctx = dexData.assetCtxs[i];
853
- if (!ctx) continue;
854
-
855
- const hourlyRate = parseFloat(ctx.funding);
856
- const annualizedPct = annualizeFundingRate(hourlyRate) * 100;
857
- const openInterest = parseFloat(ctx.openInterest);
858
-
859
- if (Math.abs(annualizedPct) < threshold) continue;
860
- if (openInterest < 100) continue;
861
-
862
- results.push({
863
- // API returns HIP-3 names already prefixed (e.g., "xyz:CL")
864
- coin: asset.name,
865
- dex: dexData.dexName ?? 'main',
866
- annualizedPct: annualizedPct.toFixed(1),
867
- direction: hourlyRate > 0 ? 'longs pay shorts' : 'shorts pay longs',
868
- collectBy: hourlyRate > 0 ? 'SHORT' : 'LONG',
869
- openInterest: ctx.openInterest,
870
- markPx: ctx.markPx,
871
- });
872
- }
873
- }
874
-
875
- results.sort((a, b) => Math.abs(parseFloat(b.annualizedPct as string)) - Math.abs(parseFloat(a.annualizedPct as string)));
876
-
877
- const output: Record<string, unknown> = {
878
- threshold,
879
- scope: mainOnly ? 'main' : hip3Only ? 'hip3' : 'all',
880
- results: results.slice(0, topN),
881
- };
882
-
883
- // Find opposing pairs if requested
884
- if (params.pairs) {
885
- const longs = results.filter(r => parseFloat(r.annualizedPct as string) > 0);
886
- const shorts = results.filter(r => parseFloat(r.annualizedPct as string) < 0);
887
- const pairs: Array<Record<string, unknown>> = [];
888
-
889
- for (const l of longs) {
890
- for (const s of shorts) {
891
- const spread = parseFloat(l.annualizedPct as string) + Math.abs(parseFloat(s.annualizedPct as string));
892
- if (spread > 20) {
893
- pairs.push({
894
- short: l.coin,
895
- shortFunding: l.annualizedPct,
896
- long: s.coin,
897
- longFunding: s.annualizedPct,
898
- spreadPct: spread.toFixed(1),
899
- });
900
- }
901
- }
902
- }
903
- pairs.sort((a, b) => parseFloat(b.spreadPct as string) - parseFloat(a.spreadPct as string));
904
- output.opposingPairs = pairs.slice(0, 10);
905
- }
906
-
907
- return json(output);
908
- },
909
- },
910
-
911
- // ── Trading Tools ───────────────────────────────────────────
912
-
913
- {
914
- name: 'ob_buy',
915
- description: 'Quick market buy on Hyperliquid. Always use dry=true first to preview.',
916
- parameters: {
917
- type: 'object',
918
- properties: {
919
- coin: { type: 'string', description: 'Asset symbol (ETH, BTC, SOL, etc.)' },
920
- size: { type: 'number', description: 'Order size in base asset' },
921
- slippage: { type: 'number', description: 'Slippage tolerance in bps (default: 50)' },
922
- leverage: { type: 'number', description: 'Set leverage (e.g., 10 for 10x). Cross for main perps, isolated for HIP-3' },
923
- dry: { type: 'boolean', description: 'Preview without executing' },
924
- },
925
- required: ['coin', 'size'],
926
- },
927
- async execute(_id, params) {
928
- const { getClient } = await import('../core/client.js');
929
- const { roundSize, getSlippagePrice } = await import('../core/utils.js');
930
- const client = getClient();
931
-
932
- if (client.isReadOnly) return error('Wallet not configured. Run "openbroker setup" first.');
933
-
934
- const coin = normalizeCoin(params.coin as string);
935
- const size = params.size as number;
936
- const slippageBps = params.slippage as number | undefined;
937
- const leverage = params.leverage as number | undefined;
938
-
939
- const mids = await client.getAllMids();
940
- const midPrice = parseFloat(mids[coin]);
941
- if (!midPrice) return error(`Unknown coin: ${coin}`);
942
-
943
- const szDecimals = await client.getSzDecimals(coin);
944
- const roundedSize = roundSize(size, szDecimals);
945
- const effectiveSlippage = slippageBps ?? 50;
946
- const slippagePrice = getSlippagePrice(midPrice, true, effectiveSlippage);
947
-
948
- if (params.dry) {
949
- return json({
950
- dryRun: true,
951
- action: 'buy',
952
- coin,
953
- size: roundedSize,
954
- midPrice,
955
- slippagePrice,
956
- slippageBps: effectiveSlippage,
957
- leverage,
958
- });
959
- }
960
-
961
- const result = await client.marketOrder(coin, true, parseFloat(roundedSize), slippageBps, leverage);
962
- return json({ action: 'buy', coin, size: roundedSize, leverage, result });
963
- },
964
- },
965
-
966
- {
967
- name: 'ob_sell',
968
- description: 'Quick market sell on Hyperliquid. Always use dry=true first to preview.',
969
- parameters: {
970
- type: 'object',
971
- properties: {
972
- coin: { type: 'string', description: 'Asset symbol (ETH, BTC, SOL, etc.)' },
973
- size: { type: 'number', description: 'Order size in base asset' },
974
- slippage: { type: 'number', description: 'Slippage tolerance in bps (default: 50)' },
975
- leverage: { type: 'number', description: 'Set leverage (e.g., 10 for 10x). Cross for main perps, isolated for HIP-3' },
976
- dry: { type: 'boolean', description: 'Preview without executing' },
977
- },
978
- required: ['coin', 'size'],
979
- },
980
- async execute(_id, params) {
981
- const { getClient } = await import('../core/client.js');
982
- const { roundSize, getSlippagePrice } = await import('../core/utils.js');
983
- const client = getClient();
984
-
985
- if (client.isReadOnly) return error('Wallet not configured. Run "openbroker setup" first.');
986
-
987
- const coin = normalizeCoin(params.coin as string);
988
- const size = params.size as number;
989
- const slippageBps = params.slippage as number | undefined;
990
- const leverage = params.leverage as number | undefined;
991
-
992
- const mids = await client.getAllMids();
993
- const midPrice = parseFloat(mids[coin]);
994
- if (!midPrice) return error(`Unknown coin: ${coin}`);
995
-
996
- const szDecimals = await client.getSzDecimals(coin);
997
- const roundedSize = roundSize(size, szDecimals);
998
- const effectiveSlippage = slippageBps ?? 50;
999
- const slippagePrice = getSlippagePrice(midPrice, false, effectiveSlippage);
1000
-
1001
- if (params.dry) {
1002
- return json({
1003
- dryRun: true,
1004
- action: 'sell',
1005
- coin,
1006
- size: roundedSize,
1007
- midPrice,
1008
- slippagePrice,
1009
- slippageBps: effectiveSlippage,
1010
- leverage,
1011
- });
1012
- }
1013
-
1014
- const result = await client.marketOrder(coin, false, parseFloat(roundedSize), slippageBps, leverage);
1015
- return json({ action: 'sell', coin, size: roundedSize, leverage, result });
1016
- },
1017
- },
1018
-
1019
- {
1020
- name: 'ob_limit',
1021
- description: 'Place a limit order on Hyperliquid. Always use dry=true first to preview.',
1022
- parameters: {
1023
- type: 'object',
1024
- properties: {
1025
- coin: { type: 'string', description: 'Asset symbol' },
1026
- side: { type: 'string', enum: ['buy', 'sell'], description: 'Order direction' },
1027
- size: { type: 'number', description: 'Order size in base asset' },
1028
- price: { type: 'number', description: 'Limit price' },
1029
- tif: { type: 'string', enum: ['GTC', 'IOC', 'ALO'], description: 'Time in force (default: GTC)' },
1030
- leverage: { type: 'number', description: 'Set leverage (e.g., 10 for 10x). Cross for main perps, isolated for HIP-3' },
1031
- reduce: { type: 'boolean', description: 'Reduce-only order' },
1032
- dry: { type: 'boolean', description: 'Preview without executing' },
1033
- },
1034
- required: ['coin', 'side', 'size', 'price'],
1035
- },
1036
- async execute(_id, params) {
1037
- const { getClient } = await import('../core/client.js');
1038
- const { roundSize, roundPrice } = await import('../core/utils.js');
1039
- const client = getClient();
1040
-
1041
- if (client.isReadOnly) return error('Wallet not configured. Run "openbroker setup" first.');
1042
-
1043
- const coin = normalizeCoin(params.coin as string);
1044
- const isBuy = params.side === 'buy';
1045
- const size = params.size as number;
1046
- const price = params.price as number;
1047
- const tif = ((params.tif as string) || 'GTC').toLowerCase();
1048
- const leverage = params.leverage as number | undefined;
1049
- const reduceOnly = (params.reduce as boolean) || false;
1050
-
1051
- const szDecimals = await client.getSzDecimals(coin);
1052
- const roundedSize = roundSize(size, szDecimals);
1053
- const roundedPrice = roundPrice(price, szDecimals);
1054
-
1055
- // Map tif string to SDK format
1056
- const tifMap: Record<string, 'Gtc' | 'Ioc' | 'Alo'> = { gtc: 'Gtc', ioc: 'Ioc', alo: 'Alo' };
1057
- const sdkTif = tifMap[tif] || 'Gtc';
1058
-
1059
- if (params.dry) {
1060
- return json({
1061
- dryRun: true,
1062
- action: 'limit',
1063
- coin,
1064
- side: params.side,
1065
- size: roundedSize,
1066
- price: roundedPrice,
1067
- tif: sdkTif,
1068
- reduceOnly,
1069
- });
1070
- }
1071
-
1072
- const result = await client.limitOrder(
1073
- coin, isBuy, parseFloat(roundedSize), parseFloat(roundedPrice), sdkTif, reduceOnly, leverage,
1074
- );
1075
- return json({ action: 'limit', coin, side: params.side, size: roundedSize, price: roundedPrice, leverage, result });
1076
- },
1077
- },
1078
-
1079
- {
1080
- name: 'ob_trigger',
1081
- description: 'Place a trigger order (take profit or stop loss) on Hyperliquid. Always use dry=true first.',
1082
- parameters: {
1083
- type: 'object',
1084
- properties: {
1085
- coin: { type: 'string', description: 'Asset symbol' },
1086
- side: { type: 'string', enum: ['buy', 'sell'], description: 'Order direction' },
1087
- size: { type: 'number', description: 'Order size in base asset' },
1088
- trigger: { type: 'number', description: 'Trigger price' },
1089
- type: { type: 'string', enum: ['tp', 'sl'], description: 'Trigger type: tp (take profit) or sl (stop loss)' },
1090
- dry: { type: 'boolean', description: 'Preview without executing' },
1091
- },
1092
- required: ['coin', 'side', 'size', 'trigger', 'type'],
1093
- },
1094
- async execute(_id, params) {
1095
- const { getClient } = await import('../core/client.js');
1096
- const { roundSize, roundPrice } = await import('../core/utils.js');
1097
- const client = getClient();
1098
-
1099
- if (client.isReadOnly) return error('Wallet not configured. Run "openbroker setup" first.');
1100
-
1101
- const coin = normalizeCoin(params.coin as string);
1102
- const isBuy = params.side === 'buy';
1103
- const size = params.size as number;
1104
- const triggerPrice = params.trigger as number;
1105
- const tpsl = params.type as 'tp' | 'sl';
1106
-
1107
- const szDecimals = await client.getSzDecimals(coin);
1108
- const roundedSize = roundSize(size, szDecimals);
1109
- const roundedTrigger = roundPrice(triggerPrice, szDecimals);
1110
-
1111
- if (params.dry) {
1112
- return json({
1113
- dryRun: true,
1114
- action: 'trigger',
1115
- coin,
1116
- side: params.side,
1117
- size: roundedSize,
1118
- triggerPrice: roundedTrigger,
1119
- type: tpsl,
1120
- });
1121
- }
1122
-
1123
- let result;
1124
- if (tpsl === 'sl') {
1125
- result = await client.stopLoss(coin, isBuy, parseFloat(roundedSize), parseFloat(roundedTrigger));
1126
- } else {
1127
- result = await client.takeProfit(coin, isBuy, parseFloat(roundedSize), parseFloat(roundedTrigger));
1128
- }
1129
- return json({ action: 'trigger', coin, type: tpsl, size: roundedSize, triggerPrice: roundedTrigger, result });
1130
- },
1131
- },
1132
-
1133
- {
1134
- name: 'ob_tpsl',
1135
- description: 'Set take profit and/or stop loss on an existing position. Supports absolute price, percentage (+10%, -5%), and "entry" keyword.',
1136
- parameters: {
1137
- type: 'object',
1138
- properties: {
1139
- coin: { type: 'string', description: 'Asset symbol' },
1140
- tp: { type: 'string', description: 'Take profit price (absolute, +10%, or "entry")' },
1141
- sl: { type: 'string', description: 'Stop loss price (absolute, -5%, or "entry")' },
1142
- size: { type: 'number', description: 'Position size (defaults to full position)' },
1143
- dry: { type: 'boolean', description: 'Preview without executing' },
1144
- },
1145
- required: ['coin'],
1146
- },
1147
- async execute(_id, params) {
1148
- const { getClient } = await import('../core/client.js');
1149
- const { roundSize, roundPrice } = await import('../core/utils.js');
1150
- const client = getClient();
1151
-
1152
- if (client.isReadOnly) return error('Wallet not configured. Run "openbroker setup" first.');
1153
- if (!params.tp && !params.sl) return error('At least one of tp or sl is required.');
1154
-
1155
- const coin = normalizeCoin(params.coin as string);
1156
-
1157
- // Get current position
1158
- const state = await client.getUserStateAll();
1159
- const position = state.assetPositions.find(
1160
- ap => ap.position.coin === coin && parseFloat(ap.position.szi) !== 0,
1161
- );
1162
- if (!position) return error(`No open position for ${coin}`);
1163
-
1164
- const posSize = parseFloat(position.position.szi);
1165
- const entryPx = parseFloat(position.position.entryPx);
1166
- const isLong = posSize > 0;
1167
- const szDecimals = await client.getSzDecimals(coin);
1168
- const orderSize = params.size ? parseFloat(roundSize(params.size as number, szDecimals)) : Math.abs(posSize);
1169
-
1170
- // Parse price helper
1171
- const parsePrice = (input: string): number => {
1172
- if (input.toLowerCase() === 'entry') return entryPx;
1173
- if (input.endsWith('%')) {
1174
- const pct = parseFloat(input.replace('%', ''));
1175
- return entryPx * (1 + pct / 100);
1176
- }
1177
- return parseFloat(input);
1178
- };
1179
-
1180
- const results: Record<string, unknown> = { coin, positionSize: posSize, entryPrice: entryPx };
1181
-
1182
- if (params.tp) {
1183
- const tpPrice = parsePrice(params.tp as string);
1184
- const roundedTp = parseFloat(roundPrice(tpPrice, szDecimals));
1185
- results.tpPrice = roundedTp;
1186
-
1187
- if (!params.dry) {
1188
- // TP closes position: long → sell, short → buy
1189
- results.tpResult = await client.takeProfit(coin, !isLong, orderSize, roundedTp);
1190
- }
1191
- }
1192
-
1193
- if (params.sl) {
1194
- const slPrice = parsePrice(params.sl as string);
1195
- const roundedSl = parseFloat(roundPrice(slPrice, szDecimals));
1196
- results.slPrice = roundedSl;
1197
-
1198
- if (!params.dry) {
1199
- results.slResult = await client.stopLoss(coin, !isLong, orderSize, roundedSl);
1200
- }
1201
- }
1202
-
1203
- if (params.dry) results.dryRun = true;
1204
-
1205
- return json(results);
1206
- },
1207
- },
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
-
1316
- {
1317
- name: 'ob_cancel',
1318
- description: 'Cancel open orders on Hyperliquid',
1319
- parameters: {
1320
- type: 'object',
1321
- properties: {
1322
- coin: { type: 'string', description: 'Cancel orders for this coin only' },
1323
- oid: { type: 'number', description: 'Cancel specific order by ID' },
1324
- all: { type: 'boolean', description: 'Cancel all open orders' },
1325
- },
1326
- },
1327
- async execute(_id, params) {
1328
- const { getClient } = await import('../core/client.js');
1329
- const client = getClient();
1330
-
1331
- if (client.isReadOnly) return error('Wallet not configured. Run "openbroker setup" first.');
1332
-
1333
- if (params.oid) {
1334
- const coin = params.coin as string | undefined;
1335
- if (!coin) return error('--coin is required when cancelling by order ID');
1336
- const result = await client.cancel(normalizeCoin(coin), params.oid as number);
1337
- return json({ action: 'cancel', coin: normalizeCoin(coin), oid: params.oid, result });
1338
- }
1339
-
1340
- if (params.all || params.coin) {
1341
- const coin = params.coin ? normalizeCoin(params.coin as string) : undefined;
1342
- const results = await client.cancelAll(coin);
1343
- return json({ action: 'cancelAll', coin: coin ?? 'all', results });
1344
- }
1345
-
1346
- return error('Specify --all, --coin, or --oid to cancel orders.');
1347
- },
1348
- },
1349
-
1350
- // ── Advanced Execution (shell out — these are long-running scripts) ──
1351
-
1352
- {
1353
- name: 'ob_twap',
1354
- description: 'Place a native Hyperliquid TWAP order. The exchange handles order slicing and timing server-side. Returns immediately with a TWAP ID.',
1355
- parameters: {
1356
- type: 'object',
1357
- properties: {
1358
- coin: { type: 'string', description: 'Asset symbol (e.g., ETH, BTC)' },
1359
- side: { type: 'string', enum: ['buy', 'sell'], description: 'Order direction' },
1360
- size: { type: 'number', description: 'Total order size in base asset' },
1361
- duration: { type: 'number', description: 'Duration in minutes (5–1440)' },
1362
- randomize: { type: 'boolean', description: 'Randomize timing (default: true)' },
1363
- reduce_only: { type: 'boolean', description: 'Reduce-only order (default: false)' },
1364
- leverage: { type: 'number', description: 'Set leverage (e.g., 10 for 10x)' },
1365
- },
1366
- required: ['coin', 'side', 'size', 'duration'],
1367
- },
1368
- async execute(_id, params) {
1369
- const { getClient } = await import('../core/client.js');
1370
- const coin = normalizeCoin(params.coin as string);
1371
- const isBuy = params.side === 'buy';
1372
- const size = params.size as number;
1373
- const durationMinutes = params.duration as number;
1374
- const randomize = params.randomize !== false;
1375
- const reduceOnly = params.reduce_only === true;
1376
- const leverage = params.leverage as number | undefined;
1377
-
1378
- if (durationMinutes < 5 || durationMinutes > 1440) {
1379
- return error('Duration must be between 5 and 1440 minutes');
1380
- }
1381
-
1382
- try {
1383
- const client = getClient();
1384
- const mids = await client.getAllMids();
1385
- const midPrice = parseFloat(mids[coin]);
1386
-
1387
- const response = await client.twapOrder(coin, isBuy, size, durationMinutes, randomize, reduceOnly, leverage);
1388
- // SDK's TwapOrderSuccessResponse excludes errors; they'd throw and
1389
- // be caught below. Status is always `{ running }` on this branch.
1390
- const status = response.response.data.status;
1391
-
1392
- return json({
1393
- twapId: status.running.twapId,
1394
- coin,
1395
- side: isBuy ? 'buy' : 'sell',
1396
- size,
1397
- durationMinutes,
1398
- randomize,
1399
- reduceOnly,
1400
- estimatedNotional: midPrice ? midPrice * size : undefined,
1401
- midPrice: midPrice || undefined,
1402
- });
1403
- } catch (err) {
1404
- return error(err instanceof Error ? err.message : String(err));
1405
- }
1406
- },
1407
- },
1408
-
1409
- {
1410
- name: 'ob_twap_cancel',
1411
- description: 'Cancel a running native Hyperliquid TWAP order by its TWAP ID.',
1412
- parameters: {
1413
- type: 'object',
1414
- properties: {
1415
- coin: { type: 'string', description: 'Asset symbol (e.g., ETH)' },
1416
- twap_id: { type: 'number', description: 'TWAP order ID to cancel' },
1417
- },
1418
- required: ['coin', 'twap_id'],
1419
- },
1420
- async execute(_id, params) {
1421
- const { getClient } = await import('../core/client.js');
1422
- const coin = normalizeCoin(params.coin as string);
1423
- const twapId = params.twap_id as number;
1424
-
1425
- try {
1426
- const client = getClient();
1427
- const response = await client.twapCancel(coin, twapId);
1428
- // SDK returns the success-only response; failures throw.
1429
- const status = response.response.data.status;
1430
-
1431
- if (typeof status === 'string' && status === 'success') {
1432
- return json({ cancelled: true, coin, twapId });
1433
- }
1434
- return error(`Unexpected TWAP cancel status: ${JSON.stringify(status)}`);
1435
- } catch (err) {
1436
- return error(err instanceof Error ? err.message : String(err));
1437
- }
1438
- },
1439
- },
1440
-
1441
- {
1442
- name: 'ob_twap_status',
1443
- description: 'View TWAP order history and status. Shows active and past TWAP orders.',
1444
- parameters: {
1445
- type: 'object',
1446
- properties: {
1447
- active: { type: 'boolean', description: 'Only show active/running TWAP orders' },
1448
- },
1449
- },
1450
- async execute(_id, params) {
1451
- const { getClient } = await import('../core/client.js');
1452
-
1453
- try {
1454
- const client = getClient();
1455
- const history = await client.twapHistory();
1456
-
1457
- const filtered = params.active
1458
- ? history.filter((h: { status: { status: string } }) => h.status.status === 'activated')
1459
- : history;
1460
-
1461
- return json(filtered.map((entry: {
1462
- twapId?: number;
1463
- state: { coin: string; side: string; sz: string; executedSz: string; executedNtl: string; minutes: number; randomize: boolean; reduceOnly: boolean; timestamp: number };
1464
- status: { status: string; description?: string };
1465
- }) => ({
1466
- twapId: entry.twapId,
1467
- coin: entry.state.coin,
1468
- side: entry.state.side === 'B' ? 'buy' : 'sell',
1469
- totalSize: entry.state.sz,
1470
- executedSize: entry.state.executedSz,
1471
- executedNotional: entry.state.executedNtl,
1472
- durationMinutes: entry.state.minutes,
1473
- randomize: entry.state.randomize,
1474
- reduceOnly: entry.state.reduceOnly,
1475
- status: entry.status.status,
1476
- startedAt: new Date(entry.state.timestamp).toISOString(),
1477
- })));
1478
- } catch (err) {
1479
- return error(err instanceof Error ? err.message : String(err));
1480
- }
1481
- },
1482
- },
1483
-
1484
- {
1485
- name: 'ob_bracket',
1486
- description: 'Place a bracket order: entry + take profit + stop loss in one command',
1487
- parameters: {
1488
- type: 'object',
1489
- properties: {
1490
- coin: { type: 'string', description: 'Asset symbol' },
1491
- side: { type: 'string', enum: ['buy', 'sell'], description: 'Order direction' },
1492
- size: { type: 'number', description: 'Order size' },
1493
- tp: { type: 'number', description: 'Take profit percentage from entry' },
1494
- sl: { type: 'number', description: 'Stop loss percentage from entry' },
1495
- entry: { type: 'string', enum: ['market', 'limit'], description: 'Entry type (default: market)' },
1496
- price: { type: 'number', description: 'Entry price (required if entry=limit)' },
1497
- leverage: { type: 'number', description: 'Set leverage (e.g., 10 for 10x)' },
1498
- dry: { type: 'boolean', description: 'Preview without executing' },
1499
- },
1500
- required: ['coin', 'side', 'size', 'tp', 'sl'],
1501
- },
1502
- async execute(_id, params) {
1503
- const { execFile } = await import('node:child_process');
1504
- const args = ['bracket'];
1505
- for (const [key, value] of Object.entries(params)) {
1506
- if (value === undefined || value === null || value === false || value === '') continue;
1507
- if (value === true) args.push(`--${key}`);
1508
- else args.push(`--${key}`, String(value));
1509
- }
1510
-
1511
- return new Promise((resolve) => {
1512
- execFile('openbroker', args, { timeout: 60_000 }, (_err, stdout, stderr) => {
1513
- resolve({ content: [{ type: 'text' as const, text: (stdout + (stderr || '')).trim() }] });
1514
- });
1515
- });
1516
- },
1517
- },
1518
-
1519
- {
1520
- name: 'ob_chase',
1521
- description: 'Chase price with ALO (post-only) orders, continuously replacing until filled. This is a long-running command.',
1522
- parameters: {
1523
- type: 'object',
1524
- properties: {
1525
- coin: { type: 'string', description: 'Asset symbol' },
1526
- side: { type: 'string', enum: ['buy', 'sell'], description: 'Order direction' },
1527
- size: { type: 'number', description: 'Order size' },
1528
- offset: { type: 'number', description: 'Tick offset from best price (default: 1)' },
1529
- timeout: { type: 'number', description: 'Timeout in seconds' },
1530
- leverage: { type: 'number', description: 'Set leverage (e.g., 10 for 10x)' },
1531
- dry: { type: 'boolean', description: 'Preview without executing' },
1532
- },
1533
- required: ['coin', 'side', 'size'],
1534
- },
1535
- async execute(_id, params) {
1536
- const { execFile } = await import('node:child_process');
1537
- const args = ['chase'];
1538
- for (const [key, value] of Object.entries(params)) {
1539
- if (value === undefined || value === null || value === false || value === '') continue;
1540
- if (value === true) args.push(`--${key}`);
1541
- else args.push(`--${key}`, String(value));
1542
- }
1543
-
1544
- return new Promise((resolve) => {
1545
- execFile('openbroker', args, { timeout: 600_000 }, (_err, stdout, stderr) => {
1546
- resolve({ content: [{ type: 'text' as const, text: (stdout + (stderr || '')).trim() }] });
1547
- });
1548
- });
1549
- },
1550
- },
1551
-
1552
- // ── Watcher Tool ────────────────────────────────────────────
1553
-
1554
- {
1555
- name: 'ob_watcher_status',
1556
- description: 'Get the status of the background position watcher: tracked positions, margin usage, events detected',
1557
- parameters: { type: 'object', properties: {} },
1558
- async execute() {
1559
- if (!watcher) {
1560
- return json({ running: false, error: 'Watcher is not enabled' });
1561
- }
1562
- return json(watcher.getStatus());
1563
- },
1564
- },
1565
-
1566
- // ── Automation Tools ──────────────────────────────────────────
1567
-
1568
- {
1569
- name: 'ob_auto_run',
1570
- description: 'Start a trading automation script. Scripts are TypeScript files that export a default factory function with event handlers (price_change, funding_update, position_opened, etc.). Scripts are loaded from ~/.openbroker/automations/, an absolute path, or bundled examples (dca, grid, funding-arb, mm-spread, mm-maker).',
1571
- parameters: {
1572
- type: 'object',
1573
- properties: {
1574
- script: { type: 'string', description: 'Script name (from ~/.openbroker/automations/) or absolute path' },
1575
- example: { type: 'string', description: 'Bundled example name: dca, grid, funding-arb, mm-spread, mm-maker' },
1576
- config: { type: 'object', description: 'Key-value config to pre-seed automation state (e.g. { coin: "BTC", amount: 50 })' },
1577
- id: { type: 'string', description: 'Custom automation ID (default: filename)' },
1578
- dry: { type: 'boolean', description: 'Intercept write methods — no real trades' },
1579
- poll: { type: 'number', description: 'Poll interval in milliseconds (default: 10000)' },
1580
- },
1581
- },
1582
- async execute(_id, params) {
1583
- try {
1584
- const { resolveScriptPath, resolveExamplePath } = await import('../auto/loader.js');
1585
- const { startAutomation } = await import('../auto/runtime.js');
1586
-
1587
- if (!params.script && !params.example) {
1588
- return error('Either "script" or "example" parameter is required');
1589
- }
1590
-
1591
- const scriptPath = params.example
1592
- ? resolveExamplePath(String(params.example))
1593
- : resolveScriptPath(String(params.script));
1594
- const initialState = params.config && typeof params.config === 'object'
1595
- ? params.config as Record<string, unknown>
1596
- : undefined;
1597
-
1598
- const automation = await startAutomation({
1599
- scriptPath,
1600
- id: params.id ? String(params.id) : undefined,
1601
- dryRun: params.dry === true,
1602
- pollIntervalMs: params.poll ? Number(params.poll) : 10_000,
1603
- gatewayPort,
1604
- hooksToken,
1605
- initialState,
1606
- });
1607
-
1608
- return json({
1609
- status: 'started',
1610
- id: automation.id,
1611
- scriptPath: automation.scriptPath,
1612
- dryRun: automation.dryRun,
1613
- });
1614
- } catch (err) {
1615
- return error(err instanceof Error ? err.message : String(err));
1616
- }
1617
- },
1618
- },
1619
-
1620
- {
1621
- name: 'ob_auto_stop',
1622
- description: 'Stop a running trading automation by ID',
1623
- parameters: {
1624
- type: 'object',
1625
- properties: {
1626
- id: { type: 'string', description: 'Automation ID to stop' },
1627
- },
1628
- required: ['id'],
1629
- },
1630
- async execute(_id, params) {
1631
- try {
1632
- const { getAutomation } = await import('../auto/runtime.js');
1633
- const automation = getAutomation(String(params.id));
1634
- if (!automation) {
1635
- return error(`No running automation with ID "${params.id}"`);
1636
- }
1637
- await automation.stop();
1638
- return json({ status: 'stopped', id: params.id });
1639
- } catch (err) {
1640
- return error(err instanceof Error ? err.message : String(err));
1641
- }
1642
- },
1643
- },
1644
-
1645
- {
1646
- name: 'ob_auto_list',
1647
- description: 'List available automation scripts, bundled examples (dca, grid, funding-arb, mm-spread, mm-maker), and running automations',
1648
- parameters: { type: 'object', properties: {} },
1649
- async execute() {
1650
- const { listAutomations, loadExampleConfigs } = await import('../auto/loader.js');
1651
- const { getRunningAutomations, getRegisteredAutomations } = await import('../auto/runtime.js');
1652
-
1653
- const available = listAutomations();
1654
- const examples = await loadExampleConfigs();
1655
-
1656
- // In-process automations with live stats
1657
- const inProcess = getRunningAutomations().map(a => ({
1658
- id: a.id,
1659
- scriptPath: a.scriptPath,
1660
- uptime: Math.round((Date.now() - a.startedAt.getTime()) / 1000),
1661
- pollCount: a.pollCount,
1662
- eventsEmitted: a.eventsEmitted,
1663
- dryRun: a.dryRun,
1664
- source: 'this_process',
1665
- }));
1666
-
1667
- // File-registry entries from other processes
1668
- const registered = getRegisteredAutomations();
1669
- const external = registered
1670
- .filter(r => !inProcess.some(ip => ip.id === r.id))
1671
- .map(r => ({
1672
- id: r.id,
1673
- scriptPath: r.scriptPath,
1674
- status: r.status,
1675
- pid: r.pid,
1676
- startedAt: r.startedAt,
1677
- dryRun: r.dryRun,
1678
- error: r.error,
1679
- source: 'other_process',
1680
- }));
1681
-
1682
- return json({ available, examples, running: [...inProcess, ...external] });
1683
- },
1684
- },
1685
- ];
1686
- }