horizon-code 0.1.1 → 0.2.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/bin/horizon.js +18 -1
- package/package.json +2 -2
- package/src/app.ts +85 -11
- package/src/components/code-panel.ts +141 -14
- package/src/platform/session-sync.ts +1 -1
- package/src/state/types.ts +1 -0
- package/src/strategy/code-stream.ts +3 -1
- package/src/strategy/dashboard.ts +189 -18
- package/src/strategy/prompts.ts +426 -6
- package/src/strategy/tools.ts +311 -54
- package/src/strategy/validator.ts +98 -0
- package/src/updater.ts +118 -0
package/src/strategy/tools.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
import { tool } from "ai";
|
|
5
5
|
import { z } from "zod";
|
|
6
|
-
import { validateStrategyCode, autoFixStrategyCode } from "./validator.ts";
|
|
7
|
-
import { gammaEvents } from "../research/apis.ts";
|
|
6
|
+
import { validateStrategyCode, autoFixStrategyCode, getStrategyWarnings } from "./validator.ts";
|
|
7
|
+
import { gammaEvents, clobPriceHistory } from "../research/apis.ts";
|
|
8
8
|
import { platform } from "../platform/client.ts";
|
|
9
9
|
import { store } from "../state/store.ts";
|
|
10
10
|
import { dashboard } from "./dashboard.ts";
|
|
@@ -16,10 +16,11 @@ import type { StrategyDraft } from "../state/types.ts";
|
|
|
16
16
|
const t = tool as any;
|
|
17
17
|
|
|
18
18
|
const docsCache = new Map<string, string>();
|
|
19
|
+
let _lastFetchedData: { slug: string; path: string; points: number } | null = null;
|
|
19
20
|
|
|
20
21
|
// ── Process management ──
|
|
21
22
|
|
|
22
|
-
interface ManagedProcess {
|
|
23
|
+
export interface ManagedProcess {
|
|
23
24
|
proc: ReturnType<typeof Bun.spawn>;
|
|
24
25
|
stdout: string[];
|
|
25
26
|
stderr: string[];
|
|
@@ -120,6 +121,124 @@ function commandEnv(): Record<string, string> {
|
|
|
120
121
|
return env;
|
|
121
122
|
}
|
|
122
123
|
|
|
124
|
+
// ── Metrics reporter injection for local execution ──
|
|
125
|
+
// Emits structured JSON to stderr every 5 seconds with a __HZ_METRICS__ prefix.
|
|
126
|
+
// Returns quotes unchanged so it doesn't affect the strategy pipeline.
|
|
127
|
+
|
|
128
|
+
const METRICS_REPORTER = `
|
|
129
|
+
import json as _json, sys as _sys, time as _time
|
|
130
|
+
_hz_last_report = [0.0]
|
|
131
|
+
_hz_pnl_history = []
|
|
132
|
+
def _hz_report(ctx, quotes):
|
|
133
|
+
_now = _time.time()
|
|
134
|
+
if _now - _hz_last_report[0] >= 5.0:
|
|
135
|
+
_hz_last_report[0] = _now
|
|
136
|
+
_s = ctx.status
|
|
137
|
+
_inv = ctx.inventory
|
|
138
|
+
if _s:
|
|
139
|
+
_m = {"__hz__": 1, "pnl": _s.total_pnl(), "rpnl": _s.total_realized_pnl, "upnl": _s.total_unrealized_pnl, "orders": _s.open_orders, "positions": _s.active_positions, "uptime": _s.uptime_secs, "kill": _s.kill_switch_active}
|
|
140
|
+
if _inv:
|
|
141
|
+
_m["pos"] = [{"id": _p.market_id, "side": str(_p.side), "sz": _p.size, "entry": _p.avg_entry_price, "rpnl": _p.realized_pnl, "upnl": _p.unrealized_pnl} for _p in _inv.positions]
|
|
142
|
+
_hz_pnl_history.append(_s.total_pnl())
|
|
143
|
+
if len(_hz_pnl_history) > 60:
|
|
144
|
+
_hz_pnl_history.pop(0)
|
|
145
|
+
_m["hist"] = list(_hz_pnl_history)
|
|
146
|
+
print("__HZ_METRICS__" + _json.dumps(_m), file=_sys.stderr, flush=True)
|
|
147
|
+
return quotes if quotes is not None else []
|
|
148
|
+
`;
|
|
149
|
+
|
|
150
|
+
/** Inject the metrics reporter into strategy code for local execution */
|
|
151
|
+
function injectMetricsReporter(code: string): string {
|
|
152
|
+
// Find pipeline=[...] and append _hz_report
|
|
153
|
+
const pipelineMatch = code.match(/pipeline\s*=\s*\[([^\]]*)\]/);
|
|
154
|
+
if (!pipelineMatch) return METRICS_REPORTER + "\n" + code; // Can't find pipeline, just prepend
|
|
155
|
+
|
|
156
|
+
const pipelineContent = pipelineMatch[1]!.trim();
|
|
157
|
+
// Remove trailing comma if present
|
|
158
|
+
const cleaned = pipelineContent.replace(/,\s*$/, "");
|
|
159
|
+
const newPipeline = `pipeline=[${cleaned}, _hz_report]`;
|
|
160
|
+
|
|
161
|
+
// Replace the pipeline in the code
|
|
162
|
+
let modified = code.replace(pipelineMatch[0], newPipeline);
|
|
163
|
+
|
|
164
|
+
// Prepend the reporter function after existing imports
|
|
165
|
+
const lines = modified.split("\n");
|
|
166
|
+
let lastImportIdx = -1;
|
|
167
|
+
for (let i = 0; i < lines.length; i++) {
|
|
168
|
+
const line = lines[i]!.trim();
|
|
169
|
+
if (line.startsWith("import ") || line.startsWith("from ")) {
|
|
170
|
+
lastImportIdx = i;
|
|
171
|
+
}
|
|
172
|
+
// Stop at first non-import, non-empty, non-comment line after we've seen imports
|
|
173
|
+
if (line && !line.startsWith("import ") && !line.startsWith("from ") && !line.startsWith("#") && lastImportIdx >= 0) {
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Insert reporter after imports
|
|
179
|
+
const insertIdx = lastImportIdx >= 0 ? lastImportIdx + 1 : 0;
|
|
180
|
+
lines.splice(insertIdx, 0, METRICS_REPORTER);
|
|
181
|
+
|
|
182
|
+
return lines.join("\n");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Parse the latest metrics from a process's stderr buffer */
|
|
186
|
+
export function parseLocalMetrics(managed: ManagedProcess): {
|
|
187
|
+
pnl: number; rpnl: number; upnl: number;
|
|
188
|
+
orders: number; positions: number; uptime: number; kill: boolean;
|
|
189
|
+
pos: Array<{ id: string; side: string; sz: number; entry: number; rpnl: number; upnl: number }>;
|
|
190
|
+
hist: number[];
|
|
191
|
+
} | null {
|
|
192
|
+
// Scan stderr from the end for the latest __HZ_METRICS__ line
|
|
193
|
+
for (let i = managed.stderr.length - 1; i >= 0; i--) {
|
|
194
|
+
const line = managed.stderr[i]!;
|
|
195
|
+
if (line.startsWith("__HZ_METRICS__")) {
|
|
196
|
+
try {
|
|
197
|
+
return JSON.parse(line.slice(14));
|
|
198
|
+
} catch {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── HTML → text extraction ──
|
|
207
|
+
|
|
208
|
+
/** Extract readable text from HTML, stripping tags but keeping structure */
|
|
209
|
+
function extractTextFromHtml(html: string, maxLen: number): string {
|
|
210
|
+
// Remove script/style blocks
|
|
211
|
+
let text = html.replace(/<script[\s\S]*?<\/script>/gi, "");
|
|
212
|
+
text = text.replace(/<style[\s\S]*?<\/style>/gi, "");
|
|
213
|
+
text = text.replace(/<nav[\s\S]*?<\/nav>/gi, "");
|
|
214
|
+
text = text.replace(/<header[\s\S]*?<\/header>/gi, "");
|
|
215
|
+
text = text.replace(/<footer[\s\S]*?<\/footer>/gi, "");
|
|
216
|
+
|
|
217
|
+
// Convert meaningful tags to text markers
|
|
218
|
+
text = text.replace(/<h[1-6][^>]*>/gi, "\n## ");
|
|
219
|
+
text = text.replace(/<\/h[1-6]>/gi, "\n");
|
|
220
|
+
text = text.replace(/<li[^>]*>/gi, "\n- ");
|
|
221
|
+
text = text.replace(/<br\s*\/?>/gi, "\n");
|
|
222
|
+
text = text.replace(/<p[^>]*>/gi, "\n");
|
|
223
|
+
text = text.replace(/<\/p>/gi, "\n");
|
|
224
|
+
text = text.replace(/<pre[^>]*>/gi, "\n```\n");
|
|
225
|
+
text = text.replace(/<\/pre>/gi, "\n```\n");
|
|
226
|
+
text = text.replace(/<code[^>]*>/gi, "`");
|
|
227
|
+
text = text.replace(/<\/code>/gi, "`");
|
|
228
|
+
|
|
229
|
+
// Strip all remaining HTML tags
|
|
230
|
+
text = text.replace(/<[^>]+>/g, "");
|
|
231
|
+
|
|
232
|
+
// Decode common HTML entities
|
|
233
|
+
text = text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
234
|
+
.replace(/"/g, '"').replace(/'/g, "'").replace(/ /g, " ");
|
|
235
|
+
|
|
236
|
+
// Clean up whitespace
|
|
237
|
+
text = text.replace(/\n{3,}/g, "\n\n").trim();
|
|
238
|
+
|
|
239
|
+
return text.slice(0, maxLen);
|
|
240
|
+
}
|
|
241
|
+
|
|
123
242
|
// ── Tools ──
|
|
124
243
|
// Code generation uses text streaming (```python fences) detected by app.ts.
|
|
125
244
|
// These tools handle editing, validation, execution, and deployment.
|
|
@@ -144,11 +263,13 @@ export const strategyTools: Record<string, any> = {
|
|
|
144
263
|
const newCode = draft.code.replace(args.find, args.replace);
|
|
145
264
|
const fixedCode = autoFixStrategyCode(newCode);
|
|
146
265
|
const errors = validateStrategyCode(fixedCode);
|
|
266
|
+
const warnings = getStrategyWarnings(fixedCode);
|
|
147
267
|
|
|
148
268
|
store.updateStrategyDraft({
|
|
149
269
|
code: fixedCode,
|
|
150
270
|
validationStatus: errors.length === 0 ? "valid" : "invalid",
|
|
151
271
|
validationErrors: errors,
|
|
272
|
+
validationWarnings: warnings,
|
|
152
273
|
phase: "iterated",
|
|
153
274
|
});
|
|
154
275
|
|
|
@@ -175,32 +296,98 @@ export const strategyTools: Record<string, any> = {
|
|
|
175
296
|
if (!code) return { valid: false, errors: [{ line: null, message: "No strategy code to validate" }] };
|
|
176
297
|
|
|
177
298
|
const errors = validateStrategyCode(code);
|
|
299
|
+
const warnings = getStrategyWarnings(code);
|
|
178
300
|
|
|
179
301
|
if (!args.code && store.getActiveSession()?.strategyDraft) {
|
|
180
302
|
store.updateStrategyDraft({
|
|
181
303
|
validationStatus: errors.length === 0 ? "valid" : "invalid",
|
|
182
304
|
validationErrors: errors,
|
|
305
|
+
validationWarnings: warnings,
|
|
183
306
|
});
|
|
184
307
|
}
|
|
185
|
-
return { valid: errors.length === 0, errors };
|
|
308
|
+
return { valid: errors.length === 0, errors, warnings };
|
|
186
309
|
},
|
|
187
310
|
}),
|
|
188
311
|
|
|
189
312
|
lookup_sdk_docs: t({
|
|
190
|
-
description: "Look up
|
|
313
|
+
description: "Look up Horizon SDK documentation for a specific topic. Returns focused, relevant docs. Available topics: types, feeds, context, pipeline, exchanges, risk, backtesting, plotting, ascii-plotting, signals, arbitrage, kelly, volatility, execution-algos, portfolio, sentinel, oracle, copy-trading, wallet-intelligence, stealth, whale-galaxy, market-discovery, swarm, fund-manager",
|
|
191
314
|
parameters: z.object({
|
|
192
|
-
topic: z.string().describe("SDK topic
|
|
315
|
+
topic: z.string().describe("SDK topic to look up"),
|
|
193
316
|
}),
|
|
194
317
|
execute: async (args: any) => {
|
|
195
|
-
const topic = args.topic.toLowerCase().replace(/\s+/g, "-");
|
|
318
|
+
const topic = args.topic.toLowerCase().replace(/\s+/g, "-").replace(/_/g, "-");
|
|
319
|
+
|
|
320
|
+
// Check memory cache first (30 min TTL)
|
|
196
321
|
const cached = docsCache.get(topic);
|
|
197
|
-
if (cached) return { topic: args.topic, content: cached };
|
|
322
|
+
if (cached) return { topic: args.topic, content: cached, cached: true };
|
|
323
|
+
|
|
324
|
+
// Topic → URL mapping (specific pages for focused results)
|
|
325
|
+
const TOPIC_URLS: Record<string, string> = {
|
|
326
|
+
"types": "/core/types",
|
|
327
|
+
"feeds": "/core/feeds",
|
|
328
|
+
"context": "/core/context",
|
|
329
|
+
"pipeline": "/core/pipeline",
|
|
330
|
+
"exchanges": "/core/exchanges",
|
|
331
|
+
"risk": "/core/risk",
|
|
332
|
+
"backtesting": "/core/backtesting",
|
|
333
|
+
"backtest": "/core/backtesting",
|
|
334
|
+
"plotting": "/core/plotting",
|
|
335
|
+
"ascii-plotting": "/core/ascii-plotting",
|
|
336
|
+
"ascii": "/core/ascii-plotting",
|
|
337
|
+
"signals": "/core/signals",
|
|
338
|
+
"market-making": "/core/market-making",
|
|
339
|
+
"mm": "/core/market-making",
|
|
340
|
+
"arbitrage": "/advanced/arbitrage",
|
|
341
|
+
"arb": "/advanced/arbitrage",
|
|
342
|
+
"kelly": "/core/kelly",
|
|
343
|
+
"volatility": "/core/volatility",
|
|
344
|
+
"vol": "/core/volatility",
|
|
345
|
+
"execution-algos": "/advanced/execution-algos",
|
|
346
|
+
"execution": "/advanced/execution-algos",
|
|
347
|
+
"twap": "/advanced/execution-algos",
|
|
348
|
+
"vwap": "/advanced/execution-algos",
|
|
349
|
+
"iceberg": "/advanced/execution-algos",
|
|
350
|
+
"portfolio": "/advanced/portfolio",
|
|
351
|
+
"sentinel": "/advanced/sentinel",
|
|
352
|
+
"oracle": "/advanced/oracle",
|
|
353
|
+
"copy-trading": "/advanced/copy-trading",
|
|
354
|
+
"wallet-intelligence": "/advanced/wallet-intelligence",
|
|
355
|
+
"stealth": "/advanced/stealth",
|
|
356
|
+
"whale-galaxy": "/advanced/whale-galaxy",
|
|
357
|
+
"market-discovery": "/core/market-discovery",
|
|
358
|
+
"discovery": "/core/market-discovery",
|
|
359
|
+
"swarm": "/examples/quant-swarm",
|
|
360
|
+
"fund-manager": "/examples/quant-swarm",
|
|
361
|
+
"fund": "/examples/quant-swarm",
|
|
362
|
+
};
|
|
198
363
|
|
|
199
|
-
|
|
364
|
+
const baseDomain = "https://mathematicalcompany.mintlify.app";
|
|
365
|
+
|
|
366
|
+
// Try topic-specific URL first
|
|
367
|
+
const urlPath = TOPIC_URLS[topic];
|
|
368
|
+
if (urlPath) {
|
|
369
|
+
try {
|
|
370
|
+
const res = await fetch(`${baseDomain}${urlPath}`, {
|
|
371
|
+
signal: AbortSignal.timeout(8000),
|
|
372
|
+
headers: { "Accept": "text/html" },
|
|
373
|
+
});
|
|
374
|
+
if (res.ok) {
|
|
375
|
+
const html = await res.text();
|
|
376
|
+
// Extract main content — strip HTML tags, keep text
|
|
377
|
+
const content = extractTextFromHtml(html, 6000);
|
|
378
|
+
if (content.length > 100) {
|
|
379
|
+
docsCache.set(topic, content);
|
|
380
|
+
return { topic: args.topic, content };
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
} catch {}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Fallback: fetch llms-full.txt and extract section (existing approach)
|
|
200
387
|
try {
|
|
201
388
|
if (!docsCache.has("__full__")) {
|
|
202
|
-
const res = await fetch(
|
|
203
|
-
signal: AbortSignal.timeout(
|
|
389
|
+
const res = await fetch(`${baseDomain}/llms-full.txt`, {
|
|
390
|
+
signal: AbortSignal.timeout(10000),
|
|
204
391
|
});
|
|
205
392
|
if (res.ok) {
|
|
206
393
|
docsCache.set("__full__", await res.text());
|
|
@@ -209,53 +396,55 @@ export const strategyTools: Record<string, any> = {
|
|
|
209
396
|
|
|
210
397
|
const fullDocs = docsCache.get("__full__");
|
|
211
398
|
if (fullDocs) {
|
|
212
|
-
// Find the section matching the topic — look for markdown headers
|
|
213
399
|
const topicVariants = [topic, topic.replace(/-/g, " "), args.topic];
|
|
214
|
-
let bestMatch = "";
|
|
215
400
|
for (const variant of topicVariants) {
|
|
216
|
-
// Find a heading containing the topic
|
|
217
401
|
const headingPattern = new RegExp(`^#+\\s+.*${variant.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}.*$`, "im");
|
|
218
402
|
const headingMatch = fullDocs.match(headingPattern);
|
|
219
403
|
if (headingMatch?.index !== undefined) {
|
|
220
|
-
// Extract from this heading to the next same-level heading (or 4000 chars)
|
|
221
404
|
const level = headingMatch[0].match(/^#+/)![0].length;
|
|
222
405
|
const rest = fullDocs.slice(headingMatch.index);
|
|
223
406
|
const nextHeading = rest.slice(1).search(new RegExp(`^#{1,${level}}\\s`, "m"));
|
|
224
407
|
const section = nextHeading !== -1 ? rest.slice(0, nextHeading + 1) : rest;
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
if (bestMatch) {
|
|
231
|
-
docsCache.set(topic, bestMatch);
|
|
232
|
-
return { topic: args.topic, content: bestMatch };
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// Fallback: keyword search in the full doc
|
|
236
|
-
const lines = fullDocs.split("\n");
|
|
237
|
-
const relevant: string[] = [];
|
|
238
|
-
const keywords = topic.split("-").filter((w: string) => w.length > 2);
|
|
239
|
-
for (let i = 0; i < lines.length && relevant.length < 80; i++) {
|
|
240
|
-
const line = lines[i]!.toLowerCase();
|
|
241
|
-
if (keywords.some((kw: string) => line.includes(kw))) {
|
|
242
|
-
// Include surrounding context
|
|
243
|
-
const start = Math.max(0, i - 2);
|
|
244
|
-
const end = Math.min(lines.length, i + 5);
|
|
245
|
-
for (let j = start; j < end; j++) {
|
|
246
|
-
if (!relevant.includes(lines[j]!)) relevant.push(lines[j]!);
|
|
247
|
-
}
|
|
408
|
+
const content = section.slice(0, 6000);
|
|
409
|
+
docsCache.set(topic, content);
|
|
410
|
+
return { topic: args.topic, content };
|
|
248
411
|
}
|
|
249
412
|
}
|
|
250
|
-
if (relevant.length > 0) {
|
|
251
|
-
const content = relevant.join("\n").slice(0, 4000);
|
|
252
|
-
docsCache.set(topic, content);
|
|
253
|
-
return { topic: args.topic, content };
|
|
254
|
-
}
|
|
255
413
|
}
|
|
256
414
|
} catch {}
|
|
257
415
|
|
|
258
|
-
return { topic: args.topic, content: `
|
|
416
|
+
return { topic: args.topic, content: `No docs found for "${args.topic}". Available topics: types, feeds, context, pipeline, exchanges, risk, backtesting, plotting, ascii-plotting, signals, arbitrage, kelly, volatility, execution-algos, portfolio, sentinel, oracle, copy-trading, wallet-intelligence, stealth, whale-galaxy, market-discovery, swarm` };
|
|
417
|
+
},
|
|
418
|
+
}),
|
|
419
|
+
|
|
420
|
+
list_sdk_topics: t({
|
|
421
|
+
description: "List all available Horizon SDK documentation topics. Call this if unsure what docs are available.",
|
|
422
|
+
parameters: z.object({}),
|
|
423
|
+
execute: async () => {
|
|
424
|
+
return {
|
|
425
|
+
topics: [
|
|
426
|
+
{ name: "types", description: "Core types: Quote, Market, Position, Order, Context, EngineStatus, Risk" },
|
|
427
|
+
{ name: "feeds", description: "11 feed types: PolymarketBook, KalshiBook, BinanceWS, RESTFeed, etc." },
|
|
428
|
+
{ name: "pipeline", description: "Pipeline composition, function chaining, signature introspection" },
|
|
429
|
+
{ name: "exchanges", description: "8 supported exchanges: Polymarket, Kalshi, Alpaca, etc." },
|
|
430
|
+
{ name: "risk", description: "8-check risk pipeline, RiskConfig, kill switch, rate limits" },
|
|
431
|
+
{ name: "backtesting", description: "hz.backtest(), fill models, walk-forward optimization" },
|
|
432
|
+
{ name: "signals", description: "Signal system, combiners, alpha extractors" },
|
|
433
|
+
{ name: "market-making", description: "Avellaneda-Stoikov, reservation pricing, optimal spreads" },
|
|
434
|
+
{ name: "kelly", description: "Kelly criterion, fractional Kelly, liquidity-adjusted sizing" },
|
|
435
|
+
{ name: "volatility", description: "6 estimators: Yang-Zhang, Parkinson, EWMA, etc." },
|
|
436
|
+
{ name: "arbitrage", description: "Parity arb, event arb, stat arb, composite scanner" },
|
|
437
|
+
{ name: "execution-algos", description: "TWAP, VWAP, Iceberg execution algorithms" },
|
|
438
|
+
{ name: "portfolio", description: "Portfolio optimization, risk parity, Monte Carlo VaR" },
|
|
439
|
+
{ name: "sentinel", description: "Continuous risk monitoring, drawdown response, hedging" },
|
|
440
|
+
{ name: "oracle", description: "AI-driven market forecasting, edge scanning" },
|
|
441
|
+
{ name: "market-discovery", description: "discover_markets(), top_markets(), discover_events()" },
|
|
442
|
+
{ name: "swarm", description: "FundManager, autonomous fund operations, agent swarm" },
|
|
443
|
+
{ name: "ascii-plotting", description: "Terminal-native charts: line, bar, heatmap, scatter" },
|
|
444
|
+
{ name: "plotting", description: "Chart data extraction from backtest results" },
|
|
445
|
+
],
|
|
446
|
+
hint: "Call lookup_sdk_docs(topic) with any of these topic names for full documentation.",
|
|
447
|
+
};
|
|
259
448
|
},
|
|
260
449
|
}),
|
|
261
450
|
|
|
@@ -281,6 +470,55 @@ export const strategyTools: Record<string, any> = {
|
|
|
281
470
|
},
|
|
282
471
|
}),
|
|
283
472
|
|
|
473
|
+
fetch_market_data: t({
|
|
474
|
+
description: "Fetch real Polymarket price history for backtesting. Returns timestamped price data ready for hz.backtest(data=...). Use this before backtest_strategy to test with real market data instead of synthetic.",
|
|
475
|
+
parameters: z.object({
|
|
476
|
+
slug: z.string().describe("Polymarket event slug"),
|
|
477
|
+
interval: z.enum(["1d", "1w", "1m", "max"]).optional().describe("Time range (default 1m)"),
|
|
478
|
+
fidelity: z.number().optional().describe("Resolution in minutes: 1, 5, 15, 60 (default 5)"),
|
|
479
|
+
}),
|
|
480
|
+
execute: async (args: any) => {
|
|
481
|
+
try {
|
|
482
|
+
const data = await clobPriceHistory(args.slug, args.interval ?? "1m", args.fidelity ?? 5);
|
|
483
|
+
const points = data.priceHistory ?? [];
|
|
484
|
+
|
|
485
|
+
if (points.length < 10) {
|
|
486
|
+
return { error: `Only ${points.length} data points found. Try a longer interval or different market.` };
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Format for hz.backtest(data=...)
|
|
490
|
+
const backtestData = points.map((p: any) => ({
|
|
491
|
+
timestamp: typeof p.t === "number" ? p.t : new Date(p.t).getTime() / 1000,
|
|
492
|
+
price: typeof p.p === "number" ? p.p : parseFloat(p.p),
|
|
493
|
+
}));
|
|
494
|
+
|
|
495
|
+
// Store in a temp location so backtest_strategy can use it
|
|
496
|
+
const { mkdtemp } = await import("fs/promises");
|
|
497
|
+
const { join } = await import("path");
|
|
498
|
+
const { tmpdir } = await import("os");
|
|
499
|
+
const dir = await mkdtemp(join(tmpdir(), "horizon-data-"));
|
|
500
|
+
const dataPath = join(dir, "market_data.json");
|
|
501
|
+
await Bun.write(dataPath, JSON.stringify(backtestData));
|
|
502
|
+
|
|
503
|
+
// Store reference for backtest_strategy to pick up
|
|
504
|
+
_lastFetchedData = { slug: args.slug, path: dataPath, points: backtestData.length };
|
|
505
|
+
|
|
506
|
+
return {
|
|
507
|
+
slug: args.slug,
|
|
508
|
+
title: data.title,
|
|
509
|
+
data_points: backtestData.length,
|
|
510
|
+
time_range: args.interval ?? "1m",
|
|
511
|
+
price_range: { low: data.low, high: data.high, current: data.current },
|
|
512
|
+
change: data.change,
|
|
513
|
+
data_path: dataPath,
|
|
514
|
+
hint: "Now call backtest_strategy(use_real_data=true) to backtest with this data.",
|
|
515
|
+
};
|
|
516
|
+
} catch (err) {
|
|
517
|
+
return { error: `Failed to fetch data: ${err instanceof Error ? err.message : String(err)}` };
|
|
518
|
+
}
|
|
519
|
+
},
|
|
520
|
+
}),
|
|
521
|
+
|
|
284
522
|
// ── Backtest (real hz.backtest()) ──
|
|
285
523
|
|
|
286
524
|
backtest_strategy: t({
|
|
@@ -290,6 +528,7 @@ export const strategyTools: Record<string, any> = {
|
|
|
290
528
|
initial_capital: z.number().optional().describe("Starting capital USD (default 1000)"),
|
|
291
529
|
base_price: z.number().optional().describe("Base price (default 0.50)"),
|
|
292
530
|
fill_model: z.enum(["deterministic", "probabilistic", "glft"]).optional().describe("Fill model (default deterministic)"),
|
|
531
|
+
use_real_data: z.boolean().optional().describe("Use real market data from fetch_market_data (default false — uses synthetic)"),
|
|
293
532
|
}),
|
|
294
533
|
execute: async (args: any) => {
|
|
295
534
|
const draft = store.getActiveSession()?.strategyDraft;
|
|
@@ -325,6 +564,28 @@ export const strategyTools: Record<string, any> = {
|
|
|
325
564
|
pipeline_fns: pipelineFns,
|
|
326
565
|
};
|
|
327
566
|
|
|
567
|
+
// Determine data source
|
|
568
|
+
let dataLoadCode: string;
|
|
569
|
+
if (args.use_real_data && _lastFetchedData) {
|
|
570
|
+
// Use real market data
|
|
571
|
+
dataLoadCode = `
|
|
572
|
+
with open("${_lastFetchedData.path.replace(/\\/g, "/")}") as f:
|
|
573
|
+
data = json.load(f)
|
|
574
|
+
print(f"Using real data: {len(data)} points from ${_lastFetchedData.slug}", file=sys.stderr)
|
|
575
|
+
`;
|
|
576
|
+
} else {
|
|
577
|
+
// Synthetic random-walk data
|
|
578
|
+
dataLoadCode = `
|
|
579
|
+
data = []
|
|
580
|
+
price = _cfg["base_price"]
|
|
581
|
+
for i in range(_cfg["data_points"]):
|
|
582
|
+
noise = (((i * 7 + 13) % 100) / 100 - 0.5) * 0.05
|
|
583
|
+
revert = (_cfg["base_price"] - price) * 0.05
|
|
584
|
+
price = max(0.01, min(0.99, price + noise + revert))
|
|
585
|
+
data.append({"timestamp": float(i), "price": round(price, 4)})
|
|
586
|
+
`;
|
|
587
|
+
}
|
|
588
|
+
|
|
328
589
|
const script = `
|
|
329
590
|
import horizon as hz
|
|
330
591
|
from horizon.context import FeedData
|
|
@@ -335,14 +596,7 @@ ${pipelineCode}
|
|
|
335
596
|
# Load config from JSON file (no string interpolation)
|
|
336
597
|
with open("backtest_config.json") as f:
|
|
337
598
|
_cfg = json.load(f)
|
|
338
|
-
|
|
339
|
-
data = []
|
|
340
|
-
price = _cfg["base_price"]
|
|
341
|
-
for i in range(_cfg["data_points"]):
|
|
342
|
-
noise = (((i * 7 + 13) % 100) / 100 - 0.5) * 0.05
|
|
343
|
-
revert = (_cfg["base_price"] - price) * 0.05
|
|
344
|
-
price = max(0.01, min(0.99, price + noise + revert))
|
|
345
|
-
data.append({"timestamp": float(i), "price": round(price, 4)})
|
|
599
|
+
${dataLoadCode}
|
|
346
600
|
|
|
347
601
|
try:
|
|
348
602
|
result = hz.backtest(
|
|
@@ -435,7 +689,7 @@ except Exception as e:
|
|
|
435
689
|
}
|
|
436
690
|
|
|
437
691
|
const result = JSON.parse(jsonMatch[1]!.trim());
|
|
438
|
-
return { ...result, ascii_dashboard: dashMatch ? dashMatch[1]!.trim() : null,
|
|
692
|
+
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)` };
|
|
439
693
|
} catch (err) {
|
|
440
694
|
return { error: `Backtest execution failed: ${err instanceof Error ? err.message : String(err)}`, hint: "Ensure Horizon SDK is installed: pip install horizon" };
|
|
441
695
|
}
|
|
@@ -463,7 +717,8 @@ except Exception as e:
|
|
|
463
717
|
|
|
464
718
|
const timeout = args.timeout_secs ?? 3600;
|
|
465
719
|
try {
|
|
466
|
-
const
|
|
720
|
+
const instrumentedCode = injectMetricsReporter(draft.code);
|
|
721
|
+
const { proc, cleanup } = spawnInSandbox(instrumentedCode);
|
|
467
722
|
|
|
468
723
|
const pid = proc.pid;
|
|
469
724
|
const managed: ManagedProcess = { proc, stdout: [], stderr: [], startedAt: Date.now(), cleanup };
|
|
@@ -637,6 +892,7 @@ except Exception as e:
|
|
|
637
892
|
|
|
638
893
|
const fixedCode = autoFixStrategyCode(result.code);
|
|
639
894
|
const errors = validateStrategyCode(fixedCode);
|
|
895
|
+
const warnings = getStrategyWarnings(fixedCode);
|
|
640
896
|
|
|
641
897
|
const draft: StrategyDraft = {
|
|
642
898
|
name: args.name,
|
|
@@ -646,6 +902,7 @@ except Exception as e:
|
|
|
646
902
|
riskConfig: null,
|
|
647
903
|
validationStatus: errors.length === 0 ? "valid" : "invalid",
|
|
648
904
|
validationErrors: errors,
|
|
905
|
+
validationWarnings: warnings,
|
|
649
906
|
phase: "generated",
|
|
650
907
|
};
|
|
651
908
|
store.setStrategyDraft(draft);
|
|
@@ -214,3 +214,101 @@ export function validateStrategyCode(code: string): ValidationError[] {
|
|
|
214
214
|
|
|
215
215
|
return errors;
|
|
216
216
|
}
|
|
217
|
+
|
|
218
|
+
// ── Semantic warnings (non-blocking) ──
|
|
219
|
+
|
|
220
|
+
export interface ValidationWarning {
|
|
221
|
+
line: number | null;
|
|
222
|
+
message: string;
|
|
223
|
+
severity: "warning" | "info";
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function getStrategyWarnings(code: string): ValidationWarning[] {
|
|
227
|
+
const warnings: ValidationWarning[] = [];
|
|
228
|
+
const lines = code.split("\n");
|
|
229
|
+
|
|
230
|
+
// 1. Risk config completeness
|
|
231
|
+
const riskMatch = code.match(/hz\.Risk\(([\s\S]*?)\)/);
|
|
232
|
+
if (!riskMatch) {
|
|
233
|
+
warnings.push({ line: null, message: "No hz.Risk() found — strategy has no risk limits", severity: "warning" });
|
|
234
|
+
} else {
|
|
235
|
+
const riskBody = riskMatch[1] ?? "";
|
|
236
|
+
if (!riskBody.includes("max_drawdown_pct")) {
|
|
237
|
+
warnings.push({ line: null, message: "hz.Risk() missing max_drawdown_pct — no kill switch protection", severity: "warning" });
|
|
238
|
+
}
|
|
239
|
+
if (!riskBody.includes("max_notional")) {
|
|
240
|
+
warnings.push({ line: null, message: "hz.Risk() missing max_notional — no portfolio exposure limit", severity: "warning" });
|
|
241
|
+
}
|
|
242
|
+
if (!riskBody.includes("max_order_size")) {
|
|
243
|
+
warnings.push({ line: null, message: "hz.Risk() missing max_order_size — no single order cap", severity: "info" });
|
|
244
|
+
}
|
|
245
|
+
// Check for dangerously high values
|
|
246
|
+
const notionalMatch = riskBody.match(/max_notional\s*=\s*(\d+)/);
|
|
247
|
+
if (notionalMatch && parseInt(notionalMatch[1]!) > 10000) {
|
|
248
|
+
warnings.push({ line: null, message: `max_notional=$${notionalMatch[1]} is very high — ensure this is intentional`, severity: "warning" });
|
|
249
|
+
}
|
|
250
|
+
const ddMatch = riskBody.match(/max_drawdown_pct\s*=\s*(\d+)/);
|
|
251
|
+
if (ddMatch && parseInt(ddMatch[1]!) > 15) {
|
|
252
|
+
warnings.push({ line: null, message: `max_drawdown_pct=${ddMatch[1]}% is aggressive — typical range is 3-10%`, severity: "warning" });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// 2. Mode check
|
|
257
|
+
const modeMatch = code.match(/mode\s*=\s*["'](\w+)["']/);
|
|
258
|
+
if (modeMatch && modeMatch[1] === "live") {
|
|
259
|
+
const lineNum = lines.findIndex(l => /mode\s*=\s*["']live["']/.test(l));
|
|
260
|
+
warnings.push({ line: lineNum >= 0 ? lineNum + 1 : null, message: "mode=\"live\" — this will trade with REAL money. Use mode=\"paper\" for testing.", severity: "warning" });
|
|
261
|
+
}
|
|
262
|
+
if (!modeMatch) {
|
|
263
|
+
warnings.push({ line: null, message: "No mode= specified — default behavior may vary", severity: "info" });
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// 3. Market slug validation
|
|
267
|
+
const marketsMatch = code.match(/markets\s*=\s*\[([^\]]*)\]/);
|
|
268
|
+
if (marketsMatch) {
|
|
269
|
+
const slugs = [...marketsMatch[1]!.matchAll(/["']([^"']+)["']/g)].map(m => m[1]!);
|
|
270
|
+
for (const slug of slugs) {
|
|
271
|
+
if (slug === "market-slug" || slug === "your-market-slug" || slug === "test-market") {
|
|
272
|
+
const lineNum = lines.findIndex(l => l.includes(slug));
|
|
273
|
+
warnings.push({ line: lineNum >= 0 ? lineNum + 1 : null, message: `"${slug}" is a placeholder — replace with a real Polymarket slug`, severity: "warning" });
|
|
274
|
+
}
|
|
275
|
+
// Suspicious slugs (too short, no hyphens)
|
|
276
|
+
if (slug.length < 5 && !slug.includes("-")) {
|
|
277
|
+
const lineNum = lines.findIndex(l => l.includes(slug));
|
|
278
|
+
warnings.push({ line: lineNum >= 0 ? lineNum + 1 : null, message: `"${slug}" doesn't look like a valid market slug`, severity: "info" });
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// 4. Feed-pipeline alignment
|
|
284
|
+
const feedsMatch = code.match(/feeds\s*=\s*\{([^}]*)\}/);
|
|
285
|
+
if (feedsMatch) {
|
|
286
|
+
const feedNames = [...feedsMatch[1]!.matchAll(/["'](\w+)["']/g)].map(m => m[1]!);
|
|
287
|
+
for (const name of feedNames) {
|
|
288
|
+
// Check if any pipeline function references this feed
|
|
289
|
+
const feedUsed = code.includes(`ctx.feeds.get("${name}")`) || code.includes(`ctx.feeds.get('${name}')`);
|
|
290
|
+
if (!feedUsed) {
|
|
291
|
+
warnings.push({ line: null, message: `Feed "${name}" is declared but never accessed in pipeline functions`, severity: "info" });
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// 5. No pipeline found
|
|
297
|
+
if (!code.match(/pipeline\s*=\s*\[/)) {
|
|
298
|
+
warnings.push({ line: null, message: "No pipeline=[...] found — strategy won't execute any logic", severity: "warning" });
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// 6. Empty pipeline
|
|
302
|
+
const pipelineMatch = code.match(/pipeline\s*=\s*\[([\s\S]*?)\]/);
|
|
303
|
+
if (pipelineMatch && pipelineMatch[1]!.trim() === "") {
|
|
304
|
+
warnings.push({ line: null, message: "Pipeline is empty — add at least a fair_value and quoter function", severity: "warning" });
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// 7. Stale feed guard check
|
|
308
|
+
const pipelineFns = pipelineMatch ? pipelineMatch[1]!.split(",").map(s => s.trim()).filter(s => s && !s.startsWith("#")) : [];
|
|
309
|
+
if (pipelineFns.length > 0 && !code.includes("is_stale")) {
|
|
310
|
+
warnings.push({ line: null, message: "No is_stale() check — strategy may act on stale/disconnected feeds", severity: "info" });
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return warnings;
|
|
314
|
+
}
|