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 CHANGED
@@ -1,2 +1,19 @@
1
1
  #!/usr/bin/env bun
2
- import "../src/index.ts";
2
+ import { checkForUpdates } from "../src/updater.ts";
3
+
4
+ // Auto-update before loading the app
5
+ const { updated, from, to } = await checkForUpdates();
6
+ if (updated) {
7
+ // Re-exec so the new version's code loads
8
+ process.stderr.write(`\x1b[2mUpdated ${from} → ${to}. Restarting...\x1b[0m\n`);
9
+ const result = Bun.spawnSync(["bun", "run", import.meta.path], {
10
+ stdin: "inherit",
11
+ stdout: "inherit",
12
+ stderr: "inherit",
13
+ env: { ...process.env, __HORIZON_SKIP_UPDATE: "1" },
14
+ });
15
+ process.exit(result.exitCode ?? 0);
16
+ }
17
+
18
+ // Load the app
19
+ import("../src/index.ts");
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "horizon-code",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "AI-powered trading strategy terminal for Polymarket",
5
5
  "type": "module",
6
6
  "bin": {
7
- "horizon": "./bin/horizon.js"
7
+ "hzcode": "./bin/horizon.js"
8
8
  },
9
9
  "files": [
10
10
  "src",
package/src/app.ts CHANGED
@@ -18,7 +18,7 @@ import { store } from "./state/store.ts";
18
18
  import { createUserMessage } from "./chat/messages.ts";
19
19
  import { chat } from "./ai/client.ts";
20
20
  import { dashboard } from "./strategy/dashboard.ts";
21
- import { cleanupStrategyProcesses, runningProcesses } from "./strategy/tools.ts";
21
+ import { cleanupStrategyProcesses, runningProcesses, parseLocalMetrics } from "./strategy/tools.ts";
22
22
  import { abortChat } from "./ai/client.ts";
23
23
  import type { ModelPower } from "./platform/tiers.ts";
24
24
  import { listSavedStrategies, loadStrategy } from "./strategy/persistence.ts";
@@ -92,6 +92,7 @@ export class App {
92
92
  private inChatMode = false;
93
93
  private authenticated = false;
94
94
  private _openIdsSaveTimer: ReturnType<typeof setTimeout> | null = null;
95
+ private _hasLocalMetrics = false;
95
96
 
96
97
  // Per-tab stream state
97
98
  private tabStreams: Map<string, {
@@ -403,6 +404,27 @@ export class App {
403
404
  this.codePanel.setStrategy(draft.name, draft.phase);
404
405
  }
405
406
 
407
+ // Feed active deployment metrics into the dashboard tab
408
+ // Skip if local process metrics are active (they take priority)
409
+ if (!this._hasLocalMetrics) {
410
+ const state = store.get();
411
+ const running = state.deployments.find((d) => d.status === "running" || d.status === "starting");
412
+ if (running) {
413
+ this.codePanel.setMetrics({
414
+ name: running.name,
415
+ status: running.status,
416
+ dryRun: running.dry_run,
417
+ metrics: running.metrics,
418
+ positions: running.positions,
419
+ orders: running.orders,
420
+ pnlHistory: running.pnl_history,
421
+ startedAt: running.started_at,
422
+ });
423
+ } else {
424
+ this.codePanel.setMetrics(null);
425
+ }
426
+ }
427
+
406
428
  // Sync code panel visibility to key handler for Tab cycling
407
429
  this.keyHandler.codePanelVisible = this.codePanel.visible;
408
430
 
@@ -412,28 +434,82 @@ export class App {
412
434
 
413
435
  renderer.on("resize", () => renderer.requestRender());
414
436
 
415
- // Poll background processes — update count, live logs, clean dead processes
437
+ // Poll background processes — update count, live logs, parse metrics, clean dead
416
438
  setInterval(() => {
417
439
  let alive = 0;
418
440
  let latestLogs = "";
419
441
  const deadPids: number[] = [];
420
442
  const now = Date.now();
443
+ let localMetricsData: ReturnType<typeof parseLocalMetrics> = null;
444
+ let localProcessStartedAt = 0;
421
445
 
422
446
  for (const [pid, m] of runningProcesses) {
423
447
  if (m.proc.exitCode === null) {
424
448
  alive++;
425
- const recent = m.stdout.slice(-30).join("\n");
426
- const recentErr = m.stderr.slice(-5).join("\n");
427
- latestLogs = recent + (recentErr ? "\n--- stderr ---\n" + recentErr : "");
449
+ // Filter out __HZ_METRICS__ lines from visible logs
450
+ const stdoutLines = m.stdout.slice(-30);
451
+ const stderrLines = m.stderr.slice(-10).filter((l: string) => !l.startsWith("__HZ_METRICS__"));
452
+ latestLogs = stdoutLines.join("\n") + (stderrLines.length > 0 ? "\n--- stderr ---\n" + stderrLines.join("\n") : "");
453
+
454
+ // Parse latest metrics from this process
455
+ const metrics = parseLocalMetrics(m);
456
+ if (metrics) {
457
+ localMetricsData = metrics;
458
+ localProcessStartedAt = m.startedAt;
459
+ }
428
460
  } else if (now - m.startedAt > 300000) {
429
- // Dead for 5+ minutes — clean up
430
461
  deadPids.push(pid);
431
462
  }
432
463
  }
433
464
  for (const pid of deadPids) runningProcesses.delete(pid);
434
465
 
435
466
  this.modeBar.setBgProcessCount(alive);
436
- // Only update logs tab if it's visible (avoid unnecessary markdown re-parse)
467
+
468
+ // Feed local metrics to the Metrics tab (takes priority over platform deployments)
469
+ if (localMetricsData) {
470
+ const draft = store.getActiveSession()?.strategyDraft;
471
+ this.codePanel.setMetrics({
472
+ name: draft?.name ?? "Local Strategy",
473
+ status: "running",
474
+ dryRun: true,
475
+ metrics: {
476
+ total_pnl: localMetricsData.pnl,
477
+ realized_pnl: localMetricsData.rpnl,
478
+ unrealized_pnl: localMetricsData.upnl,
479
+ total_exposure: 0,
480
+ position_count: localMetricsData.positions,
481
+ open_order_count: localMetricsData.orders,
482
+ win_rate: 0,
483
+ total_trades: 0,
484
+ max_drawdown_pct: 0,
485
+ sharpe_ratio: 0,
486
+ profit_factor: 0,
487
+ avg_return_per_trade: 0,
488
+ gross_profit: 0,
489
+ gross_loss: 0,
490
+ },
491
+ positions: (localMetricsData.pos ?? []).map((p) => ({
492
+ market_id: p.id,
493
+ slug: p.id,
494
+ question: "",
495
+ side: p.side === "Yes" || p.side === "Buy" ? "BUY" as const : "SELL" as const,
496
+ size: p.sz,
497
+ avg_entry_price: p.entry,
498
+ cost_basis: p.sz * p.entry,
499
+ realized_pnl: p.rpnl,
500
+ unrealized_pnl: p.upnl,
501
+ })),
502
+ orders: [],
503
+ pnlHistory: localMetricsData.hist ?? [],
504
+ startedAt: localProcessStartedAt,
505
+ });
506
+ this._hasLocalMetrics = true;
507
+ } else if (this._hasLocalMetrics && alive === 0) {
508
+ // Local process stopped — clear local metrics, let platform data take over
509
+ this._hasLocalMetrics = false;
510
+ store.update({});
511
+ }
512
+
437
513
  if (alive > 0 && latestLogs && this.codePanel.visible && this.codePanel.activeTab === "logs") {
438
514
  this.codePanel.setLogs(latestLogs);
439
515
  }
@@ -655,7 +731,7 @@ export class App {
655
731
  const code = autoFixStrategyCode(loaded.code);
656
732
  store.setStrategyDraft({
657
733
  name: arg, code, params: {}, explanation: "", riskConfig: null,
658
- validationStatus: "none", validationErrors: [], phase: "generated",
734
+ validationStatus: "none", validationErrors: [], validationWarnings: [], phase: "generated",
659
735
  });
660
736
  this.codePanel.setCode(code, "pending");
661
737
  this.codePanel.setStrategy(arg, "loaded");
@@ -1226,12 +1302,10 @@ export class App {
1226
1302
  }
1227
1303
  }
1228
1304
 
1229
- // Feed dashboard HTML into code panel dashboard tab
1305
+ // Switch to dashboard/metrics tab when dashboard is spawned
1230
1306
  if (part.toolName === "spawn_dashboard") {
1231
1307
  const result = part.result as any;
1232
1308
  if (result?.success) {
1233
- const html = (part as any).args?.custom_html;
1234
- if (html) this.codePanel.setDashboardHtml(html);
1235
1309
  this.codePanel.setTab("dashboard");
1236
1310
  if (!this.codePanel.visible) this.codePanel.show();
1237
1311
  }
@@ -12,9 +12,32 @@ import {
12
12
  } from "@opentui/core";
13
13
  type TreeSitterClient = any;
14
14
  import { COLORS } from "../theme/colors.ts";
15
+ import { store } from "../state/store.ts";
16
+ import type { DeploymentMetrics, Position, Order } from "../state/types.ts";
15
17
 
16
18
  const h = (hex: string) => RGBA.fromHex(hex);
17
19
 
20
+ function sparkline(values: number[], width: number): string {
21
+ if (values.length < 2) return "";
22
+ const chars = "\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588";
23
+ const min = Math.min(...values);
24
+ const max = Math.max(...values);
25
+ const range = max - min || 1;
26
+ const step = values.length / width;
27
+ let result = "";
28
+ for (let i = 0; i < width && i * step < values.length; i++) {
29
+ const idx = Math.min(Math.floor(i * step), values.length - 1);
30
+ const normalized = (values[idx]! - min) / range;
31
+ const charIdx = Math.min(Math.floor(normalized * (chars.length - 1)), chars.length - 1);
32
+ result += chars[charIdx];
33
+ }
34
+ return result;
35
+ }
36
+
37
+ function pad(s: string, width: number): string {
38
+ return s.length >= width ? s : s + " ".repeat(width - s.length);
39
+ }
40
+
18
41
  // Python syntax colors (matches highlights.scm groups)
19
42
  const syntaxStyle = SyntaxStyle.fromStyles({
20
43
  "@keyword": { fg: h("#C586C0") }, // purple
@@ -59,7 +82,16 @@ export class CodePanel {
59
82
  private _name = "";
60
83
  private _phase = "";
61
84
  private _logs = "";
62
- private _dashboardHtml = "";
85
+ private _metricsData: {
86
+ name: string;
87
+ status: string;
88
+ dryRun: boolean;
89
+ metrics: DeploymentMetrics;
90
+ positions: Position[];
91
+ orders: Order[];
92
+ pnlHistory: number[];
93
+ startedAt: number;
94
+ } | null = null;
63
95
  private _validationStatus: "pending" | "valid" | "invalid" | "none" = "none";
64
96
 
65
97
  constructor(private renderer: CliRenderer) {
@@ -95,7 +127,7 @@ export class CodePanel {
95
127
  const tabs: { id: PanelTab; label: string }[] = [
96
128
  { id: "code", label: "Code" },
97
129
  { id: "logs", label: "Logs" },
98
- { id: "dashboard", label: "Dashboard" },
130
+ { id: "dashboard", label: "Metrics" },
99
131
  ];
100
132
  for (const tab of tabs) {
101
133
  const text = new TextRenderable(renderer, {
@@ -157,7 +189,7 @@ export class CodePanel {
157
189
  this.dashScroll.visible = false;
158
190
  this.dashMd = new MarkdownRenderable(renderer, {
159
191
  id: "dash-md",
160
- content: "*no dashboard generated*\n\n*Ask the LLM to build a dashboard.*",
192
+ content: "*no active deployment*\n\n*Deploy a strategy to see live metrics here.*",
161
193
  syntaxStyle,
162
194
  });
163
195
  this.dashScroll.add(this.dashMd);
@@ -234,8 +266,17 @@ export class CodePanel {
234
266
  if (this._activeTab === "logs") this.updateLogsContent();
235
267
  }
236
268
 
237
- setDashboardHtml(html: string): void {
238
- this._dashboardHtml = html;
269
+ setMetrics(data: {
270
+ name: string;
271
+ status: string;
272
+ dryRun: boolean;
273
+ metrics: DeploymentMetrics;
274
+ positions: Position[];
275
+ orders: Order[];
276
+ pnlHistory: number[];
277
+ startedAt: number;
278
+ } | null): void {
279
+ this._metricsData = data;
239
280
  if (this._activeTab === "dashboard") this.updateDashContent();
240
281
  }
241
282
 
@@ -262,16 +303,17 @@ export class CodePanel {
262
303
 
263
304
  private updateTabBar(): void {
264
305
  for (const [id, text] of this.tabTexts) {
306
+ const label = id === "code" ? (this._name || "Code")
307
+ : id === "dashboard" ? "Metrics"
308
+ : id.charAt(0).toUpperCase() + id.slice(1);
265
309
  if (id === this._activeTab) {
266
310
  text.fg = "#212121";
267
311
  text.bg = COLORS.accent;
268
- text.content = id === "code"
269
- ? ` ${this._name || "Code"} `
270
- : ` ${id.charAt(0).toUpperCase() + id.slice(1)} `;
312
+ text.content = ` ${label} `;
271
313
  } else {
272
314
  text.fg = COLORS.textMuted;
273
315
  text.bg = undefined;
274
- text.content = ` ${id.charAt(0).toUpperCase() + id.slice(1)} `;
316
+ text.content = ` ${label} `;
275
317
  }
276
318
  }
277
319
  }
@@ -304,7 +346,20 @@ export class CodePanel {
304
346
  this.codeMd.content = "*no strategy loaded*\n\n*Describe a strategy in the chat to get started.*";
305
347
  }
306
348
  } else {
307
- this.codeMd.content = "```python\n" + this._code + "\n```";
349
+ let content = "```python\n" + this._code + "\n```";
350
+
351
+ // Show warnings if any
352
+ const draft = store.getActiveSession()?.strategyDraft;
353
+ if (draft?.validationWarnings?.length) {
354
+ content += "\n\n---\n";
355
+ for (const w of draft.validationWarnings) {
356
+ const icon = w.severity === "warning" ? "!" : "i";
357
+ const lineRef = w.line ? ` (line ${w.line})` : "";
358
+ content += `\n${icon} ${w.message}${lineRef}`;
359
+ }
360
+ }
361
+
362
+ this.codeMd.content = content;
308
363
  }
309
364
  this.renderer.requestRender();
310
365
  }
@@ -319,11 +374,83 @@ export class CodePanel {
319
374
  }
320
375
 
321
376
  private updateDashContent(): void {
322
- if (!this._dashboardHtml) {
323
- this.dashMd.content = "*no dashboard generated*\n\n*Ask the LLM to build a dashboard.*";
324
- } else {
325
- this.dashMd.content = "```html\n" + this._dashboardHtml + "\n```";
377
+ if (!this._metricsData) {
378
+ this.dashMd.content = "*no active deployment*\n\n*Deploy a strategy to see live metrics here.*";
379
+ this.renderer.requestRender();
380
+ return;
326
381
  }
382
+
383
+ const d = this._metricsData;
384
+ const m = d.metrics;
385
+ const statusIcon = d.status === "running" ? "\u25CF" : d.status === "error" ? "x" : "\u25CB";
386
+ const modeLabel = d.dryRun ? "paper" : "LIVE";
387
+
388
+ // Uptime
389
+ const uptimeMs = Date.now() - d.startedAt;
390
+ const hours = Math.floor(uptimeMs / 3600000);
391
+ const mins = Math.floor((uptimeMs % 3600000) / 60000);
392
+ const uptime = hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
393
+
394
+ // Sparkline from pnl history
395
+ const spark = sparkline(d.pnlHistory, 30);
396
+
397
+ // Format helpers
398
+ const pnl = (v: number) => (v >= 0 ? `+$${v.toFixed(2)}` : `-$${Math.abs(v).toFixed(2)}`);
399
+ const pct = (v: number) => `${v.toFixed(1)}%`;
400
+
401
+ const lines: string[] = [];
402
+ lines.push(`**${d.name}** ${statusIcon} ${d.status} | ${modeLabel} | ${uptime}`);
403
+ lines.push("---");
404
+ lines.push("");
405
+
406
+ // P&L section
407
+ lines.push("```");
408
+ lines.push(` Total P&L ${pad(pnl(m.total_pnl), 12)} Realized ${pad(pnl(m.realized_pnl), 12)}`);
409
+ lines.push(` Unrealized ${pad(pnl(m.unrealized_pnl), 12)} Exposure ${pad("$" + m.total_exposure.toFixed(2), 12)}`);
410
+ lines.push("");
411
+ lines.push(` Sharpe ${pad(m.sharpe_ratio.toFixed(2), 12)} Win Rate ${pad(pct(m.win_rate * 100), 12)}`);
412
+ lines.push(` Max DD ${pad(pct(m.max_drawdown_pct), 12)} Trades ${pad(String(m.total_trades), 12)}`);
413
+ lines.push(` Profit F ${pad(m.profit_factor.toFixed(2), 12)} Avg Return ${pad(pnl(m.avg_return_per_trade), 12)}`);
414
+ lines.push("```");
415
+ lines.push("");
416
+
417
+ // Equity sparkline
418
+ if (spark) {
419
+ lines.push("**Equity**");
420
+ lines.push("```");
421
+ lines.push(" " + spark);
422
+ lines.push("```");
423
+ lines.push("");
424
+ }
425
+
426
+ // Positions
427
+ if (d.positions.length > 0) {
428
+ lines.push(`**Positions** (${d.positions.length})`);
429
+ lines.push("```");
430
+ for (const p of d.positions.slice(0, 8)) {
431
+ const side = p.side === "BUY" ? "BUY " : "SELL";
432
+ const slug = p.slug.length > 24 ? p.slug.slice(0, 24) + "..." : p.slug;
433
+ lines.push(` ${side} ${pad(slug, 28)} ${pad(String(p.size), 5)} @ ${p.avg_entry_price.toFixed(2)} ${pnl(p.unrealized_pnl)}`);
434
+ }
435
+ if (d.positions.length > 8) lines.push(` ... and ${d.positions.length - 8} more`);
436
+ lines.push("```");
437
+ lines.push("");
438
+ }
439
+
440
+ // Recent orders
441
+ if (d.orders.length > 0) {
442
+ lines.push(`**Recent Orders** (${d.orders.length})`);
443
+ lines.push("```");
444
+ for (const o of d.orders.slice(0, 5)) {
445
+ const time = new Date(o.created_at).toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit" });
446
+ const side = o.side === "BUY" ? "BUY " : "SELL";
447
+ const slug = o.slug.length > 20 ? o.slug.slice(0, 20) + "..." : o.slug;
448
+ lines.push(` ${time} ${side} ${pad(String(o.size), 4)} @ ${o.price.toFixed(2)} ${pad(o.status, 10)} ${slug}`);
449
+ }
450
+ lines.push("```");
451
+ }
452
+
453
+ this.dashMd.content = lines.join("\n");
327
454
  this.renderer.requestRender();
328
455
  }
329
456
  }
@@ -50,7 +50,7 @@ export async function loadSessions(): Promise<void> {
50
50
  mode: (dbs.mode as any) ?? "research",
51
51
  isStreaming: false,
52
52
  streamingMsgId: null,
53
- strategyDraft: dbs.strategy_draft ?? null,
53
+ strategyDraft: dbs.strategy_draft ? { ...dbs.strategy_draft, validationWarnings: dbs.strategy_draft.validationWarnings ?? [] } : null,
54
54
  });
55
55
 
56
56
  // Mark all loaded messages as synced
@@ -79,6 +79,7 @@ export interface StrategyDraft {
79
79
  } | null;
80
80
  validationStatus: "pending" | "valid" | "invalid" | "none";
81
81
  validationErrors: { line: number | null; message: string }[];
82
+ validationWarnings: { line: number | null; message: string; severity: "warning" | "info" }[];
82
83
  phase: "generated" | "iterated" | "validated" | "saved" | "deployed";
83
84
  strategyId?: string;
84
85
  filePath?: string;
@@ -3,7 +3,7 @@
3
3
  // This replaces propose_strategy/update_strategy tools — the LLM writes code
4
4
  // directly in its response, and we catch it and handle validation/saving.
5
5
 
6
- import { validateStrategyCode, autoFixStrategyCode } from "./validator.ts";
6
+ import { validateStrategyCode, autoFixStrategyCode, getStrategyWarnings } from "./validator.ts";
7
7
  import { saveStrategy } from "./persistence.ts";
8
8
  import { store } from "../state/store.ts";
9
9
  import type { StrategyDraft } from "../state/types.ts";
@@ -67,6 +67,7 @@ function extractParams(code: string): Record<string, unknown> {
67
67
  export async function finalizeStrategy(code: string, overrideName?: string): Promise<StrategyDraft> {
68
68
  const fixedCode = autoFixStrategyCode(code);
69
69
  const errors = validateStrategyCode(fixedCode);
70
+ const warnings = getStrategyWarnings(fixedCode);
70
71
  const name = overrideName ?? extractStrategyName(fixedCode);
71
72
 
72
73
  const draft: StrategyDraft = {
@@ -77,6 +78,7 @@ export async function finalizeStrategy(code: string, overrideName?: string): Pro
77
78
  riskConfig: extractRiskConfig(fixedCode),
78
79
  validationStatus: errors.length === 0 ? "valid" : "invalid",
79
80
  validationErrors: errors,
81
+ validationWarnings: warnings,
80
82
  phase: "generated",
81
83
  };
82
84