horizon-code 0.3.2 → 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.
- package/package.json +1 -1
- package/src/ai/client.ts +2 -2
- package/src/app.ts +25 -10
- package/src/components/code-panel.ts +2 -2
- package/src/components/footer.ts +0 -1
- package/src/components/input-bar.ts +0 -2
- package/src/components/tab-bar.ts +0 -1
- package/src/platform/supabase.ts +7 -3
- package/src/strategy/dashboard.ts +459 -217
- package/src/strategy/prompts.ts +212 -13
- package/src/strategy/tools.ts +211 -23
- package/src/strategy/validator.ts +25 -1
- package/src/strategy/workspace.ts +175 -0
- package/src/syntax/setup.ts +22 -4
package/src/strategy/prompts.ts
CHANGED
|
@@ -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
|
-
##
|
|
19
|
-
-
|
|
20
|
-
-
|
|
21
|
-
-
|
|
22
|
-
-
|
|
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 slugs — use 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 =
|
|
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" |
|
|
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
|
-
##
|
|
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
|
-
|
|
953
|
-
-
|
|
954
|
-
-
|
|
955
|
-
-
|
|
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.
|
package/src/strategy/tools.ts
CHANGED
|
@@ -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
|
|
48
|
-
while (buffer.length >
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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.
|
|
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. **
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
958
|
-
|
|
959
|
-
|
|
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
|
-
|
|
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
|
}
|