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.
@@ -0,0 +1,212 @@
1
+ // Market scanner + correlation analysis
2
+ // Scans for trading opportunities across Polymarket markets
3
+
4
+ import { gammaEvents } from "./apis.ts";
5
+
6
+ export interface MarketOpportunity {
7
+ slug: string;
8
+ title: string;
9
+ type: "wide_spread" | "high_volume" | "mispricing" | "volatile";
10
+ score: number; // 0-100
11
+ spread?: number;
12
+ volume?: number;
13
+ price?: number;
14
+ detail: string;
15
+ }
16
+
17
+ export interface CorrelationResult {
18
+ marketA: string;
19
+ marketB: string;
20
+ correlation: number; // -1 to 1
21
+ direction: string; // "positive", "negative", "neutral"
22
+ strength: string; // "strong", "moderate", "weak"
23
+ }
24
+
25
+ export interface CorrelationMatrix {
26
+ markets: string[];
27
+ matrix: number[][];
28
+ pairs: CorrelationResult[];
29
+ high_correlation_warning: string | null;
30
+ }
31
+
32
+ /**
33
+ * Scan top Polymarket markets for trading opportunities.
34
+ * Returns ranked list of opportunities for market making, edge trading, or arbitrage.
35
+ */
36
+ export async function scanOpportunities(limit: number = 30): Promise<{
37
+ opportunities: MarketOpportunity[];
38
+ total_scanned: number;
39
+ summary: string;
40
+ }> {
41
+ // Fetch active events
42
+ const events = await gammaEvents({ query: "", limit: limit * 2 });
43
+ if (!events || events.length === 0) {
44
+ return { opportunities: [], total_scanned: 0, summary: "No markets available." };
45
+ }
46
+
47
+ const opportunities: MarketOpportunity[] = [];
48
+
49
+ for (const event of events) {
50
+ const markets = event.markets ?? [event];
51
+
52
+ for (const market of markets) {
53
+ const slug = market.slug ?? market.conditionId ?? "";
54
+ const title = market.question ?? market.title ?? slug;
55
+ const spread = market.spread ?? (market.bestAsk && market.bestBid ? market.bestAsk - market.bestBid : null);
56
+ const volume = market.volume ?? market.volumeNum ?? 0;
57
+ const price = market.lastTradePrice ?? market.outcomePrices?.[0] ?? 0.5;
58
+ const priceNum = typeof price === "string" ? parseFloat(price) : price;
59
+
60
+ // Wide spread opportunity (market making)
61
+ if (spread && spread > 0.04) {
62
+ const score = Math.min(100, Math.round(spread * 500));
63
+ opportunities.push({
64
+ slug, title, type: "wide_spread", score, spread, volume: volume,
65
+ detail: `Spread ${(spread * 100).toFixed(1)}c — room for market making`,
66
+ });
67
+ }
68
+
69
+ // High volume but price near 50% (uncertain market = good for MM)
70
+ if (volume > 50000 && priceNum > 0.3 && priceNum < 0.7) {
71
+ const uncertainty = 1 - Math.abs(priceNum - 0.5) * 2;
72
+ const score = Math.min(100, Math.round(uncertainty * 60 + (volume / 100000) * 40));
73
+ opportunities.push({
74
+ slug, title, type: "high_volume", score, volume, price: priceNum,
75
+ detail: `$${(volume / 1000).toFixed(0)}K vol, price ${(priceNum * 100).toFixed(0)}c — high uncertainty`,
76
+ });
77
+ }
78
+
79
+ // Extreme prices that might indicate mispricing
80
+ if (priceNum > 0.02 && priceNum < 0.08) {
81
+ opportunities.push({
82
+ slug, title, type: "mispricing", score: 65, price: priceNum,
83
+ detail: `Low price ${(priceNum * 100).toFixed(1)}c — potential long tail value`,
84
+ });
85
+ }
86
+ if (priceNum > 0.92 && priceNum < 0.98) {
87
+ opportunities.push({
88
+ slug, title, type: "mispricing", score: 55, price: priceNum,
89
+ detail: `High price ${(priceNum * 100).toFixed(1)}c — potential short opportunity`,
90
+ });
91
+ }
92
+ }
93
+ }
94
+
95
+ // Sort by score descending and deduplicate by slug
96
+ const seen = new Set<string>();
97
+ const unique = opportunities
98
+ .sort((a, b) => b.score - a.score)
99
+ .filter(o => {
100
+ if (seen.has(o.slug)) return false;
101
+ seen.add(o.slug);
102
+ return true;
103
+ })
104
+ .slice(0, limit);
105
+
106
+ const byType = {
107
+ wide_spread: unique.filter(o => o.type === "wide_spread").length,
108
+ high_volume: unique.filter(o => o.type === "high_volume").length,
109
+ mispricing: unique.filter(o => o.type === "mispricing").length,
110
+ };
111
+
112
+ const summary = `Scanned ${events.length} markets. Found ${unique.length} opportunities: ` +
113
+ `${byType.wide_spread} wide spreads, ${byType.high_volume} high volume, ${byType.mispricing} mispricings.`;
114
+
115
+ return { opportunities: unique, total_scanned: events.length, summary };
116
+ }
117
+
118
+ /**
119
+ * Compute pairwise correlations between a set of markets.
120
+ * Uses simple price-based correlation from available data.
121
+ */
122
+ export function computeCorrelations(
123
+ marketPrices: { slug: string; prices: number[] }[],
124
+ ): CorrelationMatrix {
125
+ const n = marketPrices.length;
126
+ const matrix: number[][] = Array.from({ length: n }, () => Array(n).fill(0));
127
+ const pairs: CorrelationResult[] = [];
128
+
129
+ for (let i = 0; i < n; i++) {
130
+ matrix[i]![i] = 1.0;
131
+ for (let j = i + 1; j < n; j++) {
132
+ const corr = pearsonCorrelation(marketPrices[i]!.prices, marketPrices[j]!.prices);
133
+ matrix[i]![j] = corr;
134
+ matrix[j]![i] = corr;
135
+
136
+ const absCorr = Math.abs(corr);
137
+ pairs.push({
138
+ marketA: marketPrices[i]!.slug,
139
+ marketB: marketPrices[j]!.slug,
140
+ correlation: Math.round(corr * 100) / 100,
141
+ direction: corr > 0.1 ? "positive" : corr < -0.1 ? "negative" : "neutral",
142
+ strength: absCorr > 0.7 ? "strong" : absCorr > 0.4 ? "moderate" : "weak",
143
+ });
144
+ }
145
+ }
146
+
147
+ // Find high correlations that indicate concentration risk
148
+ const highCorr = pairs.filter(p => Math.abs(p.correlation) > 0.7);
149
+ const warning = highCorr.length > 0
150
+ ? `WARNING: ${highCorr.length} pair(s) with >0.7 correlation — portfolio is concentrated: ${highCorr.map(p => `${p.marketA}/${p.marketB} (${p.correlation})`).join(", ")}`
151
+ : null;
152
+
153
+ return {
154
+ markets: marketPrices.map(m => m.slug),
155
+ matrix,
156
+ pairs: pairs.sort((a, b) => Math.abs(b.correlation) - Math.abs(a.correlation)),
157
+ high_correlation_warning: warning,
158
+ };
159
+ }
160
+
161
+ /** Pearson correlation between two arrays */
162
+ function pearsonCorrelation(x: number[], y: number[]): number {
163
+ const n = Math.min(x.length, y.length);
164
+ if (n < 3) return 0;
165
+
166
+ let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0, sumY2 = 0;
167
+ for (let i = 0; i < n; i++) {
168
+ sumX += x[i]!;
169
+ sumY += y[i]!;
170
+ sumXY += x[i]! * y[i]!;
171
+ sumX2 += x[i]! * x[i]!;
172
+ sumY2 += y[i]! * y[i]!;
173
+ }
174
+
175
+ const numerator = n * sumXY - sumX * sumY;
176
+ const denominator = Math.sqrt((n * sumX2 - sumX * sumX) * (n * sumY2 - sumY * sumY));
177
+
178
+ if (denominator === 0) return 0;
179
+ return numerator / denominator;
180
+ }
181
+
182
+ /** Build ASCII correlation matrix for terminal display */
183
+ export function formatCorrelationMatrix(result: CorrelationMatrix): string {
184
+ const { markets, matrix } = result;
185
+ const n = markets.length;
186
+ const maxSlugLen = Math.min(12, Math.max(...markets.map(m => m.length)));
187
+
188
+ const lines: string[] = [];
189
+
190
+ // Header
191
+ const header = " ".repeat(maxSlugLen + 2) + markets.map(m => m.slice(0, 6).padStart(7)).join("");
192
+ lines.push(header);
193
+ lines.push("-".repeat(header.length));
194
+
195
+ // Rows
196
+ for (let i = 0; i < n; i++) {
197
+ const slug = markets[i]!.slice(0, maxSlugLen).padEnd(maxSlugLen);
198
+ const vals = matrix[i]!.map((v, j) => {
199
+ if (i === j) return " 1.00";
200
+ const str = v.toFixed(2);
201
+ return (v >= 0 ? " " : "") + str;
202
+ }).map(s => s.padStart(7)).join("");
203
+ lines.push(`${slug} ${vals}`);
204
+ }
205
+
206
+ if (result.high_correlation_warning) {
207
+ lines.push("");
208
+ lines.push(result.high_correlation_warning);
209
+ }
210
+
211
+ return lines.join("\n");
212
+ }
@@ -10,6 +10,7 @@ import {
10
10
  exaSearch, exaSentiment, calaSearch, evCalculator, probabilityCalc,
11
11
  } from "./apis.ts";
12
12
  import { yahooQuote, yahooChart, yahooSearch } from "./stock-apis.ts";
13
+ import { scanOpportunities, computeCorrelations, formatCorrelationMatrix } from "./scanner.ts";
13
14
 
14
15
  const t = tool as any;
15
16
 
@@ -1114,4 +1115,61 @@ ${volumes.length > 0 ? `new Chart(document.getElementById('vol'),{type:'bar',dat
1114
1115
  };
1115
1116
  },
1116
1117
  }),
1118
+
1119
+ // ── Market scanning tools ──
1120
+
1121
+ scan_opportunities: t({
1122
+ description: "Scan top Polymarket markets for trading opportunities: wide spreads (market making), high volume (edge), and mispricings. Returns ranked list with scores.",
1123
+ parameters: z.object({
1124
+ limit: z.number().optional().describe("Max opportunities to return (default 20)"),
1125
+ }),
1126
+ execute: async (args: any) => {
1127
+ try {
1128
+ return await scanOpportunities(args.limit ?? 20);
1129
+ } catch (err) {
1130
+ return { error: `Scan failed: ${err instanceof Error ? err.message : String(err)}` };
1131
+ }
1132
+ },
1133
+ }),
1134
+
1135
+ scan_correlations: t({
1136
+ description: "Compute pairwise correlations between markets you're trading. Identifies concentration risk and hedging opportunities. Pass market slugs from your strategy or portfolio.",
1137
+ parameters: z.object({
1138
+ slugs: z.array(z.string()).describe("Market slugs to analyze (2-8 markets)"),
1139
+ }),
1140
+ execute: async (args: any) => {
1141
+ try {
1142
+ const slugs: string[] = args.slugs;
1143
+ if (slugs.length < 2) return { error: "Need at least 2 markets to compute correlations" };
1144
+ if (slugs.length > 8) return { error: "Maximum 8 markets for correlation analysis" };
1145
+
1146
+ // Fetch price history for each market
1147
+ const { clobPriceHistory } = await import("./apis.ts");
1148
+ const marketPrices: { slug: string; prices: number[] }[] = [];
1149
+
1150
+ for (const slug of slugs) {
1151
+ try {
1152
+ const data = await clobPriceHistory(slug, "1w", 60);
1153
+ const prices = (data.priceHistory ?? []).map((p: any) => typeof p.p === "number" ? p.p : parseFloat(p.p));
1154
+ if (prices.length >= 3) {
1155
+ marketPrices.push({ slug, prices });
1156
+ }
1157
+ } catch {}
1158
+ }
1159
+
1160
+ if (marketPrices.length < 2) {
1161
+ return { error: "Could not fetch enough price data. Need at least 2 markets with history." };
1162
+ }
1163
+
1164
+ const result = computeCorrelations(marketPrices);
1165
+ return {
1166
+ ...result,
1167
+ ascii_matrix: formatCorrelationMatrix(result),
1168
+ summary: result.high_correlation_warning ?? `${result.pairs.length} pairs analyzed. No concentration risk detected.`,
1169
+ };
1170
+ } catch (err) {
1171
+ return { error: `Correlation analysis failed: ${err instanceof Error ? err.message : String(err)}` };
1172
+ }
1173
+ },
1174
+ }),
1117
1175
  };
@@ -0,0 +1,190 @@
1
+ // Event-driven alerts — monitor running strategies and trigger actions
2
+ // Conditions checked against live metrics in the app polling loop
3
+
4
+ export type AlertCondition =
5
+ | { type: "max_dd_exceeds"; threshold: number }
6
+ | { type: "pnl_below"; threshold: number }
7
+ | { type: "exposure_above"; threshold: number }
8
+ | { type: "win_rate_below"; threshold: number }
9
+ | { type: "loss_streak"; threshold: number };
10
+
11
+ export type AlertAction =
12
+ | { type: "log"; message?: string }
13
+ | { type: "stop_process" }
14
+ | { type: "webhook"; url: string };
15
+
16
+ export interface Alert {
17
+ id: string;
18
+ strategyName: string;
19
+ condition: AlertCondition;
20
+ action: AlertAction;
21
+ createdAt: number;
22
+ triggeredAt: number | null;
23
+ triggerCount: number;
24
+ enabled: boolean;
25
+ cooldownMs: number; // minimum time between triggers
26
+ }
27
+
28
+ export interface AlertEvalMetrics {
29
+ pnl: number;
30
+ max_dd: number;
31
+ exposure: number;
32
+ win_rate: number;
33
+ trades: number;
34
+ loss_streak?: number;
35
+ }
36
+
37
+ // Global alert store (in-memory, persisted to disk on change)
38
+ const alerts = new Map<string, Alert>();
39
+ let _alertIdCounter = 0;
40
+
41
+ /** Create a new alert */
42
+ export function createAlert(
43
+ strategyName: string,
44
+ condition: AlertCondition,
45
+ action: AlertAction,
46
+ cooldownMs: number = 60_000,
47
+ ): Alert {
48
+ const id = `alert-${++_alertIdCounter}-${Date.now()}`;
49
+ const alert: Alert = {
50
+ id,
51
+ strategyName,
52
+ condition,
53
+ action,
54
+ createdAt: Date.now(),
55
+ triggeredAt: null,
56
+ triggerCount: 0,
57
+ enabled: true,
58
+ cooldownMs,
59
+ };
60
+ alerts.set(id, alert);
61
+ return alert;
62
+ }
63
+
64
+ /** Remove an alert */
65
+ export function removeAlert(id: string): boolean {
66
+ return alerts.delete(id);
67
+ }
68
+
69
+ /** List all alerts, optionally filtered by strategy */
70
+ export function listAlerts(strategyName?: string): Alert[] {
71
+ const all = [...alerts.values()];
72
+ if (strategyName) return all.filter(a => a.strategyName === strategyName);
73
+ return all;
74
+ }
75
+
76
+ /** Evaluate a condition against metrics */
77
+ function evaluateCondition(condition: AlertCondition, metrics: AlertEvalMetrics): boolean {
78
+ switch (condition.type) {
79
+ case "max_dd_exceeds":
80
+ return metrics.max_dd > condition.threshold;
81
+ case "pnl_below":
82
+ return metrics.pnl < condition.threshold;
83
+ case "exposure_above":
84
+ return metrics.exposure > condition.threshold;
85
+ case "win_rate_below":
86
+ return metrics.win_rate < condition.threshold;
87
+ case "loss_streak":
88
+ return (metrics.loss_streak ?? 0) >= condition.threshold;
89
+ default:
90
+ return false;
91
+ }
92
+ }
93
+
94
+ /** Format condition as human-readable string */
95
+ export function formatCondition(condition: AlertCondition): string {
96
+ switch (condition.type) {
97
+ case "max_dd_exceeds": return `Max DD > ${condition.threshold}%`;
98
+ case "pnl_below": return `P&L < $${condition.threshold}`;
99
+ case "exposure_above": return `Exposure > $${condition.threshold}`;
100
+ case "win_rate_below": return `Win Rate < ${(condition.threshold * 100).toFixed(0)}%`;
101
+ case "loss_streak": return `Loss streak >= ${condition.threshold}`;
102
+ }
103
+ }
104
+
105
+ /** Format action as human-readable string */
106
+ export function formatAction(action: AlertAction): string {
107
+ switch (action.type) {
108
+ case "log": return "Log message";
109
+ case "stop_process": return "Stop strategy";
110
+ case "webhook": return `Webhook → ${action.url}`;
111
+ }
112
+ }
113
+
114
+ export interface AlertTriggerResult {
115
+ alert: Alert;
116
+ triggered: boolean;
117
+ actionTaken: string | null;
118
+ }
119
+
120
+ /**
121
+ * Check all alerts against current metrics. Returns triggered alerts.
122
+ * Called from the app polling loop.
123
+ */
124
+ export async function checkAlerts(
125
+ strategyName: string,
126
+ metrics: AlertEvalMetrics,
127
+ stopProcessFn?: () => void,
128
+ ): Promise<AlertTriggerResult[]> {
129
+ const results: AlertTriggerResult[] = [];
130
+ const now = Date.now();
131
+
132
+ for (const alert of alerts.values()) {
133
+ if (!alert.enabled) continue;
134
+ if (alert.strategyName !== strategyName) continue;
135
+
136
+ // Cooldown check
137
+ if (alert.triggeredAt && (now - alert.triggeredAt) < alert.cooldownMs) continue;
138
+
139
+ const triggered = evaluateCondition(alert.condition, metrics);
140
+ if (!triggered) {
141
+ results.push({ alert, triggered: false, actionTaken: null });
142
+ continue;
143
+ }
144
+
145
+ // Execute action
146
+ alert.triggeredAt = now;
147
+ alert.triggerCount++;
148
+ let actionTaken: string | null = null;
149
+
150
+ switch (alert.action.type) {
151
+ case "log":
152
+ actionTaken = `[ALERT] ${formatCondition(alert.condition)} — ${alert.action.message ?? "threshold breached"}`;
153
+ break;
154
+ case "stop_process":
155
+ if (stopProcessFn) {
156
+ stopProcessFn();
157
+ actionTaken = `[ALERT] ${formatCondition(alert.condition)} — process stopped`;
158
+ }
159
+ break;
160
+ case "webhook":
161
+ try {
162
+ await fetch(alert.action.url, {
163
+ method: "POST",
164
+ headers: { "Content-Type": "application/json" },
165
+ body: JSON.stringify({
166
+ alert_id: alert.id,
167
+ strategy: alert.strategyName,
168
+ condition: formatCondition(alert.condition),
169
+ metrics,
170
+ timestamp: new Date().toISOString(),
171
+ }),
172
+ signal: AbortSignal.timeout(5000),
173
+ });
174
+ actionTaken = `[ALERT] ${formatCondition(alert.condition)} — webhook sent`;
175
+ } catch (e: any) {
176
+ actionTaken = `[ALERT] ${formatCondition(alert.condition)} — webhook failed: ${e.message}`;
177
+ }
178
+ break;
179
+ }
180
+
181
+ results.push({ alert, triggered: true, actionTaken });
182
+ }
183
+
184
+ return results;
185
+ }
186
+
187
+ /** Clear all alerts */
188
+ export function clearAlerts(): void {
189
+ alerts.clear();
190
+ }
@@ -0,0 +1,159 @@
1
+ // Strategy export/import — .hz format for sharing strategies
2
+ // Format: JSON with code, params, risk config, backtest results, metadata
3
+
4
+ import { homedir } from "os";
5
+ import { resolve, join } from "path";
6
+ import { existsSync } from "fs";
7
+ import { createHash } from "crypto";
8
+ import { getHistory } from "./ledger.ts";
9
+ import { listVersions } from "./versioning.ts";
10
+
11
+ const EXPORT_DIR = resolve(homedir(), ".horizon", "workspace", "exports");
12
+
13
+ export interface HorizonStrategyFile {
14
+ format: "horizon-strategy-v1";
15
+ exported_at: string;
16
+ strategy: {
17
+ name: string;
18
+ code: string;
19
+ code_hash: string;
20
+ params: Record<string, unknown>;
21
+ risk_config: string | null;
22
+ };
23
+ performance?: {
24
+ total_runs: number;
25
+ best_sharpe: number;
26
+ best_pnl: number;
27
+ avg_win_rate: number;
28
+ last_backtest?: {
29
+ sharpe: number;
30
+ pnl: number;
31
+ win_rate: number;
32
+ max_dd: number;
33
+ trades: number;
34
+ };
35
+ };
36
+ metadata: {
37
+ versions: number;
38
+ horizon_version: string;
39
+ };
40
+ }
41
+
42
+ function codeHash(code: string): string {
43
+ return createHash("sha256").update(code).digest("hex").slice(0, 12);
44
+ }
45
+
46
+ /** Export a strategy to .hz format */
47
+ export async function exportStrategy(
48
+ name: string,
49
+ code: string,
50
+ params: Record<string, unknown>,
51
+ riskConfig: string | null,
52
+ ): Promise<{ path: string; size: number }> {
53
+ if (!existsSync(EXPORT_DIR)) {
54
+ const { mkdirSync } = await import("fs");
55
+ mkdirSync(EXPORT_DIR, { recursive: true });
56
+ }
57
+
58
+ // Gather performance data from ledger
59
+ const history = getHistory(name);
60
+ const versions = await listVersions(name);
61
+
62
+ let performance: HorizonStrategyFile["performance"] = undefined;
63
+ if (history.length > 0) {
64
+ const sharpes = history.map(e => e.metrics.sharpe).filter(v => isFinite(v));
65
+ const pnls = history.map(e => e.metrics.pnl).filter(v => isFinite(v));
66
+ const winRates = history.map(e => e.metrics.win_rate).filter(v => isFinite(v));
67
+ const avg = (arr: number[]) => arr.length > 0 ? arr.reduce((a, b) => a + b, 0) / arr.length : 0;
68
+
69
+ const lastBacktest = history.find(e => e.type === "backtest");
70
+
71
+ performance = {
72
+ total_runs: history.length,
73
+ best_sharpe: sharpes.length > 0 ? Math.max(...sharpes) : 0,
74
+ best_pnl: pnls.length > 0 ? Math.max(...pnls) : 0,
75
+ avg_win_rate: avg(winRates),
76
+ last_backtest: lastBacktest ? {
77
+ sharpe: lastBacktest.metrics.sharpe,
78
+ pnl: lastBacktest.metrics.pnl,
79
+ win_rate: lastBacktest.metrics.win_rate,
80
+ max_dd: lastBacktest.metrics.max_dd,
81
+ trades: lastBacktest.metrics.trades,
82
+ } : undefined,
83
+ };
84
+ }
85
+
86
+ const pkg = await import("../../package.json");
87
+
88
+ const file: HorizonStrategyFile = {
89
+ format: "horizon-strategy-v1",
90
+ exported_at: new Date().toISOString(),
91
+ strategy: {
92
+ name,
93
+ code,
94
+ code_hash: codeHash(code),
95
+ params,
96
+ risk_config: riskConfig,
97
+ },
98
+ performance,
99
+ metadata: {
100
+ versions: versions.length,
101
+ horizon_version: (pkg as any).version ?? "unknown",
102
+ },
103
+ };
104
+
105
+ const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "");
106
+ const fileName = `${slug}.hz`;
107
+ const filePath = join(EXPORT_DIR, fileName);
108
+
109
+ await Bun.write(filePath, JSON.stringify(file, null, 2));
110
+ const stat = await import("fs").then(fs => fs.statSync(filePath));
111
+
112
+ return { path: `exports/${fileName}`, size: stat.size };
113
+ }
114
+
115
+ /** Import a strategy from .hz file */
116
+ export async function importStrategy(path: string): Promise<{
117
+ name: string;
118
+ code: string;
119
+ params: Record<string, unknown>;
120
+ riskConfig: string | null;
121
+ performance?: HorizonStrategyFile["performance"];
122
+ }> {
123
+ // Resolve path — accept workspace-relative or absolute
124
+ let absolutePath: string;
125
+ if (path.startsWith("/") || path.startsWith("~")) {
126
+ absolutePath = path.startsWith("~") ? resolve(homedir(), path.slice(2)) : path;
127
+ } else {
128
+ absolutePath = resolve(homedir(), ".horizon", "workspace", path);
129
+ }
130
+
131
+ if (!existsSync(absolutePath)) {
132
+ throw new Error(`File not found: ${path}`);
133
+ }
134
+
135
+ const content = await Bun.file(absolutePath).text();
136
+ let file: HorizonStrategyFile;
137
+
138
+ try {
139
+ file = JSON.parse(content);
140
+ } catch {
141
+ throw new Error("Invalid .hz file: not valid JSON");
142
+ }
143
+
144
+ if (file.format !== "horizon-strategy-v1") {
145
+ throw new Error(`Unsupported format: ${file.format}`);
146
+ }
147
+
148
+ if (!file.strategy?.code || !file.strategy?.name) {
149
+ throw new Error("Invalid .hz file: missing strategy code or name");
150
+ }
151
+
152
+ return {
153
+ name: file.strategy.name,
154
+ code: file.strategy.code,
155
+ params: file.strategy.params ?? {},
156
+ riskConfig: file.strategy.risk_config,
157
+ performance: file.performance,
158
+ };
159
+ }