openbroker 1.3.2 → 1.5.0

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 (169) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/auto/audit.d.ts +57 -0
  3. package/dist/auto/audit.d.ts.map +1 -0
  4. package/dist/auto/audit.js +407 -0
  5. package/dist/auto/cli.d.ts +2 -0
  6. package/dist/auto/cli.d.ts.map +1 -0
  7. package/dist/auto/cli.js +423 -0
  8. package/dist/auto/events.d.ts +11 -0
  9. package/dist/auto/events.d.ts.map +1 -0
  10. package/dist/auto/events.js +36 -0
  11. package/dist/auto/examples/dca.d.ts +4 -0
  12. package/dist/auto/examples/dca.d.ts.map +1 -0
  13. package/dist/auto/examples/dca.js +60 -0
  14. package/dist/auto/examples/funding-arb.d.ts +4 -0
  15. package/dist/auto/examples/funding-arb.d.ts.map +1 -0
  16. package/dist/auto/examples/funding-arb.js +81 -0
  17. package/dist/auto/examples/grid.d.ts +4 -0
  18. package/dist/auto/examples/grid.d.ts.map +1 -0
  19. package/dist/auto/examples/grid.js +114 -0
  20. package/dist/auto/examples/mm-maker.d.ts +4 -0
  21. package/dist/auto/examples/mm-maker.d.ts.map +1 -0
  22. package/dist/auto/examples/mm-maker.js +131 -0
  23. package/dist/auto/examples/mm-spread.d.ts +4 -0
  24. package/dist/auto/examples/mm-spread.d.ts.map +1 -0
  25. package/dist/auto/examples/mm-spread.js +119 -0
  26. package/dist/auto/examples/price-alert.d.ts +4 -0
  27. package/dist/auto/examples/price-alert.d.ts.map +1 -0
  28. package/dist/auto/examples/price-alert.js +85 -0
  29. package/dist/auto/keep-awake.d.ts +11 -0
  30. package/dist/auto/keep-awake.d.ts.map +1 -0
  31. package/dist/auto/keep-awake.js +70 -0
  32. package/dist/auto/loader.d.ts +22 -0
  33. package/dist/auto/loader.d.ts.map +1 -0
  34. package/dist/auto/loader.js +127 -0
  35. package/dist/auto/prune.d.ts +40 -0
  36. package/dist/auto/prune.d.ts.map +1 -0
  37. package/dist/auto/prune.js +204 -0
  38. package/dist/auto/registry.d.ts +24 -0
  39. package/dist/auto/registry.d.ts.map +1 -0
  40. package/dist/auto/registry.js +93 -0
  41. package/dist/auto/report.d.ts +3 -0
  42. package/dist/auto/report.d.ts.map +1 -0
  43. package/dist/auto/report.js +385 -0
  44. package/dist/auto/runtime.d.ts +33 -0
  45. package/dist/auto/runtime.d.ts.map +1 -0
  46. package/dist/auto/runtime.js +844 -0
  47. package/dist/auto/types.d.ts +236 -0
  48. package/dist/auto/types.d.ts.map +1 -0
  49. package/dist/auto/types.js +3 -0
  50. package/dist/core/client.d.ts +691 -0
  51. package/dist/core/client.d.ts.map +1 -0
  52. package/dist/core/client.js +2061 -0
  53. package/dist/core/config.d.ts +22 -0
  54. package/dist/core/config.d.ts.map +1 -0
  55. package/dist/core/config.js +143 -0
  56. package/dist/core/types.d.ts +228 -0
  57. package/dist/core/types.d.ts.map +1 -0
  58. package/dist/core/types.js +2 -0
  59. package/dist/core/utils.d.ts +61 -0
  60. package/dist/core/utils.d.ts.map +1 -0
  61. package/dist/core/utils.js +142 -0
  62. package/dist/core/ws.d.ts +121 -0
  63. package/dist/core/ws.d.ts.map +1 -0
  64. package/dist/core/ws.js +222 -0
  65. package/dist/info/account.d.ts +3 -0
  66. package/dist/info/account.d.ts.map +1 -0
  67. package/dist/info/account.js +198 -0
  68. package/dist/info/all-markets.d.ts +3 -0
  69. package/dist/info/all-markets.d.ts.map +1 -0
  70. package/dist/info/all-markets.js +272 -0
  71. package/dist/info/candles.d.ts +3 -0
  72. package/dist/info/candles.d.ts.map +1 -0
  73. package/dist/info/candles.js +120 -0
  74. package/dist/info/fees.d.ts +3 -0
  75. package/dist/info/fees.d.ts.map +1 -0
  76. package/dist/info/fees.js +87 -0
  77. package/dist/info/fills.d.ts +3 -0
  78. package/dist/info/fills.d.ts.map +1 -0
  79. package/dist/info/fills.js +105 -0
  80. package/dist/info/funding-history.d.ts +3 -0
  81. package/dist/info/funding-history.d.ts.map +1 -0
  82. package/dist/info/funding-history.js +98 -0
  83. package/dist/info/funding-scan.d.ts +3 -0
  84. package/dist/info/funding-scan.d.ts.map +1 -0
  85. package/dist/info/funding-scan.js +178 -0
  86. package/dist/info/funding.d.ts +3 -0
  87. package/dist/info/funding.d.ts.map +1 -0
  88. package/dist/info/funding.js +158 -0
  89. package/dist/info/markets.d.ts +3 -0
  90. package/dist/info/markets.d.ts.map +1 -0
  91. package/dist/info/markets.js +178 -0
  92. package/dist/info/order-status.d.ts +3 -0
  93. package/dist/info/order-status.d.ts.map +1 -0
  94. package/dist/info/order-status.js +85 -0
  95. package/dist/info/orders.d.ts +3 -0
  96. package/dist/info/orders.d.ts.map +1 -0
  97. package/dist/info/orders.js +162 -0
  98. package/dist/info/outcomes.d.ts +3 -0
  99. package/dist/info/outcomes.d.ts.map +1 -0
  100. package/dist/info/outcomes.js +175 -0
  101. package/dist/info/positions.d.ts +3 -0
  102. package/dist/info/positions.d.ts.map +1 -0
  103. package/dist/info/positions.js +127 -0
  104. package/dist/info/rate-limit.d.ts +3 -0
  105. package/dist/info/rate-limit.d.ts.map +1 -0
  106. package/dist/info/rate-limit.js +58 -0
  107. package/dist/info/search-markets.d.ts +3 -0
  108. package/dist/info/search-markets.d.ts.map +1 -0
  109. package/dist/info/search-markets.js +296 -0
  110. package/dist/info/spot.d.ts +3 -0
  111. package/dist/info/spot.d.ts.map +1 -0
  112. package/dist/info/spot.js +192 -0
  113. package/dist/info/trades.d.ts +3 -0
  114. package/dist/info/trades.d.ts.map +1 -0
  115. package/dist/info/trades.js +97 -0
  116. package/dist/lib.d.ts +14 -0
  117. package/dist/lib.d.ts.map +1 -0
  118. package/dist/lib.js +17 -0
  119. package/dist/operations/bracket.d.ts +28 -0
  120. package/dist/operations/bracket.d.ts.map +1 -0
  121. package/dist/operations/bracket.js +266 -0
  122. package/dist/operations/cancel.d.ts +3 -0
  123. package/dist/operations/cancel.d.ts.map +1 -0
  124. package/dist/operations/cancel.js +107 -0
  125. package/dist/operations/chase.d.ts +25 -0
  126. package/dist/operations/chase.d.ts.map +1 -0
  127. package/dist/operations/chase.js +215 -0
  128. package/dist/operations/limit-order.d.ts +3 -0
  129. package/dist/operations/limit-order.d.ts.map +1 -0
  130. package/dist/operations/limit-order.js +144 -0
  131. package/dist/operations/market-order.d.ts +3 -0
  132. package/dist/operations/market-order.d.ts.map +1 -0
  133. package/dist/operations/market-order.js +153 -0
  134. package/dist/operations/outcome-order.d.ts +3 -0
  135. package/dist/operations/outcome-order.d.ts.map +1 -0
  136. package/dist/operations/outcome-order.js +171 -0
  137. package/dist/operations/scale.d.ts +3 -0
  138. package/dist/operations/scale.d.ts.map +1 -0
  139. package/dist/operations/scale.js +212 -0
  140. package/dist/operations/set-tpsl.d.ts +3 -0
  141. package/dist/operations/set-tpsl.d.ts.map +1 -0
  142. package/dist/operations/set-tpsl.js +277 -0
  143. package/dist/operations/spot-order.d.ts +3 -0
  144. package/dist/operations/spot-order.d.ts.map +1 -0
  145. package/dist/operations/spot-order.js +173 -0
  146. package/dist/operations/trigger-order.d.ts +3 -0
  147. package/dist/operations/trigger-order.d.ts.map +1 -0
  148. package/dist/operations/trigger-order.js +177 -0
  149. package/dist/operations/twap-cancel.d.ts +3 -0
  150. package/dist/operations/twap-cancel.d.ts.map +1 -0
  151. package/dist/operations/twap-cancel.js +57 -0
  152. package/dist/operations/twap-status.d.ts +3 -0
  153. package/dist/operations/twap-status.d.ts.map +1 -0
  154. package/dist/operations/twap-status.js +81 -0
  155. package/dist/operations/twap.d.ts +3 -0
  156. package/dist/operations/twap.d.ts.map +1 -0
  157. package/dist/operations/twap.js +124 -0
  158. package/dist/setup/approve-builder.d.ts +3 -0
  159. package/dist/setup/approve-builder.d.ts.map +1 -0
  160. package/dist/setup/approve-builder.js +155 -0
  161. package/dist/setup/env.d.ts +4 -0
  162. package/dist/setup/env.d.ts.map +1 -0
  163. package/dist/setup/env.js +8 -0
  164. package/dist/setup/onboard.d.ts +10 -0
  165. package/dist/setup/onboard.d.ts.map +1 -0
  166. package/dist/setup/onboard.js +462 -0
  167. package/package.json +10 -4
  168. package/scripts/core/client.ts +19 -1
  169. package/scripts/core/types.ts +7 -0
@@ -0,0 +1,844 @@
1
+ // Automation Runtime — loads scripts, polls market data, dispatches events
2
+ // Supports real-time WebSocket feeds with REST polling as fallback heartbeat
3
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
4
+ import path from 'path';
5
+ import os from 'os';
6
+ import { getClient } from '../core/client.js';
7
+ import { roundPrice, roundSize, sleep, normalizeCoin, formatUsd, formatPercent, annualizeFundingRate, } from '../core/utils.js';
8
+ import { WebSocketManager } from '../core/ws.js';
9
+ import { AutomationEventBus } from './events.js';
10
+ import { loadAutomation } from './loader.js';
11
+ import { registerAutomation, unregisterAutomation, getRegisteredAutomations as getRegisteredFromFile } from './registry.js';
12
+ import { createAutomationAudit, toSerializable } from './audit.js';
13
+ import { startKeepAwake } from './keep-awake.js';
14
+ // ── Observer fan-out ────────────────────────────────────────────────
15
+ //
16
+ // External monitoring packages (e.g. `openbroker-monitoring`) plug into
17
+ // the audit pipeline as observers. We auto-load them via convention
18
+ // dynamic-import: any package whose default export is a factory returning
19
+ // an `AutomationAuditObserver` (or null) and that resolves through Node's
20
+ // module resolver gets wired in at startup.
21
+ const CONVENTION_OBSERVER_PACKAGES = ['openbroker-monitoring'];
22
+ async function loadConventionObservers(log) {
23
+ const observers = [];
24
+ for (const name of CONVENTION_OBSERVER_PACKAGES) {
25
+ try {
26
+ const mod = await import(name);
27
+ const exported = mod.default ?? mod;
28
+ const observer = typeof exported === 'function' ? exported() : exported;
29
+ if (observer && typeof observer === 'object') {
30
+ observers.push(observer);
31
+ log.info(`[audit-observer] loaded: ${name}`);
32
+ }
33
+ else {
34
+ // Package resolved but its factory returned null/undefined — typically
35
+ // because required env (e.g. OB_DASHBOARD_URL) is missing. Surface this
36
+ // so operators don't silently lose telemetry.
37
+ log.warn(`[audit-observer] ${name} resolved but factory returned no observer — check the package's required env vars`);
38
+ }
39
+ }
40
+ catch (err) {
41
+ const code = err?.code;
42
+ if (code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND') {
43
+ log.info(`[audit-observer] ${name} not installed — skipping (install it alongside openbroker to forward telemetry)`);
44
+ }
45
+ else {
46
+ log.warn(`[audit-observer] failed to load "${name}": ${err instanceof Error ? err.message : String(err)}`);
47
+ }
48
+ }
49
+ }
50
+ return observers;
51
+ }
52
+ function fanOutNote(observers, kind, payload) {
53
+ for (const o of observers) {
54
+ try {
55
+ o.onNote?.(kind, payload);
56
+ }
57
+ catch { /* observer must not break runtime */ }
58
+ }
59
+ }
60
+ function fanOutMetric(observers, name, value, tags) {
61
+ for (const o of observers) {
62
+ try {
63
+ o.onMetric?.(name, value, tags);
64
+ }
65
+ catch { /* observer must not break runtime */ }
66
+ }
67
+ }
68
+ function fanOutAgentAction(observers, action, status, details, txHash) {
69
+ for (const o of observers) {
70
+ try {
71
+ o.onAgentAction?.(action, status, details, txHash);
72
+ }
73
+ catch { /* observer must not break runtime */ }
74
+ }
75
+ }
76
+ const STATE_DIR = path.join(os.homedir(), '.openbroker', 'state');
77
+ const AUDITED_WRITE_METHODS = new Set([
78
+ 'order', 'marketOrder', 'limitOrder', 'triggerOrder',
79
+ 'takeProfit', 'stopLoss', 'cancel', 'cancelAll',
80
+ 'spotOrder', 'spotMarketOrder', 'spotLimitOrder', 'spotCancel',
81
+ 'updateLeverage', 'approveBuilderFee', 'twapOrder', 'twapCancel',
82
+ ]);
83
+ function createState(id) {
84
+ mkdirSync(STATE_DIR, { recursive: true });
85
+ const stateFile = path.join(STATE_DIR, `${id}.json`);
86
+ let data = {};
87
+ let audit = null;
88
+ if (existsSync(stateFile)) {
89
+ try {
90
+ data = JSON.parse(readFileSync(stateFile, 'utf-8'));
91
+ }
92
+ catch {
93
+ data = {};
94
+ }
95
+ }
96
+ let flushTimer = null;
97
+ function scheduleFlush() {
98
+ if (flushTimer)
99
+ return;
100
+ flushTimer = setTimeout(() => {
101
+ flushTimer = null;
102
+ writeFileSync(stateFile, JSON.stringify(data, null, 2));
103
+ }, 500);
104
+ }
105
+ return {
106
+ state: {
107
+ get(key, defaultValue) {
108
+ return (key in data ? data[key] : defaultValue);
109
+ },
110
+ set(key, value) {
111
+ data[key] = value;
112
+ audit?.recordStateChange('set', key, value);
113
+ scheduleFlush();
114
+ },
115
+ delete(key) {
116
+ const previous = key in data ? data[key] : undefined;
117
+ delete data[key];
118
+ audit?.recordStateChange('delete', key, previous);
119
+ scheduleFlush();
120
+ },
121
+ clear() {
122
+ data = {};
123
+ audit?.recordStateChange('clear', null);
124
+ scheduleFlush();
125
+ },
126
+ },
127
+ snapshot() {
128
+ return toSerializable(data);
129
+ },
130
+ attachAudit(nextAudit) {
131
+ audit = nextAudit;
132
+ },
133
+ };
134
+ }
135
+ // ── Logger ──────────────────────────────────────────────────────────
136
+ function createLogger(id, verbose, audit) {
137
+ const prefix = `[auto:${id}]`;
138
+ return {
139
+ info: (msg) => {
140
+ audit?.recordLog('info', msg);
141
+ console.log(`${prefix} ${msg}`);
142
+ },
143
+ warn: (msg) => {
144
+ audit?.recordLog('warn', msg);
145
+ console.log(`${prefix} ⚠ ${msg}`);
146
+ },
147
+ error: (msg) => {
148
+ audit?.recordLog('error', msg);
149
+ console.error(`${prefix} ✗ ${msg}`);
150
+ },
151
+ debug: (msg) => {
152
+ if (verbose) {
153
+ audit?.recordLog('debug', msg);
154
+ console.log(`${prefix} … ${msg}`);
155
+ }
156
+ },
157
+ };
158
+ }
159
+ // ── Dry-run client proxy ────────────────────────────────────────────
160
+ const WRITE_METHODS = new Set([
161
+ 'order', 'marketOrder', 'limitOrder', 'triggerOrder',
162
+ 'takeProfit', 'stopLoss', 'cancel', 'cancelAll',
163
+ 'updateLeverage', 'approveBuilderFee',
164
+ 'spotOrder', 'spotMarketOrder', 'spotLimitOrder', 'spotCancel',
165
+ 'twapOrder', 'twapCancel',
166
+ ]);
167
+ function createDryClient(client, log) {
168
+ return new Proxy(client, {
169
+ get(target, prop, receiver) {
170
+ const value = Reflect.get(target, prop, receiver);
171
+ if (typeof prop === 'string' && WRITE_METHODS.has(prop) && typeof value === 'function') {
172
+ return (...args) => {
173
+ log.info(`[DRY] ${prop}(${args.map(a => JSON.stringify(a)).join(', ')})`);
174
+ return Promise.resolve({ status: 'ok', response: { type: 'dry_run' } });
175
+ };
176
+ }
177
+ return value;
178
+ },
179
+ });
180
+ }
181
+ function createAuditedClient(client, audit, dryRun, observers) {
182
+ return new Proxy(client, {
183
+ get(target, prop, receiver) {
184
+ const value = Reflect.get(target, prop, receiver);
185
+ if (typeof prop === 'string' && AUDITED_WRITE_METHODS.has(prop) && typeof value === 'function') {
186
+ return async (...args) => {
187
+ const actionId = `${prop}:${Date.now()}:${Math.random().toString(16).slice(2)}`;
188
+ audit.recordAction({
189
+ actionId,
190
+ phase: 'request',
191
+ method: prop,
192
+ payload: { args },
193
+ dryRun,
194
+ });
195
+ try {
196
+ const result = await value.apply(target, args);
197
+ audit.recordAction({
198
+ actionId,
199
+ phase: 'response',
200
+ method: prop,
201
+ result,
202
+ dryRun,
203
+ });
204
+ fanOutAgentAction(observers, prop, 'success', { args: toSerializable(args), result: toSerializable(result), dryRun });
205
+ return result;
206
+ }
207
+ catch (error) {
208
+ audit.recordAction({
209
+ actionId,
210
+ phase: 'error',
211
+ method: prop,
212
+ error,
213
+ dryRun,
214
+ });
215
+ fanOutAgentAction(observers, prop, 'error', { args: toSerializable(args), error: String(error), dryRun });
216
+ throw error;
217
+ }
218
+ };
219
+ }
220
+ return value;
221
+ },
222
+ });
223
+ }
224
+ // ── Snapshot building ───────────────────────────────────────────────
225
+ async function buildSnapshot(client) {
226
+ // `metaAndAssetCtxs` contains both mostly-static market metadata and live
227
+ // funding/premium values. The client caches it for market lookups, so clear
228
+ // it before each automation poll snapshot or funding_update events freeze at
229
+ // the first fetched value.
230
+ client.invalidateMetaCache();
231
+ const [state, mids, metaCtxs] = await Promise.all([
232
+ client.getUserStateAll(),
233
+ client.getAllMids(),
234
+ client.getMetaAndAssetCtxs(),
235
+ ]);
236
+ const prices = new Map();
237
+ for (const [coin, mid] of Object.entries(mids)) {
238
+ prices.set(coin, parseFloat(mid));
239
+ }
240
+ const positions = new Map();
241
+ for (const ap of state.assetPositions) {
242
+ const p = ap.position;
243
+ const size = parseFloat(p.szi);
244
+ if (size === 0)
245
+ continue;
246
+ positions.set(p.coin, {
247
+ coin: p.coin,
248
+ size,
249
+ entryPrice: parseFloat(p.entryPx),
250
+ positionValue: parseFloat(p.positionValue),
251
+ unrealizedPnl: parseFloat(p.unrealizedPnl),
252
+ liquidationPx: p.liquidationPx ? parseFloat(p.liquidationPx) : null,
253
+ leverage: typeof p.leverage === 'object' ? p.leverage.value : parseFloat(String(p.leverage)),
254
+ marginUsed: parseFloat(p.marginUsed),
255
+ });
256
+ }
257
+ const equity = parseFloat(state.marginSummary.accountValue);
258
+ const marginUsed = parseFloat(state.marginSummary.totalMarginUsed);
259
+ // Build funding rates from asset contexts
260
+ const fundingRates = new Map();
261
+ const addFundingRates = (universe, assetCtxs) => {
262
+ if (!universe || !assetCtxs)
263
+ return;
264
+ for (let i = 0; i < universe.length; i++) {
265
+ const meta = universe[i];
266
+ const ctx = assetCtxs[i];
267
+ if (ctx && meta?.name) {
268
+ fundingRates.set(meta.name, {
269
+ rate: parseFloat(String(ctx.funding || '0')),
270
+ premium: parseFloat(String(ctx.premium || '0')),
271
+ });
272
+ }
273
+ }
274
+ };
275
+ if (metaCtxs && Array.isArray(metaCtxs)) {
276
+ for (const group of metaCtxs) {
277
+ addFundingRates(group.universe, group.assetCtxs);
278
+ }
279
+ }
280
+ else if (metaCtxs) {
281
+ addFundingRates(metaCtxs.meta?.universe, metaCtxs.assetCtxs);
282
+ }
283
+ return {
284
+ prices,
285
+ positions,
286
+ openOrderIds: new Set(), // filled by separate call if needed
287
+ equity,
288
+ marginUsed,
289
+ marginUsedPct: equity > 0 ? (marginUsed / equity) * 100 : 0,
290
+ fundingRates,
291
+ timestamp: Date.now(),
292
+ };
293
+ }
294
+ // ── Publish (webhook) ───────────────────────────────────────────────
295
+ function createPublish(automationId, log, gatewayPort, hooksToken) {
296
+ return async (message, options) => {
297
+ const token = hooksToken;
298
+ const port = gatewayPort || 18789;
299
+ if (!token) {
300
+ log.debug('publish() skipped — no hooks token configured (pass --hooks-token, set OPENCLAW_HOOKS_TOKEN before invoking the CLI, or configure plugin.hooksToken)');
301
+ return false;
302
+ }
303
+ const body = {
304
+ message,
305
+ name: options?.name || `ob-auto-${automationId}`,
306
+ wakeMode: options?.wakeMode || 'now',
307
+ };
308
+ if (options?.deliver !== undefined)
309
+ body.deliver = options.deliver;
310
+ if (options?.channel)
311
+ body.channel = options.channel;
312
+ try {
313
+ const res = await fetch(`http://127.0.0.1:${port}/hooks/agent`, {
314
+ method: 'POST',
315
+ headers: {
316
+ 'Content-Type': 'application/json',
317
+ 'Authorization': `Bearer ${token}`,
318
+ },
319
+ body: JSON.stringify(body),
320
+ });
321
+ if (!res.ok) {
322
+ log.warn(`publish() failed: HTTP ${res.status} ${res.statusText}`);
323
+ return false;
324
+ }
325
+ log.debug(`publish() delivered to /hooks/agent (${message.length} chars)`);
326
+ return true;
327
+ }
328
+ catch (err) {
329
+ log.warn(`publish() error: ${err instanceof Error ? err.message : String(err)}`);
330
+ return false;
331
+ }
332
+ };
333
+ }
334
+ function createAuditedPublish(publish, audit) {
335
+ return async (message, options) => {
336
+ try {
337
+ const delivered = await publish(message, options);
338
+ audit.recordPublish(message, options, delivered);
339
+ return delivered;
340
+ }
341
+ catch (error) {
342
+ audit.recordError('publish', error);
343
+ throw error;
344
+ }
345
+ };
346
+ }
347
+ /** Registry of all running automations */
348
+ const registry = new Map();
349
+ export function getRunningAutomations() {
350
+ return [...registry.values()];
351
+ }
352
+ export function getAutomation(id) {
353
+ return registry.get(id);
354
+ }
355
+ /** Get all automations from file-based registry (cross-process visibility) */
356
+ export { getRegisteredFromFile as getRegisteredAutomations };
357
+ export async function startAutomation(options) {
358
+ const { scriptPath, dryRun = false, verbose = false, gatewayPort, hooksToken, initialState, useWebSocket = true, } = options;
359
+ // When WebSocket is enabled, REST poll becomes a heartbeat (30s default)
360
+ // When disabled, use the original 10s polling interval
361
+ const pollIntervalMs = options.pollIntervalMs ?? (useWebSocket ? 30_000 : 10_000);
362
+ const id = options.id || path.basename(scriptPath, '.ts');
363
+ if (registry.has(id)) {
364
+ throw new Error(`Automation "${id}" is already running`);
365
+ }
366
+ const stateController = createState(id);
367
+ // Apply --set flags before the factory function runs so CLI overrides win over persisted state.
368
+ if (initialState) {
369
+ for (const [key, value] of Object.entries(initialState)) {
370
+ stateController.state.set(key, value);
371
+ }
372
+ }
373
+ const eventBus = new AutomationEventBus();
374
+ const rawClient = getClient();
375
+ const audit = createAutomationAudit({
376
+ automationId: id,
377
+ scriptPath,
378
+ dryRun,
379
+ verbose,
380
+ pollIntervalMs,
381
+ useWebSocket,
382
+ accountAddress: rawClient.address,
383
+ walletAddress: rawClient.walletAddress,
384
+ isApiWallet: rawClient.isApiWallet,
385
+ initialState,
386
+ persistedState: stateController.snapshot(),
387
+ });
388
+ stateController.attachAudit(audit);
389
+ const log = createLogger(id, verbose, audit);
390
+ const keepAwakeEnabled = options.keepAwake ?? !dryRun;
391
+ let keepAwake = null;
392
+ if (keepAwakeEnabled) {
393
+ keepAwake = startKeepAwake(`OpenBroker automation ${id} is running`, log);
394
+ if (keepAwake) {
395
+ log.info(`keep-awake enabled via ${keepAwake.backend}.`);
396
+ }
397
+ }
398
+ const observers = await loadConventionObservers(log);
399
+ const baseClient = dryRun ? createDryClient(rawClient, log) : rawClient;
400
+ const client = createAuditedClient(baseClient, audit, dryRun, observers);
401
+ const startHooks = [];
402
+ const stopHooks = [];
403
+ const errorHooks = [];
404
+ const scheduledTasks = [];
405
+ // Build the API object
406
+ const publish = createAuditedPublish(createPublish(id, log, gatewayPort, hooksToken), audit);
407
+ const auditApi = {
408
+ record: (kind, payload) => {
409
+ audit.recordNote(kind, payload);
410
+ fanOutNote(observers, kind, payload);
411
+ },
412
+ metric: (name, value, tags) => {
413
+ audit.recordMetric(name, value, tags);
414
+ fanOutMetric(observers, name, value, tags);
415
+ },
416
+ };
417
+ const api = {
418
+ client,
419
+ utils: { roundPrice, roundSize, sleep, normalizeCoin, formatUsd, formatPercent, annualizeFundingRate },
420
+ on: (event, handler) => eventBus.on(event, handler),
421
+ every: (intervalMs, handler) => scheduledTasks.push({ intervalMs, handler, lastRun: 0 }),
422
+ onStart: (handler) => startHooks.push(handler),
423
+ onStop: (handler) => stopHooks.push(handler),
424
+ onError: (handler) => errorHooks.push(handler),
425
+ publish,
426
+ state: stateController.state,
427
+ log,
428
+ audit: auditApi,
429
+ id,
430
+ dryRun,
431
+ };
432
+ try {
433
+ // Load and execute the factory function (registers handlers)
434
+ log.info(`Loading automation: ${scriptPath}`);
435
+ const factory = await loadAutomation(scriptPath);
436
+ await factory(api);
437
+ // Call onStart hooks
438
+ for (const hook of startHooks) {
439
+ try {
440
+ await hook();
441
+ }
442
+ catch (err) {
443
+ const error = err instanceof Error ? err : new Error(String(err));
444
+ audit.recordError('onStart', error);
445
+ log.error(`onStart hook error: ${error.message}`);
446
+ }
447
+ }
448
+ }
449
+ catch (err) {
450
+ const error = err instanceof Error ? err : new Error(String(err));
451
+ audit.recordError('startup', error);
452
+ await audit.stop({
453
+ status: 'error',
454
+ stopReason: 'startup_error',
455
+ pollCount: 0,
456
+ eventsEmitted: 0,
457
+ });
458
+ keepAwake?.stop();
459
+ throw error;
460
+ }
461
+ // Polling state (declared early so WebSocket handlers can reference)
462
+ let previousSnapshot = null;
463
+ let pollCount = 0;
464
+ let eventsEmitted = 0;
465
+ let isPolling = false;
466
+ let stopped = false;
467
+ async function handleErrors(errors) {
468
+ for (const err of errors) {
469
+ audit.recordError('handler', err);
470
+ log.error(`Handler error: ${err.message}`);
471
+ for (const hook of errorHooks) {
472
+ try {
473
+ await hook(err);
474
+ }
475
+ catch { /* swallow */ }
476
+ }
477
+ }
478
+ }
479
+ function shouldPersistEvent(event) {
480
+ return event !== 'tick' && event !== 'price_change';
481
+ }
482
+ async function emitAutomationEvent(event, payload, source) {
483
+ if (shouldPersistEvent(event)) {
484
+ audit.recordEvent(event, source, payload);
485
+ }
486
+ const errors = await eventBus.emit(event, payload);
487
+ if (errors.length)
488
+ await handleErrors(errors);
489
+ eventsEmitted++;
490
+ }
491
+ // ── WebSocket setup ─────────────────────────────────────────────
492
+ let ws = null;
493
+ let wsConnected = false;
494
+ // Track latest prices from WebSocket for real-time price_change events
495
+ let wsPrices = new Map();
496
+ if (useWebSocket) {
497
+ try {
498
+ ws = new WebSocketManager(verbose);
499
+ // Wire WebSocket events to the automation event bus
500
+ ws.on('allMids', ({ mids }) => {
501
+ const now = Date.now();
502
+ for (const [coin, mid] of Object.entries(mids)) {
503
+ const newPrice = parseFloat(mid);
504
+ if (isNaN(newPrice) || newPrice === 0)
505
+ continue;
506
+ const oldPrice = wsPrices.get(coin);
507
+ wsPrices.set(coin, newPrice);
508
+ if (oldPrice !== undefined && oldPrice !== 0 && eventBus.has('price_change')) {
509
+ const changePct = ((newPrice - oldPrice) / oldPrice) * 100;
510
+ if (Math.abs(changePct) >= 0.01) {
511
+ void emitAutomationEvent('price_change', { coin, oldPrice, newPrice, changePct }, 'ws');
512
+ }
513
+ }
514
+ }
515
+ });
516
+ ws.on('orderUpdate', (update) => {
517
+ audit.recordOrderUpdate({
518
+ coin: update.order.coin,
519
+ oid: update.order.oid,
520
+ side: update.order.side === 'B' ? 'buy' : 'sell',
521
+ size: parseFloat(update.order.sz),
522
+ price: parseFloat(update.order.limitPx),
523
+ origSize: parseFloat(update.order.origSz),
524
+ status: update.status,
525
+ statusTimestamp: update.statusTimestamp,
526
+ raw: update,
527
+ });
528
+ if (eventBus.has('order_update')) {
529
+ void emitAutomationEvent('order_update', {
530
+ coin: update.order.coin,
531
+ oid: update.order.oid,
532
+ side: update.order.side === 'B' ? 'buy' : 'sell',
533
+ size: parseFloat(update.order.sz),
534
+ price: parseFloat(update.order.limitPx),
535
+ origSize: parseFloat(update.order.origSz),
536
+ status: update.status,
537
+ statusTimestamp: update.statusTimestamp,
538
+ }, 'ws');
539
+ }
540
+ // NOTE: order_filled is emitted from the userFill handler below, not from
541
+ // here. The previous implementation fired it from orderUpdate.status
542
+ // === 'filled' using update.order.sz as the size, but that field is the
543
+ // REMAINING size (0 on a terminal fill), not the fill delta — so every
544
+ // consumer saw size=0. Additionally, Hyperliquid does not emit
545
+ // orderUpdate events for pure partial fills that don't transition
546
+ // status, so partial fills were silently dropped entirely. Sourcing
547
+ // order_filled from userFill fixes both issues: sz there IS the fill
548
+ // delta, and the userFills stream fires on every fill (partial and
549
+ // terminal).
550
+ });
551
+ ws.on('userFill', (fill) => {
552
+ audit.recordFill({
553
+ coin: fill.coin,
554
+ side: fill.side === 'B' ? 'buy' : 'sell',
555
+ size: fill.sz,
556
+ price: fill.px,
557
+ time: fill.time,
558
+ closedPnl: fill.closedPnl,
559
+ fee: fill.fee,
560
+ oid: fill.oid,
561
+ crossed: fill.crossed,
562
+ }, fill.time);
563
+ log.debug(`Fill: ${fill.side === 'B' ? 'BUY' : 'SELL'} ${fill.sz} ${fill.coin} @ ${fill.px} (PnL: ${fill.closedPnl})`);
564
+ // Emit order_filled with the authoritative fill delta + fee/pnl from
565
+ // the userFills WS stream. Covers both partial and terminal fills.
566
+ // Fee is converted to USD using feeToken: for non-USDC fees (spot
567
+ // buys pay in the received asset), fee × price yields USD since the
568
+ // fee token is the base of the traded pair and `price` is quote/base.
569
+ if (eventBus.has('order_filled')) {
570
+ const size = parseFloat(fill.sz);
571
+ const price = parseFloat(fill.px);
572
+ const rawFee = parseFloat(fill.fee);
573
+ const closedPnl = parseFloat(fill.closedPnl);
574
+ const feeToken = fill.feeToken;
575
+ let feeUsd;
576
+ if (Number.isFinite(rawFee)) {
577
+ if (feeToken === 'USDC' || !feeToken) {
578
+ feeUsd = rawFee;
579
+ }
580
+ else if (Number.isFinite(price) && price > 0) {
581
+ feeUsd = rawFee * price;
582
+ }
583
+ else {
584
+ feeUsd = undefined;
585
+ }
586
+ }
587
+ void emitAutomationEvent('order_filled', {
588
+ coin: fill.coin,
589
+ oid: fill.oid,
590
+ side: fill.side === 'B' ? 'buy' : 'sell',
591
+ size,
592
+ price,
593
+ fee: feeUsd,
594
+ feeToken,
595
+ closedPnl: Number.isFinite(closedPnl) ? closedPnl : undefined,
596
+ crossed: fill.crossed,
597
+ }, 'ws');
598
+ }
599
+ });
600
+ ws.on('userEvent', (event) => {
601
+ audit.recordUserEvent(event);
602
+ // Handle liquidation events — only available through WebSocket
603
+ if ('liquidation' in event && eventBus.has('liquidation')) {
604
+ const liq = event.liquidation;
605
+ void emitAutomationEvent('liquidation', {
606
+ lid: liq.lid,
607
+ liquidator: liq.liquidator,
608
+ liquidatedUser: liq.liquidated_user,
609
+ liquidatedNtlPos: parseFloat(liq.liquidated_ntl_pos),
610
+ liquidatedAccountValue: parseFloat(liq.liquidated_account_value),
611
+ }, 'ws');
612
+ }
613
+ });
614
+ ws.on('error', ({ error }) => {
615
+ audit.recordError('websocket', error);
616
+ log.warn(`WebSocket error: ${error.message}`);
617
+ });
618
+ ws.on('disconnected', () => {
619
+ wsConnected = false;
620
+ log.warn('WebSocket disconnected — falling back to REST polling');
621
+ });
622
+ ws.on('connected', () => {
623
+ wsConnected = true;
624
+ log.info('WebSocket connected — real-time events active');
625
+ });
626
+ // Connect and subscribe
627
+ const userAddress = rawClient.address;
628
+ await ws.subscribeAll(userAddress);
629
+ log.info('WebSocket subscriptions active (allMids, orderUpdates, userFills, userEvents)');
630
+ }
631
+ catch (err) {
632
+ const error = err instanceof Error ? err : new Error(String(err));
633
+ audit.recordError('websocket_setup', error);
634
+ if (verbose && error.stack) {
635
+ log.debug(`WebSocket setup stack: ${error.stack}`);
636
+ }
637
+ log.warn(`WebSocket setup failed: ${error.message} — using REST polling only`);
638
+ ws = null;
639
+ wsConnected = false;
640
+ }
641
+ }
642
+ async function poll() {
643
+ if (isPolling || stopped)
644
+ return;
645
+ isPolling = true;
646
+ try {
647
+ const snapshot = await buildSnapshot(rawClient);
648
+ pollCount++;
649
+ const now = Date.now();
650
+ audit.recordSnapshot({
651
+ pollCount,
652
+ equity: snapshot.equity,
653
+ marginUsed: snapshot.marginUsed,
654
+ marginUsedPct: snapshot.marginUsedPct,
655
+ positions: [...snapshot.positions.values()],
656
+ timestamp: now,
657
+ });
658
+ // Always emit tick
659
+ await emitAutomationEvent('tick', { timestamp: now, pollCount }, 'poll');
660
+ if (previousSnapshot) {
661
+ // Price changes (skip when WebSocket is handling real-time prices)
662
+ if (eventBus.has('price_change') && !wsConnected) {
663
+ for (const [coin, newPrice] of snapshot.prices) {
664
+ const oldPrice = previousSnapshot.prices.get(coin);
665
+ if (oldPrice === undefined || oldPrice === 0)
666
+ continue;
667
+ const changePct = ((newPrice - oldPrice) / oldPrice) * 100;
668
+ if (Math.abs(changePct) >= 0.01) { // 0.01% minimum to fire (filters rounding noise)
669
+ await emitAutomationEvent('price_change', { coin, oldPrice, newPrice, changePct }, 'poll');
670
+ }
671
+ }
672
+ }
673
+ // Funding updates
674
+ if (eventBus.has('funding_update')) {
675
+ for (const [coin, data] of snapshot.fundingRates) {
676
+ await emitAutomationEvent('funding_update', {
677
+ coin,
678
+ fundingRate: data.rate,
679
+ annualized: annualizeFundingRate(data.rate),
680
+ premium: data.premium,
681
+ }, 'poll');
682
+ }
683
+ }
684
+ // Position opened
685
+ if (eventBus.has('position_opened')) {
686
+ for (const [coin, pos] of snapshot.positions) {
687
+ if (!previousSnapshot.positions.has(coin)) {
688
+ await emitAutomationEvent('position_opened', {
689
+ coin,
690
+ side: pos.size > 0 ? 'long' : 'short',
691
+ size: Math.abs(pos.size),
692
+ entryPrice: pos.entryPrice,
693
+ }, 'poll');
694
+ }
695
+ }
696
+ }
697
+ // Position closed
698
+ if (eventBus.has('position_closed')) {
699
+ for (const [coin, prevPos] of previousSnapshot.positions) {
700
+ if (!snapshot.positions.has(coin)) {
701
+ await emitAutomationEvent('position_closed', {
702
+ coin,
703
+ previousSize: prevPos.size,
704
+ entryPrice: prevPos.entryPrice,
705
+ }, 'poll');
706
+ }
707
+ }
708
+ }
709
+ // Position size changed
710
+ if (eventBus.has('position_changed')) {
711
+ for (const [coin, pos] of snapshot.positions) {
712
+ const prevPos = previousSnapshot.positions.get(coin);
713
+ if (prevPos && pos.size !== prevPos.size) {
714
+ await emitAutomationEvent('position_changed', {
715
+ coin,
716
+ oldSize: prevPos.size,
717
+ newSize: pos.size,
718
+ entryPrice: pos.entryPrice,
719
+ }, 'poll');
720
+ }
721
+ }
722
+ }
723
+ // PnL threshold (5% of position value)
724
+ if (eventBus.has('pnl_threshold')) {
725
+ for (const [coin, pos] of snapshot.positions) {
726
+ const prevPos = previousSnapshot.positions.get(coin);
727
+ if (!prevPos || pos.positionValue === 0)
728
+ continue;
729
+ const pnlChange = Math.abs(pos.unrealizedPnl - prevPos.unrealizedPnl);
730
+ const changePct = (pnlChange / pos.positionValue) * 100;
731
+ if (changePct >= 5) {
732
+ await emitAutomationEvent('pnl_threshold', {
733
+ coin,
734
+ unrealizedPnl: pos.unrealizedPnl,
735
+ changePct,
736
+ positionValue: pos.positionValue,
737
+ }, 'poll');
738
+ }
739
+ }
740
+ }
741
+ // Margin warning (80%)
742
+ if (eventBus.has('margin_warning') && snapshot.marginUsedPct >= 80) {
743
+ const prevPct = previousSnapshot.marginUsedPct;
744
+ if (prevPct < 80 || snapshot.marginUsedPct - prevPct >= 5) {
745
+ await emitAutomationEvent('margin_warning', {
746
+ marginUsedPct: snapshot.marginUsedPct,
747
+ equity: snapshot.equity,
748
+ marginUsed: snapshot.marginUsed,
749
+ }, 'poll');
750
+ }
751
+ }
752
+ // Order filled — compare open order IDs
753
+ // (Skipped for MVP — requires tracking open orders per poll, will add when needed)
754
+ }
755
+ // Run scheduled tasks
756
+ for (const task of scheduledTasks) {
757
+ if (now - task.lastRun >= task.intervalMs) {
758
+ try {
759
+ await task.handler();
760
+ }
761
+ catch (err) {
762
+ const error = err instanceof Error ? err : new Error(String(err));
763
+ audit.recordError('scheduled_task', error);
764
+ log.error(`Scheduled task error: ${error.message}`);
765
+ await handleErrors([error]);
766
+ }
767
+ task.lastRun = now;
768
+ }
769
+ }
770
+ previousSnapshot = snapshot;
771
+ }
772
+ catch (err) {
773
+ const error = err instanceof Error ? err : new Error(String(err));
774
+ audit.recordError('poll', error);
775
+ log.error(`Poll error: ${error.message}`);
776
+ }
777
+ finally {
778
+ isPolling = false;
779
+ }
780
+ }
781
+ // Start polling
782
+ const wsLabel = wsConnected ? ', ws=on' : (useWebSocket ? ', ws=failed' : '');
783
+ log.info(`Started (poll every ${pollIntervalMs / 1000}s, dry=${dryRun}${wsLabel})`);
784
+ const timer = setInterval(poll, pollIntervalMs);
785
+ // Initial poll to seed state
786
+ await poll();
787
+ // Stop function
788
+ async function stop(opts) {
789
+ if (stopped)
790
+ return;
791
+ stopped = true;
792
+ clearInterval(timer);
793
+ // Close WebSocket
794
+ if (ws) {
795
+ ws.removeAllListeners();
796
+ await ws.close();
797
+ ws = null;
798
+ }
799
+ for (const hook of stopHooks) {
800
+ try {
801
+ await hook();
802
+ }
803
+ catch (err) {
804
+ const error = err instanceof Error ? err : new Error(String(err));
805
+ audit.recordError('onStop', error);
806
+ log.error(`onStop hook error: ${error.message}`);
807
+ }
808
+ }
809
+ keepAwake?.stop();
810
+ eventBus.removeAll();
811
+ registry.delete(id);
812
+ // persist defaults to true — fully remove from file registry.
813
+ // When false (gateway shutdown), keep the entry so it restarts next time.
814
+ if (opts?.persist !== false) {
815
+ unregisterAutomation(id);
816
+ }
817
+ log.info(`Stopped (${pollCount} polls, ${eventsEmitted} events)`);
818
+ await audit.stop({
819
+ status: 'stopped',
820
+ stopReason: opts?.persist === false ? 'shutdown_keep_registry' : 'manual_stop',
821
+ pollCount,
822
+ eventsEmitted,
823
+ });
824
+ }
825
+ const entry = {
826
+ id,
827
+ scriptPath,
828
+ startedAt: new Date(),
829
+ get pollCount() { return pollCount; },
830
+ get eventsEmitted() { return eventsEmitted; },
831
+ dryRun,
832
+ stop,
833
+ };
834
+ registry.set(id, entry);
835
+ // Persist to file-based registry so other processes (CLI, plugin) can see it
836
+ registerAutomation({
837
+ id,
838
+ scriptPath,
839
+ dryRun,
840
+ verbose,
841
+ pollIntervalMs,
842
+ });
843
+ return entry;
844
+ }