openbroker 1.9.3 → 1.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to Open Broker will be documented in this file.
4
4
 
5
+ ## [1.9.3] - 2026-06-23
6
+
7
+ ### Changed
8
+ - Made automation reads WebSocket-first for mids, asset contexts, account state, spot balances, per-dex open orders, and lazily subscribed L2 books, with transparent REST fallback.
9
+ - Activated the existing all-dex context, all-dex clearinghouse, and spot-state subscriptions before strategy `onStart` hooks.
10
+ - Decoupled `api.every()` from REST snapshot polling. Short `--poll` values now control the disconnected fallback cadence; connected runtimes reconcile over REST at most once per minute.
11
+ - Cached and request-de-duplicated the REST-only predicted-funding feed for 60 seconds, retaining the last good value through transient rate limits.
12
+ - Limited automation startup metadata to the native universe and deferred HIP-3 metadata until a prefixed market is actually referenced.
13
+
14
+ ### Added
15
+ - Added realtime-cache tests covering live market/account reads, lazy order-book seeding, and disconnected REST fallback.
16
+
5
17
  ## [1.9.2] - 2026-06-22
6
18
 
7
19
  ### Breaking
package/README.md CHANGED
@@ -764,6 +764,22 @@ openbroker approve-builder --max-fee "0.05%" # Custom max fee
764
764
  | `--builder` | Custom builder address (advanced) | Open Broker |
765
765
  | `--verbose` | Show debug output | — |
766
766
 
767
+ ## Automation Runtime
768
+
769
+ Run audited TypeScript automations with required runtime guardrails:
770
+
771
+ ```bash
772
+ openbroker auto run ./my-automation.ts --id my-auto --set coin=HYPE
773
+ ```
774
+
775
+ WebSocket mode is enabled by default. Before an automation's `onStart` hooks run, OpenBroker subscribes to live mids, all-dex asset contexts, all-dex account state, spot balances, per-dex open-order snapshots, order updates, fills, and user events. L2 books are subscribed lazily per requested coin.
776
+
777
+ Existing strategies continue to use `api.client`. Inside the automation runtime, `getAllMids()`, `getMetaAndAssetCtxs()`, `getUserState()`, `getUserStateAll()`, `getSpotBalances()`, `getOpenOrders()`, and `getL2Book()` read the live WebSocket cache first and automatically fall back to REST when the socket is unavailable or a feed has not seeded. `getPredictedFundings()` has no socket equivalent, so it is request-de-duplicated, cached for 60 seconds, and serves the last successful value through transient rate limits.
778
+
779
+ `api.every(intervalMs, handler)` uses an independent scheduler and does not trigger a REST snapshot. `--poll` controls the disconnected REST fallback cadence; while WebSocket is healthy, full REST reconciliation runs no more than once per minute even when an older launch command passes a shorter value such as `--poll 5000`. Use `--no-ws` only when you intentionally need REST-only behavior.
780
+
781
+ Every automation must export `guardrails` plus its default factory. See [SKILL.md](./SKILL.md) for the complete schema, WebSocket event model, and runtime enforcement rules.
782
+
767
783
  ## OpenClaw Plugin
768
784
 
769
785
  OpenBroker ships as an [OpenClaw](https://openclaw.ai) plugin. When installed via OpenClaw, it registers structured agent tools and a background position watcher — no Bash wrappers needed.
package/SKILL.md CHANGED
@@ -213,7 +213,7 @@ Run flags:
213
213
  |---|---|
214
214
  | `--set key=value` | Repeatable typed config values |
215
215
  | `--id <name>` | Stable automation ID |
216
- | `--poll <ms>` | Poll interval, minimum 1000 ms |
216
+ | `--poll <ms>` | REST fallback interval, minimum 1000 ms. While WebSocket is healthy, REST reconciliation is capped at once per minute. |
217
217
  | `--dry` | Intercept write methods |
218
218
  | `--no-ws` | Disable WebSocket and rely on REST polling |
219
219
  | `--allow-sleep` | Do not request OS sleep inhibition |
@@ -279,6 +279,23 @@ Treat `api.client` as the only supported execution path. Automation files are tr
279
279
 
280
280
  Core events include `tick`, `price_change`, `funding_update`, `position_opened`, `position_closed`, `position_changed`, `pnl_threshold`, `margin_warning`, `order_filled`, `order_update`, and `liquidation`.
281
281
 
282
+ ### WebSocket-first runtime
283
+
284
+ WebSocket mode is enabled by default. Before `onStart`, the runtime subscribes to:
285
+
286
+ - `allMids` for instant perp and spot prices;
287
+ - `allDexsAssetCtxs` for funding, mark, oracle, open-interest, and premium changes;
288
+ - `allDexsClearinghouseState` plus `spotState` for positions, margin, collateral, and balances;
289
+ - `openOrders` for the native dex and every active HIP-3 dex so guardrail order-count checks stay live;
290
+ - `orderUpdates`, `userFills`, and `userEvents` for order lifecycle, fills, funding payments, and liquidations;
291
+ - `l2Book` lazily, the first time an automation requests a book for a coin.
292
+
293
+ The normal `api.client` read methods are WebSocket-aware inside automations. `getAllMids()`, `getMetaAndAssetCtxs()`, `getUserState()`, `getUserStateAll()`, `getSpotBalances()`, `getOpenOrders()`, and `getL2Book()` return fresh socket data when available and transparently fall back to REST when the socket is disconnected, a subscription has not seeded yet, or live market data is stale. Automation code should keep using `api.client`; do not import a second exchange SDK or create a second socket.
294
+
295
+ `api.every(intervalMs, handler)` runs on an independent scheduler and no longer causes a REST snapshot. `--poll` controls the disconnected fallback cadence. While the socket is healthy, the runtime performs a REST reconciliation no more than once per minute, even when an older launch command passes `--poll 5000`. Predicted cross-venue funding has no equivalent socket feed, so `getPredictedFundings()` is de-duplicated, cached for 60 seconds, and serves the last good value if a refresh is temporarily rate-limited.
296
+
297
+ Use `--no-ws` only for debugging or networks that cannot maintain WebSockets. In that mode, `--poll` is the active REST cadence and event latency follows it.
298
+
282
299
  ### Monitoring and dashboard
283
300
 
284
301
  `openbroker-monitoring` is optional but useful for long-running automations, live debugging, and post-run inspection.
@@ -300,7 +317,7 @@ These matter more than boilerplate:
300
317
 
301
318
  1. **Model the strategy as a state machine.** Persist flags, streaks, targets, and recovery state with `api.state`; handlers can fire repeatedly and processes can restart.
302
319
  2. **Use hysteresis, not one-print decisions.** Confirmation loops, separate enter/exit thresholds, and debounce logic prevent churn from noisy funding or tiny price moves.
303
- 3. **Use the freshest correct signal.** For funding strategies, prefer `getPredictedFundings()` when available; if you fall back to instantaneous funding from metadata, ensure the metadata cache is refreshed so the signal does not freeze after startup.
320
+ 3. **Use the freshest correct signal.** Instantaneous funding and prices are WebSocket-backed by default. Prefer `getPredictedFundings()` for cross-venue forecasts; the runtime refreshes that REST-only signal at most once per minute and retains the last good value through transient rate limits.
304
321
  4. **Price the flip, not just the signal.** Before closing and later reopening a carry, compare expected hold cost with round-trip trading cost. The HYPE carry automation counts maker fees across both legs plus builder fees before deciding whether a mildly negative funding window is worth exiting.
305
322
  5. **Respect settlement timing.** If the current predicted funding is still positive, a close right before hourly settlement can be economically wrong even when the broader signal weakened. Add a settlement-proximity guard when the strategy depends on funding capture.
306
323
  6. **Sequence multi-leg hedges deliberately.** For spot-long / perp-short carry, build spot first, then short only up to spot-backed exposure; unwind spot first, then close the short reduce-only. Recover accidental one-sided exposure explicitly instead of pretending it cannot happen.
@@ -312,7 +329,7 @@ These matter more than boilerplate:
312
329
  Additional practical caveats:
313
330
 
314
331
  - Positive-funding carry and negative-funding carry are not automatically symmetric. If the hedge requires short spot and the client/runtime cannot express that safely, do not invent an unhedged mirror trade.
315
- - `funding_update` fires for many assets every poll; filter by coin early.
332
+ - `funding_update` is emitted from changed WebSocket asset contexts and may cover many assets; filter by coin early.
316
333
  - Dust matters: if residual size falls below exchange precision or `minTradeUsd`, stop chasing it.
317
334
  - `ALO` / post-only orders can be rejected when they would cross; treat that as an execution branch, not a surprise.
318
335
  - Naked directional positions usually need explicit TP/SL or equivalent risk logic. Hedged multi-leg strategies need strategy-specific exits instead of cargo-cult TP/SL rules.
package/dist/auto/cli.js CHANGED
@@ -29,7 +29,7 @@ Options (for run):
29
29
  --dry Intercept write methods (no real trades)
30
30
  --verbose Show debug output
31
31
  --id <name> Custom automation ID (default: filename)
32
- --poll <ms> Poll interval in milliseconds (default: 10000)
32
+ --poll <ms> REST fallback interval (default: 30000 with WS, 10000 with --no-ws)
33
33
  --no-ws Disable WebSocket; fall back to REST-only polling
34
34
  --allow-sleep Do not request OS idle-sleep inhibition for this run
35
35
 
@@ -74,7 +74,7 @@ export default function priceAlert(api) {
74
74
  api.log.error(`LIQUIDATION: $${liquidatedNtlPos.toFixed(2)} notional, account value: $${liquidatedAccountValue.toFixed(2)}`);
75
75
  api.publish(`LIQUIDATED: $${liquidatedNtlPos.toFixed(2)} notional, account value: $${liquidatedAccountValue.toFixed(2)}`, { name: 'liquidation-alert' });
76
76
  });
77
- // Periodic summary via REST heartbeat
77
+ // Periodic summary via the independent runtime scheduler
78
78
  api.on('tick', ({ pollCount }) => {
79
79
  if (pollCount % 10 === 0 && alertCount > 0) {
80
80
  api.log.info(`Summary: ${alertCount} alerts fired, last price: $${lastAlertPrice.toFixed(2)}`);
@@ -0,0 +1,45 @@
1
+ import type { AssetCtx, ClearinghouseState, OpenOrder } from '../core/types.js';
2
+ import type { HyperliquidClient, RealtimeBookSnapshot, RealtimeDataProvider } from '../core/client.js';
3
+ import { WebSocketManager, type WsEventMap } from '../core/ws.js';
4
+ /**
5
+ * Runtime-owned WebSocket cache used transparently by HyperliquidClient read
6
+ * methods. While the socket is healthy, automation code calling getAllMids,
7
+ * getUserState(All), getSpotBalances, getMetaAndAssetCtxs, or getL2Book reads
8
+ * this cache. Missing/stale data returns null so the client falls back to REST.
9
+ */
10
+ export declare class AutomationRealtimeData implements RealtimeDataProvider {
11
+ private readonly ws;
12
+ private readonly client;
13
+ private readonly user;
14
+ private readonly unified;
15
+ private readonly expectedOrderDexes;
16
+ private mids;
17
+ private assetCtxs;
18
+ private clearinghouse;
19
+ private spot;
20
+ private books;
21
+ private openOrders;
22
+ private bookSubscriptions;
23
+ private bookWaiters;
24
+ constructor(ws: WebSocketManager, client: HyperliquidClient, user: string, unified: boolean | null, expectedOrderDexes?: string[]);
25
+ get connected(): boolean;
26
+ getAllMids(): Record<string, string> | null;
27
+ getMainAssetCtxs(): AssetCtx[] | null;
28
+ getAllDexsAssetCtxs(): WsEventMap['allDexsAssetCtxs']['ctxs'] | null;
29
+ getUserState(user: string, dex?: string): ClearinghouseState | null;
30
+ getUserStateAll(user: string): ClearinghouseState | null;
31
+ getSpotBalances(user: string): ReturnType<RealtimeDataProvider['getSpotBalances']>;
32
+ getOpenOrders(user: string): OpenOrder[] | null;
33
+ getL2Book(coin: string): Promise<RealtimeBookSnapshot | null>;
34
+ /** Wait briefly for initial subscription snapshots before the first strategy hook runs. */
35
+ waitUntilReady(timeoutMs?: number): Promise<boolean>;
36
+ readinessSummary(): {
37
+ expectedOrderDexes: number;
38
+ seededOrderDexes: number;
39
+ missingOrderDexes: string[];
40
+ };
41
+ private coreFeedsReady;
42
+ private sameUser;
43
+ private fresh;
44
+ }
45
+ //# sourceMappingURL=realtime.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"realtime.d.ts","sourceRoot":"","sources":["../../scripts/auto/realtime.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,kBAAkB,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAChF,OAAO,KAAK,EACV,iBAAiB,EACjB,oBAAoB,EACpB,oBAAoB,EACrB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,gBAAgB,EAAE,KAAK,UAAU,EAAE,MAAM,eAAe,CAAC;AAWlE;;;;;GAKG;AACH,qBAAa,sBAAuB,YAAW,oBAAoB;IAW/D,OAAO,CAAC,QAAQ,CAAC,EAAE;IACnB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,kBAAkB;IAdrC,OAAO,CAAC,IAAI,CAA8C;IAC1D,OAAO,CAAC,SAAS,CAA8D;IAC/E,OAAO,CAAC,aAAa,CAAsF;IAC3G,OAAO,CAAC,IAAI,CAA+C;IAC3D,OAAO,CAAC,KAAK,CAAkD;IAC/D,OAAO,CAAC,UAAU,CAAyC;IAC3D,OAAO,CAAC,iBAAiB,CAAqB;IAC9C,OAAO,CAAC,WAAW,CAAsC;gBAGtC,EAAE,EAAE,gBAAgB,EACpB,MAAM,EAAE,iBAAiB,EACzB,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,OAAO,GAAG,IAAI,EACvB,kBAAkB,GAAE,MAAM,EAAS;IAqBtD,IAAI,SAAS,IAAI,OAAO,CAEvB;IAED,UAAU,IAAI,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI;IAI3C,gBAAgB,IAAI,QAAQ,EAAE,GAAG,IAAI;IAOrC,mBAAmB,IAAI,UAAU,CAAC,kBAAkB,CAAC,CAAC,MAAM,CAAC,GAAG,IAAI;IAIpE,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,kBAAkB,GAAG,IAAI;IAmBnE,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,kBAAkB,GAAG,IAAI;IASxD,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,CAAC,oBAAoB,CAAC,iBAAiB,CAAC,CAAC;IAUlF,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,EAAE,GAAG,IAAI;IAWzC,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,oBAAoB,GAAG,IAAI,CAAC;IAiCnE,2FAA2F;IACrF,cAAc,CAAC,SAAS,SAAS,GAAG,OAAO,CAAC,OAAO,CAAC;IAS1D,gBAAgB,IAAI;QAAE,kBAAkB,EAAE,MAAM,CAAC;QAAC,gBAAgB,EAAE,MAAM,CAAC;QAAC,iBAAiB,EAAE,MAAM,EAAE,CAAA;KAAE;IASzG,OAAO,CAAC,cAAc;IAMtB,OAAO,CAAC,QAAQ;IAIhB,OAAO,CAAC,KAAK;CAId"}
@@ -0,0 +1,177 @@
1
+ const MARKET_DATA_STALE_MS = 45_000;
2
+ const BOOK_STALE_MS = 10_000;
3
+ const BOOK_SEED_TIMEOUT_MS = 1_500;
4
+ /**
5
+ * Runtime-owned WebSocket cache used transparently by HyperliquidClient read
6
+ * methods. While the socket is healthy, automation code calling getAllMids,
7
+ * getUserState(All), getSpotBalances, getMetaAndAssetCtxs, or getL2Book reads
8
+ * this cache. Missing/stale data returns null so the client falls back to REST.
9
+ */
10
+ export class AutomationRealtimeData {
11
+ ws;
12
+ client;
13
+ user;
14
+ unified;
15
+ expectedOrderDexes;
16
+ mids = null;
17
+ assetCtxs = null;
18
+ clearinghouse = null;
19
+ spot = null;
20
+ books = new Map();
21
+ openOrders = new Map();
22
+ bookSubscriptions = new Set();
23
+ bookWaiters = new Map();
24
+ constructor(ws, client, user, unified, expectedOrderDexes = ['']) {
25
+ this.ws = ws;
26
+ this.client = client;
27
+ this.user = user;
28
+ this.unified = unified;
29
+ this.expectedOrderDexes = expectedOrderDexes;
30
+ ws.on('allMids', (data) => { this.mids = { value: data.mids, timestamp: Date.now() }; });
31
+ ws.on('allDexsAssetCtxs', (data) => { this.assetCtxs = { value: data.ctxs, timestamp: Date.now() }; });
32
+ ws.on('allDexsClearinghouseState', (data) => {
33
+ this.clearinghouse = { value: data.clearinghouseStates, timestamp: Date.now() };
34
+ });
35
+ ws.on('spotState', (data) => { this.spot = { value: data, timestamp: Date.now() }; });
36
+ ws.on('openOrders', (data) => {
37
+ if (this.sameUser(data.user))
38
+ this.openOrders.set(data.dex || '', { value: data.orders, timestamp: Date.now() });
39
+ });
40
+ ws.on('l2Book', (data) => {
41
+ this.books.set(data.coin, { value: data, timestamp: Date.now() });
42
+ const waiters = this.bookWaiters.get(data.coin);
43
+ if (waiters) {
44
+ for (const resolve of waiters)
45
+ resolve();
46
+ waiters.clear();
47
+ }
48
+ });
49
+ }
50
+ get connected() {
51
+ return this.ws.connected;
52
+ }
53
+ getAllMids() {
54
+ return this.fresh(this.mids, MARKET_DATA_STALE_MS)?.value ?? null;
55
+ }
56
+ getMainAssetCtxs() {
57
+ const groups = this.fresh(this.assetCtxs, MARKET_DATA_STALE_MS)?.value;
58
+ if (!groups)
59
+ return null;
60
+ const main = groups.find(([dex]) => !dex || dex === 'main') ?? groups[0];
61
+ return main?.[1] ?? null;
62
+ }
63
+ getAllDexsAssetCtxs() {
64
+ return this.fresh(this.assetCtxs, MARKET_DATA_STALE_MS)?.value ?? null;
65
+ }
66
+ getUserState(user, dex) {
67
+ if (!this.sameUser(user))
68
+ return null;
69
+ const groups = this.clearinghouse?.value;
70
+ if (!groups)
71
+ return null;
72
+ const targetDex = dex ?? '';
73
+ const raw = groups.find(([name]) => (name || '') === targetDex)?.[1];
74
+ if (!raw)
75
+ return null;
76
+ const withdrawable = raw.withdrawable;
77
+ return {
78
+ ...raw,
79
+ marginSummary: raw.marginSummary && withdrawable != null
80
+ ? { ...raw.marginSummary, withdrawable }
81
+ : raw.marginSummary,
82
+ crossMarginSummary: raw.crossMarginSummary && withdrawable != null
83
+ ? { ...raw.crossMarginSummary, withdrawable }
84
+ : raw.crossMarginSummary,
85
+ };
86
+ }
87
+ getUserStateAll(user) {
88
+ if (!this.sameUser(user) || !this.clearinghouse || this.unified === null)
89
+ return null;
90
+ return this.client.userStateAllFromWs(this.clearinghouse.value, this.unified, this.spot ? { balances: this.spot.value.balances } : undefined);
91
+ }
92
+ getSpotBalances(user) {
93
+ if (!this.sameUser(user) || !this.spot)
94
+ return null;
95
+ return {
96
+ balances: this.spot.value.balances.map((balance) => ({
97
+ ...balance,
98
+ entryNtl: balance.entryNtl ?? '0',
99
+ })),
100
+ };
101
+ }
102
+ getOpenOrders(user) {
103
+ if (!this.sameUser(user) || !this.connected)
104
+ return null;
105
+ const orders = [];
106
+ for (const dex of new Set(['', ...this.expectedOrderDexes])) {
107
+ const entry = this.openOrders.get(dex);
108
+ if (!entry)
109
+ return null;
110
+ orders.push(...entry.value);
111
+ }
112
+ return orders;
113
+ }
114
+ async getL2Book(coin) {
115
+ const cached = this.fresh(this.books.get(coin) ?? null, BOOK_STALE_MS);
116
+ if (cached)
117
+ return cached.value;
118
+ if (!this.connected)
119
+ return null;
120
+ if (!this.bookSubscriptions.has(coin)) {
121
+ this.bookSubscriptions.add(coin);
122
+ try {
123
+ await this.ws.subscribeL2Book(coin);
124
+ }
125
+ catch {
126
+ this.bookSubscriptions.delete(coin);
127
+ return null;
128
+ }
129
+ }
130
+ const seeded = this.fresh(this.books.get(coin) ?? null, BOOK_STALE_MS);
131
+ if (seeded)
132
+ return seeded.value;
133
+ await new Promise((resolve) => {
134
+ const waiters = this.bookWaiters.get(coin) ?? new Set();
135
+ this.bookWaiters.set(coin, waiters);
136
+ const done = () => {
137
+ clearTimeout(timer);
138
+ waiters.delete(done);
139
+ resolve();
140
+ };
141
+ const timer = setTimeout(done, BOOK_SEED_TIMEOUT_MS);
142
+ waiters.add(done);
143
+ });
144
+ return this.fresh(this.books.get(coin) ?? null, BOOK_STALE_MS)?.value ?? null;
145
+ }
146
+ /** Wait briefly for initial subscription snapshots before the first strategy hook runs. */
147
+ async waitUntilReady(timeoutMs = 10_000) {
148
+ const deadline = Date.now() + timeoutMs;
149
+ while (Date.now() < deadline) {
150
+ if (this.coreFeedsReady())
151
+ return true;
152
+ await new Promise((resolve) => setTimeout(resolve, 25));
153
+ }
154
+ return this.coreFeedsReady();
155
+ }
156
+ readinessSummary() {
157
+ const expected = [...new Set(['', ...this.expectedOrderDexes])];
158
+ return {
159
+ expectedOrderDexes: expected.length,
160
+ seededOrderDexes: expected.filter((dex) => this.openOrders.has(dex)).length,
161
+ missingOrderDexes: expected.filter((dex) => !this.openOrders.has(dex)),
162
+ };
163
+ }
164
+ coreFeedsReady() {
165
+ const ordersReady = [...new Set(['', ...this.expectedOrderDexes])]
166
+ .every((dex) => this.openOrders.has(dex));
167
+ return Boolean(this.mids && this.assetCtxs && this.clearinghouse && this.spot && ordersReady);
168
+ }
169
+ sameUser(user) {
170
+ return user.toLowerCase() === this.user.toLowerCase();
171
+ }
172
+ fresh(entry, maxAgeMs) {
173
+ if (!this.connected || !entry || Date.now() - entry.timestamp > maxAgeMs)
174
+ return null;
175
+ return entry;
176
+ }
177
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=realtime.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"realtime.test.d.ts","sourceRoot":"","sources":["../../scripts/auto/realtime.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,73 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { AutomationRealtimeData } from './realtime.js';
4
+ class FakeWebSocket {
5
+ connected = true;
6
+ handlers = new Map();
7
+ on(event, handler) {
8
+ const set = this.handlers.get(event) ?? new Set();
9
+ set.add(handler);
10
+ this.handlers.set(event, set);
11
+ }
12
+ emit(event, value) {
13
+ for (const handler of this.handlers.get(event) ?? [])
14
+ handler(value);
15
+ }
16
+ async subscribeL2Book(coin) {
17
+ queueMicrotask(() => this.emit('l2Book', {
18
+ coin,
19
+ time: Date.now(),
20
+ levels: [
21
+ [{ px: '99', sz: '2', n: 1 }],
22
+ [{ px: '101', sz: '3', n: 1 }],
23
+ ],
24
+ }));
25
+ return {};
26
+ }
27
+ }
28
+ const ADDRESS = '0x0000000000000000000000000000000000000001';
29
+ test('realtime cache serves subscribed market/account data and seeds books on demand', async () => {
30
+ const ws = new FakeWebSocket();
31
+ const mergedState = {
32
+ assetPositions: [],
33
+ marginSummary: { accountValue: '100', totalNtlPos: '0', totalRawUsd: '100', totalMarginUsed: '0', withdrawable: '100' },
34
+ crossMarginSummary: { accountValue: '100', totalNtlPos: '0', totalRawUsd: '100', totalMarginUsed: '0', withdrawable: '100' },
35
+ crossMaintenanceMarginUsed: '0',
36
+ };
37
+ const client = {
38
+ userStateAllFromWs: () => mergedState,
39
+ };
40
+ const cache = new AutomationRealtimeData(ws, client, ADDRESS, true);
41
+ ws.emit('allMids', { mids: { HYPE: '100' } });
42
+ ws.emit('allDexsAssetCtxs', { ctxs: [['', [{ funding: '0.0001', openInterest: '1', dayNtlVlm: '2', premium: '0', oraclePx: '100', markPx: '100', prevDayPx: '99' }]]] });
43
+ ws.emit('spotState', { balances: [{ coin: 'USDC', token: 0, total: '100', hold: '0' }] });
44
+ ws.emit('openOrders', { user: ADDRESS, dex: '', orders: [] });
45
+ ws.emit('allDexsClearinghouseState', {
46
+ user: ADDRESS,
47
+ clearinghouseStates: [['', {
48
+ assetPositions: [],
49
+ marginSummary: { accountValue: '100', totalNtlPos: '0', totalRawUsd: '100', totalMarginUsed: '0', withdrawable: '100' },
50
+ crossMarginSummary: { accountValue: '100', totalNtlPos: '0', totalRawUsd: '100', totalMarginUsed: '0', withdrawable: '100' },
51
+ crossMaintenanceMarginUsed: '0',
52
+ withdrawable: '100',
53
+ }]],
54
+ });
55
+ assert.equal(await cache.waitUntilReady(20), true);
56
+ assert.equal(cache.getAllMids()?.HYPE, '100');
57
+ assert.equal(cache.getMainAssetCtxs()?.[0]?.funding, '0.0001');
58
+ assert.equal(cache.getSpotBalances(ADDRESS)?.balances[0]?.entryNtl, '0');
59
+ assert.deepEqual(cache.getOpenOrders(ADDRESS), []);
60
+ assert.equal(cache.getUserState(ADDRESS)?.marginSummary.accountValue, '100');
61
+ assert.equal(cache.getUserStateAll(ADDRESS)?.marginSummary.accountValue, '100');
62
+ const book = await cache.getL2Book('HYPE');
63
+ assert.equal(book?.levels[0][0]?.px, '99');
64
+ assert.equal(book?.levels[1][0]?.px, '101');
65
+ });
66
+ test('realtime cache declines reads while disconnected so the client can fall back to REST', () => {
67
+ const ws = new FakeWebSocket();
68
+ const client = { userStateAllFromWs: () => null };
69
+ const cache = new AutomationRealtimeData(ws, client, ADDRESS, false);
70
+ ws.emit('allMids', { mids: { HYPE: '100' } });
71
+ ws.connected = false;
72
+ assert.equal(cache.getAllMids(), null);
73
+ });
@@ -13,9 +13,9 @@ export interface RuntimeOptions {
13
13
  /** Pre-seed state before the factory function runs (e.g. from --set key=value) */
14
14
  initialState?: Record<string, unknown>;
15
15
  /**
16
- * Enable WebSocket for real-time events (allMids, orderUpdates, userFills, userEvents).
17
- * When enabled, REST polling interval is relaxed to a heartbeat (default 60s).
18
- * Falls back gracefully to polling if WebSocket connection fails.
16
+ * Enable WebSocket-first market/account data and events. REST is used for
17
+ * initial static metadata, minute reconciliation, and automatic fallback
18
+ * while the socket is unavailable.
19
19
  * @default true
20
20
  */
21
21
  useWebSocket?: boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../../scripts/auto/runtime.ts"],"names":[],"mappings":"AAgBA,OAAO,EAA4C,wBAAwB,IAAI,qBAAqB,EAAE,MAAM,eAAe,CAAC;AAG5H,OAAO,KAAK,EAYV,iBAAiB,EAElB,MAAM,YAAY,CAAC;AAqYpB,MAAM,WAAW,cAAc;IAC7B,UAAU,EAAE,MAAM,CAAC;IACnB,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,sFAAsF;IACtF,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,uEAAuE;IACvE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,kFAAkF;IAClF,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACvC;;;;;OAKG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB;;;OAGG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAKD,wBAAgB,qBAAqB,IAAI,iBAAiB,EAAE,CAE3D;AAED,wBAAgB,aAAa,CAAC,EAAE,EAAE,MAAM,GAAG,iBAAiB,GAAG,SAAS,CAEvE;AAED,8EAA8E;AAC9E,OAAO,EAAE,qBAAqB,IAAI,wBAAwB,EAAE,CAAC;AAE7D,wBAAsB,eAAe,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAijBzF"}
1
+ {"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../../scripts/auto/runtime.ts"],"names":[],"mappings":"AAgBA,OAAO,EAA4C,wBAAwB,IAAI,qBAAqB,EAAE,MAAM,eAAe,CAAC;AAG5H,OAAO,KAAK,EAYV,iBAAiB,EAElB,MAAM,YAAY,CAAC;AAqYpB,MAAM,WAAW,cAAc;IAC7B,UAAU,EAAE,MAAM,CAAC;IACnB,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,sFAAsF;IACtF,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,uEAAuE;IACvE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,kFAAkF;IAClF,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACvC;;;;;OAKG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB;;;OAGG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAKD,wBAAgB,qBAAqB,IAAI,iBAAiB,EAAE,CAE3D;AAED,wBAAgB,aAAa,CAAC,EAAE,EAAE,MAAM,GAAG,iBAAiB,GAAG,SAAS,CAEvE;AAED,8EAA8E;AAC9E,OAAO,EAAE,qBAAqB,IAAI,wBAAwB,EAAE,CAAC;AAE7D,wBAAsB,eAAe,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,iBAAiB,CAAC,CA+nBzF"}