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
package/src/research/tools.ts
CHANGED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Strategy health score — 0-100 quality rating for strategy code
|
|
2
|
+
// Computed from code analysis: risk config, feed guards, pipeline quality, etc.
|
|
3
|
+
|
|
4
|
+
export interface HealthCheck {
|
|
5
|
+
category: string;
|
|
6
|
+
name: string;
|
|
7
|
+
points: number;
|
|
8
|
+
maxPoints: number;
|
|
9
|
+
passed: boolean;
|
|
10
|
+
detail: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface HealthReport {
|
|
14
|
+
score: number;
|
|
15
|
+
maxScore: number;
|
|
16
|
+
grade: string; // A, B, C, D, F
|
|
17
|
+
checks: HealthCheck[];
|
|
18
|
+
summary: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function check(category: string, name: string, maxPoints: number, passed: boolean, detail: string): HealthCheck {
|
|
22
|
+
return { category, name, points: passed ? maxPoints : 0, maxPoints, passed, detail };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Compute health score for strategy code */
|
|
26
|
+
export function computeHealthScore(code: string): HealthReport {
|
|
27
|
+
const checks: HealthCheck[] = [];
|
|
28
|
+
|
|
29
|
+
// ── Risk Configuration (20 pts) ──
|
|
30
|
+
|
|
31
|
+
checks.push(check("risk", "has_risk_config", 5, /hz\.Risk\s*\(/.test(code),
|
|
32
|
+
"Strategy uses hz.Risk() for risk management"));
|
|
33
|
+
|
|
34
|
+
checks.push(check("risk", "max_drawdown", 5, /max_drawdown_pct\s*=/.test(code),
|
|
35
|
+
"max_drawdown_pct is configured"));
|
|
36
|
+
|
|
37
|
+
checks.push(check("risk", "max_position", 5, /max_position\s*=/.test(code),
|
|
38
|
+
"max_position limit is set"));
|
|
39
|
+
|
|
40
|
+
checks.push(check("risk", "max_notional", 5, /max_notional\s*=/.test(code) || /max_order_size\s*=/.test(code),
|
|
41
|
+
"max_notional or max_order_size is set"));
|
|
42
|
+
|
|
43
|
+
// ── Feed Staleness Guards (15 pts) ──
|
|
44
|
+
|
|
45
|
+
checks.push(check("feeds", "staleness_check", 10, /is_stale\s*\(/.test(code),
|
|
46
|
+
"Feed staleness is checked with is_stale()"));
|
|
47
|
+
|
|
48
|
+
checks.push(check("feeds", "feed_null_check", 5,
|
|
49
|
+
/if\s+(not\s+)?feed/.test(code) || /feed\s+(is\s+None|==\s*None|is\s+not)/.test(code),
|
|
50
|
+
"Feed null/None check before use"));
|
|
51
|
+
|
|
52
|
+
// ── Pipeline Quality (20 pts) ──
|
|
53
|
+
|
|
54
|
+
const pipelineMatch = code.match(/pipeline\s*=\s*\[([\s\S]*?)\]/);
|
|
55
|
+
const pipelineFns = pipelineMatch
|
|
56
|
+
? pipelineMatch[1]!.split(",").map(s => s.trim()).filter(s => s && !s.startsWith("#"))
|
|
57
|
+
: [];
|
|
58
|
+
|
|
59
|
+
checks.push(check("pipeline", "has_pipeline", 5, pipelineFns.length > 0,
|
|
60
|
+
`Pipeline has ${pipelineFns.length} function(s)`));
|
|
61
|
+
|
|
62
|
+
checks.push(check("pipeline", "multi_stage", 5, pipelineFns.length >= 2,
|
|
63
|
+
"Pipeline has 2+ stages (signal + quoting)"));
|
|
64
|
+
|
|
65
|
+
checks.push(check("pipeline", "returns_quotes", 5, /return\s+.*quotes|return\s+\[\]/.test(code),
|
|
66
|
+
"Pipeline returns list[Quote] or []"));
|
|
67
|
+
|
|
68
|
+
checks.push(check("pipeline", "hz_run_call", 5, /hz\.run\s*\(/.test(code),
|
|
69
|
+
"Strategy has hz.run() call"));
|
|
70
|
+
|
|
71
|
+
// ── Parameter Tunability (10 pts) ──
|
|
72
|
+
|
|
73
|
+
checks.push(check("params", "uses_ctx_params", 5, /ctx\.params/.test(code),
|
|
74
|
+
"Uses ctx.params for tunable values"));
|
|
75
|
+
|
|
76
|
+
checks.push(check("params", "params_dict", 5, /params\s*=\s*\{/.test(code),
|
|
77
|
+
"params={} dictionary defined in hz.run()"));
|
|
78
|
+
|
|
79
|
+
// ── Mode Safety (10 pts) ──
|
|
80
|
+
|
|
81
|
+
checks.push(check("safety", "paper_mode", 5, /mode\s*=\s*["']paper["']/.test(code),
|
|
82
|
+
"Strategy uses mode='paper' for safety"));
|
|
83
|
+
|
|
84
|
+
checks.push(check("safety", "no_hardcoded_slugs", 5,
|
|
85
|
+
!/markets\s*=\s*\["[a-z]/.test(code) || /ctx\.market\.slug/.test(code) || /ctx\.params/.test(code),
|
|
86
|
+
"Market slugs not hardcoded or use ctx.params"));
|
|
87
|
+
|
|
88
|
+
// ── Market & Feeds (10 pts) ──
|
|
89
|
+
|
|
90
|
+
checks.push(check("markets", "has_feeds", 5, /feeds\s*=\s*\[/.test(code),
|
|
91
|
+
"Feed sources configured"));
|
|
92
|
+
|
|
93
|
+
checks.push(check("markets", "has_markets", 5, /markets\s*=\s*\[/.test(code),
|
|
94
|
+
"Market slugs configured"));
|
|
95
|
+
|
|
96
|
+
// ── Error Handling (15 pts) ──
|
|
97
|
+
|
|
98
|
+
checks.push(check("robustness", "empty_return", 5, /return\s+\[\]/.test(code),
|
|
99
|
+
"Has empty return [] for edge cases"));
|
|
100
|
+
|
|
101
|
+
checks.push(check("robustness", "try_except", 5, /try\s*:/.test(code) || /except/.test(code) || /if\s+not\s+/.test(code),
|
|
102
|
+
"Has error handling or guard clauses"));
|
|
103
|
+
|
|
104
|
+
checks.push(check("robustness", "inventory_check", 5,
|
|
105
|
+
/ctx\.inventory/.test(code) || /net_for_market/.test(code),
|
|
106
|
+
"Checks inventory for position awareness"));
|
|
107
|
+
|
|
108
|
+
// ── Compute Score ──
|
|
109
|
+
|
|
110
|
+
const score = checks.reduce((sum, c) => sum + c.points, 0);
|
|
111
|
+
const maxScore = checks.reduce((sum, c) => sum + c.maxPoints, 0);
|
|
112
|
+
const pct = (score / maxScore) * 100;
|
|
113
|
+
|
|
114
|
+
let grade: string;
|
|
115
|
+
if (pct >= 90) grade = "A";
|
|
116
|
+
else if (pct >= 80) grade = "B";
|
|
117
|
+
else if (pct >= 65) grade = "C";
|
|
118
|
+
else if (pct >= 50) grade = "D";
|
|
119
|
+
else grade = "F";
|
|
120
|
+
|
|
121
|
+
const failed = checks.filter(c => !c.passed);
|
|
122
|
+
const summary = failed.length === 0
|
|
123
|
+
? `Perfect score! All ${checks.length} checks passed.`
|
|
124
|
+
: `${score}/${maxScore} (${grade}). Missing: ${failed.map(c => c.name.replace(/_/g, " ")).join(", ")}.`;
|
|
125
|
+
|
|
126
|
+
return { score, maxScore, grade, checks, summary };
|
|
127
|
+
}
|