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
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { CLIENT_WRITE_METHODS, GuardrailViolation, createGuardrailedClient, validateAutomationGuardrails, } from './guardrails.js';
|
|
4
|
+
import { listExamples, loadAutomation } from './loader.js';
|
|
5
|
+
const logger = {
|
|
6
|
+
info() { },
|
|
7
|
+
warn() { },
|
|
8
|
+
error() { },
|
|
9
|
+
debug() { },
|
|
10
|
+
};
|
|
11
|
+
function tradingPolicy(overrides = {}) {
|
|
12
|
+
return {
|
|
13
|
+
mode: 'trading',
|
|
14
|
+
allowedMarkets: ['ETH'],
|
|
15
|
+
maxOrderUsd: 1_000,
|
|
16
|
+
maxPositionUsd: 2_000,
|
|
17
|
+
maxTotalExposureUsd: 5_000,
|
|
18
|
+
maxLeverage: 2,
|
|
19
|
+
maxMarginUsedPct: 50,
|
|
20
|
+
maxOpenOrders: 10,
|
|
21
|
+
maxOrdersPerMinute: 10,
|
|
22
|
+
maxSlippageBps: 40,
|
|
23
|
+
allowMarketOrders: true,
|
|
24
|
+
allowAccountWideCancel: false,
|
|
25
|
+
...overrides,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function mockClient(options = {}) {
|
|
29
|
+
const calls = [];
|
|
30
|
+
const client = {
|
|
31
|
+
address: '0x0000000000000000000000000000000000000001',
|
|
32
|
+
walletAddress: '0x0000000000000000000000000000000000000002',
|
|
33
|
+
isApiWallet: true,
|
|
34
|
+
async getUserStateAll() {
|
|
35
|
+
return {
|
|
36
|
+
assetPositions: (options.positions ?? []).map((position) => ({
|
|
37
|
+
position: {
|
|
38
|
+
coin: position.coin,
|
|
39
|
+
szi: String(position.size),
|
|
40
|
+
positionValue: String(Math.abs(position.size * position.price)),
|
|
41
|
+
leverage: { type: 'cross', value: position.leverage ?? 1 },
|
|
42
|
+
},
|
|
43
|
+
})),
|
|
44
|
+
marginSummary: { accountValue: '10000', totalMarginUsed: '0' },
|
|
45
|
+
};
|
|
46
|
+
},
|
|
47
|
+
async getAllMids() { return { ETH: '2000', BTC: '50000' }; },
|
|
48
|
+
async getOpenOrders() { return []; },
|
|
49
|
+
async getSpotBalances() { return { balances: options.spotBalances ?? [] }; },
|
|
50
|
+
async getSpotMetaAndAssetCtxs() {
|
|
51
|
+
return options.spotData ?? { meta: { tokens: [], universe: [] }, assetCtxs: [] };
|
|
52
|
+
},
|
|
53
|
+
resolveOutcomeRef() {
|
|
54
|
+
return { outcome: 1, side: 0, encoding: 10, coin: '#10', tokenName: '+10', assetId: 100000010 };
|
|
55
|
+
},
|
|
56
|
+
async marketOrder(...args) {
|
|
57
|
+
calls.push({ method: 'marketOrder', args });
|
|
58
|
+
return { status: 'ok' };
|
|
59
|
+
},
|
|
60
|
+
async limitOrder(...args) {
|
|
61
|
+
calls.push({ method: 'limitOrder', args });
|
|
62
|
+
return { status: 'ok' };
|
|
63
|
+
},
|
|
64
|
+
async bulkOrder(...args) {
|
|
65
|
+
calls.push({ method: 'bulkOrder', args });
|
|
66
|
+
return { status: 'ok' };
|
|
67
|
+
},
|
|
68
|
+
async spotLimitOrder(...args) {
|
|
69
|
+
calls.push({ method: 'spotLimitOrder', args });
|
|
70
|
+
return { status: 'ok' };
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
return { client, calls };
|
|
74
|
+
}
|
|
75
|
+
test('guardrail schema rejects missing and internally inconsistent limits', () => {
|
|
76
|
+
assert.throws(() => validateAutomationGuardrails({ mode: 'trading' }), /allowedMarkets/);
|
|
77
|
+
assert.throws(() => validateAutomationGuardrails(tradingPolicy({ maxOrderUsd: 3_000 })), /maxOrderUsd.*maxPositionUsd/);
|
|
78
|
+
assert.deepEqual(validateAutomationGuardrails({ mode: 'read-only' }), { mode: 'read-only' });
|
|
79
|
+
});
|
|
80
|
+
test('all client write families are included in the enforcement boundary', () => {
|
|
81
|
+
for (const method of [
|
|
82
|
+
'bulkOrder', 'bulkCancel', 'scheduleCancel',
|
|
83
|
+
'outcomeOrder', 'outcomeMarketOrder', 'outcomeLimitOrder',
|
|
84
|
+
]) {
|
|
85
|
+
assert.equal(CLIENT_WRITE_METHODS.has(method), true, `${method} must be guarded`);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
test('every bundled example exports a valid guardrail policy', async () => {
|
|
89
|
+
for (const example of listExamples()) {
|
|
90
|
+
const loaded = await loadAutomation(example.path, { config: {} });
|
|
91
|
+
assert.ok(loaded.guardrails.mode === 'read-only' || loaded.guardrails.mode === 'trading');
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
test('read-only policy blocks every write before it reaches the client', async () => {
|
|
95
|
+
const { client, calls } = mockClient();
|
|
96
|
+
const guarded = createGuardrailedClient(client, {
|
|
97
|
+
policy: { mode: 'read-only' },
|
|
98
|
+
rawClient: client,
|
|
99
|
+
log: logger,
|
|
100
|
+
});
|
|
101
|
+
await assert.rejects(guarded.bulkOrder([{ coin: 'ETH', isBuy: true, size: 0.1, price: 2000 }]), (error) => error instanceof GuardrailViolation && error.code === 'read-only');
|
|
102
|
+
assert.equal(calls.length, 0);
|
|
103
|
+
});
|
|
104
|
+
test('trading policy blocks disallowed markets, missing leverage, and oversized orders', async () => {
|
|
105
|
+
const { client, calls } = mockClient();
|
|
106
|
+
const guarded = createGuardrailedClient(client, {
|
|
107
|
+
policy: tradingPolicy(),
|
|
108
|
+
rawClient: client,
|
|
109
|
+
log: logger,
|
|
110
|
+
});
|
|
111
|
+
await assert.rejects(guarded.limitOrder('BTC', true, 0.01, 50_000, 'Gtc', false, 1), (error) => error instanceof GuardrailViolation && error.code === 'market-not-allowed');
|
|
112
|
+
await assert.rejects(guarded.limitOrder('ETH', true, 0.1, 2_000), (error) => error instanceof GuardrailViolation && error.code === 'leverage-required');
|
|
113
|
+
await assert.rejects(guarded.limitOrder('ETH', true, 0.6, 2_000, 'Gtc', false, 1), (error) => error instanceof GuardrailViolation && error.code === 'order-notional');
|
|
114
|
+
assert.equal(calls.length, 0);
|
|
115
|
+
});
|
|
116
|
+
test('valid market orders execute with runtime-capped slippage', async () => {
|
|
117
|
+
const { client, calls } = mockClient();
|
|
118
|
+
const guarded = createGuardrailedClient(client, {
|
|
119
|
+
policy: tradingPolicy(),
|
|
120
|
+
rawClient: client,
|
|
121
|
+
log: logger,
|
|
122
|
+
});
|
|
123
|
+
await guarded.marketOrder('ETH', true, 0.1, undefined, 1);
|
|
124
|
+
assert.equal(calls.length, 1);
|
|
125
|
+
assert.deepEqual(calls[0], {
|
|
126
|
+
method: 'marketOrder',
|
|
127
|
+
args: ['ETH', true, 0.1, 40, 1],
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
test('account-wide exposure is checked while genuine reductions remain available above caps', async () => {
|
|
131
|
+
const exposed = mockClient({ positions: [{ coin: 'BTC', size: 0.1, price: 50_000 }] });
|
|
132
|
+
const exposureGuarded = createGuardrailedClient(exposed.client, {
|
|
133
|
+
policy: tradingPolicy({ maxTotalExposureUsd: 5_100 }),
|
|
134
|
+
rawClient: exposed.client,
|
|
135
|
+
log: logger,
|
|
136
|
+
});
|
|
137
|
+
await assert.rejects(exposureGuarded.limitOrder('ETH', true, 0.1, 2_000, 'Gtc', false, 1), (error) => error instanceof GuardrailViolation && error.code === 'total-exposure');
|
|
138
|
+
const reducing = mockClient({ positions: [{ coin: 'ETH', size: 1, price: 2_000 }] });
|
|
139
|
+
const reductionGuarded = createGuardrailedClient(reducing.client, {
|
|
140
|
+
policy: tradingPolicy({ maxPositionUsd: 1_000, maxTotalExposureUsd: 1_000 }),
|
|
141
|
+
rawClient: reducing.client,
|
|
142
|
+
log: logger,
|
|
143
|
+
});
|
|
144
|
+
await reductionGuarded.marketOrder('ETH', false, 0.1);
|
|
145
|
+
assert.equal(reducing.calls.length, 1);
|
|
146
|
+
});
|
|
147
|
+
test('spot exposure joins market contexts by pair identifier instead of array position', async () => {
|
|
148
|
+
const { client, calls } = mockClient({
|
|
149
|
+
spotBalances: [{ coin: 'HYPE', total: '3' }],
|
|
150
|
+
spotData: {
|
|
151
|
+
meta: {
|
|
152
|
+
tokens: [{ index: 0, name: 'USDC' }, { index: 1, name: 'HYPE' }],
|
|
153
|
+
universe: [{ name: '@107', tokens: [1, 0] }],
|
|
154
|
+
},
|
|
155
|
+
assetCtxs: [
|
|
156
|
+
{ coin: '@999', midPx: '99999', markPx: '99999' },
|
|
157
|
+
{ coin: '@107', midPx: '62.5', markPx: '62.5' },
|
|
158
|
+
],
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
const guarded = createGuardrailedClient(client, {
|
|
162
|
+
policy: tradingPolicy({
|
|
163
|
+
allowedMarkets: ['spot:HYPE'],
|
|
164
|
+
maxOrderUsd: 100,
|
|
165
|
+
maxPositionUsd: 500,
|
|
166
|
+
maxTotalExposureUsd: 500,
|
|
167
|
+
}),
|
|
168
|
+
rawClient: client,
|
|
169
|
+
log: logger,
|
|
170
|
+
});
|
|
171
|
+
await guarded.spotLimitOrder('HYPE', true, 0.1, 62.5, 'Gtc');
|
|
172
|
+
assert.equal(calls.length, 1);
|
|
173
|
+
});
|
package/dist/auto/loader.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { AutomationConfig, AutomationGuardrailContext, LoadedAutomation } from './types.js';
|
|
2
2
|
/** Resolve a script path from a name or path */
|
|
3
3
|
export declare function resolveScriptPath(nameOrPath: string): string;
|
|
4
4
|
/** Resolve a bundled example by name */
|
|
@@ -10,8 +10,8 @@ export declare function listExamples(): Array<{
|
|
|
10
10
|
}>;
|
|
11
11
|
/** Load config metadata from all bundled examples */
|
|
12
12
|
export declare function loadExampleConfigs(): Promise<Record<string, AutomationConfig>>;
|
|
13
|
-
/** Load an automation module and validate
|
|
14
|
-
export declare function loadAutomation(scriptPath: string): Promise<
|
|
13
|
+
/** Load an automation module and validate its factory plus required guardrails export. */
|
|
14
|
+
export declare function loadAutomation(scriptPath: string, context?: AutomationGuardrailContext): Promise<LoadedAutomation>;
|
|
15
15
|
/** List available automation scripts in ~/.openbroker/automations/ */
|
|
16
16
|
export declare function listAutomations(): Array<{
|
|
17
17
|
name: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"loader.d.ts","sourceRoot":"","sources":["../../scripts/auto/loader.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"loader.d.ts","sourceRoot":"","sources":["../../scripts/auto/loader.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAEV,gBAAgB,EAChB,0BAA0B,EAE1B,gBAAgB,EACjB,MAAM,YAAY,CAAC;AAQpB,gDAAgD;AAChD,wBAAgB,iBAAiB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CA2B5D;AAED,wCAAwC;AACxC,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAOvD;AAED,uCAAuC;AACvC,wBAAgB,YAAY,IAAI,KAAK,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CASpE;AAED,qDAAqD;AACrD,wBAAsB,kBAAkB,IAAI,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAiBpF;AAmDD,0FAA0F;AAC1F,wBAAsB,cAAc,CAClC,UAAU,EAAE,MAAM,EAClB,OAAO,GAAE,0BAA2C,GACnD,OAAO,CAAC,gBAAgB,CAAC,CA0B3B;AAED,sEAAsE;AACtE,wBAAgB,eAAe,IAAI,KAAK,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CASvE;AAED,8CAA8C;AAC9C,wBAAgB,oBAAoB,IAAI,IAAI,CAE3C"}
|
package/dist/auto/loader.js
CHANGED
|
@@ -3,6 +3,7 @@ import { existsSync, readdirSync, mkdirSync } from 'fs';
|
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import os from 'os';
|
|
5
5
|
import { fileURLToPath } from 'url';
|
|
6
|
+
import { resolveAutomationGuardrails } from './guardrails.js';
|
|
6
7
|
const __filename = fileURLToPath(import.meta.url);
|
|
7
8
|
const __dirname = path.dirname(__filename);
|
|
8
9
|
const AUTOMATIONS_DIR = path.join(os.homedir(), '.openbroker', 'automations');
|
|
@@ -98,8 +99,21 @@ function resolveAutomationConfig(mod) {
|
|
|
98
99
|
}
|
|
99
100
|
return null;
|
|
100
101
|
}
|
|
101
|
-
|
|
102
|
-
|
|
102
|
+
function resolveGuardrailsExport(mod) {
|
|
103
|
+
const candidates = [
|
|
104
|
+
mod.guardrails,
|
|
105
|
+
mod.default?.guardrails,
|
|
106
|
+
mod["module.exports"]?.guardrails,
|
|
107
|
+
];
|
|
108
|
+
for (const candidate of candidates) {
|
|
109
|
+
if (candidate && (typeof candidate === 'object' || typeof candidate === 'function')) {
|
|
110
|
+
return candidate;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
/** Load an automation module and validate its factory plus required guardrails export. */
|
|
116
|
+
export async function loadAutomation(scriptPath, context = { config: {} }) {
|
|
103
117
|
const absolutePath = path.resolve(scriptPath);
|
|
104
118
|
// Dynamic import — tsx handles TypeScript transpilation
|
|
105
119
|
const mod = await import(absolutePath);
|
|
@@ -108,7 +122,15 @@ export async function loadAutomation(scriptPath) {
|
|
|
108
122
|
throw new Error(`Automation script must export a default function.\n` +
|
|
109
123
|
`Got: ${typeof factory} from ${scriptPath}`);
|
|
110
124
|
}
|
|
111
|
-
|
|
125
|
+
const guardrailsExport = resolveGuardrailsExport(mod);
|
|
126
|
+
if (!guardrailsExport) {
|
|
127
|
+
throw new Error(`Automation script must export "guardrails".\n` +
|
|
128
|
+
`Use { mode: "read-only" } for monitoring-only scripts or a validated trading policy.`);
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
factory: factory,
|
|
132
|
+
guardrails: resolveAutomationGuardrails(guardrailsExport, context),
|
|
133
|
+
};
|
|
112
134
|
}
|
|
113
135
|
/** List available automation scripts in ~/.openbroker/automations/ */
|
|
114
136
|
export function listAutomations() {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { AssetCtx, ClearinghouseState, OpenOrder } from '../core/types.js';
|
|
2
|
+
import type { HyperliquidClient, RealtimeBookSnapshot, RealtimeDataProvider } from '../core/client.js';
|
|
3
|
+
import { WebSocketManager, type WsEventMap } from '../core/ws.js';
|
|
4
|
+
/**
|
|
5
|
+
* Runtime-owned WebSocket cache used transparently by HyperliquidClient read
|
|
6
|
+
* methods. While the socket is healthy, automation code calling getAllMids,
|
|
7
|
+
* getUserState(All), getSpotBalances, getMetaAndAssetCtxs, or getL2Book reads
|
|
8
|
+
* this cache. Missing/stale data returns null so the client falls back to REST.
|
|
9
|
+
*/
|
|
10
|
+
export declare class AutomationRealtimeData implements RealtimeDataProvider {
|
|
11
|
+
private readonly ws;
|
|
12
|
+
private readonly client;
|
|
13
|
+
private readonly user;
|
|
14
|
+
private readonly unified;
|
|
15
|
+
private readonly expectedOrderDexes;
|
|
16
|
+
private mids;
|
|
17
|
+
private assetCtxs;
|
|
18
|
+
private clearinghouse;
|
|
19
|
+
private spot;
|
|
20
|
+
private books;
|
|
21
|
+
private openOrders;
|
|
22
|
+
private bookSubscriptions;
|
|
23
|
+
private bookWaiters;
|
|
24
|
+
constructor(ws: WebSocketManager, client: HyperliquidClient, user: string, unified: boolean | null, expectedOrderDexes?: string[]);
|
|
25
|
+
get connected(): boolean;
|
|
26
|
+
getAllMids(): Record<string, string> | null;
|
|
27
|
+
getMainAssetCtxs(): AssetCtx[] | null;
|
|
28
|
+
getAllDexsAssetCtxs(): WsEventMap['allDexsAssetCtxs']['ctxs'] | null;
|
|
29
|
+
getUserState(user: string, dex?: string): ClearinghouseState | null;
|
|
30
|
+
getUserStateAll(user: string): ClearinghouseState | null;
|
|
31
|
+
getSpotBalances(user: string): ReturnType<RealtimeDataProvider['getSpotBalances']>;
|
|
32
|
+
getOpenOrders(user: string): OpenOrder[] | null;
|
|
33
|
+
getL2Book(coin: string): Promise<RealtimeBookSnapshot | null>;
|
|
34
|
+
/** Wait briefly for initial subscription snapshots before the first strategy hook runs. */
|
|
35
|
+
waitUntilReady(timeoutMs?: number): Promise<boolean>;
|
|
36
|
+
readinessSummary(): {
|
|
37
|
+
expectedOrderDexes: number;
|
|
38
|
+
seededOrderDexes: number;
|
|
39
|
+
missingOrderDexes: string[];
|
|
40
|
+
};
|
|
41
|
+
private coreFeedsReady;
|
|
42
|
+
private sameUser;
|
|
43
|
+
private fresh;
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=realtime.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"realtime.d.ts","sourceRoot":"","sources":["../../scripts/auto/realtime.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,kBAAkB,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAChF,OAAO,KAAK,EACV,iBAAiB,EACjB,oBAAoB,EACpB,oBAAoB,EACrB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,gBAAgB,EAAE,KAAK,UAAU,EAAE,MAAM,eAAe,CAAC;AAWlE;;;;;GAKG;AACH,qBAAa,sBAAuB,YAAW,oBAAoB;IAW/D,OAAO,CAAC,QAAQ,CAAC,EAAE;IACnB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,kBAAkB;IAdrC,OAAO,CAAC,IAAI,CAA8C;IAC1D,OAAO,CAAC,SAAS,CAA8D;IAC/E,OAAO,CAAC,aAAa,CAAsF;IAC3G,OAAO,CAAC,IAAI,CAA+C;IAC3D,OAAO,CAAC,KAAK,CAAkD;IAC/D,OAAO,CAAC,UAAU,CAAyC;IAC3D,OAAO,CAAC,iBAAiB,CAAqB;IAC9C,OAAO,CAAC,WAAW,CAAsC;gBAGtC,EAAE,EAAE,gBAAgB,EACpB,MAAM,EAAE,iBAAiB,EACzB,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,OAAO,GAAG,IAAI,EACvB,kBAAkB,GAAE,MAAM,EAAS;IAqBtD,IAAI,SAAS,IAAI,OAAO,CAEvB;IAED,UAAU,IAAI,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI;IAI3C,gBAAgB,IAAI,QAAQ,EAAE,GAAG,IAAI;IAOrC,mBAAmB,IAAI,UAAU,CAAC,kBAAkB,CAAC,CAAC,MAAM,CAAC,GAAG,IAAI;IAIpE,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,kBAAkB,GAAG,IAAI;IAmBnE,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,kBAAkB,GAAG,IAAI;IASxD,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,CAAC,oBAAoB,CAAC,iBAAiB,CAAC,CAAC;IAUlF,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,EAAE,GAAG,IAAI;IAWzC,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,oBAAoB,GAAG,IAAI,CAAC;IAiCnE,2FAA2F;IACrF,cAAc,CAAC,SAAS,SAAS,GAAG,OAAO,CAAC,OAAO,CAAC;IAS1D,gBAAgB,IAAI;QAAE,kBAAkB,EAAE,MAAM,CAAC;QAAC,gBAAgB,EAAE,MAAM,CAAC;QAAC,iBAAiB,EAAE,MAAM,EAAE,CAAA;KAAE;IASzG,OAAO,CAAC,cAAc;IAMtB,OAAO,CAAC,QAAQ;IAIhB,OAAO,CAAC,KAAK;CAId"}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
const MARKET_DATA_STALE_MS = 45_000;
|
|
2
|
+
const BOOK_STALE_MS = 10_000;
|
|
3
|
+
const BOOK_SEED_TIMEOUT_MS = 1_500;
|
|
4
|
+
/**
|
|
5
|
+
* Runtime-owned WebSocket cache used transparently by HyperliquidClient read
|
|
6
|
+
* methods. While the socket is healthy, automation code calling getAllMids,
|
|
7
|
+
* getUserState(All), getSpotBalances, getMetaAndAssetCtxs, or getL2Book reads
|
|
8
|
+
* this cache. Missing/stale data returns null so the client falls back to REST.
|
|
9
|
+
*/
|
|
10
|
+
export class AutomationRealtimeData {
|
|
11
|
+
ws;
|
|
12
|
+
client;
|
|
13
|
+
user;
|
|
14
|
+
unified;
|
|
15
|
+
expectedOrderDexes;
|
|
16
|
+
mids = null;
|
|
17
|
+
assetCtxs = null;
|
|
18
|
+
clearinghouse = null;
|
|
19
|
+
spot = null;
|
|
20
|
+
books = new Map();
|
|
21
|
+
openOrders = new Map();
|
|
22
|
+
bookSubscriptions = new Set();
|
|
23
|
+
bookWaiters = new Map();
|
|
24
|
+
constructor(ws, client, user, unified, expectedOrderDexes = ['']) {
|
|
25
|
+
this.ws = ws;
|
|
26
|
+
this.client = client;
|
|
27
|
+
this.user = user;
|
|
28
|
+
this.unified = unified;
|
|
29
|
+
this.expectedOrderDexes = expectedOrderDexes;
|
|
30
|
+
ws.on('allMids', (data) => { this.mids = { value: data.mids, timestamp: Date.now() }; });
|
|
31
|
+
ws.on('allDexsAssetCtxs', (data) => { this.assetCtxs = { value: data.ctxs, timestamp: Date.now() }; });
|
|
32
|
+
ws.on('allDexsClearinghouseState', (data) => {
|
|
33
|
+
this.clearinghouse = { value: data.clearinghouseStates, timestamp: Date.now() };
|
|
34
|
+
});
|
|
35
|
+
ws.on('spotState', (data) => { this.spot = { value: data, timestamp: Date.now() }; });
|
|
36
|
+
ws.on('openOrders', (data) => {
|
|
37
|
+
if (this.sameUser(data.user))
|
|
38
|
+
this.openOrders.set(data.dex || '', { value: data.orders, timestamp: Date.now() });
|
|
39
|
+
});
|
|
40
|
+
ws.on('l2Book', (data) => {
|
|
41
|
+
this.books.set(data.coin, { value: data, timestamp: Date.now() });
|
|
42
|
+
const waiters = this.bookWaiters.get(data.coin);
|
|
43
|
+
if (waiters) {
|
|
44
|
+
for (const resolve of waiters)
|
|
45
|
+
resolve();
|
|
46
|
+
waiters.clear();
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
get connected() {
|
|
51
|
+
return this.ws.connected;
|
|
52
|
+
}
|
|
53
|
+
getAllMids() {
|
|
54
|
+
return this.fresh(this.mids, MARKET_DATA_STALE_MS)?.value ?? null;
|
|
55
|
+
}
|
|
56
|
+
getMainAssetCtxs() {
|
|
57
|
+
const groups = this.fresh(this.assetCtxs, MARKET_DATA_STALE_MS)?.value;
|
|
58
|
+
if (!groups)
|
|
59
|
+
return null;
|
|
60
|
+
const main = groups.find(([dex]) => !dex || dex === 'main') ?? groups[0];
|
|
61
|
+
return main?.[1] ?? null;
|
|
62
|
+
}
|
|
63
|
+
getAllDexsAssetCtxs() {
|
|
64
|
+
return this.fresh(this.assetCtxs, MARKET_DATA_STALE_MS)?.value ?? null;
|
|
65
|
+
}
|
|
66
|
+
getUserState(user, dex) {
|
|
67
|
+
if (!this.sameUser(user))
|
|
68
|
+
return null;
|
|
69
|
+
const groups = this.clearinghouse?.value;
|
|
70
|
+
if (!groups)
|
|
71
|
+
return null;
|
|
72
|
+
const targetDex = dex ?? '';
|
|
73
|
+
const raw = groups.find(([name]) => (name || '') === targetDex)?.[1];
|
|
74
|
+
if (!raw)
|
|
75
|
+
return null;
|
|
76
|
+
const withdrawable = raw.withdrawable;
|
|
77
|
+
return {
|
|
78
|
+
...raw,
|
|
79
|
+
marginSummary: raw.marginSummary && withdrawable != null
|
|
80
|
+
? { ...raw.marginSummary, withdrawable }
|
|
81
|
+
: raw.marginSummary,
|
|
82
|
+
crossMarginSummary: raw.crossMarginSummary && withdrawable != null
|
|
83
|
+
? { ...raw.crossMarginSummary, withdrawable }
|
|
84
|
+
: raw.crossMarginSummary,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
getUserStateAll(user) {
|
|
88
|
+
if (!this.sameUser(user) || !this.clearinghouse || this.unified === null)
|
|
89
|
+
return null;
|
|
90
|
+
return this.client.userStateAllFromWs(this.clearinghouse.value, this.unified, this.spot ? { balances: this.spot.value.balances } : undefined);
|
|
91
|
+
}
|
|
92
|
+
getSpotBalances(user) {
|
|
93
|
+
if (!this.sameUser(user) || !this.spot)
|
|
94
|
+
return null;
|
|
95
|
+
return {
|
|
96
|
+
balances: this.spot.value.balances.map((balance) => ({
|
|
97
|
+
...balance,
|
|
98
|
+
entryNtl: balance.entryNtl ?? '0',
|
|
99
|
+
})),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
getOpenOrders(user) {
|
|
103
|
+
if (!this.sameUser(user) || !this.connected)
|
|
104
|
+
return null;
|
|
105
|
+
const orders = [];
|
|
106
|
+
for (const dex of new Set(['', ...this.expectedOrderDexes])) {
|
|
107
|
+
const entry = this.openOrders.get(dex);
|
|
108
|
+
if (!entry)
|
|
109
|
+
return null;
|
|
110
|
+
orders.push(...entry.value);
|
|
111
|
+
}
|
|
112
|
+
return orders;
|
|
113
|
+
}
|
|
114
|
+
async getL2Book(coin) {
|
|
115
|
+
const cached = this.fresh(this.books.get(coin) ?? null, BOOK_STALE_MS);
|
|
116
|
+
if (cached)
|
|
117
|
+
return cached.value;
|
|
118
|
+
if (!this.connected)
|
|
119
|
+
return null;
|
|
120
|
+
if (!this.bookSubscriptions.has(coin)) {
|
|
121
|
+
this.bookSubscriptions.add(coin);
|
|
122
|
+
try {
|
|
123
|
+
await this.ws.subscribeL2Book(coin);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
this.bookSubscriptions.delete(coin);
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const seeded = this.fresh(this.books.get(coin) ?? null, BOOK_STALE_MS);
|
|
131
|
+
if (seeded)
|
|
132
|
+
return seeded.value;
|
|
133
|
+
await new Promise((resolve) => {
|
|
134
|
+
const waiters = this.bookWaiters.get(coin) ?? new Set();
|
|
135
|
+
this.bookWaiters.set(coin, waiters);
|
|
136
|
+
const done = () => {
|
|
137
|
+
clearTimeout(timer);
|
|
138
|
+
waiters.delete(done);
|
|
139
|
+
resolve();
|
|
140
|
+
};
|
|
141
|
+
const timer = setTimeout(done, BOOK_SEED_TIMEOUT_MS);
|
|
142
|
+
waiters.add(done);
|
|
143
|
+
});
|
|
144
|
+
return this.fresh(this.books.get(coin) ?? null, BOOK_STALE_MS)?.value ?? null;
|
|
145
|
+
}
|
|
146
|
+
/** Wait briefly for initial subscription snapshots before the first strategy hook runs. */
|
|
147
|
+
async waitUntilReady(timeoutMs = 10_000) {
|
|
148
|
+
const deadline = Date.now() + timeoutMs;
|
|
149
|
+
while (Date.now() < deadline) {
|
|
150
|
+
if (this.coreFeedsReady())
|
|
151
|
+
return true;
|
|
152
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
153
|
+
}
|
|
154
|
+
return this.coreFeedsReady();
|
|
155
|
+
}
|
|
156
|
+
readinessSummary() {
|
|
157
|
+
const expected = [...new Set(['', ...this.expectedOrderDexes])];
|
|
158
|
+
return {
|
|
159
|
+
expectedOrderDexes: expected.length,
|
|
160
|
+
seededOrderDexes: expected.filter((dex) => this.openOrders.has(dex)).length,
|
|
161
|
+
missingOrderDexes: expected.filter((dex) => !this.openOrders.has(dex)),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
coreFeedsReady() {
|
|
165
|
+
const ordersReady = [...new Set(['', ...this.expectedOrderDexes])]
|
|
166
|
+
.every((dex) => this.openOrders.has(dex));
|
|
167
|
+
return Boolean(this.mids && this.assetCtxs && this.clearinghouse && this.spot && ordersReady);
|
|
168
|
+
}
|
|
169
|
+
sameUser(user) {
|
|
170
|
+
return user.toLowerCase() === this.user.toLowerCase();
|
|
171
|
+
}
|
|
172
|
+
fresh(entry, maxAgeMs) {
|
|
173
|
+
if (!this.connected || !entry || Date.now() - entry.timestamp > maxAgeMs)
|
|
174
|
+
return null;
|
|
175
|
+
return entry;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"realtime.test.d.ts","sourceRoot":"","sources":["../../scripts/auto/realtime.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { AutomationRealtimeData } from './realtime.js';
|
|
4
|
+
class FakeWebSocket {
|
|
5
|
+
connected = true;
|
|
6
|
+
handlers = new Map();
|
|
7
|
+
on(event, handler) {
|
|
8
|
+
const set = this.handlers.get(event) ?? new Set();
|
|
9
|
+
set.add(handler);
|
|
10
|
+
this.handlers.set(event, set);
|
|
11
|
+
}
|
|
12
|
+
emit(event, value) {
|
|
13
|
+
for (const handler of this.handlers.get(event) ?? [])
|
|
14
|
+
handler(value);
|
|
15
|
+
}
|
|
16
|
+
async subscribeL2Book(coin) {
|
|
17
|
+
queueMicrotask(() => this.emit('l2Book', {
|
|
18
|
+
coin,
|
|
19
|
+
time: Date.now(),
|
|
20
|
+
levels: [
|
|
21
|
+
[{ px: '99', sz: '2', n: 1 }],
|
|
22
|
+
[{ px: '101', sz: '3', n: 1 }],
|
|
23
|
+
],
|
|
24
|
+
}));
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const ADDRESS = '0x0000000000000000000000000000000000000001';
|
|
29
|
+
test('realtime cache serves subscribed market/account data and seeds books on demand', async () => {
|
|
30
|
+
const ws = new FakeWebSocket();
|
|
31
|
+
const mergedState = {
|
|
32
|
+
assetPositions: [],
|
|
33
|
+
marginSummary: { accountValue: '100', totalNtlPos: '0', totalRawUsd: '100', totalMarginUsed: '0', withdrawable: '100' },
|
|
34
|
+
crossMarginSummary: { accountValue: '100', totalNtlPos: '0', totalRawUsd: '100', totalMarginUsed: '0', withdrawable: '100' },
|
|
35
|
+
crossMaintenanceMarginUsed: '0',
|
|
36
|
+
};
|
|
37
|
+
const client = {
|
|
38
|
+
userStateAllFromWs: () => mergedState,
|
|
39
|
+
};
|
|
40
|
+
const cache = new AutomationRealtimeData(ws, client, ADDRESS, true);
|
|
41
|
+
ws.emit('allMids', { mids: { HYPE: '100' } });
|
|
42
|
+
ws.emit('allDexsAssetCtxs', { ctxs: [['', [{ funding: '0.0001', openInterest: '1', dayNtlVlm: '2', premium: '0', oraclePx: '100', markPx: '100', prevDayPx: '99' }]]] });
|
|
43
|
+
ws.emit('spotState', { balances: [{ coin: 'USDC', token: 0, total: '100', hold: '0' }] });
|
|
44
|
+
ws.emit('openOrders', { user: ADDRESS, dex: '', orders: [] });
|
|
45
|
+
ws.emit('allDexsClearinghouseState', {
|
|
46
|
+
user: ADDRESS,
|
|
47
|
+
clearinghouseStates: [['', {
|
|
48
|
+
assetPositions: [],
|
|
49
|
+
marginSummary: { accountValue: '100', totalNtlPos: '0', totalRawUsd: '100', totalMarginUsed: '0', withdrawable: '100' },
|
|
50
|
+
crossMarginSummary: { accountValue: '100', totalNtlPos: '0', totalRawUsd: '100', totalMarginUsed: '0', withdrawable: '100' },
|
|
51
|
+
crossMaintenanceMarginUsed: '0',
|
|
52
|
+
withdrawable: '100',
|
|
53
|
+
}]],
|
|
54
|
+
});
|
|
55
|
+
assert.equal(await cache.waitUntilReady(20), true);
|
|
56
|
+
assert.equal(cache.getAllMids()?.HYPE, '100');
|
|
57
|
+
assert.equal(cache.getMainAssetCtxs()?.[0]?.funding, '0.0001');
|
|
58
|
+
assert.equal(cache.getSpotBalances(ADDRESS)?.balances[0]?.entryNtl, '0');
|
|
59
|
+
assert.deepEqual(cache.getOpenOrders(ADDRESS), []);
|
|
60
|
+
assert.equal(cache.getUserState(ADDRESS)?.marginSummary.accountValue, '100');
|
|
61
|
+
assert.equal(cache.getUserStateAll(ADDRESS)?.marginSummary.accountValue, '100');
|
|
62
|
+
const book = await cache.getL2Book('HYPE');
|
|
63
|
+
assert.equal(book?.levels[0][0]?.px, '99');
|
|
64
|
+
assert.equal(book?.levels[1][0]?.px, '101');
|
|
65
|
+
});
|
|
66
|
+
test('realtime cache declines reads while disconnected so the client can fall back to REST', () => {
|
|
67
|
+
const ws = new FakeWebSocket();
|
|
68
|
+
const client = { userStateAllFromWs: () => null };
|
|
69
|
+
const cache = new AutomationRealtimeData(ws, client, ADDRESS, false);
|
|
70
|
+
ws.emit('allMids', { mids: { HYPE: '100' } });
|
|
71
|
+
ws.connected = false;
|
|
72
|
+
assert.equal(cache.getAllMids(), null);
|
|
73
|
+
});
|
package/dist/auto/runtime.d.ts
CHANGED
|
@@ -13,9 +13,9 @@ export interface RuntimeOptions {
|
|
|
13
13
|
/** Pre-seed state before the factory function runs (e.g. from --set key=value) */
|
|
14
14
|
initialState?: Record<string, unknown>;
|
|
15
15
|
/**
|
|
16
|
-
* Enable WebSocket
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* Enable WebSocket-first market/account data and events. REST is used for
|
|
17
|
+
* initial static metadata, minute reconciliation, and automatic fallback
|
|
18
|
+
* while the socket is unavailable.
|
|
19
19
|
* @default true
|
|
20
20
|
*/
|
|
21
21
|
useWebSocket?: boolean;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../../scripts/auto/runtime.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../../scripts/auto/runtime.ts"],"names":[],"mappings":"AAgBA,OAAO,EAA4C,wBAAwB,IAAI,qBAAqB,EAAE,MAAM,eAAe,CAAC;AAG5H,OAAO,KAAK,EAYV,iBAAiB,EAElB,MAAM,YAAY,CAAC;AAqYpB,MAAM,WAAW,cAAc;IAC7B,UAAU,EAAE,MAAM,CAAC;IACnB,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,sFAAsF;IACtF,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,uEAAuE;IACvE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,kFAAkF;IAClF,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACvC;;;;;OAKG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB;;;OAGG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAKD,wBAAgB,qBAAqB,IAAI,iBAAiB,EAAE,CAE3D;AAED,wBAAgB,aAAa,CAAC,EAAE,EAAE,MAAM,GAAG,iBAAiB,GAAG,SAAS,CAEvE;AAED,8EAA8E;AAC9E,OAAO,EAAE,qBAAqB,IAAI,wBAAwB,EAAE,CAAC;AAE7D,wBAAsB,eAAe,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,iBAAiB,CAAC,CA+nBzF"}
|