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,15 +4,16 @@
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
+ import { CLIENT_WRITE_METHODS, createGuardrailedClient } from './guardrails.js';
16
17
  import { registerAutomation, unregisterAutomation, getRegisteredAutomations as getRegisteredFromFile } from './registry.js';
17
18
  import { createAutomationAudit, toSerializable, type AutomationAuditSink } from './audit.js';
18
19
  import { startKeepAwake, type KeepAwakeHandle } from './keep-awake.js';
@@ -29,6 +30,7 @@ import type {
29
30
  PublishOptions,
30
31
  ScheduledTask,
31
32
  RunningAutomation,
33
+ LoadedAutomation,
32
34
  } from './types.js';
33
35
 
34
36
  // ── Observer fan-out ────────────────────────────────────────────────
@@ -105,13 +107,6 @@ function fanOutAgentAction(
105
107
  }
106
108
 
107
109
  const STATE_DIR = path.join(os.homedir(), '.openbroker', 'state');
108
- const AUDITED_WRITE_METHODS = new Set([
109
- 'order', 'marketOrder', 'limitOrder', 'triggerOrder',
110
- 'takeProfit', 'stopLoss', 'cancel', 'cancelAll',
111
- 'spotOrder', 'spotMarketOrder', 'spotLimitOrder', 'spotCancel',
112
- 'updateLeverage', 'approveBuilderFee', 'twapOrder', 'twapCancel',
113
- ]);
114
-
115
110
  // ── State persistence ───────────────────────────────────────────────
116
111
 
117
112
  interface StateController {
@@ -202,19 +197,11 @@ function createLogger(id: string, verbose: boolean, audit?: AutomationAuditSink)
202
197
 
203
198
  // ── Dry-run client proxy ────────────────────────────────────────────
204
199
 
205
- const WRITE_METHODS = new Set([
206
- 'order', 'marketOrder', 'limitOrder', 'triggerOrder',
207
- 'takeProfit', 'stopLoss', 'cancel', 'cancelAll',
208
- 'updateLeverage', 'approveBuilderFee',
209
- 'spotOrder', 'spotMarketOrder', 'spotLimitOrder', 'spotCancel',
210
- 'twapOrder', 'twapCancel',
211
- ]);
212
-
213
200
  function createDryClient(client: HyperliquidClient, log: AutomationLogger): HyperliquidClient {
214
201
  return new Proxy(client, {
215
202
  get(target, prop, receiver) {
216
203
  const value = Reflect.get(target, prop, receiver);
217
- if (typeof prop === 'string' && WRITE_METHODS.has(prop) && typeof value === 'function') {
204
+ if (typeof prop === 'string' && CLIENT_WRITE_METHODS.has(prop) && typeof value === 'function') {
218
205
  return (...args: unknown[]) => {
219
206
  log.info(`[DRY] ${prop}(${args.map(a => JSON.stringify(a)).join(', ')})`);
220
207
  return Promise.resolve({ status: 'ok', response: { type: 'dry_run' } });
@@ -234,7 +221,7 @@ function createAuditedClient(
234
221
  return new Proxy(client, {
235
222
  get(target, prop, receiver) {
236
223
  const value = Reflect.get(target, prop, receiver);
237
- if (typeof prop === 'string' && AUDITED_WRITE_METHODS.has(prop) && typeof value === 'function') {
224
+ if (typeof prop === 'string' && CLIENT_WRITE_METHODS.has(prop) && typeof value === 'function') {
238
225
  return async (...args: unknown[]) => {
239
226
  const actionId = `${prop}:${Date.now()}:${Math.random().toString(16).slice(2)}`;
240
227
  audit.recordAction({
@@ -446,9 +433,9 @@ export interface RuntimeOptions {
446
433
  /** Pre-seed state before the factory function runs (e.g. from --set key=value) */
447
434
  initialState?: Record<string, unknown>;
448
435
  /**
449
- * Enable WebSocket for real-time events (allMids, orderUpdates, userFills, userEvents).
450
- * When enabled, REST polling interval is relaxed to a heartbeat (default 60s).
451
- * 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.
452
439
  * @default true
453
440
  */
454
441
  useWebSocket?: boolean;
@@ -484,8 +471,8 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
484
471
  useWebSocket = true,
485
472
  } = options;
486
473
 
487
- // When WebSocket is enabled, REST poll becomes a heartbeat (30s default)
488
- // 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.
489
476
  const pollIntervalMs = options.pollIntervalMs ?? (useWebSocket ? 30_000 : 10_000);
490
477
 
491
478
  const id = options.id || path.basename(scriptPath, '.ts');
@@ -505,7 +492,10 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
505
492
 
506
493
  const eventBus = new AutomationEventBus();
507
494
 
508
- 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();
509
499
  const audit = createAutomationAudit({
510
500
  automationId: id,
511
501
  scriptPath,
@@ -531,8 +521,38 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
531
521
  }
532
522
  }
533
523
  const observers = await loadConventionObservers(log);
524
+ let loaded: LoadedAutomation;
525
+ try {
526
+ log.info(`Loading automation: ${scriptPath}`);
527
+ loaded = await loadAutomation(scriptPath, { config: stateController.snapshot() });
528
+ } catch (err) {
529
+ const error = err instanceof Error ? err : new Error(String(err));
530
+ audit.recordError('guardrail_validation', error);
531
+ await audit.stop({
532
+ status: 'error',
533
+ stopReason: 'guardrail_validation_error',
534
+ pollCount: 0,
535
+ eventsEmitted: 0,
536
+ });
537
+ keepAwake?.stop();
538
+ throw error;
539
+ }
540
+ audit.recordNote('guardrails', loaded.guardrails);
534
541
  const baseClient = dryRun ? createDryClient(rawClient, log) : rawClient;
535
- const client = createAuditedClient(baseClient, audit, dryRun, observers);
542
+ const guardedClient = createGuardrailedClient(baseClient, {
543
+ policy: loaded.guardrails,
544
+ rawClient,
545
+ log,
546
+ onViolation: (error, method, args) => {
547
+ audit.recordNote('guardrail_block', {
548
+ code: error.code,
549
+ message: error.message,
550
+ method,
551
+ args: toSerializable(args),
552
+ });
553
+ },
554
+ });
555
+ const client = createAuditedClient(guardedClient, audit, dryRun, observers);
536
556
 
537
557
  const startHooks: Array<() => void | Promise<void>> = [];
538
558
  const stopHooks: Array<() => void | Promise<void>> = [];
@@ -555,7 +575,7 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
555
575
  client,
556
576
  utils: { roundPrice, roundSize, sleep, normalizeCoin, formatUsd, formatPercent, annualizeFundingRate },
557
577
  on: (event, handler) => eventBus.on(event, handler),
558
- every: (intervalMs, handler) => scheduledTasks.push({ intervalMs, handler, lastRun: 0 }),
578
+ every: (intervalMs, handler) => scheduledTasks.push({ intervalMs, handler, lastRun: Date.now() }),
559
579
  onStart: (handler) => startHooks.push(handler),
560
580
  onStop: (handler) => stopHooks.push(handler),
561
581
  onError: (handler) => errorHooks.push(handler),
@@ -565,22 +585,12 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
565
585
  audit: auditApi,
566
586
  id,
567
587
  dryRun,
588
+ guardrails: loaded.guardrails,
568
589
  };
569
590
 
570
591
  try {
571
- // Load and execute the factory function (registers handlers)
572
- log.info(`Loading automation: ${scriptPath}`);
573
- const factory = await loadAutomation(scriptPath);
574
- await factory(api);
575
-
576
- // Call onStart hooks
577
- for (const hook of startHooks) {
578
- try { await hook(); } catch (err) {
579
- const error = err instanceof Error ? err : new Error(String(err));
580
- audit.recordError('onStart', error);
581
- log.error(`onStart hook error: ${error.message}`);
582
- }
583
- }
592
+ // Execute the already validated factory function (registers handlers).
593
+ await loaded.factory(api);
584
594
  } catch (err) {
585
595
  const error = err instanceof Error ? err : new Error(String(err));
586
596
  audit.recordError('startup', error);
@@ -600,6 +610,7 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
600
610
  let eventsEmitted = 0;
601
611
  let isPolling = false;
602
612
  let stopped = false;
613
+ let automationReady = false;
603
614
 
604
615
  async function handleErrors(errors: Error[]) {
605
616
  for (const err of errors) {
@@ -631,12 +642,26 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
631
642
  // ── WebSocket setup ─────────────────────────────────────────────
632
643
  let ws: WebSocketManager | null = null;
633
644
  let wsConnected = false;
645
+ let realtimeData: AutomationRealtimeData | null = null;
634
646
  // Track latest prices from WebSocket for real-time price_change events
635
647
  let wsPrices = new Map<string, number>();
648
+ let wsFundingRates = new Map<string, number>();
636
649
 
637
650
  if (useWebSocket) {
638
651
  try {
639
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);
640
665
 
641
666
  // Wire WebSocket events to the automation event bus
642
667
  ws.on('allMids', ({ mids }) => {
@@ -647,7 +672,7 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
647
672
  const oldPrice = wsPrices.get(coin);
648
673
  wsPrices.set(coin, newPrice);
649
674
 
650
- if (oldPrice !== undefined && oldPrice !== 0 && eventBus.has('price_change')) {
675
+ if (automationReady && oldPrice !== undefined && oldPrice !== 0 && eventBus.has('price_change')) {
651
676
  const changePct = ((newPrice - oldPrice) / oldPrice) * 100;
652
677
  if (Math.abs(changePct) >= 0.01) {
653
678
  void emitAutomationEvent('price_change', { coin, oldPrice, newPrice, changePct }, 'ws');
@@ -656,6 +681,22 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
656
681
  }
657
682
  });
658
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
+
659
700
  ws.on('orderUpdate', (update) => {
660
701
  audit.recordOrderUpdate({
661
702
  coin: update.order.coin,
@@ -669,7 +710,7 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
669
710
  raw: update,
670
711
  });
671
712
 
672
- if (eventBus.has('order_update')) {
713
+ if (automationReady && eventBus.has('order_update')) {
673
714
  void emitAutomationEvent('order_update', {
674
715
  coin: update.order.coin,
675
716
  oid: update.order.oid,
@@ -713,7 +754,7 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
713
754
  // Fee is converted to USD using feeToken: for non-USDC fees (spot
714
755
  // buys pay in the received asset), fee × price yields USD since the
715
756
  // fee token is the base of the traded pair and `price` is quote/base.
716
- if (eventBus.has('order_filled')) {
757
+ if (automationReady && eventBus.has('order_filled')) {
717
758
  const size = parseFloat(fill.sz);
718
759
  const price = parseFloat(fill.px);
719
760
  const rawFee = parseFloat(fill.fee);
@@ -746,7 +787,7 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
746
787
  ws.on('userEvent', (event) => {
747
788
  audit.recordUserEvent(event);
748
789
  // Handle liquidation events — only available through WebSocket
749
- if ('liquidation' in event && eventBus.has('liquidation')) {
790
+ if (automationReady && 'liquidation' in event && eventBus.has('liquidation')) {
750
791
  const liq = event.liquidation;
751
792
  void emitAutomationEvent('liquidation', {
752
793
  lid: liq.lid,
@@ -773,10 +814,20 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
773
814
  log.info('WebSocket connected — real-time events active');
774
815
  });
775
816
 
776
- // Connect and subscribe
817
+ // Connect subscriptions and warm only the main static universe in
818
+ // parallel. HIP-3 metadata is loaded lazily when referenced.
777
819
  const userAddress = rawClient.address as `0x${string}`;
778
- await ws.subscribeAll(userAddress);
779
- 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
+ );
780
831
  } catch (err) {
781
832
  const error = err instanceof Error ? err : new Error(String(err));
782
833
  audit.recordError('websocket_setup', error);
@@ -784,6 +835,8 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
784
835
  log.debug(`WebSocket setup stack: ${error.stack}`);
785
836
  }
786
837
  log.warn(`WebSocket setup failed: ${error.message} — using REST polling only`);
838
+ rawClient.setRealtimeDataProvider(null);
839
+ realtimeData = null;
787
840
  ws = null;
788
841
  wsConnected = false;
789
842
  }
@@ -823,7 +876,7 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
823
876
  }
824
877
 
825
878
  // Funding updates
826
- if (eventBus.has('funding_update')) {
879
+ if (eventBus.has('funding_update') && !wsConnected) {
827
880
  for (const [coin, data] of snapshot.fundingRates) {
828
881
  await emitAutomationEvent('funding_update', {
829
882
  coin,
@@ -910,21 +963,6 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
910
963
  // (Skipped for MVP — requires tracking open orders per poll, will add when needed)
911
964
  }
912
965
 
913
- // Run scheduled tasks
914
- for (const task of scheduledTasks) {
915
- if (now - task.lastRun >= task.intervalMs) {
916
- try {
917
- await task.handler();
918
- } catch (err) {
919
- const error = err instanceof Error ? err : new Error(String(err));
920
- audit.recordError('scheduled_task', error);
921
- log.error(`Scheduled task error: ${error.message}`);
922
- await handleErrors([error]);
923
- }
924
- task.lastRun = now;
925
- }
926
- }
927
-
928
966
  previousSnapshot = snapshot;
929
967
  } catch (err) {
930
968
  const error = err instanceof Error ? err : new Error(String(err));
@@ -935,19 +973,73 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
935
973
  }
936
974
  }
937
975
 
938
- // Start polling
939
- const wsLabel = wsConnected ? ', ws=on' : (useWebSocket ? ', ws=failed' : '');
940
- log.info(`Started (poll every ${pollIntervalMs / 1000}s, dry=${dryRun}${wsLabel})`);
941
- 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
+ }
942
994
 
943
- // 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.
944
998
  await poll();
945
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
+
946
1037
  // Stop function
947
1038
  async function stop(opts?: { persist?: boolean }) {
948
1039
  if (stopped) return;
949
1040
  stopped = true;
950
- clearInterval(timer);
1041
+ clearInterval(scheduleTimer);
1042
+ clearInterval(pollTimer);
951
1043
 
952
1044
  // Close WebSocket
953
1045
  if (ws) {
@@ -955,6 +1047,8 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
955
1047
  await ws.close();
956
1048
  ws = null;
957
1049
  }
1050
+ rawClient.setRealtimeDataProvider(null);
1051
+ realtimeData = null;
958
1052
 
959
1053
  for (const hook of stopHooks) {
960
1054
  try { await hook(); } catch (err) {
@@ -8,6 +8,57 @@ import type { HyperliquidClient } from '../core/client.js';
8
8
  /** What an automation .ts file exports */
9
9
  export type AutomationFactory = (api: AutomationAPI) => void | Promise<void>;
10
10
 
11
+ /** Runtime-enforced policy exported by every automation module. */
12
+ export type AutomationGuardrails =
13
+ | ReadOnlyAutomationGuardrails
14
+ | TradingAutomationGuardrails;
15
+
16
+ export interface ReadOnlyAutomationGuardrails {
17
+ /** Read-only automations cannot call any client write method. */
18
+ mode: 'read-only';
19
+ }
20
+
21
+ export interface TradingAutomationGuardrails {
22
+ mode: 'trading';
23
+ /** Canonical markets this automation may trade: `ETH`, `xyz:CL`, `spot:HYPE`, `#1230`. */
24
+ allowedMarkets: string[];
25
+ /** Maximum USD notional for one submitted order. */
26
+ maxOrderUsd: number;
27
+ /** Maximum absolute USD exposure in any one market after an order. */
28
+ maxPositionUsd: number;
29
+ /** Maximum absolute USD exposure across the account after an order. */
30
+ maxTotalExposureUsd: number;
31
+ /** Maximum leverage the automation may request or use when increasing perp exposure. */
32
+ maxLeverage: number;
33
+ /** Maximum account margin utilization allowed after a risk-increasing perp order. */
34
+ maxMarginUsedPct: number;
35
+ /** Maximum account-wide open orders after this automation submits an order. */
36
+ maxOpenOrders: number;
37
+ /** Maximum risk-increasing order submissions in a rolling 60-second window. */
38
+ maxOrdersPerMinute: number;
39
+ /** Maximum slippage passed to market-order helpers. */
40
+ maxSlippageBps: number;
41
+ /** Whether market-order helpers may be used. */
42
+ allowMarketOrders: boolean;
43
+ /** Whether `cancelAll()` without a market or an armed `scheduleCancel()` is allowed. */
44
+ allowAccountWideCancel: boolean;
45
+ }
46
+
47
+ export interface AutomationGuardrailContext {
48
+ /** Persisted automation state with current `--set` overrides applied. */
49
+ config: Readonly<Record<string, unknown>>;
50
+ }
51
+
52
+ /** Guardrails may be static or derived from startup config, but the result is always validated. */
53
+ export type AutomationGuardrailsExport =
54
+ | AutomationGuardrails
55
+ | ((context: AutomationGuardrailContext) => AutomationGuardrails);
56
+
57
+ export interface LoadedAutomation {
58
+ factory: AutomationFactory;
59
+ guardrails: AutomationGuardrails;
60
+ }
61
+
11
62
  /** Config field descriptor for example automations */
12
63
  export interface AutomationConfigField {
13
64
  type: 'string' | 'number' | 'boolean';
@@ -169,7 +220,7 @@ export interface AutomationAPI {
169
220
  /** Subscribe to a market/account event */
170
221
  on<E extends AutomationEventType>(event: E, handler: AutomationEventHandler<E>): void;
171
222
 
172
- /** 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. */
173
224
  every(intervalMs: number, handler: () => void | Promise<void>): void;
174
225
 
175
226
  /** Called after all handlers are registered and polling begins */
@@ -206,6 +257,9 @@ export interface AutomationAPI {
206
257
 
207
258
  /** True if running in --dry mode (write methods are intercepted) */
208
259
  dryRun: boolean;
260
+
261
+ /** Validated policy currently enforced by the runtime client proxy. */
262
+ guardrails: Readonly<AutomationGuardrails>;
209
263
  }
210
264
 
211
265
  // ── Runtime internals ───────────────────────────────────────────────
@@ -236,6 +290,7 @@ export interface ScheduledTask {
236
290
  intervalMs: number;
237
291
  handler: () => void | Promise<void>;
238
292
  lastRun: number;
293
+ running?: boolean;
239
294
  }
240
295
 
241
296
  export interface RunningAutomation {