horizon-code 0.5.0 → 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.
@@ -13,6 +13,13 @@ import { saveStrategy, loadStrategy, listSavedStrategies } from "./persistence.t
13
13
  import { hyperlink } from "../util/hyperlink.ts";
14
14
  import { writeWorkspaceFile, readWorkspaceFile, listWorkspaceFiles } from "./workspace.ts";
15
15
  import type { StrategyDraft } from "../state/types.ts";
16
+ import { saveVersion, listVersions, getVersion, diffVersions } from "./versioning.ts";
17
+ import { logEntry, getHistory, generateReport, compareVersions, codeHash } from "./ledger.ts";
18
+ import { computeHealthScore } from "./health.ts";
19
+ import { createAlert, removeAlert, listAlerts, formatCondition, formatAction } from "./alerts.ts";
20
+ import { exportStrategy, importStrategy } from "./export.ts";
21
+ import { startReplay, stopReplay, recordMetrics, loadReplay, listReplays, summarizeReplay } from "./replay.ts";
22
+ import { learnFromStrategy, recordBacktest } from "../platform/profile.ts";
16
23
 
17
24
  const t = tool as any;
18
25
 
@@ -332,6 +339,7 @@ export const strategyTools: Record<string, any> = {
332
339
 
333
340
  // Auto-save
334
341
  await saveStrategy(draft.name, fixedCode).catch(() => {});
342
+ saveVersion(draft.name, fixedCode, `edit: ${args.find}`);
335
343
 
336
344
  return {
337
345
  strategy_name: draft.name,
@@ -746,7 +754,40 @@ except Exception as e:
746
754
  }
747
755
 
748
756
  const result = JSON.parse(jsonMatch[1]!.trim());
749
- return { ...result, ascii_dashboard: dashMatch ? dashMatch[1]!.trim() : null, data_source: args.use_real_data && _lastFetchedData ? `real (${_lastFetchedData.slug}, ${_lastFetchedData.points} points)` : `synthetic (${dataPoints} ticks)` };
757
+ recordBacktest();
758
+ // Parse metrics from backtest result for ledger + Kelly sizing
759
+ const m = result.metrics ?? result;
760
+ const backtestMetrics = {
761
+ pnl: m.total_return ?? m.pnl ?? 0,
762
+ rpnl: m.realized_pnl ?? 0,
763
+ upnl: m.unrealized_pnl ?? 0,
764
+ sharpe: m.sharpe_ratio ?? m.sharpe ?? 0,
765
+ max_dd: m.max_drawdown ?? m.max_dd ?? 0,
766
+ win_rate: m.win_rate ?? 0,
767
+ trades: m.trade_count ?? m.trades ?? 0,
768
+ exposure: m.exposure ?? 0,
769
+ };
770
+ logEntry({ strategy_name: draft.name, code_hash: codeHash(draft.code), type: "backtest", timestamp: new Date().toISOString(), duration_secs: 0, params: draft.params ?? {}, metrics: backtestMetrics });
771
+
772
+ // Auto Kelly sizing — estimate edge and suggest position size
773
+ let kelly_sizing: any = null;
774
+ if (backtestMetrics.win_rate > 0 && backtestMetrics.trades >= 5) {
775
+ const winRate = backtestMetrics.win_rate;
776
+ const avgWin = backtestMetrics.pnl > 0 ? backtestMetrics.pnl / (backtestMetrics.trades * winRate || 1) : 0;
777
+ const avgLoss = backtestMetrics.pnl < 0 ? Math.abs(backtestMetrics.pnl) / (backtestMetrics.trades * (1 - winRate) || 1) : avgWin * 0.5;
778
+ const payoffRatio = avgLoss > 0 ? avgWin / avgLoss : 2;
779
+ const kellyFraction = Math.max(0, winRate - (1 - winRate) / payoffRatio);
780
+ const bankroll = args.initial_capital ?? 1000;
781
+ kelly_sizing = {
782
+ edge_pct: ((winRate - 0.5) * 200).toFixed(1) + "%",
783
+ kelly_fraction: kellyFraction.toFixed(3),
784
+ full_kelly: "$" + (bankroll * kellyFraction).toFixed(2),
785
+ half_kelly: "$" + (bankroll * kellyFraction * 0.5).toFixed(2) + " (recommended)",
786
+ quarter_kelly: "$" + (bankroll * kellyFraction * 0.25).toFixed(2) + " (conservative)",
787
+ };
788
+ }
789
+
790
+ return { ...result, kelly_sizing, ascii_dashboard: dashMatch ? dashMatch[1]!.trim() : null, data_source: args.use_real_data && _lastFetchedData ? `real (${_lastFetchedData.slug}, ${_lastFetchedData.points} points)` : `synthetic (${dataPoints} ticks)` };
750
791
  } catch (err) {
751
792
  return { error: `Backtest execution failed: ${err instanceof Error ? err.message : String(err)}`, hint: "Ensure Horizon SDK is installed: pip install horizon" };
752
793
  }
@@ -833,6 +874,9 @@ except Exception as e:
833
874
  watchForCrash();
834
875
  }
835
876
 
877
+ startReplay(pid, draft.name);
878
+ learnFromStrategy(draft.code);
879
+
836
880
  return {
837
881
  success: true, pid, status: "running", timeout_secs: timeout,
838
882
  auto_restart: autoRestart,
@@ -1000,6 +1044,7 @@ except Exception as e:
1000
1044
  phase: "generated",
1001
1045
  };
1002
1046
  store.setStrategyDraft(draft);
1047
+ saveVersion(draft.name, fixedCode);
1003
1048
 
1004
1049
  return {
1005
1050
  strategy_name: args.name,
@@ -1206,4 +1251,453 @@ Your HTML can fetch("/api/local/metrics").then(r => r.json()) to get live data.
1206
1251
  }
1207
1252
  },
1208
1253
  }),
1254
+
1255
+ // ── Versioning ──
1256
+
1257
+ strategy_versions: t({
1258
+ description: "List all saved versions of a strategy. Shows version number, timestamp, and change description for each.",
1259
+ parameters: z.object({
1260
+ name: z.string().describe("Strategy name"),
1261
+ }),
1262
+ execute: async (args: any) => {
1263
+ try {
1264
+ const versions = await listVersions(args.name);
1265
+ return { strategy_name: args.name, versions, count: versions.length };
1266
+ } catch (err) {
1267
+ return { error: `Failed to list versions: ${err instanceof Error ? err.message : String(err)}` };
1268
+ }
1269
+ },
1270
+ }),
1271
+
1272
+ revert_strategy: t({
1273
+ description: "Revert a strategy to a previous version. Loads the code from that version and sets it as the active draft.",
1274
+ parameters: z.object({
1275
+ name: z.string().describe("Strategy name"),
1276
+ version: z.number().describe("Version number to revert to"),
1277
+ }),
1278
+ execute: async (args: any) => {
1279
+ try {
1280
+ const versionData = await getVersion(args.name, args.version);
1281
+ if (!versionData) return { error: `Version ${args.version} not found for "${args.name}"` };
1282
+
1283
+ const code = versionData.code;
1284
+ const fixedCode = autoFixStrategyCode(code);
1285
+ const errors = validateStrategyCode(fixedCode);
1286
+ const warnings = getStrategyWarnings(fixedCode);
1287
+
1288
+ const draft: StrategyDraft = {
1289
+ name: args.name,
1290
+ code: fixedCode,
1291
+ params: {},
1292
+ explanation: "",
1293
+ riskConfig: null,
1294
+ validationStatus: errors.length === 0 ? "valid" : "invalid",
1295
+ validationErrors: errors,
1296
+ validationWarnings: warnings,
1297
+ phase: "generated",
1298
+ };
1299
+ store.setStrategyDraft(draft);
1300
+
1301
+ return {
1302
+ strategy_name: args.name,
1303
+ version: args.version,
1304
+ code: fixedCode,
1305
+ validation: { valid: errors.length === 0, errors },
1306
+ message: `Reverted to version ${args.version}`,
1307
+ };
1308
+ } catch (err) {
1309
+ return { error: `Failed to revert: ${err instanceof Error ? err.message : String(err)}` };
1310
+ }
1311
+ },
1312
+ }),
1313
+
1314
+ diff_versions: t({
1315
+ description: "Show a diff between two versions of a strategy. Useful for reviewing what changed between iterations.",
1316
+ parameters: z.object({
1317
+ name: z.string().describe("Strategy name"),
1318
+ v1: z.number().describe("First version number"),
1319
+ v2: z.number().describe("Second version number"),
1320
+ }),
1321
+ execute: async (args: any) => {
1322
+ try {
1323
+ const diff = await diffVersions(args.name, args.v1, args.v2);
1324
+ return { strategy_name: args.name, v1: args.v1, v2: args.v2, diff };
1325
+ } catch (err) {
1326
+ return { error: `Failed to diff: ${err instanceof Error ? err.message : String(err)}` };
1327
+ }
1328
+ },
1329
+ }),
1330
+
1331
+ // ── Ledger & Reports ──
1332
+
1333
+ strategy_report: t({
1334
+ description: "Show performance history from the strategy ledger. Includes backtest/run history and version-over-version comparison.",
1335
+ parameters: z.object({
1336
+ name: z.string().describe("Strategy name"),
1337
+ }),
1338
+ execute: async (args: any) => {
1339
+ try {
1340
+ const report = generateReport(args.name);
1341
+ const comparison = compareVersions(args.name);
1342
+ return { strategy_name: args.name, report, comparison };
1343
+ } catch (err) {
1344
+ return { error: `Failed to generate report: ${err instanceof Error ? err.message : String(err)}` };
1345
+ }
1346
+ },
1347
+ }),
1348
+
1349
+ // ── Health Score ──
1350
+
1351
+ health_score: t({
1352
+ description: "Compute a health score for the current strategy code. Analyzes code quality, risk management, and best practices.",
1353
+ parameters: z.object({}),
1354
+ execute: async () => {
1355
+ const draft = store.getActiveSession()?.strategyDraft;
1356
+ if (!draft) return { error: "No strategy loaded." };
1357
+ try {
1358
+ const score = computeHealthScore(draft.code);
1359
+ return { strategy_name: draft.name, ...score };
1360
+ } catch (err) {
1361
+ return { error: `Failed to compute health score: ${err instanceof Error ? err.message : String(err)}` };
1362
+ }
1363
+ },
1364
+ }),
1365
+
1366
+ // ── Alerts ──
1367
+
1368
+ set_alert: t({
1369
+ description: "Create an alert that triggers when a condition is met (e.g. max drawdown exceeds threshold). Supports log, stop_process, and webhook actions.",
1370
+ parameters: z.object({
1371
+ condition_type: z.enum(["max_dd_exceeds", "pnl_below", "exposure_above", "win_rate_below", "loss_streak"]).describe("Type of condition to monitor"),
1372
+ threshold: z.number().describe("Threshold value for the condition"),
1373
+ action_type: z.enum(["log", "stop_process", "webhook"]).describe("Action to take when triggered"),
1374
+ webhook_url: z.string().optional().describe("Webhook URL (required if action_type is 'webhook')"),
1375
+ cooldown_secs: z.number().optional().describe("Cooldown between triggers in seconds (default: none)"),
1376
+ }),
1377
+ execute: async (args: any) => {
1378
+ try {
1379
+ const draft = store.getActiveSession()?.strategyDraft;
1380
+ const condition = { type: args.condition_type, threshold: args.threshold } as any;
1381
+ const action = args.action_type === "webhook"
1382
+ ? { type: "webhook" as const, url: args.webhook_url ?? "" }
1383
+ : { type: args.action_type as "log" | "stop_process" };
1384
+ const alert = createAlert(
1385
+ draft?.name ?? "local",
1386
+ condition,
1387
+ action,
1388
+ (args.cooldown_secs ?? 60) * 1000,
1389
+ );
1390
+ return {
1391
+ success: true,
1392
+ alert,
1393
+ message: `Alert created: ${formatCondition(alert.condition)} → ${formatAction(alert.action)}`,
1394
+ };
1395
+ } catch (err) {
1396
+ return { error: `Failed to create alert: ${err instanceof Error ? err.message : String(err)}` };
1397
+ }
1398
+ },
1399
+ }),
1400
+
1401
+ list_alerts: t({
1402
+ description: "List all active alerts. Optionally filter by strategy name.",
1403
+ parameters: z.object({
1404
+ strategy_name: z.string().optional().describe("Filter by strategy name"),
1405
+ }),
1406
+ execute: async (args: any) => {
1407
+ try {
1408
+ const alerts = listAlerts(args.strategy_name);
1409
+ return {
1410
+ count: alerts.length,
1411
+ alerts: alerts.map((a: any) => ({
1412
+ id: a.id,
1413
+ condition: formatCondition(a.condition),
1414
+ action: formatAction(a.action),
1415
+ cooldown_secs: a.cooldown_secs,
1416
+ last_triggered: a.last_triggered,
1417
+ })),
1418
+ };
1419
+ } catch (err) {
1420
+ return { error: `Failed to list alerts: ${err instanceof Error ? err.message : String(err)}` };
1421
+ }
1422
+ },
1423
+ }),
1424
+
1425
+ remove_alert: t({
1426
+ description: "Remove an alert by its ID.",
1427
+ parameters: z.object({
1428
+ id: z.string().describe("Alert ID to remove"),
1429
+ }),
1430
+ execute: async (args: any) => {
1431
+ try {
1432
+ removeAlert(args.id);
1433
+ return { success: true, message: `Alert ${args.id} removed.` };
1434
+ } catch (err) {
1435
+ return { error: `Failed to remove alert: ${err instanceof Error ? err.message : String(err)}` };
1436
+ }
1437
+ },
1438
+ }),
1439
+
1440
+ // ── Export / Import ──
1441
+
1442
+ export_strategy: t({
1443
+ description: "Export the current strategy to a .hz file. Bundles code, params, risk config, and metadata.",
1444
+ parameters: z.object({
1445
+ name: z.string().optional().describe("Strategy name override. Defaults to current draft name."),
1446
+ }),
1447
+ execute: async (args: any) => {
1448
+ const draft = store.getActiveSession()?.strategyDraft;
1449
+ if (!draft) return { error: "No strategy loaded." };
1450
+ try {
1451
+ const result = await exportStrategy(args.name ?? draft.name, draft.code, draft.params ?? {}, null);
1452
+ return { success: true, ...result };
1453
+ } catch (err) {
1454
+ return { error: `Export failed: ${err instanceof Error ? err.message : String(err)}` };
1455
+ }
1456
+ },
1457
+ }),
1458
+
1459
+ import_strategy: t({
1460
+ description: "Import a strategy from a .hz file. Loads the code, params, and config into the active draft.",
1461
+ parameters: z.object({
1462
+ path: z.string().describe("Path to the .hz file"),
1463
+ }),
1464
+ execute: async (args: any) => {
1465
+ try {
1466
+ const imported = await importStrategy(args.path);
1467
+ const fixedCode = autoFixStrategyCode(imported.code);
1468
+ const errors = validateStrategyCode(fixedCode);
1469
+ const warnings = getStrategyWarnings(fixedCode);
1470
+
1471
+ const draft: StrategyDraft = {
1472
+ name: imported.name,
1473
+ code: fixedCode,
1474
+ params: imported.params ?? {},
1475
+ explanation: "",
1476
+ riskConfig: null,
1477
+ validationStatus: errors.length === 0 ? "valid" : "invalid",
1478
+ validationErrors: errors,
1479
+ validationWarnings: warnings,
1480
+ phase: "generated",
1481
+ };
1482
+ store.setStrategyDraft(draft);
1483
+
1484
+ return {
1485
+ strategy_name: imported.name,
1486
+ code: fixedCode,
1487
+ validation: { valid: errors.length === 0, errors },
1488
+ message: `Imported strategy "${imported.name}" from ${args.path}`,
1489
+ };
1490
+ } catch (err) {
1491
+ return { error: `Import failed: ${err instanceof Error ? err.message : String(err)}` };
1492
+ }
1493
+ },
1494
+ }),
1495
+
1496
+ // ── Replay ──
1497
+
1498
+ replay_session: t({
1499
+ description: "View execution replay data. Without a file_name, lists available replays. With a file_name, loads and summarizes that replay session.",
1500
+ parameters: z.object({
1501
+ file_name: z.string().optional().describe("Replay file name to load. Omit to list available replays."),
1502
+ }),
1503
+ execute: async (args: any) => {
1504
+ try {
1505
+ if (!args.file_name) {
1506
+ const replays = listReplays();
1507
+ return { count: replays.length, replays };
1508
+ }
1509
+ const replay = loadReplay(args.file_name);
1510
+ if (!replay) return { error: `Replay file not found: ${args.file_name}` };
1511
+ const summary = summarizeReplay(replay);
1512
+ return { file_name: args.file_name, summary, event_count: replay.events.length };
1513
+ } catch (err) {
1514
+ return { error: `Replay failed: ${err instanceof Error ? err.message : String(err)}` };
1515
+ }
1516
+ },
1517
+ }),
1518
+
1519
+ // ── Portfolio ──
1520
+
1521
+ portfolio_summary: t({
1522
+ description: "Aggregate metrics across all running strategy processes. Shows combined PnL, exposure, trades, and per-process breakdown.",
1523
+ parameters: z.object({}),
1524
+ execute: async () => {
1525
+ const processes = [...runningProcesses.entries()].filter(([_, m]) => m.proc.exitCode === null);
1526
+ if (processes.length === 0) return { error: "No running processes." };
1527
+
1528
+ let totalPnl = 0, totalRpnl = 0, totalUpnl = 0, totalTrades = 0, totalExposure = 0;
1529
+ const breakdown: any[] = [];
1530
+
1531
+ for (const [pid, managed] of processes) {
1532
+ const metrics = parseLocalMetrics(managed);
1533
+ if (metrics) {
1534
+ totalPnl += metrics.pnl;
1535
+ totalRpnl += metrics.rpnl;
1536
+ totalUpnl += metrics.upnl;
1537
+ totalTrades += metrics.trades;
1538
+ totalExposure += metrics.exposure;
1539
+ breakdown.push({
1540
+ pid,
1541
+ uptime_secs: Math.round((Date.now() - managed.startedAt) / 1000),
1542
+ pnl: metrics.pnl,
1543
+ rpnl: metrics.rpnl,
1544
+ upnl: metrics.upnl,
1545
+ trades: metrics.trades,
1546
+ win_rate: metrics.win_rate,
1547
+ max_dd: metrics.max_dd,
1548
+ sharpe: metrics.sharpe,
1549
+ exposure: metrics.exposure,
1550
+ });
1551
+ } else {
1552
+ breakdown.push({ pid, uptime_secs: Math.round((Date.now() - managed.startedAt) / 1000), metrics: "unavailable" });
1553
+ }
1554
+ }
1555
+
1556
+ return {
1557
+ running_processes: processes.length,
1558
+ aggregate: { pnl: totalPnl, rpnl: totalRpnl, upnl: totalUpnl, trades: totalTrades, exposure: totalExposure },
1559
+ breakdown,
1560
+ };
1561
+ },
1562
+ }),
1563
+
1564
+ // ── Parameter Sweep ──
1565
+
1566
+ parameter_sweep: t({
1567
+ description: "Run multiple backtests with different parameter values. Modifies the strategy params dict for each value, runs backtest, and returns a comparison table.",
1568
+ parameters: z.object({
1569
+ param_name: z.string().describe("Name of the parameter to sweep (must exist in params={...})"),
1570
+ values: z.array(z.number()).describe("Array of values to test"),
1571
+ data_points: z.number().optional().describe("Synthetic data points per backtest (default 500)"),
1572
+ }),
1573
+ execute: async (args: any) => {
1574
+ const draft = store.getActiveSession()?.strategyDraft;
1575
+ if (!draft) return { error: "No strategy loaded yet." };
1576
+ if (draft.validationStatus === "invalid") {
1577
+ return { error: "Strategy has validation errors. Fix them first.", errors: draft.validationErrors };
1578
+ }
1579
+
1580
+ const dataPoints = args.data_points ?? 500;
1581
+ const results: any[] = [];
1582
+
1583
+ for (const value of args.values) {
1584
+ // Modify params dict in the code
1585
+ const paramPattern = new RegExp(`(["']${args.param_name}["']\\s*:\\s*)([\\d.]+)`);
1586
+ let modifiedCode = draft.code;
1587
+ if (paramPattern.test(modifiedCode)) {
1588
+ modifiedCode = modifiedCode.replace(paramPattern, `$1${value}`);
1589
+ } else {
1590
+ // Try bare key format: param_name: value
1591
+ const barePattern = new RegExp(`(${args.param_name}\\s*[:=]\\s*)([\\d.]+)`);
1592
+ if (barePattern.test(modifiedCode)) {
1593
+ modifiedCode = modifiedCode.replace(barePattern, `$1${value}`);
1594
+ } else {
1595
+ return { error: `Parameter "${args.param_name}" not found in strategy code params.` };
1596
+ }
1597
+ }
1598
+
1599
+ const hzRunIdx = modifiedCode.search(/hz\.run\s*\(/);
1600
+ const pipelineCode = hzRunIdx !== -1 ? modifiedCode.slice(0, hzRunIdx).trimEnd() : modifiedCode;
1601
+ const { pipelineFns, riskArgs, paramsStr, marketNames } = extractRunKwargs(modifiedCode);
1602
+
1603
+ const backtestConfig = {
1604
+ name: draft.name.replace(/[^a-zA-Z0-9_-]/g, "_"),
1605
+ markets: marketNames,
1606
+ data_points: dataPoints,
1607
+ base_price: 0.50,
1608
+ initial_capital: 1000,
1609
+ fill_model: "deterministic",
1610
+ risk_args: riskArgs,
1611
+ params_str: paramsStr,
1612
+ pipeline_fns: pipelineFns,
1613
+ };
1614
+
1615
+ const script = `
1616
+ import horizon as hz
1617
+ from horizon.context import FeedData
1618
+ import json, sys
1619
+
1620
+ ${pipelineCode}
1621
+
1622
+ with open("backtest_config.json") as f:
1623
+ _cfg = json.load(f)
1624
+
1625
+ data = []
1626
+ price = _cfg["base_price"]
1627
+ for i in range(_cfg["data_points"]):
1628
+ noise = (((i * 7 + 13) % 100) / 100 - 0.5) * 0.05
1629
+ revert = (_cfg["base_price"] - price) * 0.05
1630
+ price = max(0.01, min(0.99, price + noise + revert))
1631
+ data.append({"timestamp": float(i), "price": round(price, 4)})
1632
+
1633
+ try:
1634
+ result = hz.backtest(
1635
+ name=_cfg["name"],
1636
+ markets=_cfg["markets"],
1637
+ data=data,
1638
+ pipeline=[${pipelineFns.join(", ")}],
1639
+ risk=hz.Risk(${riskArgs}),
1640
+ params=${paramsStr},
1641
+ initial_capital=_cfg["initial_capital"],
1642
+ fill_model=_cfg["fill_model"],
1643
+ )
1644
+ m = result.metrics
1645
+ out = {
1646
+ "total_return": round(getattr(m, 'total_return', 0), 4),
1647
+ "max_drawdown": round(getattr(m, 'max_drawdown', 0), 4),
1648
+ "sharpe_ratio": round(getattr(m, 'sharpe_ratio', 0), 2),
1649
+ "win_rate": round(getattr(m, 'win_rate', 0), 4),
1650
+ "profit_factor": round(getattr(m, 'profit_factor', 0), 2),
1651
+ "trade_count": getattr(m, 'trade_count', 0),
1652
+ }
1653
+ print("---SWEEP_JSON---")
1654
+ print(json.dumps(out))
1655
+ print("---END_SWEEP_JSON---")
1656
+ except Exception as e:
1657
+ print("---SWEEP_ERROR---")
1658
+ print(str(e))
1659
+ print("---END_SWEEP_ERROR---")
1660
+ sys.exit(1)
1661
+ `;
1662
+
1663
+ try {
1664
+ const { mkdtemp } = await import("fs/promises");
1665
+ const { join } = await import("path");
1666
+ const { tmpdir } = await import("os");
1667
+ const sandboxDir = await mkdtemp(join(tmpdir(), "horizon-sweep-"));
1668
+ await Bun.write(join(sandboxDir, "backtest_config.json"), JSON.stringify(backtestConfig));
1669
+
1670
+ const { stdout, stderr, exitCode, timedOut } = await runInSandbox({ code: script, timeout: 60000, cwd: sandboxDir });
1671
+
1672
+ if (timedOut) {
1673
+ results.push({ param_value: value, error: "timed out" });
1674
+ continue;
1675
+ }
1676
+
1677
+ if (stdout.includes("---SWEEP_ERROR---")) {
1678
+ const errMsg = stdout.split("---SWEEP_ERROR---")[1]?.split("---END_SWEEP_ERROR---")[0]?.trim();
1679
+ results.push({ param_value: value, error: errMsg });
1680
+ continue;
1681
+ }
1682
+
1683
+ const jsonMatch = stdout.match(/---SWEEP_JSON---([\s\S]*?)---END_SWEEP_JSON---/);
1684
+ if (jsonMatch) {
1685
+ const metrics = JSON.parse(jsonMatch[1]!.trim());
1686
+ results.push({ param_value: value, ...metrics });
1687
+ } else {
1688
+ results.push({ param_value: value, error: "no output" });
1689
+ }
1690
+ } catch (err) {
1691
+ results.push({ param_value: value, error: err instanceof Error ? err.message : String(err) });
1692
+ }
1693
+ }
1694
+
1695
+ return {
1696
+ strategy_name: draft.name,
1697
+ param_name: args.param_name,
1698
+ sweep_count: args.values.length,
1699
+ results,
1700
+ };
1701
+ },
1702
+ }),
1209
1703
  };
@@ -0,0 +1,168 @@
1
+ // Strategy version control — tracks every code change with diffs
2
+ // Storage: ~/.horizon/versions/<strategy-slug>/v<N>.json
3
+
4
+ import { homedir } from "os";
5
+ import { resolve, join } from "path";
6
+ import { existsSync, mkdirSync, readdirSync, statSync } from "fs";
7
+ import { createHash } from "crypto";
8
+
9
+ const VERSIONS_ROOT = resolve(homedir(), ".horizon", "versions");
10
+
11
+ interface StrategyVersion {
12
+ version: number;
13
+ code: string;
14
+ hash: string;
15
+ label: string;
16
+ timestamp: string;
17
+ diff: string | null; // unified diff from previous version
18
+ }
19
+
20
+ function ensureDir(dir: string): void {
21
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
22
+ }
23
+
24
+ function slugify(name: string): string {
25
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "");
26
+ }
27
+
28
+ function codeHash(code: string): string {
29
+ return createHash("sha256").update(code).digest("hex").slice(0, 12);
30
+ }
31
+
32
+ /** Compute a simple unified diff between two code strings */
33
+ function computeDiff(oldCode: string, newCode: string): string {
34
+ const oldLines = oldCode.split("\n");
35
+ const newLines = newCode.split("\n");
36
+ const lines: string[] = [];
37
+ const maxLen = Math.max(oldLines.length, newLines.length);
38
+
39
+ let added = 0, removed = 0, unchanged = 0;
40
+
41
+ for (let i = 0; i < maxLen; i++) {
42
+ const oldLine = oldLines[i];
43
+ const newLine = newLines[i];
44
+
45
+ if (oldLine === newLine) {
46
+ unchanged++;
47
+ } else {
48
+ if (oldLine !== undefined && (newLine === undefined || oldLine !== newLine)) {
49
+ lines.push(`- ${oldLine}`);
50
+ removed++;
51
+ }
52
+ if (newLine !== undefined && (oldLine === undefined || oldLine !== newLine)) {
53
+ lines.push(`+ ${newLine}`);
54
+ added++;
55
+ }
56
+ }
57
+ }
58
+
59
+ if (added === 0 && removed === 0) return "(no changes)";
60
+ return `+${added} -${removed} (~${unchanged} unchanged)\n${lines.slice(0, 100).join("\n")}${lines.length > 100 ? `\n... (${lines.length - 100} more lines)` : ""}`;
61
+ }
62
+
63
+ /** Save a new version of a strategy. Returns version number. */
64
+ export async function saveVersion(name: string, code: string, label?: string): Promise<{ version: number; hash: string }> {
65
+ const slug = slugify(name);
66
+ const dir = join(VERSIONS_ROOT, slug);
67
+ ensureDir(dir);
68
+
69
+ const existing = getVersionList(slug);
70
+ const nextVersion = existing.length + 1;
71
+ const hash = codeHash(code);
72
+
73
+ // Skip if code is identical to latest version
74
+ if (existing.length > 0) {
75
+ const latestPath = join(dir, `v${existing.length}.json`);
76
+ try {
77
+ const latest: StrategyVersion = JSON.parse(await Bun.file(latestPath).text());
78
+ if (latest.hash === hash) {
79
+ return { version: existing.length, hash };
80
+ }
81
+ } catch {}
82
+ }
83
+
84
+ // Compute diff from previous version
85
+ let diff: string | null = null;
86
+ if (existing.length > 0) {
87
+ try {
88
+ const prevPath = join(dir, `v${existing.length}.json`);
89
+ const prev: StrategyVersion = JSON.parse(await Bun.file(prevPath).text());
90
+ diff = computeDiff(prev.code, code);
91
+ } catch {}
92
+ }
93
+
94
+ const version: StrategyVersion = {
95
+ version: nextVersion,
96
+ code,
97
+ hash,
98
+ label: label ?? `v${nextVersion}`,
99
+ timestamp: new Date().toISOString(),
100
+ diff,
101
+ };
102
+
103
+ await Bun.write(join(dir, `v${nextVersion}.json`), JSON.stringify(version, null, 2));
104
+ return { version: nextVersion, hash };
105
+ }
106
+
107
+ function getVersionList(slug: string): string[] {
108
+ const dir = join(VERSIONS_ROOT, slug);
109
+ if (!existsSync(dir)) return [];
110
+ return readdirSync(dir)
111
+ .filter(f => f.startsWith("v") && f.endsWith(".json"))
112
+ .sort((a, b) => {
113
+ const numA = parseInt(a.slice(1));
114
+ const numB = parseInt(b.slice(1));
115
+ return numA - numB;
116
+ });
117
+ }
118
+
119
+ /** List all versions of a strategy */
120
+ export async function listVersions(name: string): Promise<{ version: number; hash: string; label: string; timestamp: string }[]> {
121
+ const slug = slugify(name);
122
+ const dir = join(VERSIONS_ROOT, slug);
123
+ const files = getVersionList(slug);
124
+
125
+ const versions: { version: number; hash: string; label: string; timestamp: string }[] = [];
126
+ for (const file of files) {
127
+ try {
128
+ const v: StrategyVersion = JSON.parse(await Bun.file(join(dir, file)).text());
129
+ versions.push({ version: v.version, hash: v.hash, label: v.label, timestamp: v.timestamp });
130
+ } catch {}
131
+ }
132
+ return versions;
133
+ }
134
+
135
+ /** Get a specific version's full data */
136
+ export async function getVersion(name: string, version: number): Promise<StrategyVersion | null> {
137
+ const slug = slugify(name);
138
+ const path = join(VERSIONS_ROOT, slug, `v${version}.json`);
139
+ if (!existsSync(path)) return null;
140
+ try {
141
+ return JSON.parse(await Bun.file(path).text());
142
+ } catch { return null; }
143
+ }
144
+
145
+ /** Get diff between two versions */
146
+ export async function diffVersions(name: string, v1: number, v2: number): Promise<string | null> {
147
+ const ver1 = await getVersion(name, v1);
148
+ const ver2 = await getVersion(name, v2);
149
+ if (!ver1 || !ver2) return null;
150
+ return computeDiff(ver1.code, ver2.code);
151
+ }
152
+
153
+ /** Get the latest version's code */
154
+ export async function getLatestCode(name: string): Promise<string | null> {
155
+ const slug = slugify(name);
156
+ const files = getVersionList(slug);
157
+ if (files.length === 0) return null;
158
+ const v = await getVersion(name, files.length);
159
+ return v?.code ?? null;
160
+ }
161
+
162
+ /** List all strategy names that have version history */
163
+ export function listVersionedStrategies(): string[] {
164
+ ensureDir(VERSIONS_ROOT);
165
+ return readdirSync(VERSIONS_ROOT, { withFileTypes: true })
166
+ .filter(d => d.isDirectory())
167
+ .map(d => d.name);
168
+ }