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¶
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 directorysymbol: cache symbol folder, for exampleGBPUSDinstrument: instrument identifier used by your strategyperiod: target tradedesk period such as5MINUTEorHOURdate_from/date_to: inclusive date rangeprice_side:"bid"or"ask"("bid"is the default)
Example shared cache location used at Radius Red:
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_closefires 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 throughOrderExecutionHandler, so any configured spread limits ororder_gatecallbacks still apply. - The same order path also preserves recording parity: when a live client does
not publish its own position-open events,
OrderExecutionHandleremitsPositionOpenedEventafter 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
Metricsobject 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:
SessionStartedEventfires — strategywarmup()is calledSessionReadyEventfires — post-warmup checks run- BacktestStreamer replays candles in sequence
- Each completed candle calls
portfolio._handle_event(CandleClosedEvent): - Publishes the event to the dispatcher (recording subscribers react here)
- Calls
portfolio.on_candle_close(event)→strategy.on_candle_close(event) BacktestClient.place_market_order_confirmedsimulates the fill at the candle's close price, and the execution path emits the matching lifecycle events used by recordingSessionEndedEventfires 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