Backtesting Guide
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:
GET /api/v1/market/data-range
{
"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
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.
{
"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.
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
POST /api/v1/backtest/{session_id}/start
This is where the heavy lifting happens. The engine:
- Creates a
TimeSimulator— a virtual clock starting atstart_time - Creates a
BacktestSandbox— an in-memory fake exchange with your starting balance - Preloads all candle data for your date range into a Python dictionary (one SQL query, then no further DB reads during stepping)
- Takes an initial equity snapshot
- 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:
POST /api/v1/backtest/{session_id}/step
# or
POST /api/v1/backtest/{session_id}/step/batch
{"steps": 100}
Step response:
{
"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:
- Clock advances by
candle_interval - Prices loaded from the in-memory dictionary (no DB query)
- Pending limit/stop orders checked against new prices
- Equity snapshot taken (every 60 steps)
- DB progress updated (every 500 steps)
Step 5 — Place Orders
Between steps, read the prices from the step response and decide to trade:
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:
# 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:
- Calculates slippage (0.01% to 10% based on order size)
- Computes executed price = close price × (1 + slippage)
- Deducts 0.1% trading fee
- Deducts total cost from your USDT balance
- Adds the position to your portfolio
Step 6 — Query State
At any point during a running backtest, you can query the sandbox state:
# 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:
GET /api/v1/backtest/{session_id}/results
{
"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
GET /api/v1/backtest/{sid}/results/equity-curve # time-series equity snapshots
GET /api/v1/backtest/{sid}/results/trades # full trade log
Cancelling Early
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
# 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:
{
"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
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 — complete strategy implementations
- Strategy Testing — automated multi-episode testing
- Gymnasium Environments — train RL agents on historical data