openbroker 1.0.75 → 1.0.80

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.
@@ -13,12 +13,16 @@ import {
13
13
  import { WebSocketManager } from '../core/ws.js';
14
14
  import { AutomationEventBus } from './events.js';
15
15
  import { loadAutomation } from './loader.js';
16
- import { registerAutomation, unregisterAutomation, markAutomationError, getRegisteredAutomations as getRegisteredFromFile } from './registry.js';
16
+ import { registerAutomation, unregisterAutomation, getRegisteredAutomations as getRegisteredFromFile } from './registry.js';
17
+ import { createAutomationAudit, toSerializable, type AutomationAuditSink } from './audit.js';
17
18
  import type {
18
19
  AutomationAPI,
20
+ AutomationEventPayloads,
21
+ AutomationEventType,
19
22
  AutomationLogger,
20
23
  AutomationState,
21
24
  AutomationSnapshot,
25
+ AutomationAudit,
22
26
  PositionSnapshot,
23
27
  PublishOptions,
24
28
  ScheduledTask,
@@ -26,14 +30,27 @@ import type {
26
30
  } from './types.js';
27
31
 
28
32
  const STATE_DIR = path.join(os.homedir(), '.openbroker', 'state');
33
+ const AUDITED_WRITE_METHODS = new Set([
34
+ 'order', 'marketOrder', 'limitOrder', 'triggerOrder',
35
+ 'takeProfit', 'stopLoss', 'cancel', 'cancelAll',
36
+ 'spotOrder', 'spotMarketOrder', 'spotLimitOrder', 'spotCancel',
37
+ 'updateLeverage', 'approveBuilderFee', 'twapOrder', 'twapCancel',
38
+ ]);
29
39
 
30
40
  // ── State persistence ───────────────────────────────────────────────
31
41
 
32
- function createState(id: string): AutomationState {
42
+ interface StateController {
43
+ state: AutomationState;
44
+ snapshot(): Record<string, unknown>;
45
+ attachAudit(audit: AutomationAuditSink): void;
46
+ }
47
+
48
+ function createState(id: string): StateController {
33
49
  mkdirSync(STATE_DIR, { recursive: true });
34
50
  const stateFile = path.join(STATE_DIR, `${id}.json`);
35
51
 
36
52
  let data: Record<string, unknown> = {};
53
+ let audit: AutomationAuditSink | null = null;
37
54
  if (existsSync(stateFile)) {
38
55
  try {
39
56
  data = JSON.parse(readFileSync(stateFile, 'utf-8'));
@@ -52,33 +69,59 @@ function createState(id: string): AutomationState {
52
69
  }
53
70
 
54
71
  return {
55
- get<T = unknown>(key: string, defaultValue?: T): T | undefined {
56
- return (key in data ? data[key] : defaultValue) as T | undefined;
57
- },
58
- set<T = unknown>(key: string, value: T): void {
59
- data[key] = value;
60
- scheduleFlush();
72
+ state: {
73
+ get<T = unknown>(key: string, defaultValue?: T): T | undefined {
74
+ return (key in data ? data[key] : defaultValue) as T | undefined;
75
+ },
76
+ set<T = unknown>(key: string, value: T): void {
77
+ data[key] = value;
78
+ audit?.recordStateChange('set', key, value);
79
+ scheduleFlush();
80
+ },
81
+ delete(key: string): void {
82
+ const previous = key in data ? data[key] : undefined;
83
+ delete data[key];
84
+ audit?.recordStateChange('delete', key, previous);
85
+ scheduleFlush();
86
+ },
87
+ clear(): void {
88
+ data = {};
89
+ audit?.recordStateChange('clear', null);
90
+ scheduleFlush();
91
+ },
61
92
  },
62
- delete(key: string): void {
63
- delete data[key];
64
- scheduleFlush();
93
+ snapshot(): Record<string, unknown> {
94
+ return toSerializable(data);
65
95
  },
66
- clear(): void {
67
- data = {};
68
- scheduleFlush();
96
+ attachAudit(nextAudit: AutomationAuditSink): void {
97
+ audit = nextAudit;
69
98
  },
70
99
  };
71
100
  }
72
101
 
73
102
  // ── Logger ──────────────────────────────────────────────────────────
74
103
 
75
- function createLogger(id: string, verbose: boolean): AutomationLogger {
104
+ function createLogger(id: string, verbose: boolean, audit?: AutomationAuditSink): AutomationLogger {
76
105
  const prefix = `[auto:${id}]`;
77
106
  return {
78
- info: (msg: string) => console.log(`${prefix} ${msg}`),
79
- warn: (msg: string) => console.log(`${prefix} ⚠ ${msg}`),
80
- error: (msg: string) => console.error(`${prefix} ${msg}`),
81
- debug: (msg: string) => { if (verbose) console.log(`${prefix} … ${msg}`); },
107
+ info: (msg: string) => {
108
+ audit?.recordLog('info', msg);
109
+ console.log(`${prefix} ${msg}`);
110
+ },
111
+ warn: (msg: string) => {
112
+ audit?.recordLog('warn', msg);
113
+ console.log(`${prefix} ⚠ ${msg}`);
114
+ },
115
+ error: (msg: string) => {
116
+ audit?.recordLog('error', msg);
117
+ console.error(`${prefix} ✗ ${msg}`);
118
+ },
119
+ debug: (msg: string) => {
120
+ if (verbose) {
121
+ audit?.recordLog('debug', msg);
122
+ console.log(`${prefix} … ${msg}`);
123
+ }
124
+ },
82
125
  };
83
126
  }
84
127
 
@@ -88,6 +131,8 @@ const WRITE_METHODS = new Set([
88
131
  'order', 'marketOrder', 'limitOrder', 'triggerOrder',
89
132
  'takeProfit', 'stopLoss', 'cancel', 'cancelAll',
90
133
  'updateLeverage', 'approveBuilderFee',
134
+ 'spotOrder', 'spotMarketOrder', 'spotLimitOrder', 'spotCancel',
135
+ 'twapOrder', 'twapCancel',
91
136
  ]);
92
137
 
93
138
  function createDryClient(client: HyperliquidClient, log: AutomationLogger): HyperliquidClient {
@@ -105,6 +150,52 @@ function createDryClient(client: HyperliquidClient, log: AutomationLogger): Hype
105
150
  });
106
151
  }
107
152
 
153
+ function createAuditedClient(
154
+ client: HyperliquidClient,
155
+ audit: AutomationAuditSink,
156
+ dryRun: boolean,
157
+ ): HyperliquidClient {
158
+ return new Proxy(client, {
159
+ get(target, prop, receiver) {
160
+ const value = Reflect.get(target, prop, receiver);
161
+ if (typeof prop === 'string' && AUDITED_WRITE_METHODS.has(prop) && typeof value === 'function') {
162
+ return async (...args: unknown[]) => {
163
+ const actionId = `${prop}:${Date.now()}:${Math.random().toString(16).slice(2)}`;
164
+ audit.recordAction({
165
+ actionId,
166
+ phase: 'request',
167
+ method: prop,
168
+ payload: { args },
169
+ dryRun,
170
+ });
171
+
172
+ try {
173
+ const result = await value.apply(target, args);
174
+ audit.recordAction({
175
+ actionId,
176
+ phase: 'response',
177
+ method: prop,
178
+ result,
179
+ dryRun,
180
+ });
181
+ return result;
182
+ } catch (error) {
183
+ audit.recordAction({
184
+ actionId,
185
+ phase: 'error',
186
+ method: prop,
187
+ error,
188
+ dryRun,
189
+ });
190
+ throw error;
191
+ }
192
+ };
193
+ }
194
+ return value;
195
+ },
196
+ });
197
+ }
198
+
108
199
  // ── Snapshot building ───────────────────────────────────────────────
109
200
 
110
201
  async function buildSnapshot(
@@ -221,6 +312,22 @@ function createPublish(
221
312
  };
222
313
  }
223
314
 
315
+ function createAuditedPublish(
316
+ publish: (message: string, options?: PublishOptions) => Promise<boolean>,
317
+ audit: AutomationAuditSink,
318
+ ): (message: string, options?: PublishOptions) => Promise<boolean> {
319
+ return async (message: string, options?: PublishOptions): Promise<boolean> => {
320
+ try {
321
+ const delivered = await publish(message, options);
322
+ audit.recordPublish(message, options, delivered);
323
+ return delivered;
324
+ } catch (error) {
325
+ audit.recordError('publish', error);
326
+ throw error;
327
+ }
328
+ };
329
+ }
330
+
224
331
  // ── Runtime ─────────────────────────────────────────────────────────
225
332
 
226
333
  export interface RuntimeOptions {
@@ -279,14 +386,13 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
279
386
  throw new Error(`Automation "${id}" is already running`);
280
387
  }
281
388
 
282
- const log = createLogger(id, verbose);
283
- const state = createState(id);
389
+ const stateController = createState(id);
284
390
 
285
391
  // Pre-seed state from --set flags (doesn't overwrite already-persisted keys)
286
392
  if (initialState) {
287
393
  for (const [key, value] of Object.entries(initialState)) {
288
- if (state.get(key) === undefined) {
289
- state.set(key, value);
394
+ if (stateController.state.get(key) === undefined) {
395
+ stateController.state.set(key, value);
290
396
  }
291
397
  }
292
398
  }
@@ -294,7 +400,24 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
294
400
  const eventBus = new AutomationEventBus();
295
401
 
296
402
  const rawClient = getClient();
297
- const client = dryRun ? createDryClient(rawClient, log) : rawClient;
403
+ const audit = createAutomationAudit({
404
+ automationId: id,
405
+ scriptPath,
406
+ dryRun,
407
+ verbose,
408
+ pollIntervalMs,
409
+ useWebSocket,
410
+ accountAddress: rawClient.address,
411
+ walletAddress: rawClient.walletAddress,
412
+ isApiWallet: rawClient.isApiWallet,
413
+ initialState,
414
+ persistedState: stateController.snapshot(),
415
+ });
416
+ stateController.attachAudit(audit);
417
+
418
+ const log = createLogger(id, verbose, audit);
419
+ const baseClient = dryRun ? createDryClient(rawClient, log) : rawClient;
420
+ const client = createAuditedClient(baseClient, audit, dryRun);
298
421
 
299
422
  const startHooks: Array<() => void | Promise<void>> = [];
300
423
  const stopHooks: Array<() => void | Promise<void>> = [];
@@ -302,7 +425,11 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
302
425
  const scheduledTasks: ScheduledTask[] = [];
303
426
 
304
427
  // Build the API object
305
- const publish = createPublish(id, log, gatewayPort, hooksToken);
428
+ const publish = createAuditedPublish(createPublish(id, log, gatewayPort, hooksToken), audit);
429
+ const auditApi: AutomationAudit = {
430
+ record: (kind: string, payload?: unknown) => audit.recordNote(kind, payload),
431
+ metric: (name: string, value: number, tags?: Record<string, unknown>) => audit.recordMetric(name, value, tags),
432
+ };
306
433
  const api: AutomationAPI = {
307
434
  client,
308
435
  utils: { roundPrice, roundSize, sleep, normalizeCoin, formatUsd, formatPercent, annualizeFundingRate },
@@ -312,22 +439,37 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
312
439
  onStop: (handler) => stopHooks.push(handler),
313
440
  onError: (handler) => errorHooks.push(handler),
314
441
  publish,
315
- state,
442
+ state: stateController.state,
316
443
  log,
444
+ audit: auditApi,
317
445
  id,
318
446
  dryRun,
319
447
  };
320
448
 
321
- // Load and execute the factory function (registers handlers)
322
- log.info(`Loading automation: ${scriptPath}`);
323
- const factory = await loadAutomation(scriptPath);
324
- await factory(api);
449
+ try {
450
+ // Load and execute the factory function (registers handlers)
451
+ log.info(`Loading automation: ${scriptPath}`);
452
+ const factory = await loadAutomation(scriptPath);
453
+ await factory(api);
325
454
 
326
- // Call onStart hooks
327
- for (const hook of startHooks) {
328
- try { await hook(); } catch (err) {
329
- log.error(`onStart hook error: ${err instanceof Error ? err.message : String(err)}`);
455
+ // Call onStart hooks
456
+ for (const hook of startHooks) {
457
+ try { await hook(); } catch (err) {
458
+ const error = err instanceof Error ? err : new Error(String(err));
459
+ audit.recordError('onStart', error);
460
+ log.error(`onStart hook error: ${error.message}`);
461
+ }
330
462
  }
463
+ } catch (err) {
464
+ const error = err instanceof Error ? err : new Error(String(err));
465
+ audit.recordError('startup', error);
466
+ await audit.stop({
467
+ status: 'error',
468
+ stopReason: 'startup_error',
469
+ pollCount: 0,
470
+ eventsEmitted: 0,
471
+ });
472
+ throw error;
331
473
  }
332
474
 
333
475
  // Polling state (declared early so WebSocket handlers can reference)
@@ -339,6 +481,7 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
339
481
 
340
482
  async function handleErrors(errors: Error[]) {
341
483
  for (const err of errors) {
484
+ audit.recordError('handler', err);
342
485
  log.error(`Handler error: ${err.message}`);
343
486
  for (const hook of errorHooks) {
344
487
  try { await hook(err); } catch { /* swallow */ }
@@ -346,6 +489,23 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
346
489
  }
347
490
  }
348
491
 
492
+ function shouldPersistEvent(event: AutomationEventType): boolean {
493
+ return event !== 'tick' && event !== 'price_change';
494
+ }
495
+
496
+ async function emitAutomationEvent<E extends AutomationEventType>(
497
+ event: E,
498
+ payload: AutomationEventPayloads[E],
499
+ source: 'poll' | 'ws' | 'manual',
500
+ ): Promise<void> {
501
+ if (shouldPersistEvent(event)) {
502
+ audit.recordEvent(event, source, payload);
503
+ }
504
+ const errors = await eventBus.emit(event, payload);
505
+ if (errors.length) await handleErrors(errors);
506
+ eventsEmitted++;
507
+ }
508
+
349
509
  // ── WebSocket setup ─────────────────────────────────────────────
350
510
  let ws: WebSocketManager | null = null;
351
511
  let wsConnected = false;
@@ -368,17 +528,27 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
368
528
  if (oldPrice !== undefined && oldPrice !== 0 && eventBus.has('price_change')) {
369
529
  const changePct = ((newPrice - oldPrice) / oldPrice) * 100;
370
530
  if (Math.abs(changePct) >= 0.01) {
371
- eventBus.emit('price_change', { coin, oldPrice, newPrice, changePct })
372
- .then(errors => { if (errors.length) handleErrors(errors); });
373
- eventsEmitted++;
531
+ void emitAutomationEvent('price_change', { coin, oldPrice, newPrice, changePct }, 'ws');
374
532
  }
375
533
  }
376
534
  }
377
535
  });
378
536
 
379
537
  ws.on('orderUpdate', (update) => {
538
+ audit.recordOrderUpdate({
539
+ coin: update.order.coin,
540
+ oid: update.order.oid,
541
+ side: update.order.side === 'B' ? 'buy' : 'sell',
542
+ size: parseFloat(update.order.sz),
543
+ price: parseFloat(update.order.limitPx),
544
+ origSize: parseFloat(update.order.origSz),
545
+ status: update.status,
546
+ statusTimestamp: update.statusTimestamp,
547
+ raw: update,
548
+ });
549
+
380
550
  if (eventBus.has('order_update')) {
381
- eventBus.emit('order_update', {
551
+ void emitAutomationEvent('order_update', {
382
552
  coin: update.order.coin,
383
553
  oid: update.order.oid,
384
554
  side: update.order.side === 'B' ? 'buy' : 'sell',
@@ -387,45 +557,55 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
387
557
  origSize: parseFloat(update.order.origSz),
388
558
  status: update.status,
389
559
  statusTimestamp: update.statusTimestamp,
390
- }).then(errors => { if (errors.length) handleErrors(errors); });
391
- eventsEmitted++;
560
+ }, 'ws');
392
561
  }
393
562
 
394
563
  // Also emit order_filled for backward compatibility
395
564
  if (update.status === 'filled' && eventBus.has('order_filled')) {
396
- eventBus.emit('order_filled', {
565
+ void emitAutomationEvent('order_filled', {
397
566
  coin: update.order.coin,
398
567
  oid: update.order.oid,
399
568
  side: update.order.side === 'B' ? 'buy' : 'sell',
400
569
  size: parseFloat(update.order.sz),
401
570
  price: parseFloat(update.order.limitPx),
402
- }).then(errors => { if (errors.length) handleErrors(errors); });
403
- eventsEmitted++;
571
+ }, 'ws');
404
572
  }
405
573
  });
406
574
 
407
575
  ws.on('userFill', (fill) => {
408
576
  // userFill events are already covered by order_update with status=filled
409
577
  // But this provides the realized PnL and fee data that order_update doesn't have
578
+ audit.recordFill({
579
+ coin: fill.coin,
580
+ side: fill.side === 'B' ? 'buy' : 'sell',
581
+ size: fill.sz,
582
+ price: fill.px,
583
+ time: fill.time,
584
+ closedPnl: fill.closedPnl,
585
+ fee: fill.fee,
586
+ oid: fill.oid,
587
+ crossed: fill.crossed,
588
+ }, fill.time);
410
589
  log.debug(`Fill: ${fill.side === 'B' ? 'BUY' : 'SELL'} ${fill.sz} ${fill.coin} @ ${fill.px} (PnL: ${fill.closedPnl})`);
411
590
  });
412
591
 
413
592
  ws.on('userEvent', (event) => {
593
+ audit.recordUserEvent(event);
414
594
  // Handle liquidation events — only available through WebSocket
415
595
  if ('liquidation' in event && eventBus.has('liquidation')) {
416
596
  const liq = event.liquidation;
417
- eventBus.emit('liquidation', {
597
+ void emitAutomationEvent('liquidation', {
418
598
  lid: liq.lid,
419
599
  liquidator: liq.liquidator,
420
600
  liquidatedUser: liq.liquidated_user,
421
601
  liquidatedNtlPos: parseFloat(liq.liquidated_ntl_pos),
422
602
  liquidatedAccountValue: parseFloat(liq.liquidated_account_value),
423
- }).then(errors => { if (errors.length) handleErrors(errors); });
424
- eventsEmitted++;
603
+ }, 'ws');
425
604
  }
426
605
  });
427
606
 
428
607
  ws.on('error', ({ error }) => {
608
+ audit.recordError('websocket', error);
429
609
  log.warn(`WebSocket error: ${error.message}`);
430
610
  });
431
611
 
@@ -444,7 +624,9 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
444
624
  await ws.subscribeAll(userAddress);
445
625
  log.info('WebSocket subscriptions active (allMids, orderUpdates, userFills, userEvents)');
446
626
  } catch (err) {
447
- log.warn(`WebSocket setup failed: ${err instanceof Error ? err.message : String(err)} — using REST polling only`);
627
+ const error = err instanceof Error ? err : new Error(String(err));
628
+ audit.recordError('websocket_setup', error);
629
+ log.warn(`WebSocket setup failed: ${error.message} — using REST polling only`);
448
630
  ws = null;
449
631
  wsConnected = false;
450
632
  }
@@ -458,11 +640,17 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
458
640
  const snapshot = await buildSnapshot(rawClient);
459
641
  pollCount++;
460
642
  const now = Date.now();
643
+ audit.recordSnapshot({
644
+ pollCount,
645
+ equity: snapshot.equity,
646
+ marginUsed: snapshot.marginUsed,
647
+ marginUsedPct: snapshot.marginUsedPct,
648
+ positions: [...snapshot.positions.values()],
649
+ timestamp: now,
650
+ });
461
651
 
462
652
  // Always emit tick
463
- const tickErrors = await eventBus.emit('tick', { timestamp: now, pollCount });
464
- if (tickErrors.length) await handleErrors(tickErrors);
465
- eventsEmitted++;
653
+ await emitAutomationEvent('tick', { timestamp: now, pollCount }, 'poll');
466
654
 
467
655
  if (previousSnapshot) {
468
656
  // Price changes (skip when WebSocket is handling real-time prices)
@@ -472,9 +660,7 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
472
660
  if (oldPrice === undefined || oldPrice === 0) continue;
473
661
  const changePct = ((newPrice - oldPrice) / oldPrice) * 100;
474
662
  if (Math.abs(changePct) >= 0.01) { // 0.01% minimum to fire (filters rounding noise)
475
- const errors = await eventBus.emit('price_change', { coin, oldPrice, newPrice, changePct });
476
- if (errors.length) await handleErrors(errors);
477
- eventsEmitted++;
663
+ await emitAutomationEvent('price_change', { coin, oldPrice, newPrice, changePct }, 'poll');
478
664
  }
479
665
  }
480
666
  }
@@ -482,14 +668,12 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
482
668
  // Funding updates
483
669
  if (eventBus.has('funding_update')) {
484
670
  for (const [coin, data] of snapshot.fundingRates) {
485
- const errors = await eventBus.emit('funding_update', {
671
+ await emitAutomationEvent('funding_update', {
486
672
  coin,
487
673
  fundingRate: data.rate,
488
674
  annualized: annualizeFundingRate(data.rate),
489
675
  premium: data.premium,
490
- });
491
- if (errors.length) await handleErrors(errors);
492
- eventsEmitted++;
676
+ }, 'poll');
493
677
  }
494
678
  }
495
679
 
@@ -497,14 +681,12 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
497
681
  if (eventBus.has('position_opened')) {
498
682
  for (const [coin, pos] of snapshot.positions) {
499
683
  if (!previousSnapshot.positions.has(coin)) {
500
- const errors = await eventBus.emit('position_opened', {
684
+ await emitAutomationEvent('position_opened', {
501
685
  coin,
502
686
  side: pos.size > 0 ? 'long' : 'short',
503
687
  size: Math.abs(pos.size),
504
688
  entryPrice: pos.entryPrice,
505
- });
506
- if (errors.length) await handleErrors(errors);
507
- eventsEmitted++;
689
+ }, 'poll');
508
690
  }
509
691
  }
510
692
  }
@@ -513,13 +695,11 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
513
695
  if (eventBus.has('position_closed')) {
514
696
  for (const [coin, prevPos] of previousSnapshot.positions) {
515
697
  if (!snapshot.positions.has(coin)) {
516
- const errors = await eventBus.emit('position_closed', {
698
+ await emitAutomationEvent('position_closed', {
517
699
  coin,
518
700
  previousSize: prevPos.size,
519
701
  entryPrice: prevPos.entryPrice,
520
- });
521
- if (errors.length) await handleErrors(errors);
522
- eventsEmitted++;
702
+ }, 'poll');
523
703
  }
524
704
  }
525
705
  }
@@ -529,14 +709,12 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
529
709
  for (const [coin, pos] of snapshot.positions) {
530
710
  const prevPos = previousSnapshot.positions.get(coin);
531
711
  if (prevPos && pos.size !== prevPos.size) {
532
- const errors = await eventBus.emit('position_changed', {
712
+ await emitAutomationEvent('position_changed', {
533
713
  coin,
534
714
  oldSize: prevPos.size,
535
715
  newSize: pos.size,
536
716
  entryPrice: pos.entryPrice,
537
- });
538
- if (errors.length) await handleErrors(errors);
539
- eventsEmitted++;
717
+ }, 'poll');
540
718
  }
541
719
  }
542
720
  }
@@ -549,14 +727,12 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
549
727
  const pnlChange = Math.abs(pos.unrealizedPnl - prevPos.unrealizedPnl);
550
728
  const changePct = (pnlChange / pos.positionValue) * 100;
551
729
  if (changePct >= 5) {
552
- const errors = await eventBus.emit('pnl_threshold', {
730
+ await emitAutomationEvent('pnl_threshold', {
553
731
  coin,
554
732
  unrealizedPnl: pos.unrealizedPnl,
555
733
  changePct,
556
734
  positionValue: pos.positionValue,
557
- });
558
- if (errors.length) await handleErrors(errors);
559
- eventsEmitted++;
735
+ }, 'poll');
560
736
  }
561
737
  }
562
738
  }
@@ -565,13 +741,11 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
565
741
  if (eventBus.has('margin_warning') && snapshot.marginUsedPct >= 80) {
566
742
  const prevPct = previousSnapshot.marginUsedPct;
567
743
  if (prevPct < 80 || snapshot.marginUsedPct - prevPct >= 5) {
568
- const errors = await eventBus.emit('margin_warning', {
744
+ await emitAutomationEvent('margin_warning', {
569
745
  marginUsedPct: snapshot.marginUsedPct,
570
746
  equity: snapshot.equity,
571
747
  marginUsed: snapshot.marginUsed,
572
- });
573
- if (errors.length) await handleErrors(errors);
574
- eventsEmitted++;
748
+ }, 'poll');
575
749
  }
576
750
  }
577
751
 
@@ -586,6 +760,7 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
586
760
  await task.handler();
587
761
  } catch (err) {
588
762
  const error = err instanceof Error ? err : new Error(String(err));
763
+ audit.recordError('scheduled_task', error);
589
764
  log.error(`Scheduled task error: ${error.message}`);
590
765
  await handleErrors([error]);
591
766
  }
@@ -595,7 +770,9 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
595
770
 
596
771
  previousSnapshot = snapshot;
597
772
  } catch (err) {
598
- log.error(`Poll error: ${err instanceof Error ? err.message : String(err)}`);
773
+ const error = err instanceof Error ? err : new Error(String(err));
774
+ audit.recordError('poll', error);
775
+ log.error(`Poll error: ${error.message}`);
599
776
  } finally {
600
777
  isPolling = false;
601
778
  }
@@ -624,7 +801,9 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
624
801
 
625
802
  for (const hook of stopHooks) {
626
803
  try { await hook(); } catch (err) {
627
- log.error(`onStop hook error: ${err instanceof Error ? err.message : String(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}`);
628
807
  }
629
808
  }
630
809
 
@@ -637,6 +816,12 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
637
816
  unregisterAutomation(id);
638
817
  }
639
818
  log.info(`Stopped (${pollCount} polls, ${eventsEmitted} events)`);
819
+ await audit.stop({
820
+ status: 'stopped',
821
+ stopReason: opts?.persist === false ? 'shutdown_keep_registry' : 'manual_stop',
822
+ pollCount,
823
+ eventsEmitted,
824
+ });
640
825
  }
641
826
 
642
827
  const entry: RunningAutomation = {
@@ -87,6 +87,13 @@ export interface AutomationLogger {
87
87
  debug(message: string): void;
88
88
  }
89
89
 
90
+ export interface AutomationAudit {
91
+ /** Record a custom audit note for later reporting. */
92
+ record(kind: string, payload?: unknown): void;
93
+ /** Record a numeric metric with optional dimensions/tags. */
94
+ metric(name: string, value: number, tags?: Record<string, unknown>): void;
95
+ }
96
+
90
97
  // ── Publish (webhook) ───────────────────────────────────────────────
91
98
 
92
99
  export interface PublishOptions {
@@ -149,6 +156,9 @@ export interface AutomationAPI {
149
156
  /** Structured logger */
150
157
  log: AutomationLogger;
151
158
 
159
+ /** Local audit trail persisted to ~/.openbroker/automation-audit.sqlite */
160
+ audit: AutomationAudit;
161
+
152
162
  /** Unique automation ID (derived from filename or --id flag) */
153
163
  id: string;
154
164