ML4T Backtest
ML4T Backtest Documentation
Event-driven backtesting with realistic execution
Skip to content

Quickstart

Build and run your first backtest in 5 minutes.

Minimal Example

import polars as pl
from ml4t.backtest import Engine, DataFeed, Strategy

class BuyAndHold(Strategy):
    def on_data(self, timestamp, data, context, broker):
        for asset, bar in data.items():
            if broker.get_position(asset) is None:
                broker.submit_order(asset, 100)

# Create sample price data
prices = pl.DataFrame({
    "timestamp": pl.datetime_range(
        start=pl.datetime(2023, 1, 2),
        end=pl.datetime(2023, 6, 30),
        interval="1d",
        eager=True,
    ),
    "asset": "AAPL",
    "open": [150.0 + i * 0.1 for i in range(180)],
    "high": [151.0 + i * 0.1 for i in range(180)],
    "low": [149.0 + i * 0.1 for i in range(180)],
    "close": [150.5 + i * 0.1 for i in range(180)],
    "volume": [1_000_000] * 180,
})

feed = DataFeed(prices_df=prices)
engine = Engine(feed=feed, strategy=BuyAndHold())
result = engine.run()

print(f"Final Value:  ${result.metrics['final_value']:,.2f}")
print(f"Total Return: {result.metrics['total_return_pct']:.1f}%")
print(f"Sharpe Ratio: {result.metrics['sharpe']:.2f}")
print(f"Trades:       {result.metrics['num_trades']}")

Data Format

DataFeed expects a Polars DataFrame keyed by timestamp and asset plus at least one price column. Standard OHLCV is the default:

Column Type Required
timestamp Datetime Yes
asset String Yes
open Float Yes
high Float Yes
low Float Yes
close Float Yes
volume Float Yes

For multi-asset backtests, stack all assets in a single DataFrame -- the engine handles partitioning by timestamp automatically.

bar["price"] is always populated. By default it follows close, but it switches to FeedSpec.price_col or the price_col= override when you provide one.

Strategy Callbacks

Every strategy subclasses Strategy and implements on_data:

class MyStrategy(Strategy):
    def on_start(self, broker):
        """Called once before the backtest starts. Set up risk rules here."""
        pass

    def on_data(self, timestamp, data, context, broker):
        """Called on every bar. Generate orders here.

        Args:
            timestamp: Current bar's datetime
            data: Dict of {asset: {price, open, high, low, close, volume, signals, ...}}
            context: Dict of context data (if provided)
            broker: Broker for submitting orders and querying positions
        """
        pass

    def on_end(self, broker):
        """Called once after the backtest ends."""
        pass

Signal-Based Strategy

Pass pre-computed signals alongside prices:

from ml4t.backtest import run_backtest

class SignalStrategy(Strategy):
    def on_data(self, timestamp, data, context, broker):
        for asset, bar in data.items():
            signal = bar.get("signals", {}).get("prediction", 0)
            position = broker.get_position(asset)

            if signal > 0.7 and position is None:
                # Buy 10% of portfolio value
                equity = broker.get_account_value()
                shares = int(equity * 0.10 / bar["price"])
                if shares > 0:
                    broker.submit_order(asset, shares)

            elif signal < 0.3 and position is not None:
                broker.close_position(asset)

# Signals DataFrame has same timestamp/asset columns plus your signal columns
result = run_backtest(prices, SignalStrategy(), signals=signals_df)

Quote-Aware Feeds

If you have quotes, add them without changing your strategy interface:

from ml4t.backtest import BacktestConfig, DataFeed
from ml4t.backtest.config import ExecutionPrice

feed = DataFeed(
    prices_df=quotes_df,
    price_col="mid_price",
    bid_col="bid",
    ask_col="ask",
    bid_size_col="bid_size",
    ask_size_col="ask_size",
)

config = BacktestConfig(
    execution_price=ExecutionPrice.QUOTE_SIDE,
    mark_price=ExecutionPrice.QUOTE_SIDE,
)

Buys then fill from the ask, sells fill from the bid, and bar["price"] still gives your configured reference price.

Adding Transaction Costs

from ml4t.backtest import BacktestConfig
from ml4t.backtest.config import CommissionType, SlippageType

config = BacktestConfig(
    initial_cash=100_000,
    commission_type=CommissionType.PERCENTAGE,
    commission_rate=0.001,       # 0.1% per trade
    slippage_type=SlippageType.PERCENTAGE,
    slippage_rate=0.0005,        # 0.05% slippage
)

result = run_backtest(prices, strategy, config=config)

Using Framework Profiles

Match the exact behavior of another backtesting framework:

# Backtrader-compatible: next-bar execution, integer shares, margin account
result = run_backtest(prices, strategy, config="backtrader")

# VectorBT-compatible: same-bar execution, fractional shares, no costs
result = run_backtest(prices, strategy, config="vectorbt")

# Zipline-compatible: next-bar execution, per-share commission
result = run_backtest(prices, strategy, config="zipline")

# Conservative production settings
result = run_backtest(prices, strategy, config="realistic")

See Profiles for all available presets and their settings.

Adding Risk Management

Set position rules in on_start to automatically manage exits:

from ml4t.backtest import StopLoss, TakeProfit, TrailingStop, RuleChain

class ProtectedStrategy(Strategy):
    def on_start(self, broker):
        # Rules evaluate in order; first trigger wins
        broker.set_position_rules(RuleChain([
            StopLoss(pct=0.05),        # Exit at -5% loss
            TakeProfit(pct=0.15),      # Exit at +15% profit
            TrailingStop(pct=0.03),    # Trail 3% from high water mark
        ]))

    def on_data(self, timestamp, data, context, broker):
        for asset, bar in data.items():
            if broker.get_position(asset) is None:
                equity = broker.get_account_value()
                shares = int(equity * 0.10 / bar["price"])
                if shares > 0:
                    broker.submit_order(asset, shares)

Analyzing Results

engine.run() returns a BacktestResult with metrics, trades, and export methods:

result = engine.run()

# Key metrics (dict)
print(result.metrics["sharpe"])
print(result.metrics["max_drawdown_pct"])
print(result.metrics["win_rate"])
print(result.metrics["profit_factor"])

# Trades as Polars DataFrame
trades_df = result.to_trades_dataframe()
print(trades_df.head())

# Equity curve as Polars DataFrame
equity_df = result.to_equity_dataframe()
print(equity_df.head())

# Fills as Polars DataFrame
fills_df = result.to_fills_dataframe()
print(fills_df.head())

# Portfolio state snapshots
portfolio_df = result.to_portfolio_state_dataframe()
print(portfolio_df.head())

# Export to Parquet for analysis with ml4t-diagnostic
result.to_parquet("./results/my_backtest")

For quote-aware backtests, fills_df and trades_df preserve the quote context used for execution, while portfolio_df shows the effect of the configured marking source over time.

Convenience Function

For quick experiments, run_backtest combines DataFeed + Engine in one call:

from ml4t.backtest import run_backtest

# Accepts DataFrames or file paths
result = run_backtest(
    prices="data/prices.parquet",
    strategy=MyStrategy(),
    signals="data/signals.parquet",
    config="backtrader",
)

Next Steps