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