horizon-code 0.5.1 → 0.6.1

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,191 @@
1
+ // Execution replay — structured event log for post-mortem analysis
2
+ // Records orders, fills, position changes, and metrics snapshots
3
+
4
+ import { homedir } from "os";
5
+ import { resolve, join } from "path";
6
+ import { existsSync, mkdirSync, appendFileSync, readFileSync } from "fs";
7
+
8
+ const REPLAY_ROOT = resolve(homedir(), ".horizon", "replays");
9
+
10
+ export type ReplayEventType = "start" | "order" | "fill" | "position_change" | "metric_snapshot" | "error" | "stop";
11
+
12
+ export interface ReplayEvent {
13
+ timestamp: string;
14
+ elapsed_secs: number;
15
+ type: ReplayEventType;
16
+ data: Record<string, unknown>;
17
+ }
18
+
19
+ export interface ReplaySession {
20
+ pid: number;
21
+ strategy_name: string;
22
+ started_at: string;
23
+ events: ReplayEvent[];
24
+ }
25
+
26
+ function ensureDir(): void {
27
+ if (!existsSync(REPLAY_ROOT)) mkdirSync(REPLAY_ROOT, { recursive: true });
28
+ }
29
+
30
+ // Active replay sessions indexed by PID
31
+ const activeSessions = new Map<number, { path: string; startedAt: number; strategyName: string }>();
32
+
33
+ /** Start recording a replay session for a process */
34
+ export function startReplay(pid: number, strategyName: string): void {
35
+ ensureDir();
36
+ const slug = strategyName.toLowerCase().replace(/[^a-z0-9]+/g, "_");
37
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
38
+ const fileName = `${slug}_${ts}.jsonl`;
39
+ const path = join(REPLAY_ROOT, fileName);
40
+
41
+ activeSessions.set(pid, { path, startedAt: Date.now(), strategyName });
42
+
43
+ // Write header
44
+ const header = JSON.stringify({
45
+ timestamp: new Date().toISOString(),
46
+ elapsed_secs: 0,
47
+ type: "start",
48
+ data: { pid, strategy_name: strategyName },
49
+ });
50
+ appendFileSync(path, header + "\n");
51
+ }
52
+
53
+ /** Record a replay event */
54
+ export function recordEvent(pid: number, type: ReplayEventType, data: Record<string, unknown>): void {
55
+ const session = activeSessions.get(pid);
56
+ if (!session) return;
57
+
58
+ const event: ReplayEvent = {
59
+ timestamp: new Date().toISOString(),
60
+ elapsed_secs: (Date.now() - session.startedAt) / 1000,
61
+ type,
62
+ data,
63
+ };
64
+
65
+ try {
66
+ appendFileSync(session.path, JSON.stringify(event) + "\n");
67
+ } catch {}
68
+ }
69
+
70
+ /** Record metrics snapshot from parsed __HZ_METRICS__ data */
71
+ export function recordMetrics(pid: number, metrics: Record<string, unknown>): void {
72
+ recordEvent(pid, "metric_snapshot", metrics);
73
+ }
74
+
75
+ /** Record an order event parsed from logs */
76
+ export function recordOrder(pid: number, order: {
77
+ side: string;
78
+ market: string;
79
+ size: number;
80
+ price: number;
81
+ type?: string;
82
+ }): void {
83
+ recordEvent(pid, "order", order);
84
+ }
85
+
86
+ /** Stop recording and finalize the replay */
87
+ export function stopReplay(pid: number, exitCode?: number): void {
88
+ const session = activeSessions.get(pid);
89
+ if (!session) return;
90
+
91
+ recordEvent(pid, "stop", { exit_code: exitCode ?? null, duration_secs: (Date.now() - session.startedAt) / 1000 });
92
+ activeSessions.delete(pid);
93
+ }
94
+
95
+ /** Load a replay session from disk */
96
+ export function loadReplay(fileName: string): ReplaySession | null {
97
+ const path = join(REPLAY_ROOT, fileName);
98
+ if (!existsSync(path)) return null;
99
+
100
+ const lines = readFileSync(path, "utf-8").split("\n").filter(Boolean);
101
+ const events: ReplayEvent[] = [];
102
+ let pid = 0;
103
+ let strategyName = "";
104
+ let startedAt = "";
105
+
106
+ for (const line of lines) {
107
+ try {
108
+ const event: ReplayEvent = JSON.parse(line);
109
+ events.push(event);
110
+ if (event.type === "start") {
111
+ pid = (event.data.pid as number) ?? 0;
112
+ strategyName = (event.data.strategy_name as string) ?? "";
113
+ startedAt = event.timestamp;
114
+ }
115
+ } catch {}
116
+ }
117
+
118
+ return { pid, strategy_name: strategyName, started_at: startedAt, events };
119
+ }
120
+
121
+ /** List all replay files, newest first */
122
+ export function listReplays(): { fileName: string; strategy: string; date: string; eventCount: number }[] {
123
+ ensureDir();
124
+ const { readdirSync, statSync } = require("fs");
125
+ const files: string[] = readdirSync(REPLAY_ROOT).filter((f: string) => f.endsWith(".jsonl"));
126
+
127
+ return files
128
+ .map((fileName: string) => {
129
+ const path = join(REPLAY_ROOT, fileName);
130
+ const stat = statSync(path);
131
+ // Extract strategy name from filename
132
+ const parts = fileName.replace(".jsonl", "").split("_");
133
+ const strategy = parts.slice(0, -6).join("_") || parts[0] || "unknown";
134
+ const lineCount = readFileSync(path, "utf-8").split("\n").filter(Boolean).length;
135
+
136
+ return {
137
+ fileName,
138
+ strategy,
139
+ date: stat.mtime.toISOString(),
140
+ eventCount: lineCount,
141
+ };
142
+ })
143
+ .sort((a: any, b: any) => new Date(b.date).getTime() - new Date(a.date).getTime());
144
+ }
145
+
146
+ /** Generate a human-readable replay summary */
147
+ export function summarizeReplay(session: ReplaySession): string {
148
+ const metrics = session.events.filter(e => e.type === "metric_snapshot");
149
+ const orders = session.events.filter(e => e.type === "order");
150
+ const errors = session.events.filter(e => e.type === "error");
151
+ const stopEvent = session.events.find(e => e.type === "stop");
152
+
153
+ const duration = stopEvent?.elapsed_secs ?? (metrics.length > 0 ? metrics[metrics.length - 1]!.elapsed_secs : 0);
154
+ const durationStr = duration > 3600 ? `${(duration / 3600).toFixed(1)}h` : `${(duration / 60).toFixed(1)}m`;
155
+
156
+ const lines: string[] = [];
157
+ lines.push(`Strategy: ${session.strategy_name} | Duration: ${durationStr} | Events: ${session.events.length}`);
158
+ lines.push(`Orders: ${orders.length} | Metric snapshots: ${metrics.length} | Errors: ${errors.length}`);
159
+
160
+ if (metrics.length >= 2) {
161
+ const first = metrics[0]!.data;
162
+ const last = metrics[metrics.length - 1]!.data;
163
+ lines.push(`P&L: $${first.pnl ?? 0} → $${last.pnl ?? 0}`);
164
+ lines.push(`Positions: ${first.positions ?? 0} → ${last.positions ?? 0}`);
165
+ }
166
+
167
+ if (orders.length > 0) {
168
+ lines.push("");
169
+ lines.push("Order timeline:");
170
+ for (const o of orders.slice(0, 20)) {
171
+ const elapsed = o.elapsed_secs > 60 ? `${(o.elapsed_secs / 60).toFixed(1)}m` : `${o.elapsed_secs.toFixed(0)}s`;
172
+ lines.push(` ${elapsed}: ${o.data.side} ${o.data.size} @ ${o.data.price} on ${o.data.market}`);
173
+ }
174
+ if (orders.length > 20) lines.push(` ... and ${orders.length - 20} more orders`);
175
+ }
176
+
177
+ if (errors.length > 0) {
178
+ lines.push("");
179
+ lines.push("Errors:");
180
+ for (const e of errors.slice(0, 5)) {
181
+ lines.push(` ${e.elapsed_secs.toFixed(0)}s: ${e.data.message ?? JSON.stringify(e.data)}`);
182
+ }
183
+ }
184
+
185
+ return lines.join("\n");
186
+ }
187
+
188
+ /** Check if replay is active for a PID */
189
+ export function isRecording(pid: number): boolean {
190
+ return activeSessions.has(pid);
191
+ }