horizon-code 0.5.1 → 0.6.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.
- package/package.json +1 -1
- package/src/ai/system-prompt.ts +20 -1
- package/src/app.ts +158 -0
- package/src/components/footer.ts +1 -1
- package/src/keys/handler.ts +8 -0
- package/src/platform/profile.ts +202 -0
- package/src/research/scanner.ts +212 -0
- package/src/research/tools.ts +58 -0
- package/src/strategy/alerts.ts +190 -0
- package/src/strategy/export.ts +159 -0
- package/src/strategy/health.ts +127 -0
- package/src/strategy/ledger.ts +185 -0
- package/src/strategy/prompts.ts +62 -0
- package/src/strategy/replay.ts +191 -0
- package/src/strategy/tools.ts +495 -1
- package/src/strategy/versioning.ts +168 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Performance ledger — persistent log of every backtest and run
|
|
2
|
+
// Storage: ~/.horizon/ledger/<strategy-slug>.jsonl (append-only)
|
|
3
|
+
|
|
4
|
+
import { homedir } from "os";
|
|
5
|
+
import { resolve, join } from "path";
|
|
6
|
+
import { existsSync, mkdirSync, appendFileSync, readFileSync } from "fs";
|
|
7
|
+
import { createHash } from "crypto";
|
|
8
|
+
|
|
9
|
+
const LEDGER_ROOT = resolve(homedir(), ".horizon", "ledger");
|
|
10
|
+
|
|
11
|
+
export interface LedgerEntry {
|
|
12
|
+
strategy_name: string;
|
|
13
|
+
code_hash: string;
|
|
14
|
+
type: "backtest" | "run";
|
|
15
|
+
timestamp: string;
|
|
16
|
+
duration_secs: number;
|
|
17
|
+
params: Record<string, unknown>;
|
|
18
|
+
metrics: {
|
|
19
|
+
pnl: number;
|
|
20
|
+
rpnl: number;
|
|
21
|
+
upnl: number;
|
|
22
|
+
sharpe: number;
|
|
23
|
+
max_dd: number;
|
|
24
|
+
win_rate: number;
|
|
25
|
+
trades: number;
|
|
26
|
+
exposure: number;
|
|
27
|
+
[key: string]: unknown;
|
|
28
|
+
};
|
|
29
|
+
label?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function ensureDir(): void {
|
|
33
|
+
if (!existsSync(LEDGER_ROOT)) mkdirSync(LEDGER_ROOT, { recursive: true });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function slugify(name: string): string {
|
|
37
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function codeHash(code: string): string {
|
|
41
|
+
return createHash("sha256").update(code).digest("hex").slice(0, 12);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Append a new entry to the ledger (append-only JSONL) */
|
|
45
|
+
export function logEntry(entry: LedgerEntry): void {
|
|
46
|
+
ensureDir();
|
|
47
|
+
const slug = slugify(entry.strategy_name);
|
|
48
|
+
const path = join(LEDGER_ROOT, `${slug}.jsonl`);
|
|
49
|
+
appendFileSync(path, JSON.stringify(entry) + "\n");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Get all entries for a strategy, newest first */
|
|
53
|
+
export function getHistory(name: string): LedgerEntry[] {
|
|
54
|
+
ensureDir();
|
|
55
|
+
const slug = slugify(name);
|
|
56
|
+
const path = join(LEDGER_ROOT, `${slug}.jsonl`);
|
|
57
|
+
if (!existsSync(path)) return [];
|
|
58
|
+
|
|
59
|
+
const lines = readFileSync(path, "utf-8").split("\n").filter(Boolean);
|
|
60
|
+
const entries: LedgerEntry[] = [];
|
|
61
|
+
for (const line of lines) {
|
|
62
|
+
try { entries.push(JSON.parse(line)); } catch {}
|
|
63
|
+
}
|
|
64
|
+
return entries.reverse();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Find the best run by a specific metric (e.g., "sharpe", "pnl", "win_rate") */
|
|
68
|
+
export function getBestRun(name: string, metric: string = "sharpe"): LedgerEntry | null {
|
|
69
|
+
const history = getHistory(name);
|
|
70
|
+
if (history.length === 0) return null;
|
|
71
|
+
|
|
72
|
+
return history.reduce((best, entry) => {
|
|
73
|
+
const bestVal = (best.metrics as any)[metric] ?? -Infinity;
|
|
74
|
+
const entryVal = (entry.metrics as any)[metric] ?? -Infinity;
|
|
75
|
+
return entryVal > bestVal ? entry : best;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Generate a strategy performance report */
|
|
80
|
+
export function generateReport(name: string): {
|
|
81
|
+
total_runs: number;
|
|
82
|
+
backtests: number;
|
|
83
|
+
live_runs: number;
|
|
84
|
+
best_sharpe: LedgerEntry | null;
|
|
85
|
+
best_pnl: LedgerEntry | null;
|
|
86
|
+
worst_dd: LedgerEntry | null;
|
|
87
|
+
avg_sharpe: number;
|
|
88
|
+
avg_win_rate: number;
|
|
89
|
+
avg_pnl: number;
|
|
90
|
+
trend: string;
|
|
91
|
+
versions_tested: number;
|
|
92
|
+
summary: string;
|
|
93
|
+
} {
|
|
94
|
+
const history = getHistory(name);
|
|
95
|
+
if (history.length === 0) {
|
|
96
|
+
return {
|
|
97
|
+
total_runs: 0, backtests: 0, live_runs: 0,
|
|
98
|
+
best_sharpe: null, best_pnl: null, worst_dd: null,
|
|
99
|
+
avg_sharpe: 0, avg_win_rate: 0, avg_pnl: 0,
|
|
100
|
+
trend: "no data", versions_tested: 0,
|
|
101
|
+
summary: `No performance history for "${name}".`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const backtests = history.filter(e => e.type === "backtest");
|
|
106
|
+
const runs = history.filter(e => e.type === "run");
|
|
107
|
+
const uniqueHashes = new Set(history.map(e => e.code_hash));
|
|
108
|
+
|
|
109
|
+
const sharpes = history.map(e => e.metrics.sharpe).filter(v => isFinite(v));
|
|
110
|
+
const winRates = history.map(e => e.metrics.win_rate).filter(v => isFinite(v));
|
|
111
|
+
const pnls = history.map(e => e.metrics.pnl).filter(v => isFinite(v));
|
|
112
|
+
|
|
113
|
+
const avg = (arr: number[]) => arr.length > 0 ? arr.reduce((a, b) => a + b, 0) / arr.length : 0;
|
|
114
|
+
|
|
115
|
+
// Trend: compare recent 3 vs older entries
|
|
116
|
+
let trend = "stable";
|
|
117
|
+
if (sharpes.length >= 6) {
|
|
118
|
+
const recent = avg(sharpes.slice(0, 3));
|
|
119
|
+
const older = avg(sharpes.slice(3, 6));
|
|
120
|
+
if (recent > older * 1.15) trend = "improving";
|
|
121
|
+
else if (recent < older * 0.85) trend = "degrading";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const bestSharpe = getBestRun(name, "sharpe");
|
|
125
|
+
const bestPnl = getBestRun(name, "pnl");
|
|
126
|
+
const worstDd = history.reduce((worst, e) => {
|
|
127
|
+
return (e.metrics.max_dd > (worst?.metrics.max_dd ?? 0)) ? e : worst;
|
|
128
|
+
}, history[0]!);
|
|
129
|
+
|
|
130
|
+
const summary = [
|
|
131
|
+
`${history.length} runs (${backtests.length} backtests, ${runs.length} live) across ${uniqueHashes.size} code versions.`,
|
|
132
|
+
`Avg Sharpe: ${avg(sharpes).toFixed(2)}, Avg Win Rate: ${(avg(winRates) * 100).toFixed(1)}%, Avg P&L: $${avg(pnls).toFixed(2)}.`,
|
|
133
|
+
bestSharpe ? `Best Sharpe: ${bestSharpe.metrics.sharpe.toFixed(2)} (${bestSharpe.code_hash}, ${new Date(bestSharpe.timestamp).toLocaleDateString()})` : "",
|
|
134
|
+
`Trend: ${trend}.`,
|
|
135
|
+
].filter(Boolean).join(" ");
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
total_runs: history.length,
|
|
139
|
+
backtests: backtests.length,
|
|
140
|
+
live_runs: runs.length,
|
|
141
|
+
best_sharpe: bestSharpe,
|
|
142
|
+
best_pnl: bestPnl,
|
|
143
|
+
worst_dd: worstDd ?? null,
|
|
144
|
+
avg_sharpe: avg(sharpes),
|
|
145
|
+
avg_win_rate: avg(winRates),
|
|
146
|
+
avg_pnl: avg(pnls),
|
|
147
|
+
trend,
|
|
148
|
+
versions_tested: uniqueHashes.size,
|
|
149
|
+
summary,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Get performance comparison between code versions */
|
|
154
|
+
export function compareVersions(name: string): { hash: string; runs: number; avg_sharpe: number; avg_pnl: number; best_sharpe: number }[] {
|
|
155
|
+
const history = getHistory(name);
|
|
156
|
+
const byHash = new Map<string, LedgerEntry[]>();
|
|
157
|
+
|
|
158
|
+
for (const entry of history) {
|
|
159
|
+
const existing = byHash.get(entry.code_hash) ?? [];
|
|
160
|
+
existing.push(entry);
|
|
161
|
+
byHash.set(entry.code_hash, existing);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return [...byHash.entries()].map(([hash, entries]) => {
|
|
165
|
+
const sharpes = entries.map(e => e.metrics.sharpe).filter(v => isFinite(v));
|
|
166
|
+
const pnls = entries.map(e => e.metrics.pnl).filter(v => isFinite(v));
|
|
167
|
+
const avg = (arr: number[]) => arr.length > 0 ? arr.reduce((a, b) => a + b, 0) / arr.length : 0;
|
|
168
|
+
return {
|
|
169
|
+
hash,
|
|
170
|
+
runs: entries.length,
|
|
171
|
+
avg_sharpe: avg(sharpes),
|
|
172
|
+
avg_pnl: avg(pnls),
|
|
173
|
+
best_sharpe: sharpes.length > 0 ? Math.max(...sharpes) : 0,
|
|
174
|
+
};
|
|
175
|
+
}).sort((a, b) => b.avg_sharpe - a.avg_sharpe);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** List all strategies with ledger entries */
|
|
179
|
+
export function listLedgerStrategies(): string[] {
|
|
180
|
+
ensureDir();
|
|
181
|
+
const { readdirSync } = require("fs");
|
|
182
|
+
return readdirSync(LEDGER_ROOT)
|
|
183
|
+
.filter((f: string) => f.endsWith(".jsonl"))
|
|
184
|
+
.map((f: string) => f.replace(".jsonl", ""));
|
|
185
|
+
}
|
package/src/strategy/prompts.ts
CHANGED
|
@@ -1042,6 +1042,18 @@ Read what the user says and match it to the RIGHT action. Do NOT generate code w
|
|
|
1042
1042
|
| "what markets are available?" | Call polymarket_data |
|
|
1043
1043
|
| "stop it" / "kill it" | Call stop_strategy |
|
|
1044
1044
|
| "load my old strategy" | Call list_saved_strategies then load_saved_strategy |
|
|
1045
|
+
| "undo that" / "revert" | Call revert_strategy |
|
|
1046
|
+
| "show me versions" | Call strategy_versions |
|
|
1047
|
+
| "how has this performed?" | Call strategy_report |
|
|
1048
|
+
| "health check" / "score" | Call health_score |
|
|
1049
|
+
| "alert me if..." | Call set_alert |
|
|
1050
|
+
| "export this" / "share this" | Call export_strategy |
|
|
1051
|
+
| "try different spreads" | Call parameter_sweep |
|
|
1052
|
+
| "find opportunities" | Call scan_opportunities |
|
|
1053
|
+
| "what should I trade?" | Call scan_opportunities |
|
|
1054
|
+
| "are my markets correlated?" | Call scan_correlations |
|
|
1055
|
+
| "show me the replay" | Call replay_session |
|
|
1056
|
+
| "portfolio status" | Call portfolio_summary |
|
|
1045
1057
|
|
|
1046
1058
|
**Key rule:** If the user asks a question about the code, strategy concepts, or markets — answer with text. Do NOT write code or call tools unless they explicitly want a change or action.
|
|
1047
1059
|
|
|
@@ -1153,6 +1165,56 @@ P&L (with realized sub-label), Win Rate (with trade count), Sharpe Ratio, Max Dr
|
|
|
1153
1165
|
3. Main grid: equity chart (5fr) + positions list (2fr)
|
|
1154
1166
|
4. Logs card (full width, max-height 180px with overflow scroll)
|
|
1155
1167
|
|
|
1168
|
+
## Version Control
|
|
1169
|
+
|
|
1170
|
+
Every code change is automatically versioned. Tools:
|
|
1171
|
+
- **strategy_versions(name)** — List all versions with timestamps
|
|
1172
|
+
- **revert_strategy(name, version)** — Restore a previous version
|
|
1173
|
+
- **diff_versions(name, v1, v2)** — Show what changed between versions
|
|
1174
|
+
|
|
1175
|
+
When the user says "undo that" or "go back" → call revert_strategy.
|
|
1176
|
+
|
|
1177
|
+
## Performance Ledger
|
|
1178
|
+
|
|
1179
|
+
Every backtest and run is logged to a persistent ledger. Tools:
|
|
1180
|
+
- **strategy_report(name)** — Full performance history, version comparison, trends
|
|
1181
|
+
- **health_score()** — Code quality score 0-100 (risk config, feed guards, pipeline depth, etc.)
|
|
1182
|
+
|
|
1183
|
+
Reference ledger data when advising: "Last time you ran this, Sharpe was 0.8. This version looks better."
|
|
1184
|
+
|
|
1185
|
+
## Alerts & Monitoring
|
|
1186
|
+
|
|
1187
|
+
- **set_alert(condition_type, threshold, action_type)** — Create alerts (max_dd_exceeds, pnl_below, exposure_above, win_rate_below). Actions: log, stop_process, webhook.
|
|
1188
|
+
- **list_alerts()** / **remove_alert(id)** — Manage alerts
|
|
1189
|
+
|
|
1190
|
+
When user says "alert me if drawdown exceeds 5%" → set_alert(condition_type="max_dd_exceeds", threshold=5, action_type="log").
|
|
1191
|
+
|
|
1192
|
+
## Export / Import
|
|
1193
|
+
|
|
1194
|
+
- **export_strategy()** — Export to .hz file (code + params + performance data)
|
|
1195
|
+
- **import_strategy(path)** — Import from .hz file
|
|
1196
|
+
|
|
1197
|
+
## Replay & Attribution
|
|
1198
|
+
|
|
1199
|
+
- **replay_session(file_name?)** — View execution replay (order timeline, metric snapshots, errors). Without file_name, lists replays.
|
|
1200
|
+
- **portfolio_summary()** — Aggregate P&L across all running processes
|
|
1201
|
+
|
|
1202
|
+
After a strategy runs 30+ minutes, offer performance attribution: which positions drove P&L, which signals helped/hurt. Use replay data and metrics to explain what happened.
|
|
1203
|
+
|
|
1204
|
+
## Parameter Tuning
|
|
1205
|
+
|
|
1206
|
+
When the user asks to "try different values" or "sweep parameters" or "optimize":
|
|
1207
|
+
- **parameter_sweep(param_name, values)** — Runs backtests for each value, returns comparison table
|
|
1208
|
+
|
|
1209
|
+
Example: User says "try spreads from 0.02 to 0.08" → call parameter_sweep(param_name="spread", values=[0.02, 0.04, 0.06, 0.08]). Present results as a comparison: "0.04 had the best Sharpe (1.42), 0.02 had most trades but worst DD."
|
|
1210
|
+
|
|
1211
|
+
## Market Scanning
|
|
1212
|
+
|
|
1213
|
+
- **scan_opportunities(limit?)** — Scan top Polymarket markets for wide spreads, high volume, mispricings
|
|
1214
|
+
- **scan_correlations(slugs[])** — Compute pairwise correlations between markets in a portfolio
|
|
1215
|
+
|
|
1216
|
+
When user asks "what should I trade?" or "find opportunities" → scan_opportunities. When user has multiple markets → warn about correlation/concentration risk.
|
|
1217
|
+
|
|
1156
1218
|
## Other Tools
|
|
1157
1219
|
|
|
1158
1220
|
- **polymarket_data** — Search real markets (slugs, spreads, volume)
|
|
@@ -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
|
+
}
|