openbroker 1.9.2 → 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.
Files changed (69) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +16 -0
  3. package/SKILL.md +69 -3
  4. package/dist/auto/cli.js +4 -1
  5. package/dist/auto/examples/dca.d.ts +2 -1
  6. package/dist/auto/examples/dca.d.ts.map +1 -1
  7. package/dist/auto/examples/dca.js +19 -1
  8. package/dist/auto/examples/funding-arb.d.ts +2 -1
  9. package/dist/auto/examples/funding-arb.d.ts.map +1 -1
  10. package/dist/auto/examples/funding-arb.js +19 -2
  11. package/dist/auto/examples/grid.d.ts +2 -1
  12. package/dist/auto/examples/grid.d.ts.map +1 -1
  13. package/dist/auto/examples/grid.js +18 -2
  14. package/dist/auto/examples/mm-maker.d.ts +2 -1
  15. package/dist/auto/examples/mm-maker.d.ts.map +1 -1
  16. package/dist/auto/examples/mm-maker.js +18 -2
  17. package/dist/auto/examples/mm-spread.d.ts +2 -1
  18. package/dist/auto/examples/mm-spread.d.ts.map +1 -1
  19. package/dist/auto/examples/mm-spread.js +18 -2
  20. package/dist/auto/examples/price-alert.d.ts +2 -1
  21. package/dist/auto/examples/price-alert.d.ts.map +1 -1
  22. package/dist/auto/examples/price-alert.js +2 -1
  23. package/dist/auto/guardrails.d.ts +19 -0
  24. package/dist/auto/guardrails.d.ts.map +1 -0
  25. package/dist/auto/guardrails.js +575 -0
  26. package/dist/auto/guardrails.test.d.ts +2 -0
  27. package/dist/auto/guardrails.test.d.ts.map +1 -0
  28. package/dist/auto/guardrails.test.js +173 -0
  29. package/dist/auto/loader.d.ts +3 -3
  30. package/dist/auto/loader.d.ts.map +1 -1
  31. package/dist/auto/loader.js +25 -3
  32. package/dist/auto/realtime.d.ts +45 -0
  33. package/dist/auto/realtime.d.ts.map +1 -0
  34. package/dist/auto/realtime.js +177 -0
  35. package/dist/auto/realtime.test.d.ts +2 -0
  36. package/dist/auto/realtime.test.d.ts.map +1 -0
  37. package/dist/auto/realtime.test.js +73 -0
  38. package/dist/auto/runtime.d.ts +3 -3
  39. package/dist/auto/runtime.d.ts.map +1 -1
  40. package/dist/auto/runtime.js +155 -65
  41. package/dist/auto/types.d.ts +45 -1
  42. package/dist/auto/types.d.ts.map +1 -1
  43. package/dist/core/client.d.ts +66 -1
  44. package/dist/core/client.d.ts.map +1 -1
  45. package/dist/core/client.js +141 -4
  46. package/dist/core/ws.d.ts +14 -2
  47. package/dist/core/ws.d.ts.map +1 -1
  48. package/dist/core/ws.js +53 -7
  49. package/dist/lib.d.ts +2 -0
  50. package/dist/lib.d.ts.map +1 -1
  51. package/dist/lib.js +1 -0
  52. package/package.json +5 -3
  53. package/scripts/auto/cli.ts +4 -1
  54. package/scripts/auto/examples/dca.ts +21 -2
  55. package/scripts/auto/examples/funding-arb.ts +21 -3
  56. package/scripts/auto/examples/grid.ts +20 -3
  57. package/scripts/auto/examples/mm-maker.ts +20 -3
  58. package/scripts/auto/examples/mm-spread.ts +20 -3
  59. package/scripts/auto/examples/price-alert.ts +4 -2
  60. package/scripts/auto/guardrails.test.ts +227 -0
  61. package/scripts/auto/guardrails.ts +700 -0
  62. package/scripts/auto/loader.ts +41 -4
  63. package/scripts/auto/realtime.test.ts +84 -0
  64. package/scripts/auto/realtime.ts +194 -0
  65. package/scripts/auto/runtime.ts +163 -69
  66. package/scripts/auto/types.ts +56 -1
  67. package/scripts/core/client.ts +175 -4
  68. package/scripts/core/ws.ts +57 -8
  69. package/scripts/lib.ts +10 -0
@@ -4,7 +4,14 @@ import { existsSync, readdirSync, mkdirSync } from 'fs';
4
4
  import path from 'path';
5
5
  import os from 'os';
6
6
  import { fileURLToPath } from 'url';
7
- import type { AutomationFactory, AutomationConfig } from './types.js';
7
+ import { resolveAutomationGuardrails } from './guardrails.js';
8
+ import type {
9
+ AutomationFactory,
10
+ AutomationConfig,
11
+ AutomationGuardrailContext,
12
+ AutomationGuardrailsExport,
13
+ LoadedAutomation,
14
+ } from './types.js';
8
15
 
9
16
  const __filename = fileURLToPath(import.meta.url);
10
17
  const __dirname = path.dirname(__filename);
@@ -117,8 +124,27 @@ function resolveAutomationConfig(mod: Record<string, unknown>): AutomationConfig
117
124
  return null;
118
125
  }
119
126
 
120
- /** Load an automation module and validate the default export */
121
- export async function loadAutomation(scriptPath: string): Promise<AutomationFactory> {
127
+ function resolveGuardrailsExport(mod: Record<string, unknown>): AutomationGuardrailsExport | null {
128
+ const candidates = [
129
+ mod.guardrails,
130
+ (mod.default as Record<string, unknown> | undefined)?.guardrails,
131
+ (mod["module.exports"] as Record<string, unknown> | undefined)?.guardrails,
132
+ ];
133
+
134
+ for (const candidate of candidates) {
135
+ if (candidate && (typeof candidate === 'object' || typeof candidate === 'function')) {
136
+ return candidate as AutomationGuardrailsExport;
137
+ }
138
+ }
139
+
140
+ return null;
141
+ }
142
+
143
+ /** Load an automation module and validate its factory plus required guardrails export. */
144
+ export async function loadAutomation(
145
+ scriptPath: string,
146
+ context: AutomationGuardrailContext = { config: {} },
147
+ ): Promise<LoadedAutomation> {
122
148
  const absolutePath = path.resolve(scriptPath);
123
149
 
124
150
  // Dynamic import — tsx handles TypeScript transpilation
@@ -132,7 +158,18 @@ export async function loadAutomation(scriptPath: string): Promise<AutomationFact
132
158
  );
133
159
  }
134
160
 
135
- return factory as AutomationFactory;
161
+ const guardrailsExport = resolveGuardrailsExport(mod as Record<string, unknown>);
162
+ if (!guardrailsExport) {
163
+ throw new Error(
164
+ `Automation script must export "guardrails".\n` +
165
+ `Use { mode: "read-only" } for monitoring-only scripts or a validated trading policy.`,
166
+ );
167
+ }
168
+
169
+ return {
170
+ factory: factory as AutomationFactory,
171
+ guardrails: resolveAutomationGuardrails(guardrailsExport, context),
172
+ };
136
173
  }
137
174
 
138
175
  /** List available automation scripts in ~/.openbroker/automations/ */
@@ -0,0 +1,84 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import type { HyperliquidClient } from '../core/client.js';
4
+ import type { WebSocketManager, WsEventMap, WsEventType } from '../core/ws.js';
5
+ import { AutomationRealtimeData } from './realtime.js';
6
+
7
+ class FakeWebSocket {
8
+ connected = true;
9
+ private handlers = new Map<WsEventType, Set<(value: unknown) => void>>();
10
+
11
+ on(event: WsEventType, handler: (value: unknown) => void): void {
12
+ const set = this.handlers.get(event) ?? new Set();
13
+ set.add(handler);
14
+ this.handlers.set(event, set);
15
+ }
16
+
17
+ emit<E extends WsEventType>(event: E, value: WsEventMap[E]): void {
18
+ for (const handler of this.handlers.get(event) ?? []) handler(value);
19
+ }
20
+
21
+ async subscribeL2Book(coin: string): Promise<unknown> {
22
+ queueMicrotask(() => this.emit('l2Book', {
23
+ coin,
24
+ time: Date.now(),
25
+ levels: [
26
+ [{ px: '99', sz: '2', n: 1 }],
27
+ [{ px: '101', sz: '3', n: 1 }],
28
+ ],
29
+ }));
30
+ return {};
31
+ }
32
+ }
33
+
34
+ const ADDRESS = '0x0000000000000000000000000000000000000001';
35
+
36
+ test('realtime cache serves subscribed market/account data and seeds books on demand', async () => {
37
+ const ws = new FakeWebSocket();
38
+ const mergedState = {
39
+ assetPositions: [],
40
+ marginSummary: { accountValue: '100', totalNtlPos: '0', totalRawUsd: '100', totalMarginUsed: '0', withdrawable: '100' },
41
+ crossMarginSummary: { accountValue: '100', totalNtlPos: '0', totalRawUsd: '100', totalMarginUsed: '0', withdrawable: '100' },
42
+ crossMaintenanceMarginUsed: '0',
43
+ };
44
+ const client = {
45
+ userStateAllFromWs: () => mergedState,
46
+ } as unknown as HyperliquidClient;
47
+ const cache = new AutomationRealtimeData(ws as unknown as WebSocketManager, client, ADDRESS, true);
48
+
49
+ ws.emit('allMids', { mids: { HYPE: '100' } });
50
+ ws.emit('allDexsAssetCtxs', { ctxs: [['', [{ funding: '0.0001', openInterest: '1', dayNtlVlm: '2', premium: '0', oraclePx: '100', markPx: '100', prevDayPx: '99' }]]] });
51
+ ws.emit('spotState', { balances: [{ coin: 'USDC', token: 0, total: '100', hold: '0' }] });
52
+ ws.emit('openOrders', { user: ADDRESS, dex: '', orders: [] });
53
+ ws.emit('allDexsClearinghouseState', {
54
+ user: ADDRESS,
55
+ clearinghouseStates: [['', {
56
+ assetPositions: [],
57
+ marginSummary: { accountValue: '100', totalNtlPos: '0', totalRawUsd: '100', totalMarginUsed: '0', withdrawable: '100' },
58
+ crossMarginSummary: { accountValue: '100', totalNtlPos: '0', totalRawUsd: '100', totalMarginUsed: '0', withdrawable: '100' },
59
+ crossMaintenanceMarginUsed: '0',
60
+ withdrawable: '100',
61
+ }]],
62
+ });
63
+
64
+ assert.equal(await cache.waitUntilReady(20), true);
65
+ assert.equal(cache.getAllMids()?.HYPE, '100');
66
+ assert.equal(cache.getMainAssetCtxs()?.[0]?.funding, '0.0001');
67
+ assert.equal(cache.getSpotBalances(ADDRESS)?.balances[0]?.entryNtl, '0');
68
+ assert.deepEqual(cache.getOpenOrders(ADDRESS), []);
69
+ assert.equal(cache.getUserState(ADDRESS)?.marginSummary.accountValue, '100');
70
+ assert.equal(cache.getUserStateAll(ADDRESS)?.marginSummary.accountValue, '100');
71
+
72
+ const book = await cache.getL2Book('HYPE');
73
+ assert.equal(book?.levels[0][0]?.px, '99');
74
+ assert.equal(book?.levels[1][0]?.px, '101');
75
+ });
76
+
77
+ test('realtime cache declines reads while disconnected so the client can fall back to REST', () => {
78
+ const ws = new FakeWebSocket();
79
+ const client = { userStateAllFromWs: () => null } as unknown as HyperliquidClient;
80
+ const cache = new AutomationRealtimeData(ws as unknown as WebSocketManager, client, ADDRESS, false);
81
+ ws.emit('allMids', { mids: { HYPE: '100' } });
82
+ ws.connected = false;
83
+ assert.equal(cache.getAllMids(), null);
84
+ });
@@ -0,0 +1,194 @@
1
+ import type { AssetCtx, ClearinghouseState, OpenOrder } from '../core/types.js';
2
+ import type {
3
+ HyperliquidClient,
4
+ RealtimeBookSnapshot,
5
+ RealtimeDataProvider,
6
+ } from '../core/client.js';
7
+ import { WebSocketManager, type WsEventMap } from '../core/ws.js';
8
+
9
+ interface Timed<T> {
10
+ value: T;
11
+ timestamp: number;
12
+ }
13
+
14
+ const MARKET_DATA_STALE_MS = 45_000;
15
+ const BOOK_STALE_MS = 10_000;
16
+ const BOOK_SEED_TIMEOUT_MS = 1_500;
17
+
18
+ /**
19
+ * Runtime-owned WebSocket cache used transparently by HyperliquidClient read
20
+ * methods. While the socket is healthy, automation code calling getAllMids,
21
+ * getUserState(All), getSpotBalances, getMetaAndAssetCtxs, or getL2Book reads
22
+ * this cache. Missing/stale data returns null so the client falls back to REST.
23
+ */
24
+ export class AutomationRealtimeData implements RealtimeDataProvider {
25
+ private mids: Timed<Record<string, string>> | null = null;
26
+ private assetCtxs: Timed<WsEventMap['allDexsAssetCtxs']['ctxs']> | null = null;
27
+ private clearinghouse: Timed<WsEventMap['allDexsClearinghouseState']['clearinghouseStates']> | null = null;
28
+ private spot: Timed<WsEventMap['spotState']> | null = null;
29
+ private books = new Map<string, Timed<RealtimeBookSnapshot>>();
30
+ private openOrders = new Map<string, Timed<OpenOrder[]>>();
31
+ private bookSubscriptions = new Set<string>();
32
+ private bookWaiters = new Map<string, Set<() => void>>();
33
+
34
+ constructor(
35
+ private readonly ws: WebSocketManager,
36
+ private readonly client: HyperliquidClient,
37
+ private readonly user: string,
38
+ private readonly unified: boolean | null,
39
+ private readonly expectedOrderDexes: string[] = [''],
40
+ ) {
41
+ ws.on('allMids', (data) => { this.mids = { value: data.mids, timestamp: Date.now() }; });
42
+ ws.on('allDexsAssetCtxs', (data) => { this.assetCtxs = { value: data.ctxs, timestamp: Date.now() }; });
43
+ ws.on('allDexsClearinghouseState', (data) => {
44
+ this.clearinghouse = { value: data.clearinghouseStates, timestamp: Date.now() };
45
+ });
46
+ ws.on('spotState', (data) => { this.spot = { value: data, timestamp: Date.now() }; });
47
+ ws.on('openOrders', (data) => {
48
+ if (this.sameUser(data.user)) this.openOrders.set(data.dex || '', { value: data.orders, timestamp: Date.now() });
49
+ });
50
+ ws.on('l2Book', (data) => {
51
+ this.books.set(data.coin, { value: data, timestamp: Date.now() });
52
+ const waiters = this.bookWaiters.get(data.coin);
53
+ if (waiters) {
54
+ for (const resolve of waiters) resolve();
55
+ waiters.clear();
56
+ }
57
+ });
58
+ }
59
+
60
+ get connected(): boolean {
61
+ return this.ws.connected;
62
+ }
63
+
64
+ getAllMids(): Record<string, string> | null {
65
+ return this.fresh(this.mids, MARKET_DATA_STALE_MS)?.value ?? null;
66
+ }
67
+
68
+ getMainAssetCtxs(): AssetCtx[] | null {
69
+ const groups = this.fresh(this.assetCtxs, MARKET_DATA_STALE_MS)?.value;
70
+ if (!groups) return null;
71
+ const main = groups.find(([dex]) => !dex || dex === 'main') ?? groups[0];
72
+ return (main?.[1] as unknown as AssetCtx[] | undefined) ?? null;
73
+ }
74
+
75
+ getAllDexsAssetCtxs(): WsEventMap['allDexsAssetCtxs']['ctxs'] | null {
76
+ return this.fresh(this.assetCtxs, MARKET_DATA_STALE_MS)?.value ?? null;
77
+ }
78
+
79
+ getUserState(user: string, dex?: string): ClearinghouseState | null {
80
+ if (!this.sameUser(user)) return null;
81
+ const groups = this.clearinghouse?.value;
82
+ if (!groups) return null;
83
+ const targetDex = dex ?? '';
84
+ const raw = groups.find(([name]) => (name || '') === targetDex)?.[1];
85
+ if (!raw) return null;
86
+ const withdrawable = raw.withdrawable;
87
+ return {
88
+ ...raw,
89
+ marginSummary: raw.marginSummary && withdrawable != null
90
+ ? { ...raw.marginSummary, withdrawable }
91
+ : raw.marginSummary,
92
+ crossMarginSummary: raw.crossMarginSummary && withdrawable != null
93
+ ? { ...raw.crossMarginSummary, withdrawable }
94
+ : raw.crossMarginSummary,
95
+ };
96
+ }
97
+
98
+ getUserStateAll(user: string): ClearinghouseState | null {
99
+ if (!this.sameUser(user) || !this.clearinghouse || this.unified === null) return null;
100
+ return this.client.userStateAllFromWs(
101
+ this.clearinghouse.value,
102
+ this.unified,
103
+ this.spot ? { balances: this.spot.value.balances } : undefined,
104
+ );
105
+ }
106
+
107
+ getSpotBalances(user: string): ReturnType<RealtimeDataProvider['getSpotBalances']> {
108
+ if (!this.sameUser(user) || !this.spot) return null;
109
+ return {
110
+ balances: this.spot.value.balances.map((balance) => ({
111
+ ...balance,
112
+ entryNtl: balance.entryNtl ?? '0',
113
+ })),
114
+ };
115
+ }
116
+
117
+ getOpenOrders(user: string): OpenOrder[] | null {
118
+ if (!this.sameUser(user) || !this.connected) return null;
119
+ const orders: OpenOrder[] = [];
120
+ for (const dex of new Set(['', ...this.expectedOrderDexes])) {
121
+ const entry = this.openOrders.get(dex);
122
+ if (!entry) return null;
123
+ orders.push(...entry.value);
124
+ }
125
+ return orders;
126
+ }
127
+
128
+ async getL2Book(coin: string): Promise<RealtimeBookSnapshot | null> {
129
+ const cached = this.fresh(this.books.get(coin) ?? null, BOOK_STALE_MS);
130
+ if (cached) return cached.value;
131
+ if (!this.connected) return null;
132
+
133
+ if (!this.bookSubscriptions.has(coin)) {
134
+ this.bookSubscriptions.add(coin);
135
+ try {
136
+ await this.ws.subscribeL2Book(coin);
137
+ } catch {
138
+ this.bookSubscriptions.delete(coin);
139
+ return null;
140
+ }
141
+ }
142
+
143
+ const seeded = this.fresh(this.books.get(coin) ?? null, BOOK_STALE_MS);
144
+ if (seeded) return seeded.value;
145
+
146
+ await new Promise<void>((resolve) => {
147
+ const waiters = this.bookWaiters.get(coin) ?? new Set<() => void>();
148
+ this.bookWaiters.set(coin, waiters);
149
+ const done = () => {
150
+ clearTimeout(timer);
151
+ waiters.delete(done);
152
+ resolve();
153
+ };
154
+ const timer = setTimeout(done, BOOK_SEED_TIMEOUT_MS);
155
+ waiters.add(done);
156
+ });
157
+
158
+ return this.fresh(this.books.get(coin) ?? null, BOOK_STALE_MS)?.value ?? null;
159
+ }
160
+
161
+ /** Wait briefly for initial subscription snapshots before the first strategy hook runs. */
162
+ async waitUntilReady(timeoutMs = 10_000): Promise<boolean> {
163
+ const deadline = Date.now() + timeoutMs;
164
+ while (Date.now() < deadline) {
165
+ if (this.coreFeedsReady()) return true;
166
+ await new Promise((resolve) => setTimeout(resolve, 25));
167
+ }
168
+ return this.coreFeedsReady();
169
+ }
170
+
171
+ readinessSummary(): { expectedOrderDexes: number; seededOrderDexes: number; missingOrderDexes: string[] } {
172
+ const expected = [...new Set(['', ...this.expectedOrderDexes])];
173
+ return {
174
+ expectedOrderDexes: expected.length,
175
+ seededOrderDexes: expected.filter((dex) => this.openOrders.has(dex)).length,
176
+ missingOrderDexes: expected.filter((dex) => !this.openOrders.has(dex)),
177
+ };
178
+ }
179
+
180
+ private coreFeedsReady(): boolean {
181
+ const ordersReady = [...new Set(['', ...this.expectedOrderDexes])]
182
+ .every((dex) => this.openOrders.has(dex));
183
+ return Boolean(this.mids && this.assetCtxs && this.clearinghouse && this.spot && ordersReady);
184
+ }
185
+
186
+ private sameUser(user: string): boolean {
187
+ return user.toLowerCase() === this.user.toLowerCase();
188
+ }
189
+
190
+ private fresh<T>(entry: Timed<T> | null, maxAgeMs: number): Timed<T> | null {
191
+ if (!this.connected || !entry || Date.now() - entry.timestamp > maxAgeMs) return null;
192
+ return entry;
193
+ }
194
+ }