---
title: "Horizon Backtesting"
description: "Backtest trading strategies with hz.backtest() and analyze results with BacktestResult in the Horizon SDK."
icon: "flask-vial"
---

# Horizon Backtesting

Horizon provides a full backtesting engine via `hz.backtest()`. It uses the same pipeline, risk engine, and paper exchange as live trading. Your strategy code runs identically in both modes.

<Note>
  Backtesting uses the paper exchange with tick-based matching. Orders are filled against historical price data, and all risk checks are applied exactly as they would be in production.
</Note>

## Quick Start

```python
import horizon as hz
from horizon import Side, OrderSide, OrderRequest

def model(ctx):
    return {"fair": ctx.feed.price * 1.02}

def quoter(ctx, fair):
    if fair > ctx.feed.price:
        return OrderRequest(
            market_id=ctx.market_id,
            side=Side.Yes,
            order_side=OrderSide.Buy,
            price=ctx.feed.price,
            size=10.0,
        )

result = hz.backtest(
    name="simple-backtest",
    markets=["my-market"],
    data=[
        {"timestamp": 1000, "price": 0.50},
        {"timestamp": 1001, "price": 0.52},
        {"timestamp": 1002, "price": 0.48},
        {"timestamp": 1003, "price": 0.55},
        {"timestamp": 1004, "price": 0.60},
    ],
    pipeline=[model, quoter],
)

print(result.summary())
```

---

## hz.backtest() Signature

```python
hz.backtest(
    name: str = "backtest",
    markets: list[str] = ["market"],
    data = None,
    feeds = None,
    pipeline: list[Callable] = [...],
    risk = None,
    params = None,
    paper_fee_rate: float = 0.001,
    initial_capital: float = 1000.0,
    outcomes: dict[str, float] | None = None,
)
```

| Parameter | Type | Description |
|---|---|---|
| `name` | `str` | Name for this backtest run |
| `markets` | `list[str]` | Market IDs to simulate |
| `data` | various | Historical data (see formats below) |
| `feeds` | `dict` | Feed name mapping |
| `pipeline` | `list[Callable]` | Same pipeline functions as `hz.run()` |
| `risk` | `RiskConfig` | Risk configuration (same as live) |
| `params` | `dict` | Strategy parameters passed to pipeline |
| `paper_fee_rate` | `float` | Fee rate applied to paper fills (default 0.1%) |
| `initial_capital` | `float` | Starting capital (default $1000) |
| `outcomes` | `dict[str, float]` | Market outcomes for Brier score (0.0 or 1.0 per market) |

---

## Data Formats

Horizon accepts historical data in multiple formats.

<Tabs>
  <Tab title="List of Dicts">
    The simplest format. Each dict represents one tick.

    ```python
    data = [
        {"timestamp": 1700000000, "price": 0.55, "bid": 0.54, "ask": 0.56, "volume": 100},
        {"timestamp": 1700000001, "price": 0.56, "bid": 0.55, "ask": 0.57, "volume": 150},
        {"timestamp": 1700000002, "price": 0.54, "bid": 0.53, "ask": 0.55, "volume": 80},
    ]

    result = hz.backtest(
        markets=["my-market"],
        data=data,
        pipeline=[model, quoter],
    )
    ```

    Required fields: `timestamp`, `price`. Optional: `bid`, `ask`, `volume`.
  </Tab>
  <Tab title="CSV File">
    Pass a file path to a CSV with the same columns.

    ```python
    # data.csv:
    # timestamp,price,bid,ask,volume
    # 1700000000,0.55,0.54,0.56,100
    # 1700000001,0.56,0.55,0.57,150

    result = hz.backtest(
        markets=["my-market"],
        data="data.csv",
        pipeline=[model, quoter],
    )
    ```
  </Tab>
  <Tab title="Pandas DataFrame">
    Pass a DataFrame directly. Column names must match.

    ```python
    import pandas as pd

    df = pd.DataFrame({
        "timestamp": [1700000000, 1700000001, 1700000002],
        "price": [0.55, 0.56, 0.54],
        "bid": [0.54, 0.55, 0.53],
        "ask": [0.56, 0.57, 0.55],
        "volume": [100, 150, 80],
    })

    result = hz.backtest(
        markets=["my-market"],
        data=df,
        pipeline=[model, quoter],
    )
    ```
  </Tab>
  <Tab title="Multi-Feed Dict">
    For strategies using multiple feeds, pass a dict mapping feed names to data.

    ```python
    data = {
        "polymarket_book": [
            {"timestamp": 1700000000, "price": 0.55, "bid": 0.54, "ask": 0.56},
            {"timestamp": 1700000001, "price": 0.56, "bid": 0.55, "ask": 0.57},
        ],
        "binance": [
            {"timestamp": 1700000000, "price": 95000.0},
            {"timestamp": 1700000001, "price": 95100.0},
        ],
    }

    result = hz.backtest(
        markets=["btc-above-100k"],
        data=data,
        feeds={"btc-above-100k": "polymarket_book"},
        pipeline=[model, quoter],
    )
    ```
  </Tab>
</Tabs>

---

## BacktestResult

`hz.backtest()` returns a `BacktestResult` object with comprehensive analytics.

### result.metrics

The `metrics` property returns a lazy-computed `Metrics` object with all performance statistics.

```python
result = hz.backtest(...)
m = result.metrics

print(f"Total Return:     ${m.total_return:.2f}")
print(f"Total Return %:   {m.total_return_pct:.2%}")
print(f"CAGR:             {m.cagr:.2%}")
print(f"Sharpe Ratio:     {m.sharpe_ratio:.3f}")
print(f"Sortino Ratio:    {m.sortino_ratio:.3f}")
print(f"Calmar Ratio:     {m.calmar_ratio:.3f}")
print(f"Max Drawdown:     ${m.max_drawdown:.2f}")
print(f"Max Drawdown %:   {m.max_drawdown_pct:.2%}")
print(f"Max DD Duration:  {m.max_drawdown_duration_secs:.0f}s")
print(f"Total Trades:     {m.total_trades}")
print(f"Win Rate:         {m.win_rate:.2%}")
print(f"Profit Factor:    {m.profit_factor:.2f}")
print(f"Expectancy:       ${m.expectancy:.4f}")
print(f"Avg Win:          ${m.avg_win:.4f}")
print(f"Avg Loss:         ${m.avg_loss:.4f}")
print(f"Largest Win:      ${m.largest_win:.4f}")
print(f"Largest Loss:     ${m.largest_loss:.4f}")
print(f"Total Fees:       ${m.total_fees:.4f}")
```

#### Full Metrics Reference

| Metric | Type | Description |
|---|---|---|
| `total_return` | `float` | Absolute PnL in dollars |
| `total_return_pct` | `float` | Percentage return on initial capital |
| `cagr` | `float` | Compound annual growth rate |
| `sharpe_ratio` | `float` | Annualized Sharpe ratio |
| `sortino_ratio` | `float` | Annualized Sortino ratio (downside deviation only) |
| `calmar_ratio` | `float` | CAGR / max drawdown |
| `max_drawdown` | `float` | Largest peak-to-trough decline in dollars |
| `max_drawdown_pct` | `float` | Largest peak-to-trough decline as percentage |
| `max_drawdown_duration_secs` | `float` | Longest drawdown duration in seconds |
| `total_trades` | `int` | Number of fills |
| `win_rate` | `float` | Proportion of profitable trades |
| `profit_factor` | `float` | Gross profit / gross loss |
| `expectancy` | `float` | Average profit per trade |
| `avg_win` | `float` | Average winning trade size |
| `avg_loss` | `float` | Average losing trade size |
| `largest_win` | `float` | Best single trade |
| `largest_loss` | `float` | Worst single trade |
| `total_fees` | `float` | Total fees paid |
| `brier_score` | `float \| None` | Brier score (only if `outcomes` provided) |
| `avg_edge` | `float \| None` | Average predicted edge across trades |

### result.summary()

Returns a formatted string summary of all metrics, ready for printing.

```python
print(result.summary())
```

```
=== Backtest: simple-backtest ===
Total Return:     $142.50 (14.25%)
CAGR:             87.32%
Sharpe:           2.145
Sortino:          3.012
Calmar:           4.231
Max Drawdown:     $20.65 (2.07%)
Trades:           48 (Win Rate: 62.50%)
Profit Factor:    1.87
Fees:             $4.80
```

### result.pnl_by_market()

Returns a dict mapping each market ID to its realized PnL.

```python
pnl = result.pnl_by_market()
for market, realized in pnl.items():
    print(f"{market}: ${realized:.2f}")
```

### result.equity_curve

A list of `(timestamp, equity)` tuples showing the portfolio value over time.

```python
curve = result.equity_curve
for ts, equity in curve[:5]:
    print(f"t={ts}: ${equity:.2f}")
```

### result.trades

A list of `Fill` objects representing every trade executed during the backtest.

```python
for fill in result.trades[:5]:
    print(f"{fill.side} {fill.order_side} {fill.size} @ {fill.price}")
```

### result.to_csv()

Export results to CSV files for further analysis.

```python
# Export equity curve
result.to_csv("equity.csv", what="equity")

# Export trade log
result.to_csv("trades.csv", what="trades")
```

---

## Examples

### Basic Backtest

```python
import horizon as hz
from horizon import Side, OrderSide, OrderRequest

def mean_reversion(ctx):
    """Simple mean-reversion model."""
    price = ctx.feed.price
    fair = 0.50  # Assume fair value is 0.50
    edge = fair - price
    return {"fair": fair, "edge": edge}

def quoter(ctx, fair, edge):
    if abs(edge) < 0.03:
        return None  # No edge, skip

    if edge > 0:
        return OrderRequest(
            market_id=ctx.market_id,
            side=Side.Yes,
            order_side=OrderSide.Buy,
            price=ctx.feed.price,
            size=10.0,
        )
    else:
        return OrderRequest(
            market_id=ctx.market_id,
            side=Side.Yes,
            order_side=OrderSide.Sell,
            price=ctx.feed.price,
            size=10.0,
        )

# Generate sample data
import random
random.seed(42)
price = 0.50
data = []
for i in range(1000):
    price += random.gauss(0, 0.01)
    price = max(0.01, min(0.99, price))
    data.append({"timestamp": 1700000000 + i, "price": round(price, 4)})

result = hz.backtest(
    name="mean-reversion",
    markets=["test-market"],
    data=data,
    pipeline=[mean_reversion, quoter],
    initial_capital=1000.0,
    paper_fee_rate=0.002,
)

print(result.summary())
```

### Multi-Feed Backtest

```python
import horizon as hz
from horizon import Side, OrderSide, OrderRequest

def cross_market_model(ctx):
    """Use BTC price as a signal for a prediction market."""
    btc_price = ctx.feeds["binance"].price
    market_price = ctx.feed.price

    # Simple threshold model
    if btc_price > 100000:
        fair = 0.75
    else:
        fair = 0.35

    return {"fair": fair}

def quoter(ctx, fair):
    edge = fair - ctx.feed.price
    if edge > 0.05:
        return OrderRequest(
            market_id=ctx.market_id,
            side=Side.Yes,
            order_side=OrderSide.Buy,
            price=ctx.feed.price,
            size=20.0,
        )
    elif edge < -0.05:
        return OrderRequest(
            market_id=ctx.market_id,
            side=Side.Yes,
            order_side=OrderSide.Sell,
            price=ctx.feed.price,
            size=20.0,
        )

data = {
    "polymarket_book": [
        {"timestamp": t, "price": 0.50 + (t % 10) * 0.02, "bid": 0.49, "ask": 0.53}
        for t in range(1700000000, 1700000500)
    ],
    "binance": [
        {"timestamp": t, "price": 99000 + (t % 20) * 100}
        for t in range(1700000000, 1700000500)
    ],
}

result = hz.backtest(
    name="cross-market",
    markets=["btc-above-100k"],
    data=data,
    feeds={"btc-above-100k": "polymarket_book"},
    pipeline=[cross_market_model, quoter],
    initial_capital=5000.0,
)

print(result.summary())
print("\nPnL by market:")
for market, pnl in result.pnl_by_market().items():
    print(f"  {market}: ${pnl:.2f}")
```

### DataFrame Input

```python
import pandas as pd
import horizon as hz
from horizon import Side, OrderSide, OrderRequest

# Load real historical data
df = pd.read_csv("historical_prices.csv")

# Ensure required columns exist
assert "timestamp" in df.columns
assert "price" in df.columns

def momentum(ctx):
    return {"fair": ctx.feed.price * 1.01}

def quoter(ctx, fair):
    if fair > ctx.feed.ask:
        return OrderRequest(
            market_id=ctx.market_id,
            side=Side.Yes,
            order_side=OrderSide.Buy,
            price=ctx.feed.ask,
            size=5.0,
        )

result = hz.backtest(
    name="momentum-df",
    markets=["my-market"],
    data=df,
    pipeline=[momentum, quoter],
)

# Export for analysis in pandas
result.to_csv("equity_curve.csv", what="equity")
result.to_csv("trade_log.csv", what="trades")
```

### Brier Score with Outcomes

For prediction markets, you can evaluate calibration by providing known outcomes.

```python
import horizon as hz
from horizon import Side, OrderSide, OrderRequest

def probability_model(ctx):
    """Model that estimates event probability."""
    return {"fair": 0.65}

def quoter(ctx, fair):
    if fair > ctx.feed.price + 0.03:
        return OrderRequest(
            market_id=ctx.market_id,
            side=Side.Yes,
            order_side=OrderSide.Buy,
            price=ctx.feed.price,
            size=10.0,
        )

data = [
    {"timestamp": t, "price": 0.55 + (t % 5) * 0.01}
    for t in range(1700000000, 1700000200)
]

result = hz.backtest(
    name="calibration-test",
    markets=["will-it-rain"],
    data=data,
    pipeline=[probability_model, quoter],
    outcomes={"will-it-rain": 1.0},  # Event resolved Yes
)

m = result.metrics
print(f"Brier Score: {m.brier_score:.4f}")  # Lower is better (0 = perfect)
print(f"Avg Edge:    {m.avg_edge:.4f}")
print(result.summary())
```

<Tip>
  A Brier score of 0.0 means perfect calibration; 0.25 is equivalent to always predicting 50%. Scores below 0.2 indicate meaningful predictive power.
</Tip>

### With Risk Configuration

```python
import horizon as hz
from horizon import Side, OrderSide, OrderRequest, RiskConfig

risk = RiskConfig(
    max_position_size=100.0,
    max_order_size=20.0,
    max_notional=5000.0,
    max_drawdown_pct=0.10,
    max_orders_per_second=5,
)

def model(ctx):
    return {"fair": 0.60}

def aggressive_quoter(ctx, fair):
    return OrderRequest(
        market_id=ctx.market_id,
        side=Side.Yes,
        order_side=OrderSide.Buy,
        price=ctx.feed.price,
        size=50.0,  # Risk pipeline will cap this
    )

data = [
    {"timestamp": t, "price": 0.50 + (t % 10) * 0.005}
    for t in range(1700000000, 1700001000)
]

result = hz.backtest(
    name="risk-limited",
    markets=["test-market"],
    data=data,
    pipeline=[model, aggressive_quoter],
    risk=risk,
    initial_capital=2000.0,
)

print(result.summary())
```

<Warning>
  Backtest results assume perfect execution at historical prices. In live markets, slippage, latency, and liquidity constraints will impact actual performance. Always apply a conservative discount to backtest results.
</Warning>
