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 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.73", "openclaw": {"requires": {"bins": ["openbroker"], "env": ["HYPERLIQUID_PRIVATE_KEY"]}, "primaryEnv": "HYPERLIQUID_PRIVATE_KEY", "install": [{"id": "node", "kind": "node", "package": "openbroker", "bins": ["openbroker"], "label": "Install openbroker (npm)"}]}}
8
- allowed-tools: ob_account ob_positions ob_funding ob_markets ob_search ob_spot ob_fills ob_orders ob_order_status ob_fees ob_candles ob_funding_history ob_trades ob_rate_limit ob_funding_scan ob_buy ob_sell ob_limit ob_trigger ob_tpsl ob_cancel ob_twap ob_twap_cancel ob_twap_status ob_bracket ob_chase ob_watcher_status ob_auto_run ob_auto_stop ob_auto_list Bash(openbroker:*)
7
+ metadata: {"author": "monemetrics", "version": "1.0.79", "openclaw": {"requires": {"bins": ["openbroker"], "env": ["HYPERLIQUID_PRIVATE_KEY"]}, "primaryEnv": "HYPERLIQUID_PRIVATE_KEY", "install": [{"id": "node", "kind": "node", "package": "openbroker", "bins": ["openbroker"], "label": "Install openbroker (npm)"}]}}
8
+ allowed-tools: ob_account ob_positions ob_funding ob_markets ob_search ob_spot ob_fills ob_orders ob_order_status ob_fees ob_candles ob_funding_history ob_trades ob_rate_limit ob_funding_scan ob_buy ob_sell ob_limit ob_trigger ob_tpsl ob_cancel ob_spot_buy ob_spot_sell ob_twap ob_twap_cancel ob_twap_status ob_bracket ob_chase ob_watcher_status ob_auto_run ob_auto_stop ob_auto_list Bash(openbroker:*)
9
9
  ---
10
10
 
11
11
  # Open Broker - Hyperliquid Trading CLI
@@ -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 polls Hyperliquid every 10s (configurable) and dispatches events when changes are detected.
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` | Provides relative change data between polls |
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') {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "openbroker",
3
3
  "name": "OpenBroker — Hyperliquid Trading",
4
- "version": "1.0.73",
4
+ "version": "1.0.79",
5
5
  "description": "Trade on Hyperliquid DEX with background position monitoring",
6
6
  "configSchema": {
7
7
  "type": "object",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openbroker",
3
- "version": "1.0.73",
3
+ "version": "1.0.79",
4
4
  "description": "Hyperliquid trading CLI - execute orders, manage positions, and run trading strategies",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 pollIntervalMs = args.poll ? parseInt(String(args.poll), 10) : 10_000;
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
+ }
@@ -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
- log.info(`Started (poll every ${pollIntervalMs / 1000}s, dry=${dryRun})`);
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)}`);
@@ -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> =