Backtesting Guide
This guide explains what Quantex actually does when you run a backtest and how to interpret the results.
If you are new to the library, a backtest in Quantex means:
- your strategy class is executed on historical bars
- brokers manage orders and positions for each symbol
- equity is recorded over time
- a
BacktestReportis returned at the end
The backtester class
The central class is SimpleBacktester.
Constructor signature:
- strategy: a concrete
Strategy cash: starting capitalcommission: commission rate or cash amount, depending on commission typecommission_type: aCommissionTypelot_size: used for cash commission calculationsmargin_call: threshold used in broker margin logicleverage: leverage multiplier for amplified position sizing (default: 1.0)
Smallest working example
from quantex import Strategy, CSVDataSource, SimpleBacktester
class BuyAndHold(Strategy):
def __init__(self):
super().__init__()
self.entered = False
def init(self):
self.add_data(CSVDataSource("data.csv"), "TEST")
def next(self):
if not self.entered and len(self.data["TEST"].Close) >= 2:
self.positions["TEST"].buy(quantity=1.0)
self.entered = True
strategy = BuyAndHold()
backtester = SimpleBacktester(strategy, cash=10_000)
report = backtester.run()
print(report)
What happens inside SimpleBacktester.run()
The method does the following in order:
- splits cash evenly across all brokers in
SimpleBacktester.run() - calls
Strategy.init() - loops over the longest attached data series in
SimpleBacktester.run() - updates each source's current index
- calls
Broker._iterate()on each broker - updates indicator visibility windows
- calls
Strategy.next() - asks each broker to close open positions at the end via
Broker.close() - aggregates broker equity into the final
BacktestReport.PnlRecord
Important behavior to understand
Strategy copy behavior
SimpleBacktester.__init__() deep-copies the strategy you pass in.
That means the strategy object inside the backtester is not the same Python object you originally created.
Cash allocation in multi-symbol tests
If you attach multiple symbols, starting cash is divided equally across the symbol-specific brokers in SimpleBacktester.run().
This avoids double-counting cash across symbols, but it also means each symbol starts with only part of the total portfolio cash.
Orders are processed before Strategy.next()
In each loop, brokers process existing orders before the strategy places new ones for that same step. This means a newly placed order is not processed until the following iteration.
Market orders use the open price
The broker executes market orders using DataSource.COpen, not the close.
That detail matters when you compare expected trade prices with results.
Backtest configuration
from quantex import CommissionType
backtester = SimpleBacktester(
strategy,
cash=50_000,
commission=0.002,
commission_type=CommissionType.PERCENTAGE,
lot_size=1,
margin_call=0.5,
leverage=1.0,
)
Leverage for amplified position sizing
Quantex supports leverage for amplifying position sizes. With leverage:
- A
leverageof 2.0 means you control 2x the position with the same cash - The broker calculates margin as
position_value / leverage - Commission is still applied on the full position value
Example:
# 2x leverage - control twice the position with same cash
backtester = SimpleBacktester(
strategy,
cash=10_000,
leverage=2.0, # 2x leverage
)
# Result: A buy order with quantity=0.5 will control 100% of cash worth of shares
# but only use 50% of cash as margin
Commission types
The available commission modes come from CommissionType:
Commission is applied through Broker._calc_commission().
Understanding the report object
SimpleBacktester.run() returns BacktestReport.
Key fields:
BacktestReport.starting_cashBacktestReport.final_cashBacktestReport.PnlRecordBacktestReport.ordersBacktestReport.tradeRecord
Key derived properties and methods:
BacktestReport.total_returnBacktestReport.periods_per_yearBacktestReport.kelly_criterionBacktestReport.plot()BacktestReport.__str__()
Example:
print(report.starting_cash)
print(report.final_cash)
print(report.total_return)
print(report.periods_per_year)
print(report.orders)
report.plot()
About Sharpe ratio and summary output
The summary string from BacktestReport.__str__() computes Sharpe ratio directly from the equity returns series.
Important correction: the current BacktestReport does not expose a report.sharpe attribute or a report.max_drawdown property. Older docs referenced them as direct attributes, but the current code only computes those values inside string formatting or helper functions.
If you want maximum drawdown directly, use max_drawdown() on BacktestReport.PnlRecord:
from quantex.backtester import max_drawdown
dd = max_drawdown(report.PnlRecord)
print(dd)
Visualization
BacktestReport.plot() creates two panels:
- equity curve
- drawdown area chart
report.plot(figsize=(12, 6))
Multi-symbol backtests
from quantex import Strategy, CSVDataSource, SimpleBacktester
class TwoSymbolStrategy(Strategy):
def init(self):
self.add_data(CSVDataSource("eurusd.csv"), "EURUSD")
self.add_data(CSVDataSource("gbpusd.csv"), "GBPUSD")
def next(self):
if self.data["EURUSD"].CClose > self.data["GBPUSD"].CClose:
self.positions["EURUSD"].buy(quantity=0.2)
report = SimpleBacktester(TwoSymbolStrategy(), cash=20_000).run()
Remember that each broker receives an equal share of the starting cash in this setup.
Optimization from the backtester
SimpleBacktester also provides parameter search.
Sequential grid search
params = {
"fast_period": range(5, 11),
"slow_period": range(15, 31, 5),
}
best_params, best_report, results_df = backtester.optimize(
params,
constraint=lambda p: p["fast_period"] < p["slow_period"],
)
Parallel grid search
best_params, best_report, results_df = backtester.optimize_parallel(
params,
workers=4,
chunksize=1,
)
The parallel method is defined in SimpleBacktester.optimize_parallel().
Reading optimization results
Both optimization methods return:
best_paramsbest_reportresults_df
results_df is a pandas DataFrame assembled from the evaluated parameter combinations.
Important detail from the current implementation:
SimpleBacktester.optimize()storesreport.ordersin thetradescolumn, not a trade countSimpleBacktester.optimize_parallel()stores a numeric trade count in thetradesfield returned by workers
So treat the trades column carefully depending on which optimizer you used.
Practical debugging tips
Print broker state inside the strategy
def next(self):
broker = self.positions["TEST"]
print(self.data["TEST"].Index[self.data["TEST"].current_index])
print(self.data["TEST"].COpen, self.data["TEST"].CClose)
print(broker.position, broker.cash)
Run without a progress bar when scripting
SimpleBacktester.run() defaults to progress_bar=False, which is usually the cleanest option for logs and tests.
Use the tests as reference behavior
See tests/test_backtester.py for the current expected behavior of initialization, reporting, optimization validation, and period inference.
Limitations and caveats
The current implementation does not include:
- slippage modeling
- partial fills
- public order cancellation
- built-in walk-forward analysis helpers
- built-in out-of-sample evaluation workflow
Older documentation described some of those topics conceptually, but they are not first-class features in the current code.
Summary
Use SimpleBacktester when you want to:
- execute a strategy on historical data
- get an equity curve and order log
- compare parameter combinations with grid search
For more detail on order behavior, see Execution guide. For strategy design, see Strategy guide.