> ## Documentation Index
> Fetch the complete documentation index at: https://docs.polaris.supply/llms.txt
> Use this file to discover all available pages before exploring further.

# Backtesting

> Build a reproducible trading-model backtest with Polaris historical data while accounting for timing, gaps, costs, and out-of-sample evaluation.

Use this workflow to turn Polaris historical data into chronological model
inputs and evaluate a trading rule without using future information. The
complete example uses the Python SDK and hourly bars, while the same data
surfaces are available in TypeScript and Rust.

<Warning>
  This example is a research baseline, not a production trading strategy. A
  credible backtest must model the costs, latency, liquidity, funding, and data
  gaps that apply to its intended market and execution venue.
</Warning>

## Choose the data your model needs

Start with the narrowest dataset that matches the model. Add execution-level or
order-book data only when the hypothesis depends on intrabar behavior.

| Model input                  | Python               | TypeScript          | Rust                                                           |
| ---------------------------- | -------------------- | ------------------- | -------------------------------------------------------------- |
| Interval bars                | `ohlcv(...)`         | `ohlcv(...)`        | `ohlcv(OhlcvQuery)`                                            |
| Executions and trade flow    | `trades(...)`        | `trades(...)`       | `trades(HistoricalQuery)`                                      |
| Perpetual carry              | `funding_rates(...)` | `fundingRates(...)` | `funding_rates(HistoricalQuery)`                               |
| Top-of-book spreads          | `bbo(...)`           | `bbo(...)`          | `bbo(BboQuery)`                                                |
| Depth and slippage scenarios | `depth_metrics(...)` | `depthMetrics(...)` | `depth_metrics(HistoricalQuery, depth_pct, slippage_notional)` |
| Ordered event replay         | `replay(...)`        | `replay(...)`       | `replay(ReplayQuery)`                                          |

Use `ohlcv` for bar-based signals. Use `trades`, `bbo`, or `depth_metrics` when
fill quality and market impact affect the result. Use `replay` when the model
must process mixed events in their stored order.

## Install the Python dependencies

Install the DataFrame extra with the numerical packages used below:

```bash theme={null}
pip install "polaris-data[dataframe]" numpy pandas
```

## Build a reproducible dataset

Resolve the exact source and market through Catalog, then choose explicit
boundaries inside its published coverage. Fixed boundaries make repeated runs
comparable and prevent the SDK's recent-data default from moving over time.

```python theme={null}
from datetime import timedelta

import pandas as pd
from polaris_data import PolarisClient

source = "hyperliquid"
market = "BTC"
interval = "1h"


def as_utc(value):
    timestamp = pd.Timestamp(value)
    if timestamp.tzinfo is None:
        return timestamp.tz_localize("UTC")
    return timestamp.tz_convert("UTC")


with PolarisClient() as client:
    catalog = client.catalog(source=source, market=market)
    if not catalog["markets"]:
        raise ValueError(f"No Catalog row for {source}:{market}")

    coverage = catalog["markets"][0]
    coverage_start = as_utc(coverage["start"])
    coverage_end = as_utc(coverage["end"])
    public_cutoff = coverage.get("access", {}).get("public_cutoff_date")

    if public_cutoff:
        start = max(coverage_start, as_utc(public_cutoff))
        end = min(coverage_end, start + timedelta(days=30))
    else:
        end = coverage_end
        start = max(coverage_start, end - timedelta(days=30))

    if end <= start:
        raise ValueError("The selected Catalog row has no accessible backtest range")

    bars = pd.DataFrame(
        client.ohlcv(
            source=source,
            market=market,
            from_=start,
            to=end,
            interval=interval,
        )
    )

if bars.empty:
    raise ValueError("No OHLCV bars were returned for the selected range")

bars["timestamp"] = pd.to_datetime(bars["timestamp"], unit="ms", utc=True)
bars = (
    bars.sort_values("timestamp")
    .drop_duplicates("timestamp", keep="last")
    .set_index("timestamp")
    .asfreq(interval)
)

missing_bars = int(bars["close"].isna().sum())
print(f"Range: {start} to {end}")
print(f"Rows: {len(bars):,}; missing hourly bars: {missing_bars:,}")
```

`asfreq(interval)` makes missing intervals explicit. Do not forward-fill prices
before calculating returns: that would hide gaps and create artificial
observations.

## Run a no-lookahead baseline

The following long-or-flat moving-average model calculates its signal at one
bar close and shifts the position by one row. The return for a bar therefore
uses only information available at the previous close.

```python theme={null}
import numpy as np

fast_window = 24
slow_window = 72
cost_bps = 5.0

bars["fast_ma"] = bars["close"].rolling(
    fast_window, min_periods=fast_window
).mean()
bars["slow_ma"] = bars["close"].rolling(
    slow_window, min_periods=slow_window
).mean()

bars["signal"] = (bars["fast_ma"] > bars["slow_ma"]).astype(float)
bars.loc[bars["slow_ma"].isna(), "signal"] = 0.0
bars["position"] = bars["signal"].shift(1).fillna(0.0)

bars["asset_return"] = bars["close"].pct_change(fill_method=None)
bars["turnover"] = bars["position"].diff().abs().fillna(0.0)
bars["strategy_return"] = (
    bars["position"] * bars["asset_return"]
    - bars["turnover"] * cost_bps / 10_000
)

results = bars.dropna(subset=["asset_return", "strategy_return"]).copy()
if len(results) < 2 * slow_window:
    raise ValueError("Select a longer covered range before evaluating the model")

results["strategy_equity"] = (1 + results["strategy_return"]).cumprod()
results["buy_hold_equity"] = (1 + results["asset_return"]).cumprod()
print(results[["close", "position", "strategy_equity", "buy_hold_equity"]].tail())
```

`cost_bps` is charged whenever the target position changes. Replace this flat
assumption with observed BBO spreads, depth metrics, fees, and venue-specific
funding before using the result for a trading decision.

## Evaluate out of sample

Keep model selection and evaluation separate. The example uses the first 70%
of rows as an in-sample period and reserves the remaining 30% for evaluation.

```python theme={null}
periods_per_year = 24 * 365


def summarize(frame):
    returns = frame["strategy_return"].dropna()
    equity = (1 + returns).cumprod()
    drawdown = equity / equity.cummax() - 1
    volatility = returns.std()

    return pd.Series(
        {
            "observations": len(returns),
            "total_return": equity.iloc[-1] - 1,
            "annualized_volatility": volatility * np.sqrt(periods_per_year),
            "sharpe_zero_rate": (
                returns.mean() / volatility * np.sqrt(periods_per_year)
                if volatility > 0
                else np.nan
            ),
            "max_drawdown": drawdown.min(),
            "position_changes": int(frame["position"].diff().abs().gt(0).sum()),
        }
    )


split = int(len(results) * 0.70)
in_sample = results.iloc[:split]
out_of_sample = results.iloc[split:]

report = pd.concat(
    {
        "in_sample": summarize(in_sample),
        "out_of_sample": summarize(out_of_sample),
    },
    axis=1,
).T
print(report)
```

The zero-rate Sharpe ratio is a compact diagnostic, not a complete assessment.
Also inspect exposure, turnover, tail losses, parameter stability, and results
across multiple markets and non-overlapping time periods.

## Add execution realism

| Backtest assumption           | Polaris data                                                                                 | Implementation guidance                                                                  |
| ----------------------------- | -------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| Spread and quote availability | [BBO](/schemas/bbo)                                                                          | Price entries and exits against the observable side of the book instead of the bar close |
| Size-dependent slippage       | [Depth metrics](/schemas/depth-metrics) or [L2 snapshots and updates](/schemas/l2-snapshots) | Apply the same order notional used by the intended strategy                              |
| Perpetual funding             | [Funding rates](/schemas/funding-rates)                                                      | Accrue funding using the venue's published interval and sign convention                  |
| Intrabar signal timing        | [Trades](/schemas/trades) or [Events](/schemas/events)                                       | Preserve event order and prevent later events from affecting earlier decisions           |
| Data discontinuities          | [Event envelope](/concepts/event-envelope) and Catalog coverage                              | Stop or reset model state across uncovered intervals rather than inventing observations  |

For large event-driven tests, consume iterator or Arrow-batch output instead of
loading the complete range into memory. Keep the resolved source, market,
Catalog bounds, model parameters, cost assumptions, and SDK version with every
result so another researcher can reproduce it.

## Related documentation

* [Catalog](/reference/catalog) for exact market IDs, coverage bounds, and access metadata
* [Python SDK](/sdks/python), [TypeScript SDK](/sdks/typescript), and [Rust SDK](/sdks/rust) for complete client signatures
* [Perpetuals](/guides/perpetuals) for funding, trade-flow, liquidity, and order-book workflows
* [Jupyter Notebook](/guides/jupyter-notebook-quickstart) for a notebook-first Python setup
