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.
@@ -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(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">")
234
+ .replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&nbsp;/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 advanced Horizon SDK docs (arbitrage, copy-trading, wallet-intelligence, stealth, sentinel, oracle). Do NOT call for basic SDK usage types, feeds, pipeline, risk, backtesting are in the system prompt.",
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: types, feeds, context, pipeline, hz.run, risk, backtesting, plotting, ascii-plotting, exchanges, arbitrage, signals, etc."),
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
- // Fetch the full markdown docs index (llms-full.txt) and extract the relevant section
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("https://mathematicalcompany.mintlify.app/llms-full.txt", {
203
- signal: AbortSignal.timeout(8000),
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
- bestMatch = section.slice(0, 4000);
226
- break;
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: `SDK docs for "${args.topic}" not found. Use the embedded reference in your system prompt.` };
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, duration: `${dataPoints} ticks` };
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 { proc, cleanup } = spawnInSandbox(draft.code);
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
+ }