<!-- Generated from TradeReady.io docs. Visit https://tradeready.io/docs for the full experience. -->

---
title: Backtesting Guide
description: Full API lifecycle — create, start, step, trade, and read results
---

Backtesting lets you replay historical market data and test a trading strategy against it. Instead of waiting 30 real days to see if your strategy works, you can simulate 30 days of trading in minutes. Your trading code works identically in backtest and live mode — same endpoints, same request shapes.

---

## Step 1 — Check Available Data

Before creating a session, check what historical data is available:

```bash
GET /api/v1/market/data-range
```

```json
{
  "earliest": "2025-01-01T00:00:00Z",
  "latest": "2026-02-22T23:59:59Z",
  "total_pairs": 647,
  "intervals_available": ["1m", "5m", "15m", "1h", "4h", "1d"],
  "data_gaps": []
}
```

You can only backtest within this range.

---

## Step 2 — Create a Session

```bash
POST /api/v1/backtest/create
Content-Type: application/json

{
  "start_time": "2025-06-01T00:00:00Z",
  "end_time": "2025-07-01T00:00:00Z",
  "starting_balance": 10000,
  "candle_interval": "1m",
  "strategy_label": "momentum_v1",
  "agent_id": "your-agent-uuid",
  "pairs": ["BTCUSDT", "ETHUSDT"]
}
```

`pairs` is optional — omit it to include all 600+ available pairs.

```json
{
  "session_id": "bt_550e8400...",
  "status": "created",
  "total_steps": 43200,
  "estimated_pairs": 2
}
```

`total_steps` is `(end_time - start_time) / candle_interval`. For 30 days at 1-minute candles, that is 43,200 steps.

> **Info:**
> `agent_id` is required. Each backtest session is scoped to a specific agent — the agent's risk profile is loaded and enforced in the sandbox.

---

## Step 3 — Start the Session

```bash
POST /api/v1/backtest/{session_id}/start
```

This is where the heavy lifting happens. The engine:

1. Creates a `TimeSimulator` — a virtual clock starting at `start_time`
2. Creates a `BacktestSandbox` — an in-memory fake exchange with your starting balance
3. Preloads all candle data for your date range into a Python dictionary (one SQL query, then no further DB reads during stepping)
4. Takes an initial equity snapshot
5. Sets status to `"running"`

After this call, the session is ready to step through.

---

## Step 4 — The Core Loop

On each step, the virtual clock advances by one `candle_interval`. Call `/step` for one candle at a time or `/step/batch` to advance multiple candles:

```bash
POST /api/v1/backtest/{session_id}/step

# or

POST /api/v1/backtest/{session_id}/step/batch
{"steps": 100}
```

**Step response:**

```json
{
  "virtual_time": "2025-06-01T01:40:00Z",
  "step": 100,
  "total_steps": 43200,
  "progress_pct": "0.23",
  "prices": {
    "BTCUSDT": "67500.30",
    "ETHUSDT": "3450.20"
  },
  "orders_filled": [],
  "portfolio": {
    "total_equity": "10000.00",
    "available_cash": "9700.00",
    "position_value": "300.00",
    "unrealized_pnl": "12.50",
    "realized_pnl": "0.00"
  },
  "is_complete": false,
  "remaining_steps": 43100
}
```

When `is_complete` is `true`, the session has automatically computed final metrics and saved everything to the database.

**What happens on each step internally:**

1. Clock advances by `candle_interval`
2. Prices loaded from the in-memory dictionary (no DB query)
3. Pending limit/stop orders checked against new prices
4. Equity snapshot taken (every 60 steps)
5. DB progress updated (every 500 steps)

---

## Step 5 — Place Orders

Between steps, read the prices from the step response and decide to trade:

```bash
POST /api/v1/backtest/{session_id}/trade/order
Content-Type: application/json

{
  "symbol": "BTCUSDT",
  "side": "buy",
  "type": "market",
  "quantity": "0.1"
}
```

**Supported order types:**

| Type | Fills when |
|------|-----------|
| `market` | Immediately at current price + slippage |
| `limit` | When price reaches your target (buy: price ≤ limit, sell: price ≥ limit) |
| `stop_loss` | When price drops to or below your trigger price |
| `take_profit` | When price rises to or above your trigger price |

**Limit and stop orders:**

```bash
# Limit buy at $60,000
{"symbol": "BTCUSDT", "side": "buy", "type": "limit", "quantity": "0.1", "price": "60000"}

# Stop loss at $58,000
{"symbol": "BTCUSDT", "side": "sell", "type": "stop_loss", "quantity": "0.1", "trigger_price": "58000"}

# Take profit at $72,000
{"symbol": "BTCUSDT", "side": "sell", "type": "take_profit", "quantity": "0.1", "trigger_price": "72000"}
```

Pending orders are automatically checked on every step, including during batch steps. Your stop-loss will always trigger correctly even if you skip 1,000 candles with `/step/batch`.

### The Sandbox Mechanics

On a market buy, the sandbox:

1. Calculates slippage (0.01% to 10% based on order size)
2. Computes executed price = close price × (1 + slippage)
3. Deducts 0.1% trading fee
4. Deducts total cost from your USDT balance
5. Adds the position to your portfolio

---

## Step 6 — Query State

At any point during a running backtest, you can query the sandbox state:

```bash
# Market data at virtual time
GET /api/v1/backtest/{sid}/market/prices
GET /api/v1/backtest/{sid}/market/price/BTCUSDT
GET /api/v1/backtest/{sid}/market/ticker/BTCUSDT
GET /api/v1/backtest/{sid}/market/candles/BTCUSDT

# Account state in the sandbox
GET /api/v1/backtest/{sid}/account/balance
GET /api/v1/backtest/{sid}/account/positions
GET /api/v1/backtest/{sid}/account/portfolio

# Orders
GET /api/v1/backtest/{sid}/trade/orders
GET /api/v1/backtest/{sid}/trade/orders/open
DELETE /api/v1/backtest/{sid}/trade/order/{order_id}
```

All market data endpoints return values as of the current `virtual_time` — they never reveal future prices.

---

## Step 7 — Results

When `is_complete: true`, results are automatically saved. Fetch them:

```bash
GET /api/v1/backtest/{session_id}/results
```

```json
{
  "session_id": "bt_550e...",
  "status": "completed",
  "config": {
    "start_time": "2025-06-01T00:00:00Z",
    "end_time": "2025-07-01T00:00:00Z",
    "starting_balance": "10000.00",
    "strategy_label": "momentum_v1",
    "candle_interval": "1m"
  },
  "summary": {
    "final_equity": "12458.30",
    "total_pnl": "2458.30",
    "roi_pct": "24.58",
    "total_trades": 156,
    "total_fees": "234.50"
  },
  "metrics": {
    "sharpe_ratio": 1.85,
    "sortino_ratio": 2.31,
    "max_drawdown_pct": 8.5,
    "max_drawdown_duration_days": 3,
    "win_rate": 65.71,
    "profit_factor": 2.1,
    "avg_win": "156.30",
    "avg_loss": "-74.50",
    "best_trade": "523.00",
    "worst_trade": "-210.00",
    "trades_per_day": 5.03
  },
  "by_pair": [
    {"symbol": "BTCUSDT", "trades": 45, "win_rate": 71.1, "net_pnl": "1200.00"},
    {"symbol": "ETHUSDT", "trades": 32, "win_rate": 62.5, "net_pnl": "580.00"}
  ]
}
```

### Additional Result Endpoints

```bash
GET /api/v1/backtest/{sid}/results/equity-curve    # time-series equity snapshots
GET /api/v1/backtest/{sid}/results/trades          # full trade log
```

---

## Cancelling Early

```bash
POST /api/v1/backtest/{session_id}/cancel
```

Saves partial results and sets status to `cancelled`. Useful when it is clear early that a strategy is performing poorly.

---

## Listing and Comparing

```bash
# List all your backtests
GET /api/v1/backtest/list?strategy_label=momentum&status=completed&sort_by=sharpe_ratio

# Compare sessions side by side
GET /api/v1/backtest/compare?sessions=bt_aaa,bt_bbb,bt_ccc

# Find your best session by metric
GET /api/v1/backtest/best?metric=sharpe_ratio
```

The compare endpoint returns side-by-side metrics and identifies the winner:

```json
{
  "comparisons": [
    {"session_id": "bt_aaa", "strategy_label": "momentum_v1", "roi_pct": 18.2, "sharpe_ratio": 1.42},
    {"session_id": "bt_bbb", "strategy_label": "momentum_v2", "roi_pct": 24.58, "sharpe_ratio": 1.85}
  ],
  "best_by_roi": "bt_bbb",
  "best_by_sharpe": "bt_bbb",
  "recommendation": "bt_bbb (momentum_v2) outperforms on all key metrics"
}
```

---

## How Price Data Works

Two data sources feed the backtest engine:

| Source | Coverage | Granularity |
|--------|----------|-------------|
| `candles_1m` | Recent (since ingestion started) | 1-minute |
| `candles_backfill` | Years of history | 1-hour and 1-day |

When you start a session, the engine runs one SQL query to load all candle data for your date range. During stepping, prices are looked up via in-memory binary search — zero database queries.

If your date range only has hourly data, the price stays constant for each 1-minute step within the hour. The simulation still runs correctly — just with less price granularity.

---

## Risk Profile Enforcement

If your agent has a risk profile configured, the sandbox enforces these limits on every order:

| Limit | What it enforces |
|-------|-----------------|
| `max_order_size_pct` | Order cost cannot exceed this % of available cash |
| `max_position_size_pct` | Resulting position cannot exceed this % of total equity |
| `daily_loss_limit_pct` | New orders are rejected if realized daily loss exceeds this % |

Orders that violate any limit are rejected with a descriptive error. Backtest results reflect realistic risk constraints.

---

## Step Batching for Speed

Match your batch size to your strategy timeframe:

| Strategy timeframe | Batch size | API calls for 1 year |
|-------------------|------------|---------------------|
| Every candle (scalping) | `1` | 525,600 |
| Hourly signals | `60` | 8,760 |
| Daily signals | `1440` | 365 |
| Weekly rebalancing | `10080` | 52 |

Pending limit/stop orders still check against every candle during a batch — stop-losses always trigger correctly even when fast-forwarding.

---

## Typical Agent Loop

```python
import httpx

BASE = "http://localhost:8000/api/v1"
HEADERS = {"X-API-Key": "ak_live_..."}

# 1. Create
r = httpx.post(f"{BASE}/backtest/create", headers=HEADERS, json={
    "start_time": "2025-06-01T00:00:00Z",
    "end_time": "2025-07-01T00:00:00Z",
    "starting_balance": 10000,
    "candle_interval": "1m",
    "strategy_label": "my_strategy_v1",
    "agent_id": "agent-uuid-here",
    "pairs": ["BTCUSDT", "ETHUSDT"]
})
session_id = r.json()["session_id"]

# 2. Start
httpx.post(f"{BASE}/backtest/{session_id}/start", headers=HEADERS)

# 3. Loop
prices_history = {"BTCUSDT": [], "ETHUSDT": []}

while True:
    result = httpx.post(
        f"{BASE}/backtest/{session_id}/step/batch",
        headers=HEADERS,
        json={"steps": 60}
    ).json()

    for sym, price in result["prices"].items():
        prices_history[sym].append(float(price))

    portfolio = result["portfolio"]

    # --- YOUR STRATEGY LOGIC HERE ---

    if result["is_complete"]:
        break

# 4. Results
results = httpx.get(f"{BASE}/backtest/{session_id}/results", headers=HEADERS).json()
print(f"ROI: {results['summary']['roi_pct']}%")
print(f"Sharpe: {results['metrics']['sharpe_ratio']}")
```

---

## Performance Notes

- 1 year at 1-minute candles = 525,600 steps
- Price data is preloaded into memory at start — stepping is CPU-only, no DB queries
- Equity snapshots are taken every 60 steps (hourly) to save memory
- DB progress is updated every 500 steps to reduce write overhead
- A year-long backtest completes in minutes, not hours

---

## Next Steps

- [Strategy Examples](/docs/backtesting/strategies) — complete strategy implementations
- [Strategy Testing](/docs/strategies/testing) — automated multi-episode testing
- [Gymnasium Environments](/docs/gym) — train RL agents on historical data
