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.
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 byclose_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
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 |
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:
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_ordersimulates fills at the candle's close priceSessionEndedEventfires 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