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.
@@ -0,0 +1,552 @@
1
+ import { spawn } from 'child_process';
2
+ import { randomUUID } from 'crypto';
3
+ import { once } from 'events';
4
+ import net, { type Socket } from 'net';
5
+ import path from 'path';
6
+ import readline from 'readline';
7
+ import { setTimeout as delay } from 'timers/promises';
8
+ import { fileURLToPath } from 'url';
9
+ import { ensureConfigDir } from '../core/config.js';
10
+
11
+ export interface AutomationAuditSink {
12
+ readonly runId: string;
13
+ readonly dbPath: string;
14
+ recordLog(level: 'info' | 'warn' | 'error' | 'debug', message: string, timestamp?: number): void;
15
+ recordEvent(eventType: string, source: 'poll' | 'ws' | 'manual', payload: unknown, timestamp?: number): void;
16
+ recordAction(args: {
17
+ actionId?: string;
18
+ phase: 'request' | 'response' | 'error';
19
+ method: string;
20
+ payload?: unknown;
21
+ result?: unknown;
22
+ error?: unknown;
23
+ dryRun?: boolean;
24
+ timestamp?: number;
25
+ }): void;
26
+ recordSnapshot(snapshot: {
27
+ pollCount: number;
28
+ equity: number;
29
+ marginUsed: number;
30
+ marginUsedPct: number;
31
+ positions: unknown[];
32
+ timestamp?: number;
33
+ }): void;
34
+ recordOrderUpdate(payload: unknown, timestamp?: number): void;
35
+ recordFill(payload: unknown, timestamp?: number): void;
36
+ recordUserEvent(payload: unknown, timestamp?: number): void;
37
+ recordStateChange(op: 'set' | 'delete' | 'clear', key: string | null, value?: unknown, timestamp?: number): void;
38
+ recordPublish(message: string, options: unknown, delivered: boolean, timestamp?: number): void;
39
+ recordError(stage: string, error: unknown, timestamp?: number): void;
40
+ recordNote(kind: string, payload?: unknown, timestamp?: number): void;
41
+ recordMetric(name: string, value: number, tags?: Record<string, unknown>, timestamp?: number): void;
42
+ stop(args: {
43
+ status: 'stopped' | 'error';
44
+ stopReason: string;
45
+ pollCount: number;
46
+ eventsEmitted: number;
47
+ timestamp?: number;
48
+ }): Promise<void>;
49
+ }
50
+
51
+ type AuditMessageType =
52
+ | 'init'
53
+ | 'log'
54
+ | 'event'
55
+ | 'action'
56
+ | 'snapshot'
57
+ | 'order_update'
58
+ | 'fill'
59
+ | 'user_event'
60
+ | 'state_change'
61
+ | 'publish'
62
+ | 'error'
63
+ | 'note'
64
+ | 'metric'
65
+ | 'stop';
66
+
67
+ type AuditPayload = Record<string, unknown>;
68
+
69
+ type AuditMessage = {
70
+ messageId: string;
71
+ type: AuditMessageType;
72
+ payload: AuditPayload;
73
+ };
74
+
75
+ type AuditResponse = {
76
+ messageId: string;
77
+ ok: boolean;
78
+ error?: string;
79
+ };
80
+
81
+ export interface AuditStartOptions {
82
+ automationId: string;
83
+ scriptPath: string;
84
+ dryRun: boolean;
85
+ verbose: boolean;
86
+ pollIntervalMs: number;
87
+ useWebSocket: boolean;
88
+ accountAddress: string;
89
+ walletAddress: string;
90
+ isApiWallet: boolean;
91
+ initialState?: Record<string, unknown>;
92
+ persistedState?: Record<string, unknown>;
93
+ }
94
+
95
+ export const AUDIT_DB_PATH = process.env.OPENBROKER_AUDIT_DB_PATH
96
+ || path.join(ensureConfigDir(), 'automation-audit.sqlite');
97
+
98
+ export const AUDIT_SOCKET_PATH = process.env.OPENBROKER_AUDIT_SOCKET_PATH
99
+ || (process.platform === 'win32'
100
+ ? '\\\\.\\pipe\\openbroker-automation-audit-v2'
101
+ : path.join(ensureConfigDir(), 'automation-audit.v2.sock'));
102
+
103
+ function internalWarn(automationId: string, message: string): void {
104
+ console.error(`[auto:${automationId}:audit] ${message}`);
105
+ }
106
+
107
+ export function toSerializable<T = unknown>(value: T): T {
108
+ const seen = new WeakSet<object>();
109
+ const encoded = JSON.stringify(value, (_key, currentValue) => {
110
+ if (typeof currentValue === 'bigint') {
111
+ return currentValue.toString();
112
+ }
113
+ if (currentValue instanceof Error) {
114
+ return {
115
+ name: currentValue.name,
116
+ message: currentValue.message,
117
+ stack: currentValue.stack,
118
+ };
119
+ }
120
+ if (currentValue instanceof Map) {
121
+ return Object.fromEntries(currentValue.entries());
122
+ }
123
+ if (currentValue instanceof Set) {
124
+ return [...currentValue.values()];
125
+ }
126
+ if (typeof currentValue === 'object' && currentValue !== null) {
127
+ if (seen.has(currentValue)) {
128
+ return '[Circular]';
129
+ }
130
+ seen.add(currentValue);
131
+ }
132
+ return currentValue;
133
+ });
134
+ if (encoded === undefined) {
135
+ return null as T;
136
+ }
137
+ return JSON.parse(encoded) as T;
138
+ }
139
+
140
+ class NoopAuditSink implements AutomationAuditSink {
141
+ readonly runId = randomUUID();
142
+ readonly dbPath = AUDIT_DB_PATH;
143
+ recordLog(): void {}
144
+ recordEvent(): void {}
145
+ recordAction(): void {}
146
+ recordSnapshot(): void {}
147
+ recordOrderUpdate(): void {}
148
+ recordFill(): void {}
149
+ recordUserEvent(): void {}
150
+ recordStateChange(): void {}
151
+ recordPublish(): void {}
152
+ recordError(): void {}
153
+ recordNote(): void {}
154
+ recordMetric(): void {}
155
+ async stop(): Promise<void> {}
156
+ }
157
+
158
+ class DaemonAuditSink implements AutomationAuditSink {
159
+ readonly runId = randomUUID();
160
+ readonly dbPath = AUDIT_DB_PATH;
161
+
162
+ private socketPath = AUDIT_SOCKET_PATH;
163
+ private socket: Socket | null = null;
164
+ private lineReader: readline.Interface | null = null;
165
+ private connectPromise: Promise<void> | null = null;
166
+ private flushPromise: Promise<void> | null = null;
167
+ private closed = false;
168
+ private daemonSpawnedAt = 0;
169
+ private pendingQueue: AuditMessage[] = [];
170
+ private inFlight = new Map<string, AuditMessage>();
171
+ private ackWaiters = new Map<string, { resolve: () => void; reject: (error: Error) => void }>();
172
+
173
+ constructor(private readonly automationId: string, options: AuditStartOptions) {
174
+ this.enqueue({
175
+ type: 'init',
176
+ payload: {
177
+ runId: this.runId,
178
+ automationId: options.automationId,
179
+ scriptPath: options.scriptPath,
180
+ dryRun: options.dryRun,
181
+ verbose: options.verbose,
182
+ pollIntervalMs: options.pollIntervalMs,
183
+ useWebSocket: options.useWebSocket,
184
+ accountAddress: options.accountAddress,
185
+ walletAddress: options.walletAddress,
186
+ isApiWallet: options.isApiWallet,
187
+ initialState: toSerializable(options.initialState ?? {}),
188
+ persistedState: toSerializable(options.persistedState ?? {}),
189
+ pid: process.pid,
190
+ startedAt: Date.now(),
191
+ },
192
+ });
193
+ }
194
+
195
+ private handleSocketClose(): void {
196
+ if (this.lineReader) {
197
+ this.lineReader.close();
198
+ this.lineReader = null;
199
+ }
200
+
201
+ const inflight = [...this.inFlight.values()];
202
+ this.inFlight.clear();
203
+ if (inflight.length > 0) {
204
+ this.pendingQueue = inflight.concat(this.pendingQueue);
205
+ }
206
+
207
+ this.socket = null;
208
+ if (!this.closed) {
209
+ void this.ensureConnected();
210
+ }
211
+ }
212
+
213
+ private handleResponse(line: string): void {
214
+ let response: AuditResponse;
215
+ try {
216
+ response = JSON.parse(line) as AuditResponse;
217
+ } catch (error) {
218
+ internalWarn(this.automationId, `failed to parse audit daemon response: ${error instanceof Error ? error.message : String(error)}`);
219
+ return;
220
+ }
221
+
222
+ this.inFlight.delete(response.messageId);
223
+ const waiter = this.ackWaiters.get(response.messageId);
224
+ if (!waiter) return;
225
+ this.ackWaiters.delete(response.messageId);
226
+ if (response.ok) {
227
+ waiter.resolve();
228
+ } else {
229
+ waiter.reject(new Error(response.error || 'audit daemon returned an error'));
230
+ }
231
+ }
232
+
233
+ private async openConnection(): Promise<void> {
234
+ const socket = net.createConnection(this.socketPath);
235
+
236
+ await new Promise<void>((resolve, reject) => {
237
+ let settled = false;
238
+
239
+ const onConnect = () => {
240
+ if (settled) return;
241
+ settled = true;
242
+ socket.off('error', onError);
243
+ resolve();
244
+ };
245
+
246
+ const onError = (error: Error) => {
247
+ if (settled) return;
248
+ settled = true;
249
+ socket.off('connect', onConnect);
250
+ socket.destroy();
251
+ reject(error);
252
+ };
253
+
254
+ socket.once('connect', onConnect);
255
+ socket.once('error', onError);
256
+ });
257
+
258
+ socket.setEncoding('utf8');
259
+ socket.on('close', () => this.handleSocketClose());
260
+ socket.on('error', (error) => {
261
+ if (!this.closed) {
262
+ internalWarn(this.automationId, `audit socket error: ${error.message}`);
263
+ }
264
+ });
265
+
266
+ this.lineReader = readline.createInterface({
267
+ input: socket,
268
+ crlfDelay: Infinity,
269
+ });
270
+ this.lineReader.on('line', (line) => this.handleResponse(line));
271
+
272
+ this.socket = socket;
273
+ }
274
+
275
+ private async spawnDaemon(): Promise<void> {
276
+ const now = Date.now();
277
+ if (now - this.daemonSpawnedAt < 1_000) return;
278
+ this.daemonSpawnedAt = now;
279
+
280
+ const daemonPath = fileURLToPath(new URL('./audit-daemon.js', import.meta.url));
281
+ const child = spawn(
282
+ process.execPath,
283
+ ['--no-warnings', '--experimental-sqlite', daemonPath, this.dbPath, this.socketPath],
284
+ {
285
+ detached: true,
286
+ stdio: 'ignore',
287
+ env: { ...process.env },
288
+ },
289
+ );
290
+ child.unref();
291
+ }
292
+
293
+ private async ensureConnected(): Promise<void> {
294
+ if (this.closed) return;
295
+ if (this.socket && !this.socket.destroyed) return;
296
+ if (this.connectPromise) return this.connectPromise;
297
+
298
+ this.connectPromise = (async () => {
299
+ try {
300
+ await this.openConnection();
301
+ } catch {
302
+ await this.spawnDaemon();
303
+ let lastError: Error | null = null;
304
+
305
+ for (let attempt = 0; attempt < 30 && !this.closed; attempt++) {
306
+ try {
307
+ await delay(100 + (attempt * 50));
308
+ await this.openConnection();
309
+ lastError = null;
310
+ break;
311
+ } catch (error) {
312
+ lastError = error instanceof Error ? error : new Error(String(error));
313
+ }
314
+ }
315
+
316
+ if (lastError) {
317
+ throw lastError;
318
+ }
319
+ }
320
+
321
+ await this.flushQueue();
322
+ })().catch((error) => {
323
+ internalWarn(this.automationId, `audit daemon unavailable: ${error instanceof Error ? error.message : String(error)}`);
324
+ }).finally(() => {
325
+ this.connectPromise = null;
326
+ });
327
+
328
+ return this.connectPromise;
329
+ }
330
+
331
+ private enqueue(message: { type: AuditMessageType; payload: AuditPayload }): AuditMessage {
332
+ const payload = message.type === 'init'
333
+ ? message.payload
334
+ : { runId: this.runId, ...message.payload };
335
+
336
+ const wire: AuditMessage = {
337
+ messageId: randomUUID(),
338
+ type: message.type,
339
+ payload,
340
+ };
341
+ this.pendingQueue.push(wire);
342
+ void this.ensureConnected();
343
+ if (this.socket && !this.socket.destroyed) {
344
+ void this.flushQueue();
345
+ }
346
+ return wire;
347
+ }
348
+
349
+ private async flushQueue(): Promise<void> {
350
+ if (this.flushPromise) return this.flushPromise;
351
+
352
+ this.flushPromise = (async () => {
353
+ while (!this.closed && this.socket && !this.socket.destroyed && this.pendingQueue.length > 0) {
354
+ const message = this.pendingQueue.shift()!;
355
+ this.inFlight.set(message.messageId, message);
356
+
357
+ const line = `${JSON.stringify(message)}\n`;
358
+ const writable = this.socket.write(line);
359
+ if (!writable && this.socket) {
360
+ await once(this.socket, 'drain');
361
+ }
362
+ }
363
+ })().finally(() => {
364
+ this.flushPromise = null;
365
+ });
366
+
367
+ return this.flushPromise;
368
+ }
369
+
370
+ private send(message: { type: AuditMessageType; payload: AuditPayload }, waitForAck = false): Promise<void> {
371
+ if (this.closed) return Promise.resolve();
372
+
373
+ const wire = this.enqueue(message);
374
+ if (!waitForAck) return Promise.resolve();
375
+
376
+ return new Promise<void>((resolve, reject) => {
377
+ this.ackWaiters.set(wire.messageId, { resolve, reject });
378
+ void this.flushQueue();
379
+ });
380
+ }
381
+
382
+ recordLog(level: 'info' | 'warn' | 'error' | 'debug', message: string, timestamp: number = Date.now()): void {
383
+ void this.send({ type: 'log', payload: { timestamp, level, message } });
384
+ }
385
+
386
+ recordEvent(eventType: string, source: 'poll' | 'ws' | 'manual', payload: unknown, timestamp: number = Date.now()): void {
387
+ void this.send({ type: 'event', payload: { timestamp, eventType, source, payload: toSerializable(payload) } });
388
+ }
389
+
390
+ recordAction(args: {
391
+ actionId?: string;
392
+ phase: 'request' | 'response' | 'error';
393
+ method: string;
394
+ payload?: unknown;
395
+ result?: unknown;
396
+ error?: unknown;
397
+ dryRun?: boolean;
398
+ timestamp?: number;
399
+ }): void {
400
+ void this.send({
401
+ type: 'action',
402
+ payload: {
403
+ timestamp: args.timestamp ?? Date.now(),
404
+ actionId: args.actionId ?? randomUUID(),
405
+ phase: args.phase,
406
+ method: args.method,
407
+ payload: toSerializable(args.payload),
408
+ result: toSerializable(args.result),
409
+ error: toSerializable(args.error),
410
+ dryRun: args.dryRun ?? false,
411
+ },
412
+ });
413
+ }
414
+
415
+ recordSnapshot(snapshot: {
416
+ pollCount: number;
417
+ equity: number;
418
+ marginUsed: number;
419
+ marginUsedPct: number;
420
+ positions: unknown[];
421
+ timestamp?: number;
422
+ }): void {
423
+ void this.send({
424
+ type: 'snapshot',
425
+ payload: {
426
+ timestamp: snapshot.timestamp ?? Date.now(),
427
+ pollCount: snapshot.pollCount,
428
+ equity: snapshot.equity,
429
+ marginUsed: snapshot.marginUsed,
430
+ marginUsedPct: snapshot.marginUsedPct,
431
+ positions: toSerializable(snapshot.positions),
432
+ },
433
+ });
434
+ }
435
+
436
+ recordOrderUpdate(payload: unknown, timestamp: number = Date.now()): void {
437
+ void this.send({ type: 'order_update', payload: { timestamp, payload: toSerializable(payload) } });
438
+ }
439
+
440
+ recordFill(payload: unknown, timestamp: number = Date.now()): void {
441
+ void this.send({ type: 'fill', payload: { timestamp, payload: toSerializable(payload) } });
442
+ }
443
+
444
+ recordUserEvent(payload: unknown, timestamp: number = Date.now()): void {
445
+ void this.send({ type: 'user_event', payload: { timestamp, payload: toSerializable(payload) } });
446
+ }
447
+
448
+ recordStateChange(op: 'set' | 'delete' | 'clear', key: string | null, value?: unknown, timestamp: number = Date.now()): void {
449
+ void this.send({
450
+ type: 'state_change',
451
+ payload: {
452
+ timestamp,
453
+ op,
454
+ key,
455
+ value: toSerializable(value),
456
+ },
457
+ });
458
+ }
459
+
460
+ recordPublish(message: string, options: unknown, delivered: boolean, timestamp: number = Date.now()): void {
461
+ void this.send({
462
+ type: 'publish',
463
+ payload: {
464
+ timestamp,
465
+ message,
466
+ options: toSerializable(options),
467
+ delivered,
468
+ },
469
+ });
470
+ }
471
+
472
+ recordError(stage: string, error: unknown, timestamp: number = Date.now()): void {
473
+ void this.send({
474
+ type: 'error',
475
+ payload: {
476
+ timestamp,
477
+ stage,
478
+ error: toSerializable(error),
479
+ },
480
+ });
481
+ }
482
+
483
+ recordNote(kind: string, payload?: unknown, timestamp: number = Date.now()): void {
484
+ void this.send({
485
+ type: 'note',
486
+ payload: {
487
+ timestamp,
488
+ kind,
489
+ payload: toSerializable(payload),
490
+ },
491
+ });
492
+ }
493
+
494
+ recordMetric(name: string, value: number, tags?: Record<string, unknown>, timestamp: number = Date.now()): void {
495
+ void this.send({
496
+ type: 'metric',
497
+ payload: {
498
+ timestamp,
499
+ name,
500
+ value,
501
+ tags: toSerializable(tags ?? {}),
502
+ },
503
+ });
504
+ }
505
+
506
+ async stop(args: {
507
+ status: 'stopped' | 'error';
508
+ stopReason: string;
509
+ pollCount: number;
510
+ eventsEmitted: number;
511
+ timestamp?: number;
512
+ }): Promise<void> {
513
+ if (this.closed) return;
514
+
515
+ try {
516
+ await this.send({
517
+ type: 'stop',
518
+ payload: {
519
+ timestamp: args.timestamp ?? Date.now(),
520
+ status: args.status,
521
+ stopReason: args.stopReason,
522
+ pollCount: args.pollCount,
523
+ eventsEmitted: args.eventsEmitted,
524
+ },
525
+ }, true);
526
+ } catch (error) {
527
+ internalWarn(this.automationId, `failed to flush stop audit message: ${error instanceof Error ? error.message : String(error)}`);
528
+ }
529
+
530
+ this.closed = true;
531
+ if (this.lineReader) {
532
+ this.lineReader.close();
533
+ this.lineReader = null;
534
+ }
535
+ if (this.socket && !this.socket.destroyed) {
536
+ this.socket.end();
537
+ }
538
+ this.socket = null;
539
+ this.pendingQueue = [];
540
+ this.inFlight.clear();
541
+ this.ackWaiters.clear();
542
+ }
543
+ }
544
+
545
+ export function createAutomationAudit(options: AuditStartOptions): AutomationAuditSink {
546
+ try {
547
+ return new DaemonAuditSink(options.automationId, options);
548
+ } catch (error) {
549
+ internalWarn(options.automationId, `audit disabled: ${error instanceof Error ? error.message : String(error)}`);
550
+ return new NoopAuditSink();
551
+ }
552
+ }
@@ -1,10 +1,16 @@
1
1
  // CLI entry point for `openbroker auto` commands
2
2
 
3
+ import { spawnSync } from 'child_process';
4
+ import { fileURLToPath } from 'url';
5
+ import path from 'path';
3
6
  import { parseArgs } from '../core/utils.js';
4
7
  import { resolveScriptPath, resolveExamplePath, listAutomations, listExamples, loadExampleConfigs, ensureAutomationsDir } from './loader.js';
5
8
  import { startAutomation, getRunningAutomations, getRegisteredAutomations } from './runtime.js';
6
9
  import { unregisterAutomation, cleanRegistry } from './registry.js';
7
10
 
11
+ const __filename = fileURLToPath(import.meta.url);
12
+ const __dirname = path.dirname(__filename);
13
+
8
14
  function printUsage() {
9
15
  console.log(`
10
16
  OpenBroker Automations — event-driven trading scripts
@@ -12,6 +18,7 @@ OpenBroker Automations — event-driven trading scripts
12
18
  Usage:
13
19
  openbroker auto run <script> [options] Run an automation script
14
20
  openbroker auto run --example <name> Run a bundled example automation
21
+ openbroker auto report <id> Read the local audit report for an automation
15
22
  openbroker auto examples List bundled example automations
16
23
  openbroker auto stop <id> Unregister an automation (won't restart)
17
24
  openbroker auto list List available automations
@@ -45,6 +52,7 @@ Examples:
45
52
  openbroker auto run --example dca --set coin=BTC --set amount=50 --dry
46
53
  openbroker auto run --example grid --set coin=ETH --set lower=3000 --set upper=4000
47
54
  openbroker auto run my-strategy --dry
55
+ openbroker auto report hype-mm-v2-live-r4
48
56
  openbroker auto examples
49
57
  `);
50
58
  }
@@ -270,6 +278,27 @@ function cleanCommand() {
270
278
  console.log('Cleaned stale entries from registry');
271
279
  }
272
280
 
281
+ function reportCommand(rawArgs: string[]) {
282
+ const scriptPath = path.join(__dirname, 'report.ts');
283
+ const result = spawnSync(
284
+ process.execPath,
285
+ ['--experimental-sqlite', '--no-warnings', '--import', 'tsx', scriptPath, ...rawArgs],
286
+ {
287
+ stdio: 'inherit',
288
+ cwd: path.resolve(__dirname, '../..'),
289
+ env: { ...process.env },
290
+ },
291
+ );
292
+
293
+ if (result.error) {
294
+ throw result.error;
295
+ }
296
+
297
+ if (typeof result.status === 'number' && result.status !== 0) {
298
+ process.exit(result.status);
299
+ }
300
+ }
301
+
273
302
  async function main() {
274
303
  const rawArgs = process.argv.slice(2);
275
304
 
@@ -322,6 +351,9 @@ async function main() {
322
351
  case 'clean':
323
352
  cleanCommand();
324
353
  break;
354
+ case 'report':
355
+ reportCommand(restArgs);
356
+ break;
325
357
  default:
326
358
  console.error(`Unknown subcommand: ${subcommand}`);
327
359
  console.log('Run "openbroker auto --help" for usage');