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,459 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { DatabaseSync } from 'node:sqlite';
4
+ import { AUDIT_DB_PATH } from './audit.js';
5
+ import { formatUsd, parseArgs } from '../core/utils.js';
6
+
7
+ const DEFAULT_WATCH_INTERVAL_MS = 2000;
8
+
9
+ function printUsage() {
10
+ console.log(`
11
+ Usage: openbroker auto report <id> [options]
12
+
13
+ Options:
14
+ --run <run-id|latest> Specific run ID to inspect (default: latest)
15
+ --limit <n> Number of recent rows per section (default: 10)
16
+ --watch Refresh the report continuously
17
+ --watch-interval <ms> Refresh interval for --watch (default: 2000)
18
+ --json Output JSON
19
+ --help, -h Show this help
20
+
21
+ Examples:
22
+ openbroker auto report hype-mm-v2-live-r4
23
+ openbroker auto report hype-mm-v2-live-r4 --limit 20
24
+ openbroker auto report hype-mm-v2-live-r4 --watch
25
+ openbroker auto report hype-mm-v2-live-r4 --run 123e4567... --json
26
+ `);
27
+ }
28
+
29
+ type RunRow = {
30
+ run_id: string;
31
+ automation_id: string;
32
+ script_path: string;
33
+ account_address: string | null;
34
+ wallet_address: string | null;
35
+ is_api_wallet: number;
36
+ dry_run: number;
37
+ verbose: number;
38
+ poll_interval_ms: number | null;
39
+ use_websocket: number;
40
+ pid: number;
41
+ started_at: number;
42
+ stopped_at: number | null;
43
+ status: string;
44
+ stop_reason: string | null;
45
+ initial_state_json: string | null;
46
+ persisted_state_json: string | null;
47
+ poll_count: number | null;
48
+ events_emitted: number | null;
49
+ };
50
+
51
+ function parseJson<T>(value: string | null): T | null {
52
+ if (!value) return null;
53
+ try {
54
+ return JSON.parse(value) as T;
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ function parseNumber(raw: string | boolean | undefined, fallback: number): number {
61
+ const parsed = Number(raw);
62
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
63
+ }
64
+
65
+ function formatTimestamp(timestamp: number | null): string {
66
+ return timestamp ? new Date(timestamp).toLocaleString() : '-';
67
+ }
68
+
69
+ function formatDurationMs(startedAt: number, stoppedAt: number | null): string {
70
+ const end = stoppedAt ?? Date.now();
71
+ const totalSeconds = Math.max(0, Math.round((end - startedAt) / 1000));
72
+ const hours = Math.floor(totalSeconds / 3600);
73
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
74
+ const seconds = totalSeconds % 60;
75
+ if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`;
76
+ if (minutes > 0) return `${minutes}m ${seconds}s`;
77
+ return `${seconds}s`;
78
+ }
79
+
80
+ function getPositionalArgs(rawArgs: string[]): string[] {
81
+ return rawArgs.filter((arg, index) => {
82
+ if (arg.startsWith('--')) return false;
83
+ if (index > 0 && rawArgs[index - 1]?.startsWith('--')) return false;
84
+ return true;
85
+ });
86
+ }
87
+
88
+ function loadReport(
89
+ db: DatabaseSync,
90
+ automationId: string,
91
+ runSelector: string,
92
+ limit: number,
93
+ ) {
94
+ const run = runSelector !== 'latest'
95
+ ? db.prepare(`
96
+ SELECT *
97
+ FROM automation_runs
98
+ WHERE automation_id = ? AND run_id = ?
99
+ LIMIT 1
100
+ `).get(automationId, runSelector) as RunRow | undefined
101
+ : db.prepare(`
102
+ SELECT *
103
+ FROM automation_runs
104
+ WHERE automation_id = ?
105
+ ORDER BY started_at DESC
106
+ LIMIT 1
107
+ `).get(automationId) as RunRow | undefined;
108
+
109
+ if (!run) {
110
+ throw new Error(`No audit run found for automation "${automationId}"${runSelector !== 'latest' ? ` and run "${runSelector}"` : ''}`);
111
+ }
112
+
113
+ const countTables = {
114
+ logs: 'automation_logs',
115
+ events: 'automation_events',
116
+ actions: 'automation_actions',
117
+ snapshots: 'automation_snapshots',
118
+ orderUpdates: 'automation_order_updates',
119
+ fills: 'automation_fills',
120
+ userEvents: 'automation_user_events',
121
+ stateChanges: 'automation_state_changes',
122
+ publishes: 'automation_publishes',
123
+ errors: 'automation_errors',
124
+ notes: 'automation_notes',
125
+ metrics: 'automation_metrics',
126
+ } as const;
127
+
128
+ const counts = Object.fromEntries(
129
+ Object.entries(countTables).map(([key, table]) => {
130
+ const row = db.prepare(`SELECT count(*) AS c FROM ${table} WHERE run_id = ?`).get(run.run_id) as { c: number };
131
+ return [key, row.c];
132
+ }),
133
+ );
134
+
135
+ const fillSummary = db.prepare(`
136
+ SELECT
137
+ count(*) AS count,
138
+ COALESCE(sum(fee), 0) AS total_fee,
139
+ COALESCE(sum(closed_pnl), 0) AS total_closed_pnl,
140
+ COALESCE(sum(size * price), 0) AS total_volume
141
+ FROM automation_fills
142
+ WHERE run_id = ?
143
+ `).get(run.run_id) as {
144
+ count: number;
145
+ total_fee: number;
146
+ total_closed_pnl: number;
147
+ total_volume: number;
148
+ };
149
+
150
+ const firstSnapshot = db.prepare(`
151
+ SELECT timestamp, poll_count, equity, margin_used, margin_used_pct, positions_json
152
+ FROM automation_snapshots
153
+ WHERE run_id = ?
154
+ ORDER BY timestamp ASC
155
+ LIMIT 1
156
+ `).get(run.run_id) as Record<string, unknown> | undefined;
157
+
158
+ const latestSnapshot = db.prepare(`
159
+ SELECT timestamp, poll_count, equity, margin_used, margin_used_pct, positions_json
160
+ FROM automation_snapshots
161
+ WHERE run_id = ?
162
+ ORDER BY timestamp DESC
163
+ LIMIT 1
164
+ `).get(run.run_id) as Record<string, unknown> | undefined;
165
+
166
+ const actionBreakdown = db.prepare(`
167
+ SELECT
168
+ method,
169
+ sum(CASE WHEN phase = 'request' THEN 1 ELSE 0 END) AS requests,
170
+ sum(CASE WHEN phase = 'response' THEN 1 ELSE 0 END) AS responses,
171
+ sum(CASE WHEN phase = 'error' THEN 1 ELSE 0 END) AS errors
172
+ FROM automation_actions
173
+ WHERE run_id = ?
174
+ GROUP BY method
175
+ ORDER BY requests DESC, responses DESC, errors DESC, method ASC
176
+ `).all(run.run_id);
177
+
178
+ const recentLogs = db.prepare(`
179
+ SELECT timestamp, level, message
180
+ FROM automation_logs
181
+ WHERE run_id = ?
182
+ ORDER BY timestamp DESC
183
+ LIMIT ?
184
+ `).all(run.run_id, limit);
185
+
186
+ const recentErrors = db.prepare(`
187
+ SELECT timestamp, stage, error_json
188
+ FROM automation_errors
189
+ WHERE run_id = ?
190
+ ORDER BY timestamp DESC
191
+ LIMIT ?
192
+ `).all(run.run_id, limit);
193
+
194
+ const recentFills = db.prepare(`
195
+ SELECT timestamp, coin, side, size, price, fee, closed_pnl
196
+ FROM automation_fills
197
+ WHERE run_id = ?
198
+ ORDER BY timestamp DESC
199
+ LIMIT ?
200
+ `).all(run.run_id, limit);
201
+
202
+ const recentOrderUpdates = db.prepare(`
203
+ SELECT timestamp, coin, side, size, price, status, oid
204
+ FROM automation_order_updates
205
+ WHERE run_id = ?
206
+ ORDER BY timestamp DESC
207
+ LIMIT ?
208
+ `).all(run.run_id, limit);
209
+
210
+ const recentNotes = db.prepare(`
211
+ SELECT timestamp, kind, payload_json
212
+ FROM automation_notes
213
+ WHERE run_id = ?
214
+ ORDER BY timestamp DESC
215
+ LIMIT ?
216
+ `).all(run.run_id, limit);
217
+
218
+ const recentMetrics = db.prepare(`
219
+ SELECT timestamp, name, value, tags_json
220
+ FROM automation_metrics
221
+ WHERE run_id = ?
222
+ ORDER BY timestamp DESC
223
+ LIMIT ?
224
+ `).all(run.run_id, limit);
225
+
226
+ const report = {
227
+ automationId: run.automation_id,
228
+ runId: run.run_id,
229
+ status: run.status,
230
+ stopReason: run.stop_reason,
231
+ scriptPath: run.script_path,
232
+ startedAt: new Date(run.started_at).toISOString(),
233
+ stoppedAt: run.stopped_at ? new Date(run.stopped_at).toISOString() : null,
234
+ durationSec: Math.max(0, Math.round(((run.stopped_at ?? Date.now()) - run.started_at) / 1000)),
235
+ accountAddress: run.account_address,
236
+ walletAddress: run.wallet_address,
237
+ dryRun: run.dry_run === 1,
238
+ verbose: run.verbose === 1,
239
+ useWebSocket: run.use_websocket === 1,
240
+ pollIntervalMs: run.poll_interval_ms,
241
+ pid: run.pid,
242
+ initialState: parseJson<Record<string, unknown>>(run.initial_state_json),
243
+ persistedState: parseJson<Record<string, unknown>>(run.persisted_state_json),
244
+ runtimeStats: {
245
+ pollCount: run.poll_count,
246
+ eventsEmitted: run.events_emitted,
247
+ },
248
+ counts,
249
+ fills: {
250
+ count: fillSummary.count,
251
+ totalFees: fillSummary.total_fee,
252
+ totalClosedPnl: fillSummary.total_closed_pnl,
253
+ netAfterFees: fillSummary.total_closed_pnl - fillSummary.total_fee,
254
+ totalVolume: fillSummary.total_volume,
255
+ },
256
+ equity: {
257
+ first: firstSnapshot ? {
258
+ timestamp: firstSnapshot.timestamp,
259
+ pollCount: firstSnapshot.poll_count,
260
+ equity: firstSnapshot.equity,
261
+ marginUsed: firstSnapshot.margin_used,
262
+ marginUsedPct: firstSnapshot.margin_used_pct,
263
+ positions: parseJson(firstSnapshot.positions_json as string | null),
264
+ } : null,
265
+ latest: latestSnapshot ? {
266
+ timestamp: latestSnapshot.timestamp,
267
+ pollCount: latestSnapshot.poll_count,
268
+ equity: latestSnapshot.equity,
269
+ marginUsed: latestSnapshot.margin_used,
270
+ marginUsedPct: latestSnapshot.margin_used_pct,
271
+ positions: parseJson(latestSnapshot.positions_json as string | null),
272
+ } : null,
273
+ delta: firstSnapshot && latestSnapshot
274
+ ? Number(latestSnapshot.equity) - Number(firstSnapshot.equity)
275
+ : null,
276
+ },
277
+ actionBreakdown,
278
+ recent: {
279
+ logs: recentLogs,
280
+ errors: recentErrors.map((row) => ({
281
+ ...row,
282
+ error: parseJson(row.error_json as string | null),
283
+ })),
284
+ fills: recentFills,
285
+ orderUpdates: recentOrderUpdates,
286
+ notes: recentNotes.map((row) => ({
287
+ ...row,
288
+ payload: parseJson(row.payload_json as string | null),
289
+ })),
290
+ metrics: recentMetrics.map((row) => ({
291
+ ...row,
292
+ tags: parseJson(row.tags_json as string | null),
293
+ })),
294
+ },
295
+ };
296
+
297
+ return { run, report, counts, actionBreakdown, recentErrors, recentFills, recentLogs };
298
+ }
299
+
300
+ function renderTextReport(data: ReturnType<typeof loadReport>, watchMode = false, watchIntervalMs = DEFAULT_WATCH_INTERVAL_MS): void {
301
+ const { run, report, counts, actionBreakdown, recentErrors, recentFills, recentLogs } = data;
302
+
303
+ if (watchMode && process.stdout.isTTY) {
304
+ process.stdout.write('\x1Bc');
305
+ }
306
+
307
+ console.log('Open Broker - Automation Report');
308
+ console.log('===============================\n');
309
+
310
+ console.log(`Automation: ${report.automationId}`);
311
+ console.log(`Run ID: ${report.runId}`);
312
+ console.log(`Status: ${report.status}${report.stopReason ? ` (${report.stopReason})` : ''}`);
313
+ console.log(`Started: ${formatTimestamp(run.started_at)}`);
314
+ console.log(`Stopped: ${formatTimestamp(run.stopped_at)}`);
315
+ console.log(`Duration: ${formatDurationMs(run.started_at, run.stopped_at)}`);
316
+ console.log(`Script: ${run.script_path}`);
317
+ console.log(`Account: ${run.account_address ?? '-'}`);
318
+ console.log(`Mode: ${report.dryRun ? 'dry' : 'live'}${report.useWebSocket ? ', ws' : ', polling only'}`);
319
+ console.log(`Poll interval: ${run.poll_interval_ms ?? '-'} ms`);
320
+ if (watchMode) {
321
+ console.log(`Refresh: every ${watchIntervalMs} ms (Ctrl-C to stop)`);
322
+ }
323
+
324
+ console.log('\nCounts');
325
+ console.log('------');
326
+ for (const [key, value] of Object.entries(counts)) {
327
+ console.log(`${key.padEnd(14)} ${value}`);
328
+ }
329
+
330
+ console.log('\nEconomics');
331
+ console.log('---------');
332
+ console.log(`Fills: ${report.fills.count}`);
333
+ console.log(`Volume: ${formatUsd(report.fills.totalVolume)}`);
334
+ console.log(`Closed PnL: ${formatUsd(report.fills.totalClosedPnl)}`);
335
+ console.log(`Fees: ${formatUsd(report.fills.totalFees)}`);
336
+ console.log(`Net after fees: ${formatUsd(report.fills.netAfterFees)}`);
337
+
338
+ console.log('\nEquity');
339
+ console.log('------');
340
+ if (report.equity.first) {
341
+ console.log(`First snapshot: ${formatUsd(report.equity.first.equity)} @ ${formatTimestamp(Number(report.equity.first.timestamp))}`);
342
+ } else {
343
+ console.log('First snapshot: -');
344
+ }
345
+ if (report.equity.latest) {
346
+ console.log(`Latest snapshot:${formatUsd(report.equity.latest.equity)} @ ${formatTimestamp(Number(report.equity.latest.timestamp))}`);
347
+ } else {
348
+ console.log('Latest snapshot:-');
349
+ }
350
+ console.log(`Delta: ${report.equity.delta === null ? '-' : formatUsd(report.equity.delta)}`);
351
+
352
+ if (Array.isArray(actionBreakdown) && actionBreakdown.length > 0) {
353
+ console.log('\nActions');
354
+ console.log('-------');
355
+ for (const row of actionBreakdown as Array<Record<string, unknown>>) {
356
+ console.log(
357
+ `${String(row.method).padEnd(20)} req=${String(row.requests).padStart(3)} resp=${String(row.responses).padStart(3)} err=${String(row.errors).padStart(3)}`,
358
+ );
359
+ }
360
+ }
361
+
362
+ if (recentErrors.length > 0) {
363
+ console.log('\nRecent Errors');
364
+ console.log('-------------');
365
+ for (const row of recentErrors as Array<Record<string, unknown>>) {
366
+ const parsed = parseJson<{ message?: string }>(row.error_json as string | null);
367
+ console.log(`${formatTimestamp(Number(row.timestamp))} ${String(row.stage)} ${parsed?.message || String(row.error_json)}`);
368
+ }
369
+ }
370
+
371
+ if (recentFills.length > 0) {
372
+ console.log('\nRecent Fills');
373
+ console.log('------------');
374
+ for (const row of recentFills as Array<Record<string, unknown>>) {
375
+ console.log(
376
+ `${formatTimestamp(Number(row.timestamp))} ${String(row.side).toUpperCase()} ${String(row.coin)} ${row.size} @ ${row.price} pnl=${row.closed_pnl} fee=${row.fee}`,
377
+ );
378
+ }
379
+ }
380
+
381
+ if (recentLogs.length > 0) {
382
+ console.log('\nRecent Logs');
383
+ console.log('-----------');
384
+ for (const row of recentLogs as Array<Record<string, unknown>>) {
385
+ console.log(`${formatTimestamp(Number(row.timestamp))} ${String(row.level).toUpperCase()} ${String(row.message)}`);
386
+ }
387
+ }
388
+ }
389
+
390
+ async function main() {
391
+ const rawArgs = process.argv.slice(2);
392
+ const args = parseArgs(rawArgs);
393
+
394
+ if (args.help || args.h) {
395
+ printUsage();
396
+ process.exit(0);
397
+ }
398
+
399
+ const positional = getPositionalArgs(rawArgs);
400
+ const automationId = positional[0];
401
+ if (!automationId) {
402
+ console.error('Error: automation ID is required');
403
+ printUsage();
404
+ process.exit(1);
405
+ }
406
+
407
+ const runSelector = String(args.run || 'latest');
408
+ const limit = parseNumber(args.limit, 10);
409
+ const jsonOutput = args.json === true;
410
+ const watchMode = args.watch === true;
411
+ const watchIntervalMs = parseNumber(args['watch-interval'], DEFAULT_WATCH_INTERVAL_MS);
412
+
413
+ if (watchMode && jsonOutput) {
414
+ console.error('Error: --watch cannot be combined with --json');
415
+ process.exit(1);
416
+ }
417
+
418
+ const db = new DatabaseSync(AUDIT_DB_PATH);
419
+ let stopRequested = false;
420
+
421
+ const cleanup = () => {
422
+ try {
423
+ db.close();
424
+ } catch {
425
+ // ignore close failures during shutdown
426
+ }
427
+ };
428
+
429
+ const requestStop = () => {
430
+ stopRequested = true;
431
+ };
432
+
433
+ process.once('SIGINT', requestStop);
434
+ process.once('SIGTERM', requestStop);
435
+
436
+ try {
437
+ if (!watchMode) {
438
+ const data = loadReport(db, automationId, runSelector, limit);
439
+ if (jsonOutput) {
440
+ console.log(JSON.stringify(data.report, null, 2));
441
+ } else {
442
+ renderTextReport(data);
443
+ }
444
+ return;
445
+ }
446
+
447
+ while (!stopRequested) {
448
+ const data = loadReport(db, automationId, runSelector, limit);
449
+ renderTextReport(data, true, watchIntervalMs);
450
+ await new Promise((resolve) => setTimeout(resolve, watchIntervalMs));
451
+ }
452
+ } finally {
453
+ process.off('SIGINT', requestStop);
454
+ process.off('SIGTERM', requestStop);
455
+ cleanup();
456
+ }
457
+ }
458
+
459
+ await main();