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

Change the order, change the fill

Start with the completed AAPL backtest. Here, the strategy makes the same fixed decision on the first bar in every run: submit one buy order for one share. Only the order type or execution setting changes. The prices come from the bundled synthetic equity panel.

from importlib.metadata import version

import polars as pl
from ml4t.backtest import BacktestConfig, DataFeed, Engine, ExecutionMode, OrderType, Strategy
from ml4t.backtest.config import ExecutionPrice
from ml4t.backtest.example_data import load_example_prices


class SingleOrder(Strategy):
    def __init__(self, order_type, *, limit_price=None, stop_price=None):
        self.order_type = order_type
        self.limit_price = limit_price
        self.stop_price = stop_price
        self.submitted = False

    def on_data(self, timestamp, data, context, broker):
        if self.submitted:
            return
        broker.submit_order(
            "AAPL", 1, order_type=self.order_type,
            limit_price=self.limit_price, stop_price=self.stop_price,
        )
        self.submitted = True


prices = load_example_prices("equity").filter(pl.col("asset") == "AAPL")
scenarios = [
    ("next market", ExecutionMode.NEXT_BAR, OrderType.MARKET, None, None),
    ("same market", ExecutionMode.SAME_BAR, OrderType.MARKET, None, None),
    ("next limit", ExecutionMode.NEXT_BAR, OrderType.LIMIT, 188.50, None),
    ("next stop", ExecutionMode.NEXT_BAR, OrderType.STOP, None, 191.00),
    ("unfilled limit", ExecutionMode.NEXT_BAR, OrderType.LIMIT, 170.00, None),
]
print("ml4t-backtest " + version("ml4t-backtest"))
for name, timing, kind, limit, stop in scenarios:
    strategy = SingleOrder(kind, limit_price=limit, stop_price=stop)
    engine = Engine(DataFeed(prices_df=prices), strategy, BacktestConfig(
        execution_mode=timing,
        execution_price=ExecutionPrice.CLOSE if timing is ExecutionMode.SAME_BAR else ExecutionPrice.OPEN,
    ))
    result = engine.run()
    fills = result.to_fills_dataframe()
    if fills.height:
        fill = result.fills[0]
        print(f"{name}: {fill.timestamp:%Y-%m-%d} @ ${fill.price:.2f}")
    else:
        print(f"{name}: no fill; pending={len(engine.broker.get_pending_orders())}")
ml4t-backtest {package_version}
next market: 2024-01-03 @ $188.37
same market: 2024-01-02 @ $188.00
next limit: 2024-01-03 @ $188.50
next stop: 2024-01-03 @ $191.00
unfilled limit: no fill; pending=1

The next-bar market order was submitted on January 2 and filled at January 3's open. The same-bar run uses ExecutionPrice.CLOSE, so its fill is at January 2's close. The strategy does not inspect the bar price before submitting; the order is fixed in advance. If a decision depends on the completed bar's close, use next-bar execution. A close-conditioned signal cannot trade at an earlier open or assume it joined the already completed closing auction.

The buy limit at $188.50 and buy stop at $191.00 both reached their trigger on the next bar and filled at their declared levels. The $170 limit never traded and remains pending at the end of the input. result.to_fills_dataframe() contains only executed orders; engine.broker.get_pending_orders() shows the unfilled instruction. The Order Types guide lists all supported order fields, and Execution Semantics describes processing order and price selection.

In the book

Chapter 16, Section 16.3, Vectorized and event-driven backtesting, and notebook 04, Single Asset Backtest with ml4t-backtest apply order timing to a stateful RSI example with explicit costs.