horizon-code 0.3.3 → 0.5.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.
@@ -15,11 +15,31 @@ Values clamp to [0.01, 0.99]. Invalid spreads return empty list.
15
15
  Always check feeds: \`if not feed or feed.is_stale(5): return []\`
16
16
  is_stale() returns True if no data in N seconds OR feed never connected.
17
17
 
18
- ## Common Mistakes
19
- - Returning a dict/float from the LAST pipeline function — must return list[Quote] or []
20
- - Not guarding against None/stale feeds on first cycle
21
- - Using ctx.market.id as slug both id and slug are set to the input string
22
- - Submitting identical orders rapidlydedup risk check rejects them
18
+ ## Anti-Patterns (NEVER DO THESE)
19
+ - NEVER return a raw float or dict from the last pipeline function — must return list[Quote] or []
20
+ - NEVER use getattr() it is blocked by the sandbox validator. Use ctx.params.get() instead
21
+ - NEVER use time.sleep()the engine handles timing via the interval parameter
22
+ - NEVER hardcode market slugsuse ctx.market.slug or ctx.params["market"]
23
+ - NEVER use Decimal — all values are plain float
24
+ - NEVER call exec(), eval(), subprocess, or os — sandbox blocks them
25
+ - NEVER do file I/O or network calls inside pipeline functions
26
+ - NEVER access ctx.inventory as a dict — it's InventorySnapshot with .positions, .net, .net_for_market()
27
+ - NEVER submit identical orders rapidly — the risk engine dedup check rejects them
28
+ - NEVER use \`from horizon.context import ...\` — just use \`import horizon as hz\` and access via hz.*
29
+
30
+ ## Context Object Quick Reference
31
+ ctx.feeds: dict[str, FeedData] # Feed snapshots by name
32
+ ctx.feed: FeedData # Shortcut to first feed
33
+ ctx.inventory: InventorySnapshot # Current positions (NOT a dict!)
34
+ ctx.inventory.net: float # Total net exposure
35
+ ctx.inventory.net_for_market(id) # Per-market net
36
+ ctx.inventory.positions: list[Position]
37
+ ctx.market: Market # Current market metadata
38
+ ctx.market.slug: str # Market slug
39
+ ctx.status: EngineStatus # Engine state
40
+ ctx.status.total_pnl(): float # Total P&L
41
+ ctx.status.kill_switch_active: bool
42
+ ctx.params: dict # User-defined parameters (tunable)
23
43
  `;
24
44
 
25
45
  const RISK_CAPITAL_GUIDE = `
@@ -200,11 +220,22 @@ class NWSFeed: def __init__(self, office: str = "", grid_x: int = 0, grid_y: int
200
220
  class RESTJsonPathFeed: def __init__(self, url: str, price_path: str | None = None, bid_path: str | None = None, ask_path: str | None = None, interval: float = 5.0): ...
201
221
  class ChainlinkFeed: def __init__(self, contract_address: str, rpc_url: str, decimals: int = 8, interval: float = 10.0): ...
202
222
  class MempoolFeed: def __init__(self, rpc_url: str): ... # Polygon mempool watcher
223
+ class AlpacaFeed: def __init__(self, symbols: list[str], data_source: str = "iex"): ... # Stock real-time
224
+ class CoinbaseFeed: def __init__(self, product_ids: list[str]): ... # Crypto WebSocket
225
+ class RobinhoodFeed: def __init__(self, symbols: list[str], interval: float = 5.0): ...
226
+ class IBKRFeed: def __init__(self, conids: list[int], paper: bool = True): ... # Interactive Brokers
227
+ class CalendarFeed: def __init__(self, events_json: str | None = None, interval: float = 60.0): ... # Economic events
228
+ class TreasuryFeed: def __init__(self, series_ids: list[str] | None = None, interval: float = 3600.0): ... # FRED yields
203
229
 
204
- # ── Exchange Types (for hz.run exchange=...) ──
230
+ # ── Exchange Types (for hz.run exchange=... or exchanges=[...]) ──
205
231
 
206
232
  class Polymarket: def __init__(self, private_key: str | None = None): ...
207
233
  class Kalshi: def __init__(self, email: str | None = None, password: str | None = None, api_key: str | None = None): ...
234
+ class Limitless: def __init__(self, api_key: str | None = None, private_key: str | None = None): ...
235
+ class Alpaca: def __init__(self, api_key: str | None = None, api_secret: str | None = None, paper: bool = True): ...
236
+ class Coinbase: def __init__(self, api_key: str | None = None, api_secret: str | None = None): ...
237
+ class Robinhood: def __init__(self, username: str | None = None, password: str | None = None): ...
238
+ class InteractiveBrokers: def __init__(self, host: str = "127.0.0.1", port: int = 7497, client_id: int = 0): ...
208
239
 
209
240
  # ── Built-in Pipeline Factories ──
210
241
 
@@ -376,7 +407,7 @@ hz.volatility(lookback=20, method="yang_zhang") # Pipeline function
376
407
  def adaptive_quoter(ctx, fair):
377
408
  if fair is None:
378
409
  return []
379
- vol = getattr(ctx, 'volatility', None)
410
+ vol = ctx.params.get("_volatility", None) # Injected by hz.volatility() pipeline
380
411
  base_spread = ctx.params.get("spread", 0.06)
381
412
  if vol and vol.best > 0:
382
413
  spread = base_spread * (1 + vol.best * 2) # widen on high vol
@@ -436,8 +467,111 @@ Before deploying live, strategies should pass:
436
467
  - Walk-forward p-value < 0.05
437
468
  - Probability of Backtest Overfitting (PBO) < 50%
438
469
  - Max drawdown < 10% of capital
470
+
471
+ ## Quant Functions (Rust-native, call as hz.function_name)
472
+
473
+ ### Risk Analytics
474
+ hz.var(returns, confidence=0.95) # Value at Risk
475
+ hz.cvar(returns, confidence=0.95) # Conditional VaR (expected shortfall)
476
+ hz.max_drawdown(equity_curve) # Maximum drawdown
477
+ hz.sharpe_ratio(returns, rf=0.0) # Annualized Sharpe
478
+ hz.sortino_ratio(returns, rf=0.0) # Downside-only Sharpe
479
+
480
+ ### Volatility (individual functions)
481
+ hz.parkinson_vol(highs, lows) # Range-based
482
+ hz.garman_klass_vol(opens, highs, lows, closes)
483
+ hz.yang_zhang_vol(opens, highs, lows, closes)
484
+ hz.ewma_vol(returns, lambda_=0.94) # Exponentially weighted
485
+ hz.rolling_vol(returns, window=20)
486
+
487
+ ### Information Theory
488
+ hz.shannon_entropy(distribution) # Bits of uncertainty
489
+ hz.kl_divergence(p, q) # Distribution divergence
490
+ hz.mutual_information(x, y) # Shared information
491
+
492
+ ### Market Microstructure
493
+ hz.kyles_lambda(prices, volumes) # Price impact coefficient
494
+ hz.amihud_ratio(returns, volumes) # Illiquidity measure
495
+ hz.roll_spread(returns) # Implicit bid-ask
496
+ hz.lob_imbalance(bids, asks, levels=5) # Orderbook pressure
497
+ hz.weighted_mid(bids, asks) # Depth-weighted mid
498
+
499
+ ### Statistical Testing
500
+ hz.deflated_sharpe(sharpe, n_trials, n_obs) # Backtest overfitting check
501
+ hz.benjamini_hochberg(p_values, alpha=0.05) # False discovery rate
502
+
503
+ ### Advanced Filters & Detectors
504
+ KalmanFilter(dim_state, dim_obs) # Linear state estimation
505
+ UnscentedKF(dim_state, dim_obs) # Non-linear state estimation
506
+ ParticleFilter(n_particles, dim_state) # Sequential Monte Carlo
507
+ BocpdDetector(hazard_rate=100) # Bayesian changepoint detection
508
+ MarkovRegimeModel(n_regimes=2) # Regime switching
509
+ VpinDetector(volume_bucket_size) # Informed trading probability
510
+ CusumDetector(threshold, drift) # Sequential change detection
511
+ OfiTracker(window=100) # Order flow imbalance
512
+
513
+ ### Copulas & Dependence
514
+ hz.fit_copula(u, v, family="gaussian") # Bivariate copula
515
+ hz.best_copula(u, v) # Auto-select family
516
+ hz.fit_vine(data) # Vine copula for >2 variables
517
+
518
+ ### Portfolio Optimization
519
+ hz.hrp_weights(returns) # Hierarchical Risk Parity
520
+ hz.denoise_covariance(cov_matrix, n_obs) # Marcenko-Pastur shrinkage
521
+ hz.robust_optimize(returns, gamma=1.0) # Worst-case robust
522
+
523
+ ### Optimal Execution
524
+ hz.gp_optimal_trajectory(total_size, urgency, risk_aversion, n_steps) # Garleanu-Pedersen
525
+ hz.ac_optimal_schedule(total_size, volatility, n_steps, risk_aversion) # Almgren-Chriss
526
+ hz.queue_fill_prob(queue_pos, total_depth, cancel_rate) # Fill probability
527
+
528
+ ### Data Preparation (AFML)
529
+ hz.tick_bars(trades, threshold) # Fixed-count bars
530
+ hz.volume_bars(trades, threshold) # Fixed-volume bars
531
+ hz.dollar_bars(trades, threshold) # Fixed-dollar bars
532
+ hz.triple_barrier_labels(prices, upper, lower, max_holding) # Event-driven labels
533
+ hz.frac_diff_weights(d, threshold=1e-5) # Fractional differentiation
534
+ hz.min_frac_diff(series, max_d=1.0) # Min d for stationarity
535
+
536
+ ### Lead-Lag & Causality
537
+ hz.granger_causality(x, y, max_lag=10) # Causal relationships
538
+ hz.cross_correlation_lags(x, y, max_lag) # Temporal alignment
539
+ hz.lead_lag_network(series_dict) # Multi-asset structure
540
+
541
+ ### Stat Arb
542
+ hz.cointegration_test(x, y) # Engle-Granger test
543
+ hz.spread_zscore(x, y, lookback=60) # Mean-reversion z-score
544
+
545
+ ## hz.run() — Full Parameter Reference
546
+ hz.run(
547
+ exchange=hz.Polymarket(), # Single exchange (or exchanges=[...] for multi)
548
+ markets=["slug-1", "slug-2"], # Market slugs
549
+ feeds={"mid": hz.PolymarketBook("slug-1")},
550
+ pipeline=[signal_fn, quoter_fn], # Or dict: {"slug-1": [fn1], "*": [default_fn]}
551
+ risk=hz.Risk(...),
552
+ mode="paper", # "paper" | "live"
553
+ params={"spread": 0.06},
554
+ interval=0.5, # Seconds between cycles (default 0.5)
555
+ events=[hz.Event(...)], # For multi-outcome events
556
+ db_path="./strategy.db", # SQLite persistence (default: enabled)
557
+ netting_pairs=[("mkt-a", "mkt-b")], # Cross-hedge pairs
558
+ dashboard=True, # Enable built-in TUI dashboard
559
+ )
560
+
561
+ ## hz.backtest() — Advanced Parameters
562
+ hz.backtest(
563
+ pipeline=[...], markets=[...], feeds={...}, risk=hz.Risk(...),
564
+ data_points=500, initial_capital=1000, base_price=0.50,
565
+ fill_model="deterministic", # "deterministic" | "probabilistic" | "glft"
566
+ fill_model_params={}, # Model-specific: {"fill_rate": 0.7}
567
+ impact_temporary_bps=2.0, # Temporary market impact
568
+ impact_permanent_fraction=0.1, # Permanent price impact
569
+ latency_ms=50.0, # Simulated execution latency
570
+ rng_seed=42, # For reproducibility
571
+ )
439
572
  `;
440
573
 
574
+
441
575
  const EXAMPLE_MOMENTUM = `
442
576
  import horizon as hz
443
577
  from collections import deque
@@ -903,7 +1037,7 @@ Read what the user says and match it to the RIGHT action. Do NOT generate code w
903
1037
  | "run it" / "start it" | Call run_strategy |
904
1038
  | "deploy it" / "put it live" | Run the deploy flow (see below) |
905
1039
  | "show me BTC markets" | Call polymarket_data |
906
- | "build me a dashboard" | Call spawn_dashboard with custom HTML |
1040
+ | "build me a dashboard" | write_file the HTML, then spawn_dashboard(file_path=...) |
907
1041
  | "what markets are available?" | Call polymarket_data |
908
1042
  | "stop it" / "kill it" | Call stop_strategy |
909
1043
  | "load my old strategy" | Call list_saved_strategies then load_saved_strategy |
@@ -947,12 +1081,76 @@ If the user wants live mode, warn them first, then deploy with dry_run=false.
947
1081
 
948
1082
  **The user does NOT need to know strategy_id or credential_id.** You handle that.
949
1083
 
950
- ## Dashboard
1084
+ ## File I/O (Workspace)
1085
+
1086
+ You have sandboxed file access in ~/.horizon/workspace/. Use it for dashboards, data, and scripts.
1087
+
1088
+ - \`write_file(path, content)\` — Write to workspace. Creates dirs. Max 1MB. Blocked: .sh/.bash/.zsh
1089
+ - \`read_file(path)\` — Read from workspace
1090
+ - \`list_files(directory?)\` — List files. Default subdirs: dashboards/, scripts/, data/
1091
+
1092
+ Paths are relative to workspace root. No absolute paths, no .., no dotfiles.
1093
+
1094
+ ## Dashboard (File-Based — PREFERRED)
1095
+
1096
+ **NEVER pass huge HTML strings to custom_html.** Use file-based mode instead:
1097
+
1098
+ 1. \`write_file("dashboards/monitor.html", html)\` — Write the HTML file
1099
+ 2. \`spawn_dashboard(file_path="dashboards/monitor.html")\` — Serve it (reads from disk each request)
1100
+ 3. \`check_dashboard_errors()\` — Verify no JS errors
1101
+ 4. If errors: \`read_file("dashboards/monitor.html")\` → fix → \`write_file(...)\` → browser refresh auto-shows changes
1102
+
1103
+ For quick built-in monitoring: \`spawn_dashboard(strategy_id="local")\` — no custom HTML needed.
1104
+
1105
+ ### Horizon Design System (MANDATORY for all dashboards)
1106
+
1107
+ Every dashboard you create MUST follow this design system exactly. No exceptions.
1108
+
1109
+ **Color Tokens (CSS variables — always define in :root):**
1110
+ \`\`\`css
1111
+ :root {
1112
+ --bg: #0d1117; --bg2: #161b22; --bg3: #1c2128;
1113
+ --border: #30363d; --border-focus: #4d8ef7;
1114
+ --text: #c9d1d9; --text-dim: #636e7b; --text-bright: #f0f6fc;
1115
+ --accent: #4d8ef7; --accent-dim: #2557a7;
1116
+ --green: #3fb950; --red: #f85149; --yellow: #d29922;
1117
+ --radius: 12px;
1118
+ }
1119
+ \`\`\`
951
1120
 
952
- \`spawn_dashboard\` serves a local web page. You write the HTML/CSS/JS from scratch:
953
- - Dark theme, Chart.js from CDN for charts
954
- - Auto-refresh every 3-5 seconds
955
- - The user can ask you to modify it just call spawn_dashboard again
1121
+ **Rules:**
1122
+ - body: \`background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;\`
1123
+ - Cards: \`background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius)\`
1124
+ - Card headers: \`font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: var(--text-dim); border-bottom: 1px solid var(--border)\`
1125
+ - Metric labels: \`font-size: 10px; text-transform: uppercase; letter-spacing: 1px; color: var(--text-dim)\`
1126
+ - Metric values: \`font-size: 22px; font-weight: 600; font-variant-numeric: tabular-nums\`
1127
+ - P&L positive: \`color: var(--green)\` with +$ prefix. P&L negative: \`color: var(--red)\` with -$ prefix
1128
+ - Status dot live: \`background: var(--green); box-shadow: 0 0 6px var(--green)\`
1129
+ - Header bar: \`background: var(--bg2); border-bottom: 1px solid var(--border)\` with "HORIZON" logo in accent, 11px, letter-spacing 3px
1130
+ - Monospace text (logs, code): \`font-family: 'SF Mono', 'Fira Code', monospace; font-size: 11px\`
1131
+ - Grid layout: CSS Grid with \`gap: 16px; padding: 0 24px\`
1132
+ - Metrics row: \`display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 1px; background: var(--border)\` with each metric cell on var(--bg2)
1133
+ - Chart.js config: \`animation: false; borderColor: rgba(77,142,247,0.8); fill.above: rgba(77,142,247,0.06); fill.below: rgba(248,81,73,0.06); pointRadius: 0; tension: 0.2\`
1134
+ - Grid lines: \`color: #21262d\`. Tick labels: \`color: #636e7b; font-size: 10\`
1135
+ - Empty states: centered, dim text with pulsing accent dot
1136
+ - NEVER use white backgrounds, light themes, or colors outside this palette
1137
+ - NEVER use inline styles for colors — always use CSS variables
1138
+
1139
+ **Data Sources (fetch from these live API endpoints):**
1140
+ - \`/api/local/metrics\` → \`{ pnl, rpnl, upnl, orders, positions, trades, win_rate, sharpe, max_dd, exposure, pos: [{id, side, sz, entry, rpnl, upnl}], hist: [number] }\`
1141
+ - \`/api/local/logs\` → \`string[]\` (stdout/stderr lines)
1142
+ - \`/api/local-logs\` → \`{ [pid]: { stdout, stderr, alive } }\`
1143
+ - \`/api/strategy\` → \`{ name, code, params, riskConfig }\`
1144
+ - Auto-refresh: \`setInterval(refresh, 3000)\` for local, 10000 for platform
1145
+
1146
+ **Standard Metrics to Display:**
1147
+ P&L (with realized sub-label), Win Rate (with trade count), Sharpe Ratio, Max Drawdown %, Exposure $, Orders count. Color-code: green for good (P&L>0, win_rate>=50%, sharpe>1), red for bad, yellow for warnings.
1148
+
1149
+ **Standard Layout Order:**
1150
+ 1. Header bar (logo + strategy name + LOCAL/PLATFORM badge + status dot + uptime)
1151
+ 2. Metrics row (6 metric cards in auto-fit grid)
1152
+ 3. Main grid: equity chart (5fr) + positions list (2fr)
1153
+ 4. Logs card (full width, max-height 180px with overflow scroll)
956
1154
 
957
1155
  ## Other Tools
958
1156
 
@@ -962,6 +1160,7 @@ If the user wants live mode, warn them first, then deploy with dry_run=false.
962
1160
  - **load_saved_strategy / list_saved_strategies** — Resume work on saved strategies
963
1161
  - **list_strategies** — See what's on the platform
964
1162
  - **get_metrics / get_logs** — Monitor running deployments
1163
+ - **check_dashboard_errors** — Check JS errors in file-based dashboards
965
1164
 
966
1165
  ## Communication Style
967
1166
  - SHORT and DIRECT. No filler, no preamble.
@@ -11,6 +11,7 @@ import { dashboard } from "./dashboard.ts";
11
11
  import { runInSandbox, spawnInSandbox } from "./sandbox.ts";
12
12
  import { saveStrategy, loadStrategy, listSavedStrategies } from "./persistence.ts";
13
13
  import { hyperlink } from "../util/hyperlink.ts";
14
+ import { writeWorkspaceFile, readWorkspaceFile, listWorkspaceFiles } from "./workspace.ts";
14
15
  import type { StrategyDraft } from "../state/types.ts";
15
16
 
16
17
  const t = tool as any;
@@ -44,8 +45,8 @@ function startCapturing(pid: number, managed: ManagedProcess): void {
44
45
  for (const line of text.split("\n")) {
45
46
  if (line) buffer.push(line);
46
47
  }
47
- // Rolling buffer — keep last 200 lines
48
- while (buffer.length > 200) buffer.shift();
48
+ // Rolling buffer — keep last 1000 lines
49
+ while (buffer.length > 1000) buffer.shift();
49
50
  }
50
51
  } catch {}
51
52
  };
@@ -129,21 +130,75 @@ const METRICS_REPORTER = `
129
130
  import json as _json, sys as _sys, time as _time
130
131
  _hz_last_report = [0.0]
131
132
  _hz_pnl_history = []
133
+ _hz_peak_equity = [0.0]
134
+ _hz_start_time = [0.0]
135
+ _hz_trade_count = [0]
136
+ _hz_win_count = [0]
137
+ _hz_prev_positions = [{}]
132
138
  def _hz_report(ctx, quotes):
133
139
  _now = _time.time()
140
+ if _hz_start_time[0] == 0.0:
141
+ _hz_start_time[0] = _now
134
142
  if _now - _hz_last_report[0] >= 5.0:
135
143
  _hz_last_report[0] = _now
136
144
  _s = ctx.status
137
145
  _inv = ctx.inventory
138
146
  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
+ try:
148
+ _pnl = _s.total_pnl()
149
+ # Track peak equity for drawdown calculation
150
+ if _pnl > _hz_peak_equity[0]:
151
+ _hz_peak_equity[0] = _pnl
152
+ _max_dd = 0.0
153
+ if _hz_peak_equity[0] > 0:
154
+ _max_dd = (_hz_peak_equity[0] - _pnl) / _hz_peak_equity[0] * 100
155
+ # Track trades by detecting position changes
156
+ if _inv:
157
+ _curr_pos = {_p.market_id: _p.size for _p in _inv.positions}
158
+ for _mid, _sz in _curr_pos.items():
159
+ _prev_sz = _hz_prev_positions[0].get(_mid, 0)
160
+ if _sz != _prev_sz and _prev_sz != 0:
161
+ _hz_trade_count[0] += 1
162
+ if _sz == 0 and hasattr(_inv, 'positions'):
163
+ for _p in _inv.positions:
164
+ if _p.market_id == _mid and _p.realized_pnl > 0:
165
+ _hz_win_count[0] += 1
166
+ for _mid in _hz_prev_positions[0]:
167
+ if _mid not in _curr_pos:
168
+ _hz_trade_count[0] += 1
169
+ _hz_prev_positions[0] = dict(_curr_pos)
170
+ # Compute exposure
171
+ _exposure = 0.0
172
+ if _inv:
173
+ for _p in _inv.positions:
174
+ _exposure += abs(_p.size * _p.avg_entry_price)
175
+ _wr = _hz_win_count[0] / max(_hz_trade_count[0], 1)
176
+ # Sharpe approximation from PnL history
177
+ _sharpe = 0.0
178
+ if len(_hz_pnl_history) > 5:
179
+ _returns = [_hz_pnl_history[i] - _hz_pnl_history[i-1] for i in range(1, len(_hz_pnl_history))]
180
+ _mean_r = sum(_returns) / len(_returns)
181
+ _var_r = sum((r - _mean_r)**2 for r in _returns) / len(_returns)
182
+ if _var_r > 0:
183
+ _sharpe = round(_mean_r / _var_r**0.5, 4)
184
+ _m = {
185
+ "__hz__": 1,
186
+ "pnl": _pnl, "rpnl": _s.total_realized_pnl, "upnl": _s.total_unrealized_pnl,
187
+ "orders": _s.open_orders, "positions": _s.active_positions,
188
+ "uptime": _s.uptime_secs, "kill": _s.kill_switch_active,
189
+ "trades": _hz_trade_count[0], "wins": _hz_win_count[0],
190
+ "win_rate": round(_wr, 4), "max_dd": round(_max_dd, 2),
191
+ "sharpe": _sharpe, "exposure": round(_exposure, 2),
192
+ }
193
+ if _inv:
194
+ _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]
195
+ _hz_pnl_history.append(_pnl)
196
+ if len(_hz_pnl_history) > 120:
197
+ _hz_pnl_history.pop(0)
198
+ _m["hist"] = list(_hz_pnl_history)
199
+ print("__HZ_METRICS__" + _json.dumps(_m), file=_sys.stderr, flush=True)
200
+ except Exception:
201
+ pass
147
202
  return quotes if quotes is not None else []
148
203
  `;
149
204
 
@@ -186,6 +241,8 @@ function injectMetricsReporter(code: string): string {
186
241
  export function parseLocalMetrics(managed: ManagedProcess): {
187
242
  pnl: number; rpnl: number; upnl: number;
188
243
  orders: number; positions: number; uptime: number; kill: boolean;
244
+ trades: number; wins: number; win_rate: number; max_dd: number;
245
+ sharpe: number; exposure: number;
189
246
  pos: Array<{ id: string; side: string; sz: number; entry: number; rpnl: number; upnl: number }>;
190
247
  hist: number[];
191
248
  } | null {
@@ -702,6 +759,7 @@ except Exception as e:
702
759
  description: "Run the current strategy code locally as a background Python process. Returns PID. Use read_logs(pid) to monitor. Must use mode='paper'. Process shows in the status bar as 'N running'.",
703
760
  parameters: z.object({
704
761
  timeout_secs: z.number().optional().describe("Max runtime seconds (default 3600 = 1 hour)"),
762
+ auto_restart: z.boolean().optional().describe("Auto-restart on crash (max 3 retries with exponential backoff). Default false."),
705
763
  }),
706
764
  execute: async (args: any) => {
707
765
  const draft = store.getActiveSession()?.strategyDraft;
@@ -716,10 +774,13 @@ except Exception as e:
716
774
  }
717
775
 
718
776
  const timeout = args.timeout_secs ?? 3600;
719
- try {
777
+ const autoRestart = args.auto_restart ?? false;
778
+ const maxRetries = 3;
779
+ const backoffMs = [1000, 2000, 4000];
780
+
781
+ const launchProcess = (): { pid: number; managed: ManagedProcess } => {
720
782
  const instrumentedCode = injectMetricsReporter(draft.code);
721
783
  const { proc, cleanup } = spawnInSandbox(instrumentedCode);
722
-
723
784
  const pid = proc.pid;
724
785
  const managed: ManagedProcess = { proc, stdout: [], stderr: [], startedAt: Date.now(), cleanup };
725
786
  runningProcesses.set(pid, managed);
@@ -731,19 +792,52 @@ except Exception as e:
731
792
  if (m) { m.cleanup?.(); runningProcesses.delete(pid); }
732
793
  }, timeout * 1000);
733
794
 
795
+ return { pid, managed };
796
+ };
797
+
798
+ try {
799
+ let { pid, managed } = launchProcess();
800
+
734
801
  // Wait a beat to check for immediate crash
735
802
  await new Promise((r) => setTimeout(r, 500));
736
- const exitCode = proc.exitCode;
803
+ const exitCode = managed.proc.exitCode;
737
804
  if (exitCode !== null && exitCode !== 0) {
738
805
  const err = managed.stderr.join("\n");
739
806
  runningProcesses.delete(pid);
740
807
  return { error: `Process crashed immediately (exit ${exitCode})`, stderr: err.slice(0, 1000) };
741
808
  }
742
809
 
810
+ // Set up auto-restart watcher if enabled
811
+ if (autoRestart) {
812
+ let retries = 0;
813
+ const watchForCrash = () => {
814
+ managed.proc.exited.then(async () => {
815
+ const code = managed.proc.exitCode;
816
+ if (code !== null && code !== 0 && retries < maxRetries) {
817
+ retries++;
818
+ const delay = backoffMs[retries - 1] ?? 4000;
819
+ managed.stderr.push(`[RESTART] Process exited with code ${code}. Retrying (${retries}/${maxRetries}) in ${delay}ms...`);
820
+ await new Promise((r) => setTimeout(r, delay));
821
+ try {
822
+ const next = launchProcess();
823
+ pid = next.pid;
824
+ managed = next.managed;
825
+ managed.stdout.push(`[RESTART] Attempt ${retries}/${maxRetries} started (PID ${pid})`);
826
+ watchForCrash();
827
+ } catch (e: any) {
828
+ managed.stderr.push(`[RESTART] Failed to restart: ${e.message}`);
829
+ }
830
+ }
831
+ });
832
+ };
833
+ watchForCrash();
834
+ }
835
+
743
836
  return {
744
837
  success: true, pid, status: "running", timeout_secs: timeout,
838
+ auto_restart: autoRestart,
745
839
  initial_output: managed.stdout.slice(0, 5).join("\n"),
746
- message: `Strategy running (PID ${pid}). Use read_logs(${pid}) to see output. Auto-stops after ${Math.round(timeout / 60)}min.`,
840
+ message: `Strategy running (PID ${pid}). Use read_logs(${pid}) to see output. Auto-stops after ${Math.round(timeout / 60)}min.${autoRestart ? " Auto-restart enabled (max 3 retries)." : ""}`,
747
841
  };
748
842
  } catch (err) {
749
843
  return { error: `Failed to start: ${err instanceof Error ? err.message : String(err)}` };
@@ -935,28 +1029,46 @@ except Exception as e:
935
1029
  // ── Dashboard ──
936
1030
 
937
1031
  spawn_dashboard: t({
938
- description: `Serve a local web dashboard. Two modes:
1032
+ description: `Serve a local web dashboard. Three modes:
939
1033
 
940
1034
  1. **Built-in monitor** — pass strategy_id. Auto-connects to live platform metrics.
941
- 2. **Custom HTML** — pass custom_html you write from scratch.
1035
+ 2. **File-based** (PREFERRED) — pass file_path pointing to an HTML file in the workspace. Reads from disk on every request so edits auto-reload. Error-catching JS is auto-injected.
1036
+ 3. **Custom HTML** — pass custom_html as inline string (legacy, avoid for large dashboards).
942
1037
 
943
- Custom dashboards get a FREE live API:
1038
+ All modes get a live API:
944
1039
  - GET /api/metrics → platform metrics (if strategy_id provided)
945
1040
  - GET /api/logs → platform deployment logs
946
- - GET /api/strategy → current strategy draft (code, name, params, risk)
1041
+ - GET /api/strategy → current strategy draft
947
1042
  - GET /api/local-logs → stdout/stderr from local run_strategy processes
1043
+ - GET /api/local/metrics → parsed local process metrics
1044
+ - GET /api/errors → captured JS errors (file-based mode)
948
1045
 
949
- Your HTML can fetch("/api/metrics").then(r => r.json()) to get live data. Auto-refresh with setInterval.`,
1046
+ Your HTML can fetch("/api/local/metrics").then(r => r.json()) to get live data. Auto-refresh with setInterval.`,
950
1047
  parameters: z.object({
951
1048
  strategy_id: z.string().optional().describe("Strategy UUID — enables /api/metrics and /api/logs from the platform"),
952
- custom_html: z.string().optional().describe("Complete HTML page you write from scratch (dark theme, Chart.js, etc.)"),
1049
+ file_path: z.string().optional().describe("Path to HTML file in workspace (e.g. 'dashboards/monitor.html'). PREFERRED over custom_html."),
1050
+ custom_html: z.string().optional().describe("Complete HTML page as inline string (legacy — use file_path instead)"),
953
1051
  port: z.number().optional().describe("Port (default: random)"),
954
1052
  }),
955
1053
  execute: async (args: any) => {
956
1054
  try {
957
- if (args.strategy_id && !args.custom_html) {
958
- const url = dashboard.start(args.strategy_id, args.port);
959
- return { success: true, url, message: `Monitor at ${hyperlink(url)}` };
1055
+ // Auto-detect: if local processes are running, use local mode
1056
+ const hasLocal = [...runningProcesses.values()].some(m => m.proc.exitCode === null);
1057
+
1058
+ // Mode 1: File-based dashboard (preferred)
1059
+ if (args.file_path) {
1060
+ const url = dashboard.startFromFile(args.file_path, args.strategy_id, args.port);
1061
+ return {
1062
+ success: true, url, isLocal: hasLocal, mode: "file",
1063
+ message: `File-based dashboard at ${hyperlink(url)}. Edit the file and refresh to see changes. Use check_dashboard_errors() to verify.`,
1064
+ };
1065
+ }
1066
+
1067
+ // Mode 2: Built-in monitor
1068
+ if (!args.custom_html) {
1069
+ const sid = args.strategy_id ?? "local";
1070
+ const url = dashboard.start(sid, args.port, hasLocal);
1071
+ return { success: true, url, isLocal: hasLocal, message: `Dashboard at ${hyperlink(url)}` };
960
1072
  }
961
1073
 
962
1074
  // Custom HTML with live API backend
@@ -1018,4 +1130,80 @@ Your HTML can fetch("/api/metrics").then(r => r.json()) to get live data. Auto-r
1018
1130
  }
1019
1131
  },
1020
1132
  }),
1133
+
1134
+ // ── Dashboard Error Check ──
1135
+
1136
+ check_dashboard_errors: t({
1137
+ description: "Check for JavaScript errors captured from a file-based dashboard. Call this after spawn_dashboard(file_path=...) to verify the dashboard loaded without errors.",
1138
+ parameters: z.object({}),
1139
+ execute: async () => {
1140
+ if (!dashboard.running) {
1141
+ return { error: "No dashboard is running. Call spawn_dashboard first." };
1142
+ }
1143
+ const errors = dashboard.capturedErrors;
1144
+ if (errors.length === 0) {
1145
+ return { success: true, errors: [], message: "No JavaScript errors detected." };
1146
+ }
1147
+ return {
1148
+ success: false,
1149
+ error_count: errors.length,
1150
+ errors: errors.slice(-10).map((e: any) => ({
1151
+ type: e.type,
1152
+ message: e.message,
1153
+ source: e.source,
1154
+ line: e.line,
1155
+ col: e.col,
1156
+ })),
1157
+ message: `${errors.length} JS error(s) detected. Fix the HTML and refresh — file-based dashboards auto-reload.`,
1158
+ };
1159
+ },
1160
+ }),
1161
+
1162
+ // ── Workspace File I/O ──
1163
+
1164
+ write_file: t({
1165
+ description: "Write a file to the workspace (~/.horizon/workspace/). Use for dashboards, scripts, data files. Max 1MB. Blocked: .sh, .bash, .zsh files.",
1166
+ parameters: z.object({
1167
+ path: z.string().describe("Relative path within workspace (e.g. 'dashboards/monitor.html', 'data/config.json')"),
1168
+ content: z.string().describe("File content to write"),
1169
+ }),
1170
+ execute: async (args: any) => {
1171
+ try {
1172
+ const result = await writeWorkspaceFile(args.path, args.content);
1173
+ return { success: true, path: result.path, size: result.size, message: `Written ${result.size} bytes to ${result.path}` };
1174
+ } catch (err) {
1175
+ return { error: `Write failed: ${err instanceof Error ? err.message : String(err)}` };
1176
+ }
1177
+ },
1178
+ }),
1179
+
1180
+ read_file: t({
1181
+ description: "Read a file from the workspace (~/.horizon/workspace/). Returns the file content as a string.",
1182
+ parameters: z.object({
1183
+ path: z.string().describe("Relative path within workspace"),
1184
+ }),
1185
+ execute: async (args: any) => {
1186
+ try {
1187
+ const content = await readWorkspaceFile(args.path);
1188
+ return { success: true, path: args.path, content, size: content.length };
1189
+ } catch (err) {
1190
+ return { error: `Read failed: ${err instanceof Error ? err.message : String(err)}` };
1191
+ }
1192
+ },
1193
+ }),
1194
+
1195
+ list_files: t({
1196
+ description: "List files in a workspace directory. Returns name, size, and modification date.",
1197
+ parameters: z.object({
1198
+ directory: z.string().optional().describe("Subdirectory to list (e.g. 'dashboards'). Omit for workspace root."),
1199
+ }),
1200
+ execute: async (args: any) => {
1201
+ try {
1202
+ const files = listWorkspaceFiles(args.directory);
1203
+ return { success: true, directory: args.directory ?? "/", files, count: files.length };
1204
+ } catch (err) {
1205
+ return { error: `List failed: ${err instanceof Error ? err.message : String(err)}` };
1206
+ }
1207
+ },
1208
+ }),
1021
1209
  };
@@ -9,6 +9,9 @@ export interface ValidationError {
9
9
  const ALLOWED_IMPORTS = new Set([
10
10
  "horizon", "hz", "datetime", "collections", "math",
11
11
  "typing", "enum", "statistics", "pydantic", "abc", "json",
12
+ // Horizon submodules (legitimate SDK imports)
13
+ "horizon.context", "horizon.feeds", "horizon.walkforward",
14
+ "horizon.strategy", "horizon.signals", "horizon.risk",
12
15
  ]);
13
16
 
14
17
  const FORBIDDEN_PATTERNS: { pattern: RegExp; message: string }[] = [
@@ -26,7 +29,7 @@ const FORBIDDEN_PATTERNS: { pattern: RegExp; message: string }[] = [
26
29
  { pattern: /\bglobals\s*\(/, message: "globals() is not allowed" },
27
30
  { pattern: /\blocals\s*\(/, message: "locals() is not allowed" },
28
31
  { pattern: /\bvars\s*\(/, message: "vars() is not allowed" },
29
- { pattern: /\bgetattr\s*\(/, message: "getattr() is not allowed — access attributes directly" },
32
+ // getattr() is allowed — needed for optional ctx attributes from SDK pipeline injectors
30
33
  { pattern: /\bsetattr\s*\(/, message: "setattr() is not allowed" },
31
34
  { pattern: /\bdelattr\s*\(/, message: "delattr() is not allowed" },
32
35
  { pattern: /\b__builtins__\b/, message: "__builtins__ access is not allowed" },
@@ -310,5 +313,26 @@ export function getStrategyWarnings(code: string): ValidationWarning[] {
310
313
  warnings.push({ line: null, message: "No is_stale() check — strategy may act on stale/disconnected feeds", severity: "info" });
311
314
  }
312
315
 
316
+ // 8. Last pipeline function should return quotes
317
+ if (pipelineFns.length > 0) {
318
+ const lastFn = pipelineFns[pipelineFns.length - 1]!;
319
+ // Check if the function's body returns hz.quotes or []
320
+ const fnDef = code.match(new RegExp(`def\\s+${lastFn}\\s*\\([^)]*\\)[^:]*:[\\s\\S]*?(?=\\ndef\\s|\\nhz\\.|$)`));
321
+ if (fnDef && !fnDef[0].includes("hz.quotes") && !fnDef[0].includes("return []") && !fnDef[0].includes("Quote")) {
322
+ warnings.push({ line: null, message: `Last pipeline function "${lastFn}" may not return list[Quote] — check return type`, severity: "warning" });
323
+ }
324
+ }
325
+
326
+ // 9. time.sleep usage
327
+ if (code.includes("time.sleep")) {
328
+ const lineNum = lines.findIndex(l => l.includes("time.sleep"));
329
+ warnings.push({ line: lineNum >= 0 ? lineNum + 1 : null, message: "time.sleep() in pipeline — use hz.run(interval=X) instead", severity: "warning" });
330
+ }
331
+
332
+ // 10. Decimal usage
333
+ if (code.includes("Decimal(") || code.includes("from decimal")) {
334
+ warnings.push({ line: null, message: "Decimal detected — SDK uses plain float, Decimal causes type errors", severity: "warning" });
335
+ }
336
+
313
337
  return warnings;
314
338
  }