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

Turn signals into portfolio weights

The first backtest held one stock. This tutorial uses the same bundled synthetic panels to rebalance two assets. A signals_df row contains one declared prediction for each (timestamp, asset) pair. The values 0.7 and 0.3 are fixed teaching inputs, not fitted forecasts. The strategy puts 80% of capital to work, so its first targets are 56% and 24%, with 20% left in cash. It evaluates its schedule on every callback and rebalances every second session.

Equities and ETFs

Both assets in each of these panels share the same daily bar timestamp. The NYSE calendar and New York timezone identify the synthetic 16:00 local closes. Orders submitted at the January 2 decision fill on January 3.

from importlib.metadata import version

import polars as pl
from ml4t.backtest import BacktestConfig, DataFeed, Engine, RebalanceConfig, RebalanceSchedule, Strategy, TargetWeightExecutor
from ml4t.backtest.example_data import load_example_prices


class RankedRebalance(Strategy):
    def __init__(self, calendar=None):
        self.executor = TargetWeightExecutor(RebalanceConfig(
            schedule=RebalanceSchedule.fixed_n_sessions(2),
            calendar=calendar,
            data_frequency="daily",
            max_single_weight=0.6,
        ))
        self.decisions = []

    def on_data(self, timestamp, data, context, broker):
        targets = {
            asset: round(bar["signals"]["prediction"] * 0.8, 2)
            for asset, bar in data.items()
        }
        orders = self.executor.execute(targets, data, broker, timestamp=timestamp)
        if orders:
            self.decisions.append((timestamp, targets, len(orders)))


def run(category, first_asset, calendar=None):
    prices = load_example_prices(category)
    signals = prices.select("timestamp", "asset").with_columns(
        pl.when(pl.col("asset") == first_asset)
        .then(0.7).otherwise(0.3).alias("prediction")
    )
    strategy = RankedRebalance(calendar)
    result = Engine(DataFeed(prices_df=prices, signals_df=signals), strategy,
        BacktestConfig(initial_cash=100_000, calendar=calendar, timezone="America/New_York", enforce_sessions=True)).run()
    print(f"{category}: decisions={len(strategy.decisions)}, fills={len(result.fills)}")
    first = strategy.decisions[0]
    print(f"  first target: {first[0]:%Y-%m-%d} {first[1]}")
    print(f"  first fills: {[f.asset for f in result.fills[:2]]} at {[f.timestamp.strftime('%Y-%m-%d') for f in result.fills[:2]]}")

print("ml4t-backtest " + version("ml4t-backtest"))
run("equity", "AAPL", "NYSE")
run("etf", "SPY", "NYSE")
ml4t-backtest {package_version}
equity: decisions=5, fills=6
  first target: 2024-01-02 {'AAPL': 0.56, 'MSFT': 0.24}
  first fills: ['AAPL', 'MSFT'] at ['2024-01-03', '2024-01-03']
etf: decisions=5, fills=5
  first target: 2024-01-02 {'SPY': 0.56, 'TLT': 0.24}
  first fills: ['SPY', 'TLT'] at ['2024-01-03', '2024-01-03']

TargetWeightExecutor.execute() turns the target dictionary into orders and keeps the schedule state. result.to_fills_dataframe() records actual fills; strategy.decisions records the targets used when orders were submitted. The number of fills differs across the two panels because later price changes produce different rounded share adjustments.

Futures with different close times

The synthetic ES bar closes at 21:00 UTC and ZC at 21:15 UTC. Exact-timestamp iteration therefore gives two partial callbacks per date. The observer below records that default behavior without submitting orders. The rebalance run uses the explicit session_date column, so both bars enter one decision at 21:15. The first ES and ZC orders fill on their own bars the next session. The example specifies a $50 point multiplier and per-contract margin for each synthetic contract. It does not claim that one exchange calendar covers both products; the supplied session labels define this example's grouping.

from importlib.metadata import version

import polars as pl
from ml4t.backtest import AssetClass, BacktestConfig, ContractSpec, DataFeed, Engine
from ml4t.backtest import RebalanceConfig, RebalanceSchedule, Strategy, TargetWeightExecutor
from ml4t.backtest.example_data import load_example_prices


class Observe(Strategy):
    def __init__(self):
        self.events = []

    def on_data(self, timestamp, data, context, broker):
        self.events.append((timestamp, sorted(data)))


class FuturesRebalance(Strategy):
    def __init__(self):
        self.executor = TargetWeightExecutor(RebalanceConfig(
            schedule=RebalanceSchedule.fixed_n_sessions(2),
            data_frequency="daily", timezone="UTC", max_single_weight=0.6,
        ))
        self.decisions = []

    def on_data(self, timestamp, data, context, broker):
        targets = {asset: round(bar["signals"]["prediction"] * 0.8, 2)
                   for asset, bar in data.items()}
        orders = self.executor.execute(targets, data, broker, timestamp=timestamp)
        if orders:
            self.decisions.append((timestamp, targets))


prices = load_example_prices("future")
observer = Observe()
Engine(DataFeed(prices_df=prices), observer, BacktestConfig(initial_cash=1_000_000)).run()
signals = prices.select("timestamp", "asset").with_columns(
    pl.when(pl.col("asset") == "ES").then(0.7).otherwise(0.3).alias("prediction")
)
strategy = FuturesRebalance()
result = Engine(
    DataFeed(prices_df=prices, signals_df=signals, session_col="session_date"),
    strategy,
    BacktestConfig(initial_cash=1_000_000, allow_leverage=True, timezone="UTC"),
    contract_specs={
        "ES": ContractSpec("ES", AssetClass.FUTURE, multiplier=50, margin=12000),
        "ZC": ContractSpec("ZC", AssetClass.FUTURE, multiplier=50, margin=10000),
    },
).run()
print("ml4t-backtest " + version("ml4t-backtest"))
print(f"exact events on first date: {[assets for ts, assets in observer.events if ts.day == 2]}")
print(f"session decision: {strategy.decisions[0][0]:%Y-%m-%d %H:%M} {strategy.decisions[0][1]}")
print(f"first fills: {[(f.asset, f.timestamp.strftime('%Y-%m-%d %H:%M')) for f in result.fills[:2]]}")
print(f"filled orders: {len(result.fills)}")
ml4t-backtest {package_version}
exact events on first date: [['ES'], ['ZC']]
session decision: 2024-01-02 21:15 {'ES': 0.56, 'ZC': 0.24}
first fills: [('ES', '2024-01-03 21:00'), ('ZC', '2024-01-03 21:15')]
filled orders: 2

A session decision cannot fill against the earlier 21:00 bar. The feed also rejects a session missing ES or ZC before the run starts. With the default exact-timestamp feed, do not infer a zero target from an asset absent at the current timestamp. See Data Feed for the session input contract.

USD-quoted FX pairs

The EURUSD and GBPUSD panels use UTC daily timestamps. Both prices are USD per unit of base currency, and the contract specifications declare USD settlement. No spot FX exchange calendar is enforced in this example. The schedule counts the observed daily sessions and still sends orders for next-bar execution.

from importlib.metadata import version

import polars as pl
from ml4t.backtest import AssetClass, BacktestConfig, ContractSpec, DataFeed, Engine
from ml4t.backtest import RebalanceConfig, RebalanceSchedule, Strategy, TargetWeightExecutor
from ml4t.backtest.example_data import load_example_prices


class FXRebalance(Strategy):
    def __init__(self):
        self.executor = TargetWeightExecutor(RebalanceConfig(
            schedule=RebalanceSchedule.fixed_n_sessions(2),
            data_frequency="daily", timezone="UTC", max_single_weight=0.6,
        ))
        self.decisions = []

    def on_data(self, timestamp, data, context, broker):
        targets = {asset: round(bar["signals"]["prediction"] * 0.8, 2)
                   for asset, bar in data.items()}
        orders = self.executor.execute(targets, data, broker, timestamp=timestamp)
        if orders:
            self.decisions.append((timestamp, targets))


prices = load_example_prices("fx")
signals = prices.select("timestamp", "asset").with_columns(
    pl.when(pl.col("asset") == "EURUSD").then(0.7).otherwise(0.3).alias("prediction")
)
strategy = FXRebalance()
result = Engine(
    DataFeed(prices_df=prices, signals_df=signals), strategy,
    BacktestConfig(initial_cash=100_000, timezone="UTC"),
    contract_specs={
        asset: ContractSpec(asset, AssetClass.FOREX, currency="USD")
        for asset in ("EURUSD", "GBPUSD")
    },
).run()
print("ml4t-backtest " + version("ml4t-backtest"))
print(f"first target: {strategy.decisions[0][0]:%Y-%m-%d} {strategy.decisions[0][1]}")
print(f"first fills: {[(f.asset, f.timestamp.strftime('%Y-%m-%d')) for f in result.fills[:2]]}")
print(f"filled orders: {len(result.fills)}")
ml4t-backtest {package_version}
first target: 2024-01-02 {'EURUSD': 0.56, 'GBPUSD': 0.24}
first fills: [('EURUSD', '2024-01-03'), ('GBPUSD', '2024-01-03')]
filled orders: 8

The Rebalancing guide covers target sizing, missing-price policies, and schedule metadata. The Account Policies guide covers cash and margin settings used by these variants.

In the book

Chapter 17, Section 17.7, Comparing allocator performance, and notebook 08, Library comparison compare portfolio construction workflows. Chapter 16's futures notebook adds contract and overnight-session assumptions; the FX case-study backtest applies targets to a larger prediction stream.