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.
- package/CHANGELOG.md +21 -0
- package/README.md +16 -0
- package/SKILL.md +69 -3
- package/dist/auto/cli.js +4 -1
- package/dist/auto/examples/dca.d.ts +2 -1
- package/dist/auto/examples/dca.d.ts.map +1 -1
- package/dist/auto/examples/dca.js +19 -1
- package/dist/auto/examples/funding-arb.d.ts +2 -1
- package/dist/auto/examples/funding-arb.d.ts.map +1 -1
- package/dist/auto/examples/funding-arb.js +19 -2
- package/dist/auto/examples/grid.d.ts +2 -1
- package/dist/auto/examples/grid.d.ts.map +1 -1
- package/dist/auto/examples/grid.js +18 -2
- package/dist/auto/examples/mm-maker.d.ts +2 -1
- package/dist/auto/examples/mm-maker.d.ts.map +1 -1
- package/dist/auto/examples/mm-maker.js +18 -2
- package/dist/auto/examples/mm-spread.d.ts +2 -1
- package/dist/auto/examples/mm-spread.d.ts.map +1 -1
- package/dist/auto/examples/mm-spread.js +18 -2
- package/dist/auto/examples/price-alert.d.ts +2 -1
- package/dist/auto/examples/price-alert.d.ts.map +1 -1
- package/dist/auto/examples/price-alert.js +2 -1
- package/dist/auto/guardrails.d.ts +19 -0
- package/dist/auto/guardrails.d.ts.map +1 -0
- package/dist/auto/guardrails.js +575 -0
- package/dist/auto/guardrails.test.d.ts +2 -0
- package/dist/auto/guardrails.test.d.ts.map +1 -0
- package/dist/auto/guardrails.test.js +173 -0
- package/dist/auto/loader.d.ts +3 -3
- package/dist/auto/loader.d.ts.map +1 -1
- package/dist/auto/loader.js +25 -3
- 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 +155 -65
- package/dist/auto/types.d.ts +45 -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/dist/lib.d.ts +2 -0
- package/dist/lib.d.ts.map +1 -1
- package/dist/lib.js +1 -0
- package/package.json +5 -3
- package/scripts/auto/cli.ts +4 -1
- package/scripts/auto/examples/dca.ts +21 -2
- package/scripts/auto/examples/funding-arb.ts +21 -3
- package/scripts/auto/examples/grid.ts +20 -3
- package/scripts/auto/examples/mm-maker.ts +20 -3
- package/scripts/auto/examples/mm-spread.ts +20 -3
- package/scripts/auto/examples/price-alert.ts +4 -2
- package/scripts/auto/guardrails.test.ts +227 -0
- package/scripts/auto/guardrails.ts +700 -0
- package/scripts/auto/loader.ts +41 -4
- package/scripts/auto/realtime.test.ts +84 -0
- package/scripts/auto/realtime.ts +194 -0
- package/scripts/auto/runtime.ts +163 -69
- package/scripts/auto/types.ts +56 -1
- package/scripts/core/client.ts +175 -4
- package/scripts/core/ws.ts +57 -8
- package/scripts/lib.ts +10 -0
package/scripts/auto/runtime.ts
CHANGED
|
@@ -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 {
|
|
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' &&
|
|
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' &&
|
|
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
|
|
450
|
-
*
|
|
451
|
-
*
|
|
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
|
-
//
|
|
488
|
-
//
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
//
|
|
572
|
-
|
|
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
|
|
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
|
|
779
|
-
|
|
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
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
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
|
-
//
|
|
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(
|
|
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) {
|
package/scripts/auto/types.ts
CHANGED
|
@@ -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
|
|
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 {
|