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

Inspect, join, and export results

This example reuses the bundled synthetic BTC perpetual panel. It declares America/New_York timestamps on the price bars, a fixed prediction value, and two funding rates. The strategy buys one contract and later closes it. No commission or slippage is charged. The resulting equity curve includes trading P&L and funding cash flows, while fills, trades, and funding remain separate records. See Costs and Funding for the event math.

Run and reconcile the exports

The code compares Polars timestamp dtypes before joining results to inputs. The joins use (timestamp, asset) for fills and funding, and timestamp for equity and predictions. There is no user-side zone cast. A temporary artifact is written with to_parquet() and read with BacktestResult.from_parquet(); frame equality then checks every exported surface used in this example.

from datetime import datetime
from importlib.metadata import version
from tempfile import TemporaryDirectory
from zoneinfo import ZoneInfo

import polars as pl
from polars.testing import assert_frame_equal
from ml4t.backtest import AssetClass, BacktestConfig, BacktestResult, ContractSpec, DataFeed, Engine
from ml4t.backtest.example_data import ExampleRoundTrip, load_example_prices


zone = "America/New_York"
prices = load_example_prices("crypto_perp").filter(pl.col("asset") == "BTC-PERP").with_columns(
    pl.col("timestamp").dt.replace_time_zone(zone)
)
signals = prices.select("timestamp", "asset").with_columns(pl.lit(0.4).alias("prediction"))
funding = pl.DataFrame({
    "timestamp": [datetime(2024, 1, day, tzinfo=ZoneInfo(zone)) for day in (4, 7)],
    "asset": ["BTC-PERP", "BTC-PERP"],
    "rate": [0.001, -0.0005],
})
result = Engine(
    DataFeed(prices_df=prices, signals_df=signals), ExampleRoundTrip("BTC-PERP", 1),
    BacktestConfig(initial_cash=100_000, allow_leverage=True, timezone=zone),
    contract_specs={"BTC-PERP": ContractSpec("BTC-PERP", AssetClass.FUTURE, margin=4000)},
    funding_df=funding,
).run()
equity = result.to_equity_dataframe()
fills = result.to_fills_dataframe()
trades = result.to_trades_dataframe()
state = result.to_portfolio_state_dataframe()
payments = result.to_funding_dataframe()
predictions = result.to_predictions_dataframe()
print("ml4t-backtest " + version("ml4t-backtest"))
frames = {
    "prices": prices,
    "predictions": predictions,
    "equity": equity,
    "fills": fills,
    "state": state,
    "funding": payments,
}
for name, frame in frames.items():
    assert frame.schema["timestamp"] == prices.schema["timestamp"]
assert trades.schema["entry_time"] == prices.schema["timestamp"]
assert trades.schema["exit_time"] == prices.schema["timestamp"]
print(f"timestamp zone: {prices.schema['timestamp'].time_zone}")
fill_match = fills.join(prices.select("timestamp", "asset", "close"),
                        on=["timestamp", "asset"], how="inner")
equity_match = equity.join(predictions.select("timestamp").unique(),
                           on="timestamp", how="inner")
funding_match = payments.join(prices.select("timestamp", "asset"),
                              on=["timestamp", "asset"], how="inner")
print(f"joined: fills {fill_match.height}/{fills.height}, "
      f"equity {equity_match.height}/{equity.height}, "
      f"funding {funding_match.height}/{payments.height}")
trading_pnl = trades.select(pl.col("pnl").sum()).item()
funding_cash = payments.select(pl.col("cash_delta").sum()).item()
net_change = result.metrics["final_value"] - 100_000
assert abs(trading_pnl + funding_cash - net_change) < 0.01
print(f"trading={trading_pnl:.2f} funding={funding_cash:.2f} "
      f"net={net_change:.2f}")
print(f"returns={equity.height} portfolio states={state.height}")
with TemporaryDirectory() as directory:
    result.to_parquet(directory)
    restored = BacktestResult.from_parquet(directory)
    for method in (
        "to_fills_dataframe", "to_trades_dataframe", "to_equity_dataframe",
        "to_portfolio_state_dataframe", "to_funding_dataframe",
        "to_predictions_dataframe",
    ):
        original = getattr(result, method)()
        loaded = getattr(restored, method)()
        assert_frame_equal(original, loaded)
    print("artifact round trip: fills, trades, equity, state, funding, predictions equal")
ml4t-backtest {package_version}
timestamp zone: America/New_York
joined: fills 2/2, equity 9/9, funding 2/2
trading=700.00 funding=-20.45 net=679.55
returns=9 portfolio states=9
artifact round trip: fills, trades, equity, state, funding, predictions equal

The return column in the equity frame is the bar-to-bar equity return; its first row is zero because there is no preceding bar. Portfolio state records end-of-bar cash, equity, and exposure. A fill is an execution event, while a trade is a realized exit leg. Funding payments are cash flows outside the fill stream. Thus the \(700 realized trading P&L plus -\)20.45 funding equals the $679.55 net equity change. Nine prediction and equity timestamps, two fills, and two funding events join to the original input timestamps exactly.

The default artifact includes a manifest, config and run spec, metrics, and Parquet components for trades, fills, rejected orders, predictions, equity, portfolio state, daily P&L, and funding. The manifest is checked on load; see Results & Analysis for the full format and recovery rules. The separate diagnostic handoff shows an optional, tested post-backtest analysis step.

In the book

Chapter 16, Strategy simulation and reporting, uses fill and trade reconciliation in notebook 04, Single asset ml4t-backtest. Chapter 17's portfolio metrics notebook extends the reporting workflow to allocation diagnostics.