openbroker 1.9.1 → 1.9.3

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 (57) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +11 -0
  3. package/SKILL.md +58 -1
  4. package/bin/cli.ts +3 -0
  5. package/dist/auto/cli.js +3 -0
  6. package/dist/auto/examples/dca.d.ts +2 -1
  7. package/dist/auto/examples/dca.d.ts.map +1 -1
  8. package/dist/auto/examples/dca.js +19 -1
  9. package/dist/auto/examples/funding-arb.d.ts +2 -1
  10. package/dist/auto/examples/funding-arb.d.ts.map +1 -1
  11. package/dist/auto/examples/funding-arb.js +19 -2
  12. package/dist/auto/examples/grid.d.ts +2 -1
  13. package/dist/auto/examples/grid.d.ts.map +1 -1
  14. package/dist/auto/examples/grid.js +18 -2
  15. package/dist/auto/examples/mm-maker.d.ts +2 -1
  16. package/dist/auto/examples/mm-maker.d.ts.map +1 -1
  17. package/dist/auto/examples/mm-maker.js +18 -2
  18. package/dist/auto/examples/mm-spread.d.ts +2 -1
  19. package/dist/auto/examples/mm-spread.d.ts.map +1 -1
  20. package/dist/auto/examples/mm-spread.js +18 -2
  21. package/dist/auto/examples/price-alert.d.ts +2 -1
  22. package/dist/auto/examples/price-alert.d.ts.map +1 -1
  23. package/dist/auto/examples/price-alert.js +1 -0
  24. package/dist/auto/guardrails.d.ts +19 -0
  25. package/dist/auto/guardrails.d.ts.map +1 -0
  26. package/dist/auto/guardrails.js +575 -0
  27. package/dist/auto/guardrails.test.d.ts +2 -0
  28. package/dist/auto/guardrails.test.d.ts.map +1 -0
  29. package/dist/auto/guardrails.test.js +173 -0
  30. package/dist/auto/loader.d.ts +3 -3
  31. package/dist/auto/loader.d.ts.map +1 -1
  32. package/dist/auto/loader.js +25 -3
  33. package/dist/auto/runtime.d.ts.map +1 -1
  34. package/dist/auto/runtime.js +38 -20
  35. package/dist/auto/types.d.ts +43 -0
  36. package/dist/auto/types.d.ts.map +1 -1
  37. package/dist/lib.d.ts +2 -0
  38. package/dist/lib.d.ts.map +1 -1
  39. package/dist/lib.js +1 -0
  40. package/dist/setup/install.d.ts +3 -0
  41. package/dist/setup/install.d.ts.map +1 -0
  42. package/dist/setup/install.js +113 -0
  43. package/package.json +4 -3
  44. package/scripts/auto/cli.ts +3 -0
  45. package/scripts/auto/examples/dca.ts +21 -2
  46. package/scripts/auto/examples/funding-arb.ts +21 -3
  47. package/scripts/auto/examples/grid.ts +20 -3
  48. package/scripts/auto/examples/mm-maker.ts +20 -3
  49. package/scripts/auto/examples/mm-spread.ts +20 -3
  50. package/scripts/auto/examples/price-alert.ts +3 -1
  51. package/scripts/auto/guardrails.test.ts +227 -0
  52. package/scripts/auto/guardrails.ts +700 -0
  53. package/scripts/auto/loader.ts +41 -4
  54. package/scripts/auto/runtime.ts +38 -22
  55. package/scripts/auto/types.ts +54 -0
  56. package/scripts/lib.ts +10 -0
  57. package/scripts/setup/install.ts +146 -0
@@ -0,0 +1,700 @@
1
+ // Automation guardrail schema validation and runtime enforcement.
2
+
3
+ import type { HyperliquidClient } from '../core/client.js';
4
+ import { normalizeCoin } from '../core/utils.js';
5
+ import type {
6
+ AutomationGuardrailContext,
7
+ AutomationGuardrails,
8
+ AutomationGuardrailsExport,
9
+ AutomationLogger,
10
+ TradingAutomationGuardrails,
11
+ } from './types.js';
12
+
13
+ export const CLIENT_WRITE_METHODS = new Set([
14
+ 'order', 'bulkOrder', 'marketOrder', 'limitOrder', 'triggerOrder',
15
+ 'takeProfit', 'stopLoss',
16
+ 'cancel', 'bulkCancel', 'cancelAll', 'scheduleCancel',
17
+ 'spotOrder', 'spotMarketOrder', 'spotLimitOrder', 'spotCancel',
18
+ 'outcomeOrder', 'outcomeMarketOrder', 'outcomeLimitOrder',
19
+ 'updateLeverage', 'approveBuilderFee',
20
+ 'twapOrder', 'twapCancel',
21
+ ]);
22
+
23
+ const TRADING_KEYS = new Set([
24
+ 'mode',
25
+ 'allowedMarkets',
26
+ 'maxOrderUsd',
27
+ 'maxPositionUsd',
28
+ 'maxTotalExposureUsd',
29
+ 'maxLeverage',
30
+ 'maxMarginUsedPct',
31
+ 'maxOpenOrders',
32
+ 'maxOrdersPerMinute',
33
+ 'maxSlippageBps',
34
+ 'allowMarketOrders',
35
+ 'allowAccountWideCancel',
36
+ ]);
37
+
38
+ export class GuardrailViolation extends Error {
39
+ readonly code: string;
40
+
41
+ constructor(code: string, message: string) {
42
+ super(`[guardrail:${code}] ${message}`);
43
+ this.name = 'GuardrailViolation';
44
+ this.code = code;
45
+ }
46
+ }
47
+
48
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
49
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
50
+ }
51
+
52
+ function schemaError(message: string): never {
53
+ throw new Error(`Invalid automation guardrails: ${message}`);
54
+ }
55
+
56
+ function requirePositiveNumber(
57
+ value: unknown,
58
+ field: keyof TradingAutomationGuardrails,
59
+ opts: { integer?: boolean; max?: number } = {},
60
+ ): number {
61
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
62
+ schemaError(`"${field}" must be a finite number greater than 0`);
63
+ }
64
+ if (opts.integer && !Number.isInteger(value)) {
65
+ schemaError(`"${field}" must be an integer`);
66
+ }
67
+ if (opts.max !== undefined && value > opts.max) {
68
+ schemaError(`"${field}" must be less than or equal to ${opts.max}`);
69
+ }
70
+ return value;
71
+ }
72
+
73
+ function requireBoolean(value: unknown, field: keyof TradingAutomationGuardrails): boolean {
74
+ if (typeof value !== 'boolean') schemaError(`"${field}" must be a boolean`);
75
+ return value;
76
+ }
77
+
78
+ export function canonicalMarket(market: string): string {
79
+ const trimmed = market.trim();
80
+ if (trimmed.startsWith('spot:')) {
81
+ return `spot:${trimmed.slice(5).toUpperCase()}`;
82
+ }
83
+ if (trimmed.startsWith('#')) return trimmed;
84
+ return normalizeCoin(trimmed);
85
+ }
86
+
87
+ export function validateAutomationGuardrails(value: unknown): AutomationGuardrails {
88
+ if (!isPlainObject(value)) schemaError('export must be an object');
89
+
90
+ if (value.mode === 'read-only') {
91
+ const unknown = Object.keys(value).filter((key) => key !== 'mode');
92
+ if (unknown.length > 0) {
93
+ schemaError(`read-only mode has unknown field(s): ${unknown.join(', ')}`);
94
+ }
95
+ return { mode: 'read-only' };
96
+ }
97
+
98
+ if (value.mode !== 'trading') {
99
+ schemaError('"mode" must be either "read-only" or "trading"');
100
+ }
101
+
102
+ const unknown = Object.keys(value).filter((key) => !TRADING_KEYS.has(key));
103
+ if (unknown.length > 0) schemaError(`unknown field(s): ${unknown.join(', ')}`);
104
+
105
+ if (!Array.isArray(value.allowedMarkets) || value.allowedMarkets.length === 0) {
106
+ schemaError('"allowedMarkets" must be a non-empty array');
107
+ }
108
+ const allowedMarkets = value.allowedMarkets.map((market, index) => {
109
+ if (typeof market !== 'string' || market.trim() === '') {
110
+ schemaError(`"allowedMarkets[${index}]" must be a non-empty string`);
111
+ }
112
+ if (market === '*') schemaError('wildcard markets are not allowed');
113
+ return canonicalMarket(market);
114
+ });
115
+ if (new Set(allowedMarkets).size !== allowedMarkets.length) {
116
+ schemaError('"allowedMarkets" contains duplicates after normalization');
117
+ }
118
+
119
+ const guardrails: TradingAutomationGuardrails = {
120
+ mode: 'trading',
121
+ allowedMarkets,
122
+ maxOrderUsd: requirePositiveNumber(value.maxOrderUsd, 'maxOrderUsd'),
123
+ maxPositionUsd: requirePositiveNumber(value.maxPositionUsd, 'maxPositionUsd'),
124
+ maxTotalExposureUsd: requirePositiveNumber(value.maxTotalExposureUsd, 'maxTotalExposureUsd'),
125
+ maxLeverage: requirePositiveNumber(value.maxLeverage, 'maxLeverage', { integer: true, max: 100 }),
126
+ maxMarginUsedPct: requirePositiveNumber(value.maxMarginUsedPct, 'maxMarginUsedPct', { max: 100 }),
127
+ maxOpenOrders: requirePositiveNumber(value.maxOpenOrders, 'maxOpenOrders', { integer: true }),
128
+ maxOrdersPerMinute: requirePositiveNumber(value.maxOrdersPerMinute, 'maxOrdersPerMinute', { integer: true }),
129
+ maxSlippageBps: requirePositiveNumber(value.maxSlippageBps, 'maxSlippageBps', { integer: true, max: 10_000 }),
130
+ allowMarketOrders: requireBoolean(value.allowMarketOrders, 'allowMarketOrders'),
131
+ allowAccountWideCancel: requireBoolean(value.allowAccountWideCancel, 'allowAccountWideCancel'),
132
+ };
133
+
134
+ if (guardrails.maxOrderUsd > guardrails.maxPositionUsd) {
135
+ schemaError('"maxOrderUsd" cannot exceed "maxPositionUsd"');
136
+ }
137
+ if (guardrails.maxPositionUsd > guardrails.maxTotalExposureUsd) {
138
+ schemaError('"maxPositionUsd" cannot exceed "maxTotalExposureUsd"');
139
+ }
140
+
141
+ return guardrails;
142
+ }
143
+
144
+ export function resolveAutomationGuardrails(
145
+ exported: AutomationGuardrailsExport,
146
+ context: AutomationGuardrailContext,
147
+ ): AutomationGuardrails {
148
+ let value: unknown;
149
+ try {
150
+ value = typeof exported === 'function' ? exported(context) : exported;
151
+ } catch (error) {
152
+ throw new Error(
153
+ `Automation guardrails factory failed: ${error instanceof Error ? error.message : String(error)}`,
154
+ );
155
+ }
156
+ return validateAutomationGuardrails(value);
157
+ }
158
+
159
+ interface Holding {
160
+ quantity: number;
161
+ price: number;
162
+ leverage?: number;
163
+ kind: 'perp' | 'spot' | 'outcome';
164
+ }
165
+
166
+ interface RiskSnapshot {
167
+ holdings: Map<string, Holding>;
168
+ equity: number;
169
+ marginUsed: number;
170
+ openOrders: number;
171
+ prices: Map<string, number>;
172
+ loadedAt: number;
173
+ }
174
+
175
+ interface ProposedOrder {
176
+ market: string;
177
+ kind: Holding['kind'];
178
+ isBuy: boolean;
179
+ size: number;
180
+ price?: number;
181
+ reduceOnly: boolean;
182
+ leverage?: number;
183
+ contingent?: boolean;
184
+ resting: boolean;
185
+ slippageBps?: number;
186
+ }
187
+
188
+ function finitePositive(value: unknown, label: string): number {
189
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
190
+ throw new GuardrailViolation('invalid-order', `${label} must be a finite number greater than 0`);
191
+ }
192
+ return value;
193
+ }
194
+
195
+ function canonicalSpotMarket(coin: unknown): string {
196
+ if (typeof coin !== 'string' || coin.trim() === '') {
197
+ throw new GuardrailViolation('invalid-market', 'spot market must be a non-empty string');
198
+ }
199
+ return canonicalMarket(`spot:${coin}`);
200
+ }
201
+
202
+ function canonicalPerpMarket(coin: unknown): string {
203
+ if (typeof coin !== 'string' || coin.trim() === '') {
204
+ throw new GuardrailViolation('invalid-market', 'perp market must be a non-empty string');
205
+ }
206
+ return canonicalMarket(coin);
207
+ }
208
+
209
+ function marketKind(market: string): Holding['kind'] {
210
+ if (market.startsWith('spot:')) return 'spot';
211
+ if (market.startsWith('#')) return 'outcome';
212
+ return 'perp';
213
+ }
214
+
215
+ function assertAllowedMarket(policy: TradingAutomationGuardrails, market: string): void {
216
+ if (!policy.allowedMarkets.includes(market)) {
217
+ throw new GuardrailViolation(
218
+ 'market-not-allowed',
219
+ `${market} is not in allowedMarkets (${policy.allowedMarkets.join(', ')})`,
220
+ );
221
+ }
222
+ }
223
+
224
+ function asBoolean(value: unknown, fallback = false): boolean {
225
+ return typeof value === 'boolean' ? value : fallback;
226
+ }
227
+
228
+ function orderSide(value: unknown, label = 'isBuy'): boolean {
229
+ if (typeof value !== 'boolean') {
230
+ throw new GuardrailViolation('invalid-order', `${label} must be a boolean`);
231
+ }
232
+ return value;
233
+ }
234
+
235
+ function isRestingOrderType(value: unknown): boolean {
236
+ if (!isPlainObject(value) || !isPlainObject(value.limit)) return true;
237
+ return value.limit.tif !== 'Ioc';
238
+ }
239
+
240
+ function isRestingTif(value: unknown, fallback: 'Gtc' | 'Ioc' | 'Alo' = 'Gtc'): boolean {
241
+ return (value ?? fallback) !== 'Ioc';
242
+ }
243
+
244
+ function prepareMarketOrderArgs(
245
+ method: string,
246
+ args: unknown[],
247
+ policy: TradingAutomationGuardrails,
248
+ ): unknown[] {
249
+ const next = [...args];
250
+ const slippageIndex = method === 'outcomeMarketOrder' ? 4 : 3;
251
+ const requested = next[slippageIndex];
252
+ if (requested === undefined) {
253
+ next[slippageIndex] = policy.maxSlippageBps;
254
+ } else {
255
+ const slippage = finitePositive(requested, 'slippageBps');
256
+ if (slippage > policy.maxSlippageBps) {
257
+ throw new GuardrailViolation(
258
+ 'slippage-limit',
259
+ `requested ${slippage} bps exceeds maxSlippageBps ${policy.maxSlippageBps}`,
260
+ );
261
+ }
262
+ }
263
+ return next;
264
+ }
265
+
266
+ function proposedOrders(
267
+ method: string,
268
+ args: unknown[],
269
+ rawClient: HyperliquidClient,
270
+ ): ProposedOrder[] {
271
+ switch (method) {
272
+ case 'order':
273
+ return [{
274
+ market: canonicalPerpMarket(args[0]), kind: 'perp', isBuy: orderSide(args[1]),
275
+ size: finitePositive(args[2], 'size'), price: finitePositive(args[3], 'price'),
276
+ reduceOnly: asBoolean(args[5]), leverage: args[7] as number | undefined,
277
+ resting: isRestingOrderType(args[4]),
278
+ }];
279
+ case 'bulkOrder': {
280
+ if (!Array.isArray(args[0]) || args[0].length === 0) {
281
+ throw new GuardrailViolation('invalid-order', 'bulkOrder requires at least one order');
282
+ }
283
+ return args[0].map((value, index) => {
284
+ if (!isPlainObject(value)) throw new GuardrailViolation('invalid-order', `bulkOrder[${index}] must be an object`);
285
+ return {
286
+ market: canonicalPerpMarket(value.coin), kind: 'perp', isBuy: orderSide(value.isBuy, `bulkOrder[${index}].isBuy`),
287
+ size: finitePositive(value.size, `bulkOrder[${index}].size`),
288
+ price: finitePositive(value.price, `bulkOrder[${index}].price`),
289
+ reduceOnly: asBoolean(value.reduceOnly), leverage: value.leverage as number | undefined,
290
+ resting: isRestingTif(value.tif),
291
+ };
292
+ });
293
+ }
294
+ case 'marketOrder':
295
+ return [{
296
+ market: canonicalPerpMarket(args[0]), kind: 'perp', isBuy: orderSide(args[1]),
297
+ size: finitePositive(args[2], 'size'), reduceOnly: false, leverage: args[4] as number | undefined,
298
+ resting: false, slippageBps: args[3] as number,
299
+ }];
300
+ case 'limitOrder':
301
+ return [{
302
+ market: canonicalPerpMarket(args[0]), kind: 'perp', isBuy: orderSide(args[1]),
303
+ size: finitePositive(args[2], 'size'), price: finitePositive(args[3], 'price'),
304
+ reduceOnly: asBoolean(args[5]), leverage: args[6] as number | undefined,
305
+ resting: isRestingTif(args[4]),
306
+ }];
307
+ case 'triggerOrder':
308
+ return [{
309
+ market: canonicalPerpMarket(args[0]), kind: 'perp', isBuy: orderSide(args[1]),
310
+ size: finitePositive(args[2], 'size'), price: finitePositive(args[4], 'limitPrice'),
311
+ reduceOnly: args[6] === undefined ? true : asBoolean(args[6]), leverage: args[7] as number | undefined,
312
+ contingent: true,
313
+ resting: true,
314
+ }];
315
+ case 'stopLoss':
316
+ case 'takeProfit':
317
+ return [{
318
+ market: canonicalPerpMarket(args[0]), kind: 'perp', isBuy: orderSide(args[1]),
319
+ size: finitePositive(args[2], 'size'), price: finitePositive(args[3], 'triggerPrice'),
320
+ reduceOnly: true, contingent: true, resting: true,
321
+ }];
322
+ case 'spotOrder':
323
+ return [{
324
+ market: canonicalSpotMarket(args[0]), kind: 'spot', isBuy: orderSide(args[1]),
325
+ size: finitePositive(args[2], 'size'), price: finitePositive(args[3], 'price'), reduceOnly: false,
326
+ resting: isRestingOrderType(args[4]),
327
+ }];
328
+ case 'spotMarketOrder':
329
+ return [{
330
+ market: canonicalSpotMarket(args[0]), kind: 'spot', isBuy: orderSide(args[1]),
331
+ size: finitePositive(args[2], 'size'), reduceOnly: false,
332
+ resting: false, slippageBps: args[3] as number,
333
+ }];
334
+ case 'spotLimitOrder':
335
+ return [{
336
+ market: canonicalSpotMarket(args[0]), kind: 'spot', isBuy: orderSide(args[1]),
337
+ size: finitePositive(args[2], 'size'), price: finitePositive(args[3], 'price'), reduceOnly: false,
338
+ resting: isRestingTif(args[4]),
339
+ }];
340
+ case 'outcomeOrder':
341
+ case 'outcomeLimitOrder': {
342
+ const resolved = rawClient.resolveOutcomeRef(args[0] as string | number, args[1] as string | number | undefined);
343
+ return [{
344
+ market: canonicalMarket(resolved.coin), kind: 'outcome', isBuy: orderSide(args[2]),
345
+ size: finitePositive(args[3], 'size'), price: finitePositive(args[4], 'price'), reduceOnly: false,
346
+ resting: method === 'outcomeOrder' ? isRestingOrderType(args[5]) : isRestingTif(args[5]),
347
+ }];
348
+ }
349
+ case 'outcomeMarketOrder': {
350
+ const resolved = rawClient.resolveOutcomeRef(args[0] as string | number, args[1] as string | number | undefined);
351
+ return [{
352
+ market: canonicalMarket(resolved.coin), kind: 'outcome', isBuy: orderSide(args[2]),
353
+ size: finitePositive(args[3], 'size'), reduceOnly: false,
354
+ resting: false, slippageBps: args[4] as number,
355
+ }];
356
+ }
357
+ case 'twapOrder':
358
+ return [{
359
+ market: canonicalPerpMarket(args[0]), kind: 'perp', isBuy: orderSide(args[1]),
360
+ size: finitePositive(args[2], 'size'), reduceOnly: asBoolean(args[5]),
361
+ leverage: args[6] as number | undefined,
362
+ resting: true,
363
+ }];
364
+ default:
365
+ return [];
366
+ }
367
+ }
368
+
369
+ async function loadRiskSnapshot(client: HyperliquidClient): Promise<RiskSnapshot> {
370
+ const [state, mids, openOrders, spotBalances, spotData] = await Promise.all([
371
+ client.getUserStateAll(),
372
+ client.getAllMids(),
373
+ client.getOpenOrders(),
374
+ client.getSpotBalances(),
375
+ client.getSpotMetaAndAssetCtxs(),
376
+ ]);
377
+
378
+ const prices = new Map<string, number>();
379
+ for (const [market, raw] of Object.entries(mids)) {
380
+ const price = parseFloat(raw);
381
+ if (Number.isFinite(price) && price > 0) prices.set(canonicalMarket(market), price);
382
+ }
383
+
384
+ const holdings = new Map<string, Holding>();
385
+ for (const item of state.assetPositions) {
386
+ const position = item.position;
387
+ const quantity = parseFloat(position.szi);
388
+ if (!Number.isFinite(quantity) || quantity === 0) continue;
389
+ const market = canonicalMarket(position.coin);
390
+ const positionValue = Math.abs(parseFloat(position.positionValue));
391
+ const price = positionValue > 0 ? positionValue / Math.abs(quantity) : prices.get(market) ?? 0;
392
+ holdings.set(market, {
393
+ quantity,
394
+ price,
395
+ leverage: typeof position.leverage === 'object'
396
+ ? position.leverage.value
397
+ : parseFloat(String(position.leverage)),
398
+ kind: 'perp',
399
+ });
400
+ if (price > 0) prices.set(market, price);
401
+ }
402
+
403
+ const tokens = new Map(spotData.meta.tokens.map((token) => [token.index, token.name]));
404
+ const spotContextsByCoin = new Map(
405
+ spotData.assetCtxs
406
+ .filter((context) => typeof context.coin === 'string')
407
+ .map((context) => [context.coin!, context]),
408
+ );
409
+ const contextsHaveCoinKeys = spotContextsByCoin.size > 0;
410
+ const preferredSpot = new Map<string, { market: string; price: number; quote: number }>();
411
+ for (let i = 0; i < spotData.meta.universe.length; i++) {
412
+ const pair = spotData.meta.universe[i];
413
+ const base = tokens.get(pair.tokens[0]);
414
+ if (!base) continue;
415
+ // spotMeta.universe and spotMetaAndAssetCtxs.assetCtxs are not guaranteed
416
+ // to share array indexes. Join by the canonical pair identifier whenever
417
+ // contexts include it (e.g. @107); use positional fallback only for older
418
+ // payloads that omit context.coin entirely.
419
+ const context = spotContextsByCoin.get(pair.name)
420
+ ?? (contextsHaveCoinKeys ? undefined : spotData.assetCtxs[i]);
421
+ const price = parseFloat(context?.midPx || context?.markPx || mids[pair.name] || '0');
422
+ if (!Number.isFinite(price) || price <= 0) continue;
423
+ const market = base.startsWith('+') ? `#${base.slice(1)}` : canonicalSpotMarket(base);
424
+ const existing = preferredSpot.get(base);
425
+ if (!existing || pair.tokens[1] === 0) {
426
+ preferredSpot.set(base, { market, price, quote: pair.tokens[1] });
427
+ prices.set(market, price);
428
+ }
429
+ }
430
+
431
+ for (const balance of spotBalances.balances ?? []) {
432
+ if (balance.coin.toUpperCase() === 'USDC') continue;
433
+ const quantity = parseFloat(balance.total);
434
+ if (!Number.isFinite(quantity) || quantity === 0) continue;
435
+ const mapping = preferredSpot.get(balance.coin);
436
+ if (!mapping) {
437
+ throw new GuardrailViolation('risk-data-unavailable', `cannot price spot balance ${balance.coin}`);
438
+ }
439
+ holdings.set(mapping.market, {
440
+ quantity,
441
+ price: mapping.price,
442
+ kind: marketKind(mapping.market),
443
+ });
444
+ }
445
+
446
+ return {
447
+ holdings,
448
+ equity: parseFloat(state.marginSummary.accountValue),
449
+ marginUsed: parseFloat(state.marginSummary.totalMarginUsed),
450
+ openOrders: openOrders.length,
451
+ prices,
452
+ loadedAt: Date.now(),
453
+ };
454
+ }
455
+
456
+ function totalExposure(holdings: Map<string, Holding>): number {
457
+ let total = 0;
458
+ for (const holding of holdings.values()) total += Math.abs(holding.quantity * holding.price);
459
+ return total;
460
+ }
461
+
462
+ function isRiskReducing(current: number, projected: number): boolean {
463
+ if (current === 0) return false;
464
+ if (Math.sign(current) !== Math.sign(projected) && projected !== 0) return false;
465
+ return Math.abs(projected) < Math.abs(current);
466
+ }
467
+
468
+ export interface GuardrailedClientOptions {
469
+ policy: AutomationGuardrails;
470
+ rawClient: HyperliquidClient;
471
+ log: AutomationLogger;
472
+ onViolation?: (error: GuardrailViolation, method: string, args: unknown[]) => void;
473
+ }
474
+
475
+ /** Wrap a client so every public write method crosses the validated policy boundary. */
476
+ export function createGuardrailedClient(
477
+ executionClient: HyperliquidClient,
478
+ options: GuardrailedClientOptions,
479
+ ): HyperliquidClient {
480
+ const { policy, rawClient, log, onViolation } = options;
481
+ let snapshot: RiskSnapshot | null = null;
482
+ let queue = Promise.resolve();
483
+ const orderTimestamps: number[] = [];
484
+
485
+ function serialized<T>(operation: () => Promise<T>): Promise<T> {
486
+ const result = queue.then(operation, operation);
487
+ queue = result.then(() => undefined, () => undefined);
488
+ return result;
489
+ }
490
+
491
+ function violation(error: GuardrailViolation, method: string, args: unknown[]): never {
492
+ log.warn(`${error.message} — blocked ${method}`);
493
+ onViolation?.(error, method, args);
494
+ throw error;
495
+ }
496
+
497
+ async function enforce(method: string, originalArgs: unknown[]): Promise<{ args: unknown[]; orders: ProposedOrder[] }> {
498
+ if (policy.mode === 'read-only') {
499
+ throw new GuardrailViolation('read-only', `automation is read-only; ${method} is blocked`);
500
+ }
501
+
502
+ let args = [...originalArgs];
503
+ if (method === 'approveBuilderFee') {
504
+ throw new GuardrailViolation('administrative-write', 'approveBuilderFee is not allowed from automations');
505
+ }
506
+
507
+ if (method === 'marketOrder' || method === 'spotMarketOrder' || method === 'outcomeMarketOrder') {
508
+ if (!policy.allowMarketOrders) {
509
+ throw new GuardrailViolation('market-orders-disabled', `${method} is disabled by policy`);
510
+ }
511
+ args = prepareMarketOrderArgs(method, args, policy);
512
+ }
513
+
514
+ if (method === 'updateLeverage') {
515
+ const market = canonicalPerpMarket(args[0]);
516
+ assertAllowedMarket(policy, market);
517
+ const leverage = finitePositive(args[1], 'leverage');
518
+ if (!Number.isInteger(leverage) || leverage > policy.maxLeverage) {
519
+ throw new GuardrailViolation('leverage-limit', `${leverage}x exceeds maxLeverage ${policy.maxLeverage}x`);
520
+ }
521
+ return { args, orders: [] };
522
+ }
523
+
524
+ if (method === 'cancel') assertAllowedMarket(policy, canonicalPerpMarket(args[0]));
525
+ if (method === 'spotCancel') assertAllowedMarket(policy, canonicalSpotMarket(args[0]));
526
+ if (method === 'twapCancel') assertAllowedMarket(policy, canonicalPerpMarket(args[0]));
527
+ if (method === 'bulkCancel') {
528
+ if (!Array.isArray(args[0])) throw new GuardrailViolation('invalid-cancel', 'bulkCancel requires an array');
529
+ for (const item of args[0]) {
530
+ if (!isPlainObject(item)) throw new GuardrailViolation('invalid-cancel', 'bulkCancel item must be an object');
531
+ assertAllowedMarket(policy, canonicalPerpMarket(item.coin));
532
+ }
533
+ }
534
+ if (method === 'cancelAll') {
535
+ if (args[0] === undefined) {
536
+ if (!policy.allowAccountWideCancel) {
537
+ throw new GuardrailViolation('account-wide-cancel', 'cancelAll() without a market is disabled');
538
+ }
539
+ } else {
540
+ assertAllowedMarket(policy, canonicalPerpMarket(args[0]));
541
+ }
542
+ }
543
+ if (method === 'scheduleCancel' && args[0] !== undefined && !policy.allowAccountWideCancel) {
544
+ throw new GuardrailViolation('account-wide-cancel', 'arming scheduleCancel() is disabled');
545
+ }
546
+
547
+ const orders = proposedOrders(method, args, rawClient);
548
+ if (orders.length === 0) return { args, orders };
549
+ for (const order of orders) assertAllowedMarket(policy, order.market);
550
+
551
+ if (!snapshot || Date.now() - snapshot.loadedAt >= 1_000) {
552
+ snapshot = await loadRiskSnapshot(rawClient);
553
+ }
554
+
555
+ for (const order of orders) {
556
+ if (order.price === undefined) {
557
+ const price = snapshot.prices.get(order.market);
558
+ if (!price) {
559
+ throw new GuardrailViolation('risk-data-unavailable', `cannot price ${order.market}`);
560
+ }
561
+ order.price = order.slippageBps === undefined
562
+ ? price
563
+ : price * (1 + order.slippageBps / 10_000);
564
+ }
565
+ if (order.leverage !== undefined) {
566
+ finitePositive(order.leverage, 'leverage');
567
+ if (!Number.isInteger(order.leverage) || order.leverage > policy.maxLeverage) {
568
+ throw new GuardrailViolation(
569
+ 'leverage-limit',
570
+ `${order.market} requested ${order.leverage}x; maximum is ${policy.maxLeverage}x`,
571
+ );
572
+ }
573
+ }
574
+ }
575
+
576
+ let allRiskReducing = true;
577
+ let projectedMargin = snapshot.marginUsed;
578
+ const projectedHoldings = new Map(
579
+ [...snapshot.holdings].map(([market, holding]) => [market, { ...holding }]),
580
+ );
581
+ for (const order of orders) {
582
+ const current = projectedHoldings.get(order.market) ?? {
583
+ quantity: 0, price: order.price!, kind: order.kind,
584
+ };
585
+ const signedDelta = order.isBuy ? order.size : -order.size;
586
+ let projectedQuantity = current.quantity + signedDelta;
587
+ if (order.kind !== 'perp' && projectedQuantity < 0) {
588
+ throw new GuardrailViolation('insufficient-position', `${order.market} sell exceeds current balance`);
589
+ }
590
+ const reducing = isRiskReducing(current.quantity, projectedQuantity);
591
+
592
+ if (order.reduceOnly && !reducing) {
593
+ throw new GuardrailViolation('reduce-only', `${order.market} order would not reduce the current position`);
594
+ }
595
+
596
+ const orderNotional = order.size * order.price!;
597
+ const projectedNotional = Math.abs(projectedQuantity * order.price!);
598
+ if (!reducing) {
599
+ allRiskReducing = false;
600
+ if (orderNotional > policy.maxOrderUsd) {
601
+ throw new GuardrailViolation(
602
+ 'order-notional',
603
+ `${order.market} order $${orderNotional.toFixed(2)} exceeds maxOrderUsd $${policy.maxOrderUsd}`,
604
+ );
605
+ }
606
+ if (projectedNotional > policy.maxPositionUsd) {
607
+ throw new GuardrailViolation(
608
+ 'position-notional',
609
+ `${order.market} projected exposure $${projectedNotional.toFixed(2)} exceeds maxPositionUsd $${policy.maxPositionUsd}`,
610
+ );
611
+ }
612
+ if (order.kind === 'perp') {
613
+ if (order.leverage === undefined) {
614
+ throw new GuardrailViolation(
615
+ 'leverage-required',
616
+ `${order.market} risk-increasing perp orders must pass an explicit leverage`,
617
+ );
618
+ }
619
+ const currentNotional = Math.abs(current.quantity * current.price);
620
+ projectedMargin += Math.max(0, projectedNotional - currentNotional) / order.leverage;
621
+ }
622
+ }
623
+
624
+ if (!(order.contingent && reducing)) {
625
+ projectedHoldings.set(order.market, {
626
+ quantity: projectedQuantity,
627
+ price: order.price!,
628
+ leverage: order.leverage ?? current.leverage,
629
+ kind: order.kind,
630
+ });
631
+ }
632
+ }
633
+
634
+ if (!allRiskReducing) {
635
+ const exposure = totalExposure(projectedHoldings);
636
+ if (exposure > policy.maxTotalExposureUsd) {
637
+ throw new GuardrailViolation(
638
+ 'total-exposure',
639
+ `projected account exposure $${exposure.toFixed(2)} exceeds maxTotalExposureUsd $${policy.maxTotalExposureUsd}`,
640
+ );
641
+ }
642
+ if (!Number.isFinite(snapshot.equity) || snapshot.equity <= 0) {
643
+ throw new GuardrailViolation('margin-limit', 'account equity is unavailable or zero');
644
+ }
645
+ const projectedMarginPct = (projectedMargin / snapshot.equity) * 100;
646
+ if (projectedMarginPct > policy.maxMarginUsedPct) {
647
+ throw new GuardrailViolation(
648
+ 'margin-limit',
649
+ `projected margin usage ${projectedMarginPct.toFixed(2)}% exceeds maxMarginUsedPct ${policy.maxMarginUsedPct}%`,
650
+ );
651
+ }
652
+
653
+ const now = Date.now();
654
+ while (orderTimestamps.length > 0 && now - orderTimestamps[0] >= 60_000) orderTimestamps.shift();
655
+ if (orderTimestamps.length + orders.length > policy.maxOrdersPerMinute) {
656
+ throw new GuardrailViolation(
657
+ 'order-rate',
658
+ `rolling order count would exceed maxOrdersPerMinute ${policy.maxOrdersPerMinute}`,
659
+ );
660
+ }
661
+ const restingOrders = orders.filter((order) => order.resting).length;
662
+ if (snapshot.openOrders + restingOrders > policy.maxOpenOrders) {
663
+ throw new GuardrailViolation(
664
+ 'open-orders',
665
+ `projected open orders would exceed maxOpenOrders ${policy.maxOpenOrders}`,
666
+ );
667
+ }
668
+ orderTimestamps.push(...orders.map(() => now));
669
+ }
670
+
671
+ snapshot.holdings = projectedHoldings;
672
+ snapshot.marginUsed = projectedMargin;
673
+ snapshot.openOrders += orders.filter((order) => order.resting).length;
674
+ return { args, orders };
675
+ }
676
+
677
+ return new Proxy(executionClient, {
678
+ get(target, prop, receiver) {
679
+ const value = Reflect.get(target, prop, receiver);
680
+ if (typeof prop !== 'string' || !CLIENT_WRITE_METHODS.has(prop) || typeof value !== 'function') {
681
+ return value;
682
+ }
683
+
684
+ return (...args: unknown[]) => serialized(async () => {
685
+ try {
686
+ const checked = await enforce(prop, args);
687
+ const result = await value.apply(target, checked.args);
688
+ if (
689
+ prop === 'cancel' || prop === 'bulkCancel' || prop === 'cancelAll' ||
690
+ prop === 'spotCancel' || prop === 'twapCancel' || prop === 'scheduleCancel'
691
+ ) snapshot = null;
692
+ return result;
693
+ } catch (error) {
694
+ if (error instanceof GuardrailViolation) violation(error, prop, args);
695
+ throw error;
696
+ }
697
+ });
698
+ },
699
+ });
700
+ }