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/SKILL.md
CHANGED
|
@@ -4,8 +4,8 @@ description: Hyperliquid trading plugin with background position monitoring and
|
|
|
4
4
|
license: MIT
|
|
5
5
|
compatibility: Requires Node.js 22+, network access to api.hyperliquid.xyz
|
|
6
6
|
homepage: https://www.npmjs.com/package/openbroker
|
|
7
|
-
metadata: {"author": "monemetrics", "version": "1.0.
|
|
8
|
-
allowed-tools: ob_account ob_positions ob_funding ob_markets ob_search ob_spot ob_fills ob_orders ob_order_status ob_fees ob_candles ob_funding_history ob_trades ob_rate_limit ob_funding_scan ob_buy ob_sell ob_limit ob_trigger ob_tpsl ob_cancel ob_twap ob_twap_cancel ob_twap_status ob_bracket ob_chase ob_watcher_status ob_auto_run ob_auto_stop ob_auto_list Bash(openbroker:*)
|
|
7
|
+
metadata: {"author": "monemetrics", "version": "1.0.79", "openclaw": {"requires": {"bins": ["openbroker"], "env": ["HYPERLIQUID_PRIVATE_KEY"]}, "primaryEnv": "HYPERLIQUID_PRIVATE_KEY", "install": [{"id": "node", "kind": "node", "package": "openbroker", "bins": ["openbroker"], "label": "Install openbroker (npm)"}]}}
|
|
8
|
+
allowed-tools: ob_account ob_positions ob_funding ob_markets ob_search ob_spot ob_fills ob_orders ob_order_status ob_fees ob_candles ob_funding_history ob_trades ob_rate_limit ob_funding_scan ob_buy ob_sell ob_limit ob_trigger ob_tpsl ob_cancel ob_spot_buy ob_spot_sell ob_twap ob_twap_cancel ob_twap_status ob_bracket ob_chase ob_watcher_status ob_auto_run ob_auto_stop ob_auto_list Bash(openbroker:*)
|
|
9
9
|
---
|
|
10
10
|
|
|
11
11
|
# Open Broker - Hyperliquid Trading CLI
|
|
@@ -528,11 +528,11 @@ To view bundled examples and their config schemas:
|
|
|
528
528
|
openbroker auto examples # List examples with config fields
|
|
529
529
|
```
|
|
530
530
|
|
|
531
|
-
Available examples: `dca`, `grid`, `funding-arb`, `mm-spread`, `mm-maker`
|
|
531
|
+
Available examples: `dca`, `grid`, `funding-arb`, `mm-spread`, `mm-maker`, `price-alert`
|
|
532
532
|
|
|
533
533
|
### How Automations Work
|
|
534
534
|
|
|
535
|
-
An automation is a `.ts` file that exports a default function. The function receives an `AutomationAPI` with the full Hyperliquid client, typed event subscriptions, persistent state, and a logger. The runtime
|
|
535
|
+
An automation is a `.ts` file that exports a default function. The function receives an `AutomationAPI` with the full Hyperliquid client, typed event subscriptions, persistent state, and a logger. The runtime connects a WebSocket for real-time price and order events, with REST polling every 30s as a heartbeat for position/margin data. Use `--no-ws` to disable WebSocket and fall back to pure REST polling (every 10s).
|
|
536
536
|
|
|
537
537
|
### Writing an Automation
|
|
538
538
|
|
|
@@ -780,18 +780,66 @@ api.on('margin_warning', async ({ marginUsedPct, equity }) => {
|
|
|
780
780
|
});
|
|
781
781
|
```
|
|
782
782
|
|
|
783
|
+
#### `order_update` — Real-time order lifecycle (WebSocket)
|
|
784
|
+
Fires instantly when any order changes status: `open`, `filled`, `canceled`, `triggered`, `rejected`, `marginCanceled`, `liquidatedCanceled`, `badAloPxRejected`, and 20+ other statuses. Requires WebSocket (enabled by default).
|
|
785
|
+
|
|
786
|
+
**Payload:** `{ coin: string, oid: number, side: 'buy' | 'sell', size: number, price: number, origSize: number, status: string, statusTimestamp: number }`
|
|
787
|
+
|
|
788
|
+
**Example:**
|
|
789
|
+
```typescript
|
|
790
|
+
api.on('order_update', async ({ coin, oid, status, side, size, price }) => {
|
|
791
|
+
if (status === 'filled') {
|
|
792
|
+
api.log.info(`Order ${oid} filled: ${side} ${size} ${coin} @ $${price}`);
|
|
793
|
+
} else if (status === 'canceled' || status.includes('Rejected')) {
|
|
794
|
+
api.log.warn(`Order ${oid} ${status}: ${coin}`);
|
|
795
|
+
}
|
|
796
|
+
});
|
|
797
|
+
```
|
|
798
|
+
|
|
799
|
+
#### `liquidation` — Liquidation alert (WebSocket only)
|
|
800
|
+
Fires when the account is liquidated. This event is **only available via WebSocket** — there is no REST polling equivalent.
|
|
801
|
+
|
|
802
|
+
**Payload:** `{ lid: number, liquidator: string, liquidatedUser: string, liquidatedNtlPos: number, liquidatedAccountValue: number }`
|
|
803
|
+
|
|
804
|
+
**Example:**
|
|
805
|
+
```typescript
|
|
806
|
+
api.on('liquidation', async ({ liquidatedNtlPos, liquidatedAccountValue }) => {
|
|
807
|
+
await api.publish(
|
|
808
|
+
`LIQUIDATED: $${liquidatedNtlPos.toFixed(2)} notional, account value: $${liquidatedAccountValue.toFixed(2)}`,
|
|
809
|
+
{ name: 'liquidation-alert' },
|
|
810
|
+
);
|
|
811
|
+
});
|
|
812
|
+
```
|
|
813
|
+
|
|
814
|
+
### WebSocket Real-Time Data
|
|
815
|
+
|
|
816
|
+
Automations use **WebSocket by default** for real-time market and account events. The runtime subscribes to:
|
|
817
|
+
- **allMids** — price updates for all assets (drives `price_change` events in real-time)
|
|
818
|
+
- **orderUpdates** — order lifecycle events (drives `order_update` and `order_filled`)
|
|
819
|
+
- **userFills** — trade fill details with PnL and fees
|
|
820
|
+
- **userEvents** — liquidation alerts, funding payments, system cancellations
|
|
821
|
+
|
|
822
|
+
REST polling continues as a **heartbeat** (every 60s by default) for position/margin/funding events that aren't covered by WebSocket. If the WebSocket connection fails, the runtime falls back to full REST polling (every 10s) automatically.
|
|
823
|
+
|
|
824
|
+
To disable WebSocket (pure REST polling):
|
|
825
|
+
```bash
|
|
826
|
+
openbroker auto run my-strategy.ts --no-ws
|
|
827
|
+
```
|
|
828
|
+
|
|
783
829
|
### Choosing the Right Event — Quick Guide
|
|
784
830
|
|
|
785
831
|
| Use case | Best event | Why |
|
|
786
832
|
|----------|-----------|-----|
|
|
787
833
|
| Alert when price crosses a fixed level | `tick` | Fires every poll — no minimum change threshold |
|
|
788
|
-
| React to price momentum/volatility | `price_change` |
|
|
834
|
+
| React to price momentum/volatility | `price_change` | Real-time via WebSocket, provides relative change data |
|
|
789
835
|
| Funding rate strategy | `funding_update` | Gives annualized rate directly |
|
|
790
836
|
| Auto TP/SL on new positions | `position_opened` | Fires exactly when a new position appears |
|
|
791
837
|
| Log when positions close | `position_closed` | Fires when position disappears |
|
|
792
838
|
| Track position scaling | `position_changed` | Fires on size changes only |
|
|
793
839
|
| Risk management — PnL spikes | `pnl_threshold` | Only fires on large moves (≥5% of position value) |
|
|
794
840
|
| Risk management — margin | `margin_warning` | Fires at 80%+ margin usage |
|
|
841
|
+
| React instantly to order fills/rejects | `order_update` | Real-time via WebSocket — sub-second latency |
|
|
842
|
+
| Liquidation alerts | `liquidation` | WebSocket only — no REST equivalent |
|
|
795
843
|
| Periodic task (DCA, rebalance) | `api.every(ms, fn)` | Better than tick for longer intervals |
|
|
796
844
|
|
|
797
845
|
### Client Methods Available
|
package/bin/cli.ts
CHANGED
|
@@ -47,6 +47,9 @@ const commands: Record<string, { script: string; description: string }> = {
|
|
|
47
47
|
'scale': { script: 'operations/scale.ts', description: 'Scale in/out orders' },
|
|
48
48
|
'bracket': { script: 'operations/bracket.ts', description: 'Bracket order (entry + TP + SL)' },
|
|
49
49
|
'chase': { script: 'operations/chase.ts', description: 'Chase order with ALO' },
|
|
50
|
+
'spot-buy': { script: 'operations/spot-order.ts', description: 'Spot buy order' },
|
|
51
|
+
'spot-sell': { script: 'operations/spot-order.ts', description: 'Spot sell order' },
|
|
52
|
+
'spot-order': { script: 'operations/spot-order.ts', description: 'Spot order (market or limit)' },
|
|
50
53
|
|
|
51
54
|
// Automations
|
|
52
55
|
'auto': { script: 'auto/cli.ts', description: 'Run/manage trading automations' },
|
|
@@ -88,6 +91,11 @@ Trading Commands:
|
|
|
88
91
|
tpsl Set TP/SL on existing position
|
|
89
92
|
cancel Cancel orders
|
|
90
93
|
|
|
94
|
+
Spot Trading:
|
|
95
|
+
spot-buy Spot buy order
|
|
96
|
+
spot-sell Spot sell order
|
|
97
|
+
spot-order Spot order (market or limit, specify --side)
|
|
98
|
+
|
|
91
99
|
Advanced Execution:
|
|
92
100
|
twap Native TWAP order (exchange-managed)
|
|
93
101
|
twap-cancel Cancel a running TWAP order
|
|
@@ -167,6 +175,14 @@ function main() {
|
|
|
167
175
|
runScript(commands['market'].script, ['--side', 'sell', ...commandArgs]);
|
|
168
176
|
return;
|
|
169
177
|
}
|
|
178
|
+
if (command === 'spot-buy') {
|
|
179
|
+
runScript(commands['spot-order'].script, ['--side', 'buy', ...commandArgs]);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (command === 'spot-sell') {
|
|
183
|
+
runScript(commands['spot-order'].script, ['--side', 'sell', ...commandArgs]);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
170
186
|
|
|
171
187
|
// Handle version
|
|
172
188
|
if (command === '--version' || command === '-v') {
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
package/scripts/auto/cli.ts
CHANGED
|
@@ -104,10 +104,11 @@ async function runCommand(args: Record<string, string | boolean>, positional: st
|
|
|
104
104
|
const scriptPath = exampleName ? resolveExamplePath(exampleName) : resolveScriptPath(scriptName!);
|
|
105
105
|
const dryRun = args.dry === true;
|
|
106
106
|
const verbose = args.verbose === true;
|
|
107
|
-
const
|
|
107
|
+
const useWebSocket = args['no-ws'] !== true;
|
|
108
|
+
const pollIntervalMs = args.poll ? parseInt(String(args.poll), 10) : undefined;
|
|
108
109
|
const id = args.id ? String(args.id) : undefined;
|
|
109
110
|
|
|
110
|
-
if (isNaN(pollIntervalMs) || pollIntervalMs < 1000) {
|
|
111
|
+
if (pollIntervalMs !== undefined && (isNaN(pollIntervalMs) || pollIntervalMs < 1000)) {
|
|
111
112
|
console.error('Error: --poll must be at least 1000ms');
|
|
112
113
|
process.exit(1);
|
|
113
114
|
}
|
|
@@ -118,6 +119,7 @@ async function runCommand(args: Record<string, string | boolean>, positional: st
|
|
|
118
119
|
dryRun,
|
|
119
120
|
verbose,
|
|
120
121
|
pollIntervalMs,
|
|
122
|
+
useWebSocket,
|
|
121
123
|
initialState: Object.keys(initialState).length > 0 ? initialState : undefined,
|
|
122
124
|
});
|
|
123
125
|
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Price Alert — Real-time price monitoring via WebSocket
|
|
2
|
+
// Showcases WebSocket-driven price_change and order_update events
|
|
3
|
+
|
|
4
|
+
import type { AutomationAPI, AutomationConfig } from '../types.js';
|
|
5
|
+
|
|
6
|
+
export const config: AutomationConfig = {
|
|
7
|
+
description: 'Real-time price alerts via WebSocket — log price moves and order updates',
|
|
8
|
+
fields: {
|
|
9
|
+
coin: { type: 'string', description: 'Asset to monitor', default: 'BTC' },
|
|
10
|
+
threshold: { type: 'number', description: 'Min price change % to alert on', default: 0.1 },
|
|
11
|
+
above: { type: 'number', description: 'Alert when price goes above this level (0 = disabled)', default: 0 },
|
|
12
|
+
below: { type: 'number', description: 'Alert when price goes below this level (0 = disabled)', default: 0 },
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export default function priceAlert(api: AutomationAPI) {
|
|
17
|
+
const COIN = api.state.get<string>('coin', 'BTC')!;
|
|
18
|
+
const THRESHOLD = api.state.get<number>('threshold', 0.1)!;
|
|
19
|
+
const ABOVE = api.state.get<number>('above', 0)!;
|
|
20
|
+
const BELOW = api.state.get<number>('below', 0)!;
|
|
21
|
+
|
|
22
|
+
let alertCount = 0;
|
|
23
|
+
let lastAlertPrice = 0;
|
|
24
|
+
let aboveTriggered = false;
|
|
25
|
+
let belowTriggered = false;
|
|
26
|
+
|
|
27
|
+
api.onStart(() => {
|
|
28
|
+
api.log.info(`Monitoring ${COIN} via WebSocket`);
|
|
29
|
+
api.log.info(`Threshold: ${THRESHOLD}% change`);
|
|
30
|
+
if (ABOVE > 0) api.log.info(`Alert above: $${ABOVE}`);
|
|
31
|
+
if (BELOW > 0) api.log.info(`Alert below: $${BELOW}`);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Real-time price changes via WebSocket
|
|
35
|
+
api.on('price_change', ({ coin, oldPrice, newPrice, changePct }) => {
|
|
36
|
+
if (coin !== COIN) return;
|
|
37
|
+
|
|
38
|
+
// Threshold alerts — fires when move exceeds configured %
|
|
39
|
+
if (Math.abs(changePct) >= THRESHOLD) {
|
|
40
|
+
const dir = changePct > 0 ? 'UP' : 'DOWN';
|
|
41
|
+
api.log.info(`${COIN} ${dir} ${changePct.toFixed(3)}%: $${oldPrice.toFixed(2)} -> $${newPrice.toFixed(2)}`);
|
|
42
|
+
alertCount++;
|
|
43
|
+
lastAlertPrice = newPrice;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Level alerts — fires once when price crosses a level, resets when it crosses back
|
|
47
|
+
if (ABOVE > 0) {
|
|
48
|
+
if (newPrice >= ABOVE && !aboveTriggered) {
|
|
49
|
+
aboveTriggered = true;
|
|
50
|
+
api.log.info(`${COIN} ABOVE $${ABOVE}: now $${newPrice.toFixed(2)}`);
|
|
51
|
+
api.publish(`${COIN} broke above $${ABOVE} — now $${newPrice.toFixed(2)}`, { name: 'price-alert' });
|
|
52
|
+
} else if (newPrice < ABOVE) {
|
|
53
|
+
aboveTriggered = false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (BELOW > 0) {
|
|
58
|
+
if (newPrice <= BELOW && !belowTriggered) {
|
|
59
|
+
belowTriggered = true;
|
|
60
|
+
api.log.info(`${COIN} BELOW $${BELOW}: now $${newPrice.toFixed(2)}`);
|
|
61
|
+
api.publish(`${COIN} dropped below $${BELOW} — now $${newPrice.toFixed(2)}`, { name: 'price-alert' });
|
|
62
|
+
} else if (newPrice > BELOW) {
|
|
63
|
+
belowTriggered = false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Real-time order lifecycle via WebSocket
|
|
69
|
+
api.on('order_update', ({ coin, oid, side, size, price, status }) => {
|
|
70
|
+
if (status === 'filled') {
|
|
71
|
+
api.log.info(`ORDER FILLED: ${side.toUpperCase()} ${size} ${coin} @ $${price.toFixed(2)} (oid: ${oid})`);
|
|
72
|
+
} else if (status === 'canceled' || status.includes('Canceled') || status.includes('Rejected')) {
|
|
73
|
+
api.log.warn(`ORDER ${status.toUpperCase()}: ${side} ${size} ${coin} @ $${price.toFixed(2)} (oid: ${oid})`);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Liquidation alerts via WebSocket
|
|
78
|
+
api.on('liquidation', ({ liquidatedNtlPos, liquidatedAccountValue }) => {
|
|
79
|
+
api.log.error(`LIQUIDATION: $${liquidatedNtlPos.toFixed(2)} notional, account value: $${liquidatedAccountValue.toFixed(2)}`);
|
|
80
|
+
api.publish(
|
|
81
|
+
`LIQUIDATED: $${liquidatedNtlPos.toFixed(2)} notional, account value: $${liquidatedAccountValue.toFixed(2)}`,
|
|
82
|
+
{ name: 'liquidation-alert' },
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Periodic summary via REST heartbeat
|
|
87
|
+
api.on('tick', ({ pollCount }) => {
|
|
88
|
+
if (pollCount % 10 === 0 && alertCount > 0) {
|
|
89
|
+
api.log.info(`Summary: ${alertCount} alerts fired, last price: $${lastAlertPrice.toFixed(2)}`);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
api.onStop(() => {
|
|
94
|
+
api.log.info(`Stopped. Total alerts: ${alertCount}`);
|
|
95
|
+
});
|
|
96
|
+
}
|
package/scripts/auto/runtime.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Automation Runtime — loads scripts, polls market data, dispatches events
|
|
2
|
+
// Supports real-time WebSocket feeds with REST polling as fallback heartbeat
|
|
2
3
|
|
|
3
4
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
4
5
|
import path from 'path';
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
roundPrice, roundSize, sleep, normalizeCoin,
|
|
10
11
|
formatUsd, formatPercent, annualizeFundingRate,
|
|
11
12
|
} from '../core/utils.js';
|
|
13
|
+
import { WebSocketManager } from '../core/ws.js';
|
|
12
14
|
import { AutomationEventBus } from './events.js';
|
|
13
15
|
import { loadAutomation } from './loader.js';
|
|
14
16
|
import { registerAutomation, unregisterAutomation, markAutomationError, getRegisteredAutomations as getRegisteredFromFile } from './registry.js';
|
|
@@ -233,6 +235,13 @@ export interface RuntimeOptions {
|
|
|
233
235
|
hooksToken?: string;
|
|
234
236
|
/** Pre-seed state before the factory function runs (e.g. from --set key=value) */
|
|
235
237
|
initialState?: Record<string, unknown>;
|
|
238
|
+
/**
|
|
239
|
+
* Enable WebSocket for real-time events (allMids, orderUpdates, userFills, userEvents).
|
|
240
|
+
* When enabled, REST polling interval is relaxed to a heartbeat (default 60s).
|
|
241
|
+
* Falls back gracefully to polling if WebSocket connection fails.
|
|
242
|
+
* @default true
|
|
243
|
+
*/
|
|
244
|
+
useWebSocket?: boolean;
|
|
236
245
|
}
|
|
237
246
|
|
|
238
247
|
/** Registry of all running automations */
|
|
@@ -254,12 +263,16 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
|
|
|
254
263
|
scriptPath,
|
|
255
264
|
dryRun = false,
|
|
256
265
|
verbose = false,
|
|
257
|
-
pollIntervalMs = 10_000,
|
|
258
266
|
gatewayPort,
|
|
259
267
|
hooksToken,
|
|
260
268
|
initialState,
|
|
269
|
+
useWebSocket = true,
|
|
261
270
|
} = options;
|
|
262
271
|
|
|
272
|
+
// When WebSocket is enabled, REST poll becomes a heartbeat (30s default)
|
|
273
|
+
// When disabled, use the original 10s polling interval
|
|
274
|
+
const pollIntervalMs = options.pollIntervalMs ?? (useWebSocket ? 30_000 : 10_000);
|
|
275
|
+
|
|
263
276
|
const id = options.id || path.basename(scriptPath, '.ts');
|
|
264
277
|
|
|
265
278
|
if (registry.has(id)) {
|
|
@@ -317,7 +330,7 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
|
|
|
317
330
|
}
|
|
318
331
|
}
|
|
319
332
|
|
|
320
|
-
// Polling state
|
|
333
|
+
// Polling state (declared early so WebSocket handlers can reference)
|
|
321
334
|
let previousSnapshot: AutomationSnapshot | null = null;
|
|
322
335
|
let pollCount = 0;
|
|
323
336
|
let eventsEmitted = 0;
|
|
@@ -333,6 +346,110 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
|
|
|
333
346
|
}
|
|
334
347
|
}
|
|
335
348
|
|
|
349
|
+
// ── WebSocket setup ─────────────────────────────────────────────
|
|
350
|
+
let ws: WebSocketManager | null = null;
|
|
351
|
+
let wsConnected = false;
|
|
352
|
+
// Track latest prices from WebSocket for real-time price_change events
|
|
353
|
+
let wsPrices = new Map<string, number>();
|
|
354
|
+
|
|
355
|
+
if (useWebSocket) {
|
|
356
|
+
try {
|
|
357
|
+
ws = new WebSocketManager(verbose);
|
|
358
|
+
|
|
359
|
+
// Wire WebSocket events to the automation event bus
|
|
360
|
+
ws.on('allMids', ({ mids }) => {
|
|
361
|
+
const now = Date.now();
|
|
362
|
+
for (const [coin, mid] of Object.entries(mids)) {
|
|
363
|
+
const newPrice = parseFloat(mid);
|
|
364
|
+
if (isNaN(newPrice) || newPrice === 0) continue;
|
|
365
|
+
const oldPrice = wsPrices.get(coin);
|
|
366
|
+
wsPrices.set(coin, newPrice);
|
|
367
|
+
|
|
368
|
+
if (oldPrice !== undefined && oldPrice !== 0 && eventBus.has('price_change')) {
|
|
369
|
+
const changePct = ((newPrice - oldPrice) / oldPrice) * 100;
|
|
370
|
+
if (Math.abs(changePct) >= 0.01) {
|
|
371
|
+
eventBus.emit('price_change', { coin, oldPrice, newPrice, changePct })
|
|
372
|
+
.then(errors => { if (errors.length) handleErrors(errors); });
|
|
373
|
+
eventsEmitted++;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
ws.on('orderUpdate', (update) => {
|
|
380
|
+
if (eventBus.has('order_update')) {
|
|
381
|
+
eventBus.emit('order_update', {
|
|
382
|
+
coin: update.order.coin,
|
|
383
|
+
oid: update.order.oid,
|
|
384
|
+
side: update.order.side === 'B' ? 'buy' : 'sell',
|
|
385
|
+
size: parseFloat(update.order.sz),
|
|
386
|
+
price: parseFloat(update.order.limitPx),
|
|
387
|
+
origSize: parseFloat(update.order.origSz),
|
|
388
|
+
status: update.status,
|
|
389
|
+
statusTimestamp: update.statusTimestamp,
|
|
390
|
+
}).then(errors => { if (errors.length) handleErrors(errors); });
|
|
391
|
+
eventsEmitted++;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Also emit order_filled for backward compatibility
|
|
395
|
+
if (update.status === 'filled' && eventBus.has('order_filled')) {
|
|
396
|
+
eventBus.emit('order_filled', {
|
|
397
|
+
coin: update.order.coin,
|
|
398
|
+
oid: update.order.oid,
|
|
399
|
+
side: update.order.side === 'B' ? 'buy' : 'sell',
|
|
400
|
+
size: parseFloat(update.order.sz),
|
|
401
|
+
price: parseFloat(update.order.limitPx),
|
|
402
|
+
}).then(errors => { if (errors.length) handleErrors(errors); });
|
|
403
|
+
eventsEmitted++;
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
ws.on('userFill', (fill) => {
|
|
408
|
+
// userFill events are already covered by order_update with status=filled
|
|
409
|
+
// But this provides the realized PnL and fee data that order_update doesn't have
|
|
410
|
+
log.debug(`Fill: ${fill.side === 'B' ? 'BUY' : 'SELL'} ${fill.sz} ${fill.coin} @ ${fill.px} (PnL: ${fill.closedPnl})`);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
ws.on('userEvent', (event) => {
|
|
414
|
+
// Handle liquidation events — only available through WebSocket
|
|
415
|
+
if ('liquidation' in event && eventBus.has('liquidation')) {
|
|
416
|
+
const liq = event.liquidation;
|
|
417
|
+
eventBus.emit('liquidation', {
|
|
418
|
+
lid: liq.lid,
|
|
419
|
+
liquidator: liq.liquidator,
|
|
420
|
+
liquidatedUser: liq.liquidated_user,
|
|
421
|
+
liquidatedNtlPos: parseFloat(liq.liquidated_ntl_pos),
|
|
422
|
+
liquidatedAccountValue: parseFloat(liq.liquidated_account_value),
|
|
423
|
+
}).then(errors => { if (errors.length) handleErrors(errors); });
|
|
424
|
+
eventsEmitted++;
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
ws.on('error', ({ error }) => {
|
|
429
|
+
log.warn(`WebSocket error: ${error.message}`);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
ws.on('disconnected', () => {
|
|
433
|
+
wsConnected = false;
|
|
434
|
+
log.warn('WebSocket disconnected — falling back to REST polling');
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
ws.on('connected', () => {
|
|
438
|
+
wsConnected = true;
|
|
439
|
+
log.info('WebSocket connected — real-time events active');
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
// Connect and subscribe
|
|
443
|
+
const userAddress = rawClient.address as `0x${string}`;
|
|
444
|
+
await ws.subscribeAll(userAddress);
|
|
445
|
+
log.info('WebSocket subscriptions active (allMids, orderUpdates, userFills, userEvents)');
|
|
446
|
+
} catch (err) {
|
|
447
|
+
log.warn(`WebSocket setup failed: ${err instanceof Error ? err.message : String(err)} — using REST polling only`);
|
|
448
|
+
ws = null;
|
|
449
|
+
wsConnected = false;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
336
453
|
async function poll() {
|
|
337
454
|
if (isPolling || stopped) return;
|
|
338
455
|
isPolling = true;
|
|
@@ -348,8 +465,8 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
|
|
|
348
465
|
eventsEmitted++;
|
|
349
466
|
|
|
350
467
|
if (previousSnapshot) {
|
|
351
|
-
// Price changes
|
|
352
|
-
if (eventBus.has('price_change')) {
|
|
468
|
+
// Price changes (skip when WebSocket is handling real-time prices)
|
|
469
|
+
if (eventBus.has('price_change') && !wsConnected) {
|
|
353
470
|
for (const [coin, newPrice] of snapshot.prices) {
|
|
354
471
|
const oldPrice = previousSnapshot.prices.get(coin);
|
|
355
472
|
if (oldPrice === undefined || oldPrice === 0) continue;
|
|
@@ -485,7 +602,8 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
|
|
|
485
602
|
}
|
|
486
603
|
|
|
487
604
|
// Start polling
|
|
488
|
-
|
|
605
|
+
const wsLabel = wsConnected ? ', ws=on' : (useWebSocket ? ', ws=failed' : '');
|
|
606
|
+
log.info(`Started (poll every ${pollIntervalMs / 1000}s, dry=${dryRun}${wsLabel})`);
|
|
489
607
|
const timer = setInterval(poll, pollIntervalMs);
|
|
490
608
|
|
|
491
609
|
// Initial poll to seed state
|
|
@@ -497,6 +615,13 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
|
|
|
497
615
|
stopped = true;
|
|
498
616
|
clearInterval(timer);
|
|
499
617
|
|
|
618
|
+
// Close WebSocket
|
|
619
|
+
if (ws) {
|
|
620
|
+
ws.removeAllListeners();
|
|
621
|
+
await ws.close();
|
|
622
|
+
ws = null;
|
|
623
|
+
}
|
|
624
|
+
|
|
500
625
|
for (const hook of stopHooks) {
|
|
501
626
|
try { await hook(); } catch (err) {
|
|
502
627
|
log.error(`onStop hook error: ${err instanceof Error ? err.message : String(err)}`);
|
package/scripts/auto/types.ts
CHANGED
|
@@ -33,7 +33,9 @@ export type AutomationEventType =
|
|
|
33
33
|
| 'position_changed'
|
|
34
34
|
| 'pnl_threshold'
|
|
35
35
|
| 'margin_warning'
|
|
36
|
-
| 'order_filled'
|
|
36
|
+
| 'order_filled'
|
|
37
|
+
| 'order_update'
|
|
38
|
+
| 'liquidation';
|
|
37
39
|
|
|
38
40
|
export interface AutomationEventPayloads {
|
|
39
41
|
tick: { timestamp: number; pollCount: number };
|
|
@@ -45,6 +47,25 @@ export interface AutomationEventPayloads {
|
|
|
45
47
|
pnl_threshold: { coin: string; unrealizedPnl: number; changePct: number; positionValue: number };
|
|
46
48
|
margin_warning: { marginUsedPct: number; equity: number; marginUsed: number };
|
|
47
49
|
order_filled: { coin: string; oid: number; side: 'buy' | 'sell'; size: number; price: number };
|
|
50
|
+
/** Real-time order lifecycle event via WebSocket (filled, canceled, rejected, triggered, etc.) */
|
|
51
|
+
order_update: {
|
|
52
|
+
coin: string;
|
|
53
|
+
oid: number;
|
|
54
|
+
side: 'buy' | 'sell';
|
|
55
|
+
size: number;
|
|
56
|
+
price: number;
|
|
57
|
+
origSize: number;
|
|
58
|
+
status: string;
|
|
59
|
+
statusTimestamp: number;
|
|
60
|
+
};
|
|
61
|
+
/** Liquidation event via WebSocket — only source for liquidation alerts */
|
|
62
|
+
liquidation: {
|
|
63
|
+
lid: number;
|
|
64
|
+
liquidator: string;
|
|
65
|
+
liquidatedUser: string;
|
|
66
|
+
liquidatedNtlPos: number;
|
|
67
|
+
liquidatedAccountValue: number;
|
|
68
|
+
};
|
|
48
69
|
}
|
|
49
70
|
|
|
50
71
|
export type AutomationEventHandler<E extends AutomationEventType> =
|