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 +12 -0
- package/README.md +16 -0
- package/SKILL.md +20 -3
- package/dist/auto/cli.js +1 -1
- package/dist/auto/examples/price-alert.js +1 -1
- package/dist/auto/realtime.d.ts +45 -0
- package/dist/auto/realtime.d.ts.map +1 -0
- package/dist/auto/realtime.js +177 -0
- package/dist/auto/realtime.test.d.ts +2 -0
- package/dist/auto/realtime.test.d.ts.map +1 -0
- package/dist/auto/realtime.test.js +73 -0
- package/dist/auto/runtime.d.ts +3 -3
- package/dist/auto/runtime.d.ts.map +1 -1
- package/dist/auto/runtime.js +117 -45
- package/dist/auto/types.d.ts +2 -1
- package/dist/auto/types.d.ts.map +1 -1
- package/dist/core/client.d.ts +66 -1
- package/dist/core/client.d.ts.map +1 -1
- package/dist/core/client.js +141 -4
- package/dist/core/ws.d.ts +14 -2
- package/dist/core/ws.d.ts.map +1 -1
- package/dist/core/ws.js +53 -7
- package/package.json +4 -3
- package/scripts/auto/cli.ts +1 -1
- package/scripts/auto/examples/price-alert.ts +1 -1
- package/scripts/auto/realtime.test.ts +84 -0
- package/scripts/auto/realtime.ts +194 -0
- package/scripts/auto/runtime.ts +125 -47
- package/scripts/auto/types.ts +2 -1
- package/scripts/core/client.ts +175 -4
- package/scripts/core/ws.ts +57 -8
|
@@ -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
|
+
}
|
package/scripts/auto/runtime.ts
CHANGED
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
5
5
|
import path from 'path';
|
|
6
6
|
import os from 'os';
|
|
7
|
-
import {
|
|
8
|
-
import type { HyperliquidClient } from '../core/client.js';
|
|
7
|
+
import { HyperliquidClient } from '../core/client.js';
|
|
9
8
|
import {
|
|
10
9
|
roundPrice, roundSize, sleep, normalizeCoin,
|
|
11
10
|
formatUsd, formatPercent, annualizeFundingRate,
|
|
12
11
|
} from '../core/utils.js';
|
|
13
12
|
import { WebSocketManager } from '../core/ws.js';
|
|
13
|
+
import { AutomationRealtimeData } from './realtime.js';
|
|
14
14
|
import { AutomationEventBus } from './events.js';
|
|
15
15
|
import { loadAutomation } from './loader.js';
|
|
16
16
|
import { CLIENT_WRITE_METHODS, createGuardrailedClient } from './guardrails.js';
|
|
@@ -433,9 +433,9 @@ export interface RuntimeOptions {
|
|
|
433
433
|
/** Pre-seed state before the factory function runs (e.g. from --set key=value) */
|
|
434
434
|
initialState?: Record<string, unknown>;
|
|
435
435
|
/**
|
|
436
|
-
* Enable WebSocket
|
|
437
|
-
*
|
|
438
|
-
*
|
|
436
|
+
* Enable WebSocket-first market/account data and events. REST is used for
|
|
437
|
+
* initial static metadata, minute reconciliation, and automatic fallback
|
|
438
|
+
* while the socket is unavailable.
|
|
439
439
|
* @default true
|
|
440
440
|
*/
|
|
441
441
|
useWebSocket?: boolean;
|
|
@@ -471,8 +471,8 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
|
|
|
471
471
|
useWebSocket = true,
|
|
472
472
|
} = options;
|
|
473
473
|
|
|
474
|
-
//
|
|
475
|
-
//
|
|
474
|
+
// This is the disconnected REST fallback cadence. While WebSocket is live,
|
|
475
|
+
// reconciliation is clamped to at least 60 seconds below.
|
|
476
476
|
const pollIntervalMs = options.pollIntervalMs ?? (useWebSocket ? 30_000 : 10_000);
|
|
477
477
|
|
|
478
478
|
const id = options.id || path.basename(scriptPath, '.ts');
|
|
@@ -492,7 +492,10 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
|
|
|
492
492
|
|
|
493
493
|
const eventBus = new AutomationEventBus();
|
|
494
494
|
|
|
495
|
-
|
|
495
|
+
// Each automation owns its client + realtime cache. This prevents one run
|
|
496
|
+
// from replacing or detaching another run's WebSocket provider when several
|
|
497
|
+
// automations share a host process.
|
|
498
|
+
const rawClient = new HyperliquidClient();
|
|
496
499
|
const audit = createAutomationAudit({
|
|
497
500
|
automationId: id,
|
|
498
501
|
scriptPath,
|
|
@@ -572,7 +575,7 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
|
|
|
572
575
|
client,
|
|
573
576
|
utils: { roundPrice, roundSize, sleep, normalizeCoin, formatUsd, formatPercent, annualizeFundingRate },
|
|
574
577
|
on: (event, handler) => eventBus.on(event, handler),
|
|
575
|
-
every: (intervalMs, handler) => scheduledTasks.push({ intervalMs, handler, lastRun:
|
|
578
|
+
every: (intervalMs, handler) => scheduledTasks.push({ intervalMs, handler, lastRun: Date.now() }),
|
|
576
579
|
onStart: (handler) => startHooks.push(handler),
|
|
577
580
|
onStop: (handler) => stopHooks.push(handler),
|
|
578
581
|
onError: (handler) => errorHooks.push(handler),
|
|
@@ -588,15 +591,6 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
|
|
|
588
591
|
try {
|
|
589
592
|
// Execute the already validated factory function (registers handlers).
|
|
590
593
|
await loaded.factory(api);
|
|
591
|
-
|
|
592
|
-
// Call onStart hooks
|
|
593
|
-
for (const hook of startHooks) {
|
|
594
|
-
try { await hook(); } catch (err) {
|
|
595
|
-
const error = err instanceof Error ? err : new Error(String(err));
|
|
596
|
-
audit.recordError('onStart', error);
|
|
597
|
-
log.error(`onStart hook error: ${error.message}`);
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
594
|
} catch (err) {
|
|
601
595
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
602
596
|
audit.recordError('startup', error);
|
|
@@ -616,6 +610,7 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
|
|
|
616
610
|
let eventsEmitted = 0;
|
|
617
611
|
let isPolling = false;
|
|
618
612
|
let stopped = false;
|
|
613
|
+
let automationReady = false;
|
|
619
614
|
|
|
620
615
|
async function handleErrors(errors: Error[]) {
|
|
621
616
|
for (const err of errors) {
|
|
@@ -647,12 +642,26 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
|
|
|
647
642
|
// ── WebSocket setup ─────────────────────────────────────────────
|
|
648
643
|
let ws: WebSocketManager | null = null;
|
|
649
644
|
let wsConnected = false;
|
|
645
|
+
let realtimeData: AutomationRealtimeData | null = null;
|
|
650
646
|
// Track latest prices from WebSocket for real-time price_change events
|
|
651
647
|
let wsPrices = new Map<string, number>();
|
|
648
|
+
let wsFundingRates = new Map<string, number>();
|
|
652
649
|
|
|
653
650
|
if (useWebSocket) {
|
|
654
651
|
try {
|
|
655
652
|
ws = new WebSocketManager(verbose);
|
|
653
|
+
const unified = await rawClient.isUnifiedAccount().catch(() => null);
|
|
654
|
+
const orderDexes = await rawClient.getPerpDexs()
|
|
655
|
+
.then((dexes) => dexes.slice(1).flatMap((dex) => dex?.name ? [dex.name] : []))
|
|
656
|
+
.catch(() => [] as string[]);
|
|
657
|
+
realtimeData = new AutomationRealtimeData(
|
|
658
|
+
ws,
|
|
659
|
+
rawClient,
|
|
660
|
+
rawClient.address,
|
|
661
|
+
unified,
|
|
662
|
+
orderDexes,
|
|
663
|
+
);
|
|
664
|
+
rawClient.setRealtimeDataProvider(realtimeData);
|
|
656
665
|
|
|
657
666
|
// Wire WebSocket events to the automation event bus
|
|
658
667
|
ws.on('allMids', ({ mids }) => {
|
|
@@ -663,7 +672,7 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
|
|
|
663
672
|
const oldPrice = wsPrices.get(coin);
|
|
664
673
|
wsPrices.set(coin, newPrice);
|
|
665
674
|
|
|
666
|
-
if (oldPrice !== undefined && oldPrice !== 0 && eventBus.has('price_change')) {
|
|
675
|
+
if (automationReady && oldPrice !== undefined && oldPrice !== 0 && eventBus.has('price_change')) {
|
|
667
676
|
const changePct = ((newPrice - oldPrice) / oldPrice) * 100;
|
|
668
677
|
if (Math.abs(changePct) >= 0.01) {
|
|
669
678
|
void emitAutomationEvent('price_change', { coin, oldPrice, newPrice, changePct }, 'ws');
|
|
@@ -672,6 +681,22 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
|
|
|
672
681
|
}
|
|
673
682
|
});
|
|
674
683
|
|
|
684
|
+
ws.on('allDexsAssetCtxs', ({ ctxs }) => {
|
|
685
|
+
if (!automationReady || !eventBus.has('funding_update')) return;
|
|
686
|
+
const next = rawClient.fundingRatesFromWs(ctxs);
|
|
687
|
+
for (const [coin, data] of next) {
|
|
688
|
+
const previous = wsFundingRates.get(coin);
|
|
689
|
+
wsFundingRates.set(coin, data.rate);
|
|
690
|
+
if (previous === undefined || previous === data.rate) continue;
|
|
691
|
+
void emitAutomationEvent('funding_update', {
|
|
692
|
+
coin,
|
|
693
|
+
fundingRate: data.rate,
|
|
694
|
+
annualized: annualizeFundingRate(data.rate),
|
|
695
|
+
premium: data.premium,
|
|
696
|
+
}, 'ws');
|
|
697
|
+
}
|
|
698
|
+
});
|
|
699
|
+
|
|
675
700
|
ws.on('orderUpdate', (update) => {
|
|
676
701
|
audit.recordOrderUpdate({
|
|
677
702
|
coin: update.order.coin,
|
|
@@ -685,7 +710,7 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
|
|
|
685
710
|
raw: update,
|
|
686
711
|
});
|
|
687
712
|
|
|
688
|
-
if (eventBus.has('order_update')) {
|
|
713
|
+
if (automationReady && eventBus.has('order_update')) {
|
|
689
714
|
void emitAutomationEvent('order_update', {
|
|
690
715
|
coin: update.order.coin,
|
|
691
716
|
oid: update.order.oid,
|
|
@@ -729,7 +754,7 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
|
|
|
729
754
|
// Fee is converted to USD using feeToken: for non-USDC fees (spot
|
|
730
755
|
// buys pay in the received asset), fee × price yields USD since the
|
|
731
756
|
// fee token is the base of the traded pair and `price` is quote/base.
|
|
732
|
-
if (eventBus.has('order_filled')) {
|
|
757
|
+
if (automationReady && eventBus.has('order_filled')) {
|
|
733
758
|
const size = parseFloat(fill.sz);
|
|
734
759
|
const price = parseFloat(fill.px);
|
|
735
760
|
const rawFee = parseFloat(fill.fee);
|
|
@@ -762,7 +787,7 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
|
|
|
762
787
|
ws.on('userEvent', (event) => {
|
|
763
788
|
audit.recordUserEvent(event);
|
|
764
789
|
// Handle liquidation events — only available through WebSocket
|
|
765
|
-
if ('liquidation' in event && eventBus.has('liquidation')) {
|
|
790
|
+
if (automationReady && 'liquidation' in event && eventBus.has('liquidation')) {
|
|
766
791
|
const liq = event.liquidation;
|
|
767
792
|
void emitAutomationEvent('liquidation', {
|
|
768
793
|
lid: liq.lid,
|
|
@@ -789,10 +814,20 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
|
|
|
789
814
|
log.info('WebSocket connected — real-time events active');
|
|
790
815
|
});
|
|
791
816
|
|
|
792
|
-
// Connect and
|
|
817
|
+
// Connect subscriptions and warm only the main static universe in
|
|
818
|
+
// parallel. HIP-3 metadata is loaded lazily when referenced.
|
|
793
819
|
const userAddress = rawClient.address as `0x${string}`;
|
|
794
|
-
await
|
|
795
|
-
|
|
820
|
+
await Promise.all([
|
|
821
|
+
ws.subscribeAll(userAddress, orderDexes),
|
|
822
|
+
rawClient.initializeRealtimeMetadata().catch((error) => {
|
|
823
|
+
log.warn(`Realtime metadata warmup failed: ${error instanceof Error ? error.message : String(error)}; REST fallback remains available`);
|
|
824
|
+
}),
|
|
825
|
+
]);
|
|
826
|
+
const seeded = await realtimeData.waitUntilReady();
|
|
827
|
+
const readiness = realtimeData.readinessSummary();
|
|
828
|
+
log.info(
|
|
829
|
+
`WebSocket subscriptions active (mids, asset contexts, account state, spot state, orders, fills, user events)${seeded ? '' : ` · initial snapshots incomplete; REST fallback armed · openOrders ${readiness.seededOrderDexes}/${readiness.expectedOrderDexes}`}`,
|
|
830
|
+
);
|
|
796
831
|
} catch (err) {
|
|
797
832
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
798
833
|
audit.recordError('websocket_setup', error);
|
|
@@ -800,6 +835,8 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
|
|
|
800
835
|
log.debug(`WebSocket setup stack: ${error.stack}`);
|
|
801
836
|
}
|
|
802
837
|
log.warn(`WebSocket setup failed: ${error.message} — using REST polling only`);
|
|
838
|
+
rawClient.setRealtimeDataProvider(null);
|
|
839
|
+
realtimeData = null;
|
|
803
840
|
ws = null;
|
|
804
841
|
wsConnected = false;
|
|
805
842
|
}
|
|
@@ -839,7 +876,7 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
|
|
|
839
876
|
}
|
|
840
877
|
|
|
841
878
|
// Funding updates
|
|
842
|
-
if (eventBus.has('funding_update')) {
|
|
879
|
+
if (eventBus.has('funding_update') && !wsConnected) {
|
|
843
880
|
for (const [coin, data] of snapshot.fundingRates) {
|
|
844
881
|
await emitAutomationEvent('funding_update', {
|
|
845
882
|
coin,
|
|
@@ -926,21 +963,6 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
|
|
|
926
963
|
// (Skipped for MVP — requires tracking open orders per poll, will add when needed)
|
|
927
964
|
}
|
|
928
965
|
|
|
929
|
-
// Run scheduled tasks
|
|
930
|
-
for (const task of scheduledTasks) {
|
|
931
|
-
if (now - task.lastRun >= task.intervalMs) {
|
|
932
|
-
try {
|
|
933
|
-
await task.handler();
|
|
934
|
-
} catch (err) {
|
|
935
|
-
const error = err instanceof Error ? err : new Error(String(err));
|
|
936
|
-
audit.recordError('scheduled_task', error);
|
|
937
|
-
log.error(`Scheduled task error: ${error.message}`);
|
|
938
|
-
await handleErrors([error]);
|
|
939
|
-
}
|
|
940
|
-
task.lastRun = now;
|
|
941
|
-
}
|
|
942
|
-
}
|
|
943
|
-
|
|
944
966
|
previousSnapshot = snapshot;
|
|
945
967
|
} catch (err) {
|
|
946
968
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
@@ -951,19 +973,73 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
|
|
|
951
973
|
}
|
|
952
974
|
}
|
|
953
975
|
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
976
|
+
async function runScheduledTasks(): Promise<void> {
|
|
977
|
+
const now = Date.now();
|
|
978
|
+
for (const task of scheduledTasks) {
|
|
979
|
+
if (task.running || now - task.lastRun < task.intervalMs) continue;
|
|
980
|
+
task.running = true;
|
|
981
|
+
task.lastRun = now;
|
|
982
|
+
try {
|
|
983
|
+
await task.handler();
|
|
984
|
+
} catch (err) {
|
|
985
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
986
|
+
audit.recordError('scheduled_task', error);
|
|
987
|
+
log.error(`Scheduled task error: ${error.message}`);
|
|
988
|
+
await handleErrors([error]);
|
|
989
|
+
} finally {
|
|
990
|
+
task.running = false;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
}
|
|
958
994
|
|
|
959
|
-
//
|
|
995
|
+
// Seed an audit snapshot. With a healthy socket this is assembled from the
|
|
996
|
+
// live cache; REST is used only for any feed that has not produced its first
|
|
997
|
+
// snapshot yet.
|
|
960
998
|
await poll();
|
|
961
999
|
|
|
1000
|
+
// Start hooks run after WebSocket caches are seeded, so strategy reads are
|
|
1001
|
+
// WebSocket-first from their very first decision.
|
|
1002
|
+
for (const hook of startHooks) {
|
|
1003
|
+
try {
|
|
1004
|
+
await hook();
|
|
1005
|
+
} catch (err) {
|
|
1006
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
1007
|
+
audit.recordError('onStart', error);
|
|
1008
|
+
log.error(`onStart hook error: ${error.message}`);
|
|
1009
|
+
for (const errorHook of errorHooks) {
|
|
1010
|
+
try { await errorHook(error); } catch { /* swallow */ }
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
automationReady = true;
|
|
1015
|
+
|
|
1016
|
+
// api.every() is a real scheduler now; it no longer depends on how often a
|
|
1017
|
+
// heavyweight REST reconciliation snapshot runs.
|
|
1018
|
+
const scheduleTimer = setInterval(() => { void runScheduledTasks(); }, 500);
|
|
1019
|
+
|
|
1020
|
+
// --poll controls the REST fallback cadence while the socket is down. While
|
|
1021
|
+
// connected, reconcile at most once per minute regardless of a shorter
|
|
1022
|
+
// fallback interval supplied by older launch commands.
|
|
1023
|
+
const restReconcileMs = Math.max(60_000, pollIntervalMs);
|
|
1024
|
+
let lastPollAt = Date.now();
|
|
1025
|
+
const pollTimer = setInterval(() => {
|
|
1026
|
+
const interval = wsConnected ? restReconcileMs : pollIntervalMs;
|
|
1027
|
+
if (Date.now() - lastPollAt < interval) return;
|
|
1028
|
+
lastPollAt = Date.now();
|
|
1029
|
+
void poll();
|
|
1030
|
+
}, Math.min(1_000, pollIntervalMs));
|
|
1031
|
+
|
|
1032
|
+
const wsLabel = wsConnected ? ', ws=on' : (useWebSocket ? ', ws=failed' : '');
|
|
1033
|
+
log.info(
|
|
1034
|
+
`Started (REST fallback ${pollIntervalMs / 1000}s, connected reconcile ${restReconcileMs / 1000}s, dry=${dryRun}${wsLabel})`,
|
|
1035
|
+
);
|
|
1036
|
+
|
|
962
1037
|
// Stop function
|
|
963
1038
|
async function stop(opts?: { persist?: boolean }) {
|
|
964
1039
|
if (stopped) return;
|
|
965
1040
|
stopped = true;
|
|
966
|
-
clearInterval(
|
|
1041
|
+
clearInterval(scheduleTimer);
|
|
1042
|
+
clearInterval(pollTimer);
|
|
967
1043
|
|
|
968
1044
|
// Close WebSocket
|
|
969
1045
|
if (ws) {
|
|
@@ -971,6 +1047,8 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
|
|
|
971
1047
|
await ws.close();
|
|
972
1048
|
ws = null;
|
|
973
1049
|
}
|
|
1050
|
+
rawClient.setRealtimeDataProvider(null);
|
|
1051
|
+
realtimeData = null;
|
|
974
1052
|
|
|
975
1053
|
for (const hook of stopHooks) {
|
|
976
1054
|
try { await hook(); } catch (err) {
|
package/scripts/auto/types.ts
CHANGED
|
@@ -220,7 +220,7 @@ export interface AutomationAPI {
|
|
|
220
220
|
/** Subscribe to a market/account event */
|
|
221
221
|
on<E extends AutomationEventType>(event: E, handler: AutomationEventHandler<E>): void;
|
|
222
222
|
|
|
223
|
-
/** Run a handler on
|
|
223
|
+
/** Run a handler on its own recurring scheduler, independent of REST reconciliation. */
|
|
224
224
|
every(intervalMs: number, handler: () => void | Promise<void>): void;
|
|
225
225
|
|
|
226
226
|
/** Called after all handlers are registered and polling begins */
|
|
@@ -290,6 +290,7 @@ export interface ScheduledTask {
|
|
|
290
290
|
intervalMs: number;
|
|
291
291
|
handler: () => void | Promise<void>;
|
|
292
292
|
lastRun: number;
|
|
293
|
+
running?: boolean;
|
|
293
294
|
}
|
|
294
295
|
|
|
295
296
|
export interface RunningAutomation {
|