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