Skip to content

Backtesting Guide

This guide covers running backtests with tradedesk using candle data loaded from the Dukascopy cache or supplied in-memory.

By the end you will have:

  • A working strategy
  • A cache-backed backtest via run_portfolio
  • A full recorded backtest via run_backtest (with metrics and trade output)

The same strategy runs live against a broker without modification.

Code Alignment

See docs/backtesting_guide_code_alignment.md for the specific public modules this guide references.


1. Project Structure

my_backtest/
    strategy.py
    run_backtest.py

2. Dukascopy Cache Input

BacktestClient.from_dukascopy_cache(...) reads 1-minute candle files from a Dukascopy cache directory and aggregates them to the period your strategy subscribes to.

Required inputs:

  • cache_dir: root cache directory
  • symbol: cache symbol folder, for example GBPUSD
  • instrument: instrument identifier used by your strategy
  • period: target tradedesk period such as 5MINUTE or HOUR
  • date_from / date_to: inclusive date range
  • price_side: "bid" or "ask" ("bid" is the default)

Example shared cache location used at Radius Red:

/paperclip/tradedesk/marketdata/GBPUSD/2026/00/01_bid.csv.zst

3. Implement a Strategy

# strategy.py
import logging
from tradedesk import OrderRequest
from tradedesk.execution import request_order
from tradedesk.marketdata import CandleClosedEvent, ChartSubscription
from tradedesk.strategy import BaseStrategy

log = logging.getLogger(__name__)


class SimpleMomentumStrategy(BaseStrategy):

    SUBSCRIPTIONS = [ChartSubscription("CS.D.GBPUSD.TODAY.IP", "5MINUTE")]

    async def on_candle_close(self, event: CandleClosedEvent) -> None:
        candle = event.candle

        if candle.close > candle.open:
            log.info("Bullish candle — would buy")
            # await request_order(
            #     OrderRequest(instrument=event.instrument, direction="BUY", size=1.0)
            # )
        elif candle.close < candle.open:
            log.info("Bearish candle — would sell")
            # await request_order(
            #     OrderRequest(instrument=event.instrument, direction="SELL", size=1.0)
            # )

        # Store candle in chart history (default behaviour)
        await super().on_candle_close(event)

Key points:

  • Declare subscriptions via SUBSCRIPTIONS (or pass them to __init__).
  • on_candle_close fires for each completed candle.
  • Call super().on_candle_close(event) to keep chart history up to date.
  • Prefer await request_order(OrderRequest(...)) inside strategies. That route goes through OrderExecutionHandler, so any configured spread limits or order_gate callbacks still apply.

4. Simple Backtest with run_portfolio

Use run_portfolio for a quick, no-frills backtest that exercises the same code path as live trading.

# run_backtest.py
from datetime import date

from tradedesk import SimplePortfolio, run_portfolio
from tradedesk.execution.backtest.client import BacktestClient

from strategy import SimpleMomentumStrategy


def client_factory():
    return BacktestClient.from_dukascopy_cache(
        "/paperclip/tradedesk/marketdata",
        symbol="GBPUSD",
        instrument="CS.D.GBPUSD.TODAY.IP",
        period="5MINUTE",
        date_from=date(2025, 1, 1),
        date_to=date(2025, 1, 31),
        price_side="bid",
    )


run_portfolio(
    portfolio_factory=lambda c: SimplePortfolio(c, SimpleMomentumStrategy(c)),
    client_factory=client_factory,
    setup_logging=True,
)

# Inspect results
client = client_factory()  # create a fresh client to inspect (or capture it above)

To capture the client for inspection, use a closure:

created = {}

def client_factory():
    c = BacktestClient.from_dukascopy_cache(
        "/paperclip/tradedesk/marketdata",
        symbol="GBPUSD",
        instrument="CS.D.GBPUSD.TODAY.IP",
        period="5MINUTE",
        date_from=date(2025, 1, 1),
        date_to=date(2025, 1, 31),
    )
    created["client"] = c
    return c

run_portfolio(
    portfolio_factory=lambda c: SimplePortfolio(c, SimpleMomentumStrategy(c)),
    client_factory=client_factory,
)

client = created["client"]
print(f"Trades:       {len(client.trades)}")
print(f"Positions:    {client.positions}")
print(f"Realised PnL: {client.realised_pnl}")

If you need explicit slippage, commission, or overnight financing/admin-fee modelling, configure the BacktestClient before handing it to the portfolio:

from datetime import date

from tradedesk.execution.backtest import BacktestClient, FinancingCosts, TransactionCosts

created = {}

def client_factory():
    c = BacktestClient.from_dukascopy_cache(
        "/paperclip/tradedesk/marketdata",
        symbol="GBPUSD",
        instrument="CS.D.GBPUSD.TODAY.IP",
        period="5MINUTE",
        date_from=date(2025, 1, 1),
        date_to=date(2025, 1, 31),
    )
    c.set_transaction_costs(
        TransactionCosts(slippage_points=0.00005, commission_per_fill=2.5)
    )
    c.set_financing_costs(
        "CS.D.GBPUSD.TODAY.IP",
        FinancingCosts(admin_apr=0.03, finance_apr=0.06, friday_multiplier=3),
    )
    created["client"] = c
    return c

TransactionCosts exposes two commission models, which can be combined:

  • commission_per_fill — a fixed charge applied to every fill (open, increase, or close).
  • commission_per_round_trip — a fixed charge applied once per closed round trip. When a position is scaled out across several partial closes, the round-trip commission is pro-rated by close_size / opened_size, so a position closed in N steps is charged exactly one round trip in total — not N. Accrued financing/admin fees are likewise reported only on the final, fully-closing exit to avoid double counting across partial closes.

Partial closes (where close_size < position_size) emit a PositionClosedEvent of their own, so ledgers and recorders capture realised PnL on every scale-out rather than only on the final exit.

run_backtest(...) currently exposes transaction_costs via BacktestSpec. If you need overnight financing/admin fees, build and configure the BacktestClient yourself as above before running the session.


5. In-Memory Backtest

Supply candles directly without a CSV:

from tradedesk.types import Candle

history = {
    ("CS.D.GBPUSD.TODAY.IP", "5MINUTE"): [
        Candle(timestamp="2025-01-01T00:00:00Z", open=1.25, high=1.26, low=1.24, close=1.255),
        Candle(timestamp="2025-01-01T00:05:00Z", open=1.255, high=1.27, low=1.25, close=1.265),
    ]
}

created = {}

def client_factory():
    c = BacktestClient.from_history(history)
    created["client"] = c
    return c

run_portfolio(
    portfolio_factory=lambda c: SimplePortfolio(c, SimpleMomentumStrategy(c)),
    client_factory=client_factory,
)

6. Full Backtest with Metrics (run_backtest)

run_backtest wraps run_portfolio and adds:

  • Trade ledger written to CSV
  • Equity curve tracking
  • Excursion (MAE/MFE) computation
  • A Metrics object returned with summary statistics
import asyncio
from datetime import date
from pathlib import Path

from tradedesk import SimplePortfolio
from tradedesk.execution.backtest.runner import BacktestSpec, run_backtest

from strategy import SimpleMomentumStrategy


async def main():
    spec = BacktestSpec(
        instrument="CS.D.GBPUSD.TODAY.IP",
        period="5MINUTE",
        cache_dir=Path("/paperclip/tradedesk/marketdata"),
        symbol="GBPUSD",
        date_from=date(2025, 1, 1),
        date_to=date(2025, 1, 31),
        price_side="bid",
        half_spread_adjustment=0.5,  # add half the spread to BID-sourced candles
    )

    metrics = await run_backtest(
        spec=spec,
        out_dir=Path("output"),
        portfolio_factory=lambda c: SimplePortfolio(c, SimpleMomentumStrategy(c)),
    )

    print(f"Trades:          {metrics.trades}")
    print(f"Round trips:     {metrics.round_trips}")
    print(f"Win rate:        {metrics.win_rate:.1%}")
    print(f"Avg hold (min):  {metrics.avg_hold_minutes:.1f}")
    print(f"Final equity:    {metrics.final_equity:.2f}")
    print(f"Max drawdown:    {metrics.max_drawdown:.2f}")
    print(f"Profit factor:   {metrics.profit_factor:.2f}")


if __name__ == "__main__":
    asyncio.run(main())

Output artefacts are written to out_dir/:

File Contents
trades.csv One row per fill, including executable price, raw price, and any recorded spread/slippage/commission/financing/admin costs
round_trips.csv One row per reconstructed round trip, including exit reason, excursions, and aggregated cost columns
metrics.csv Portfolio and per-instrument summary metrics such as round trips, win rate, drawdown, expectancy, and Sharpe ratio
equity.csv Equity curve snapshots
equity_daily.csv End-of-day equity snapshots for daily performance tracking
exposure.csv Per-instrument time-in-market and long/short exposure summary
analysis.md Human-readable performance summary; includes an overnight financing/admin-fee section when those costs were recorded

7. What Happens Internally

When run_portfolio (or portfolio.run()) executes:

  1. SessionStartedEvent fires — strategy warmup() is called
  2. SessionReadyEvent fires — post-warmup checks run
  3. BacktestStreamer replays candles in sequence
  4. Each completed candle calls portfolio._handle_event(CandleClosedEvent):
  5. Publishes the event to the dispatcher (recording subscribers react here)
  6. Calls portfolio.on_candle_close(event)strategy.on_candle_close(event)
  7. BacktestClient.place_market_order simulates fills at the candle's close price
  8. SessionEndedEvent fires on completion

8. Switching to Live Trading

Replace the client_factory — the strategy and portfolio are unchanged:

from tradedesk.execution.ig import IGClient

run_portfolio(
    portfolio_factory=lambda c: SimplePortfolio(c, SimpleMomentumStrategy(c)),
    client_factory=IGClient,
)

License

Licensed under the Apache License, Version 2.0. See: https://www.apache.org/licenses/LICENSE-2.0

Copyright 2026 Radius Red Ltd. | Contact