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.
  • The same order path also preserves recording parity: when a live client does not publish its own position-open events, OrderExecutionHandler emits PositionOpenedEvent after a confirmed opening fill so recording subscribers see the same entry lifecycle boundary as they do in backtests.

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

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
equity.csv Equity curve snapshots
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_confirmed simulates the fill at the candle's close price, and the execution path emits the matching lifecycle events used by recording
  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,
)

When you switch to IG-backed DEMO or LIVE runs, request_order(...) still goes through the same execution handler. If the client does not publish its own position-open event, tradedesk emits PositionOpenedEvent after the confirmed opening fill so recording subscribers and custom observers continue to receive entry lifecycle events.

Live sessions may also fetch IG historical candles during strategy warmup or post-reconciliation exit checks. If IG reports that the account has exhausted its historical-data allowance, tradedesk treats that as a distinct quota failure rather than retrying it as a generic 403/auth issue. The default warmup and reconciliation paths log the failure and continue the session without priming that history fetch.


License

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

Copyright 2026 Radius Red Ltd. | Contact