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.
@@ -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
+ }
@@ -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 { getClient } from '../core/client.js';
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 for real-time events (allMids, orderUpdates, userFills, userEvents).
437
- * When enabled, REST polling interval is relaxed to a heartbeat (default 60s).
438
- * Falls back gracefully to polling if WebSocket connection fails.
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
- // When WebSocket is enabled, REST poll becomes a heartbeat (30s default)
475
- // When disabled, use the original 10s polling interval
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
- const rawClient = getClient();
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: 0 }),
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 subscribe
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 ws.subscribeAll(userAddress);
795
- log.info('WebSocket subscriptions active (allMids, orderUpdates, userFills, userEvents)');
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
- // Start polling
955
- const wsLabel = wsConnected ? ', ws=on' : (useWebSocket ? ', ws=failed' : '');
956
- log.info(`Started (poll every ${pollIntervalMs / 1000}s, dry=${dryRun}${wsLabel})`);
957
- const timer = setInterval(poll, pollIntervalMs);
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
- // Initial poll to seed state
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(timer);
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) {
@@ -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 a recurring interval (ms). Aligned to the poll loop. */
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 {