Skip to main content

ml4t-backtest

Python 3.12+ PyPI License: MIT

Event-driven backtesting engine for quantitative trading strategies with realistic execution modeling.

Part of the ML4T Library Ecosystem

This library is one of six interconnected libraries supporting the machine learning for trading workflow described in Machine Learning for Trading:

ML4T Library Ecosystem

Together they cover data infrastructure, feature engineering, modeling, signal evaluation, strategy backtesting, and live deployment.

What This Library Does

Backtesting requires accurate simulation of order execution, position tracking, and risk management. ml4t-backtest provides:

  • Event-driven architecture with point-in-time correctness (no look-ahead bias)
  • Exit-first order processing matching real broker behavior
  • Configurable execution modes (same-bar or next-bar fills)
  • Quote-aware execution and marking with price, bid, ask, midpoint, and side-aware sources
  • Position-level risk rules (stop-loss, take-profit, trailing stops)
  • Portfolio-level constraints (max positions, drawdown limits)
  • Cash, margin, and crypto account policies
  • First-class trade, fill, and portfolio-state export for audit and downstream analysis
  • 40+ behavioral knobs for framework-specific parity

The same Strategy class used in backtesting works unchanged in ml4t-live for production deployment.

ml4t-backtest Architecture

Installation

pip install ml4t-backtest

Quick Start

from datetime import datetime

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

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

            if position is None and signal > 0.5:
                shares = (broker.get_account_value() * 0.10) / price
                if shares > 0:
                    broker.submit_order(asset, shares)
            elif position is not None and signal < -0.5:
                broker.close_position(asset)

timestamps = [datetime(2024, 1, day) for day in (2, 3, 4, 5)]
prices = pl.DataFrame(
    {
        "timestamp": timestamps,
        "asset": ["AAPL"] * 4,
        "close": [100.0, 101.0, 103.0, 102.0],
    }
)
signals = pl.DataFrame(
    {
        "timestamp": timestamps,
        "asset": ["AAPL"] * 4,
        "prediction": [1.0, 1.0, -1.0, -1.0],
    }
)

config = BacktestConfig(initial_cash=100_000)
feed = DataFeed(prices_df=prices, signals_df=signals)
engine = Engine(feed, SignalStrategy(), config)
result = engine.run()

print(f"Total Return: {result.metrics['total_return_pct']:.2f}%")
print(f"Sharpe Ratio: {result.metrics['sharpe']:.2f}")
print(result.to_fills_dataframe().head())

Each Engine instance is single-use. Create a new instance for every independent run.

bar["price"] follows FeedSpec.price_col when you provide one, so the same strategy works for close-based bars and quote-aware feeds.

Risk Management

Position-level exit rules:

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

class MyStrategy(Strategy):
    def on_start(self, broker):
        broker.set_position_rules(RuleChain([
            StopLoss(pct=0.05),
            TakeProfit(pct=0.15),
            TrailingStop(pct=0.03),
        ]))

Portfolio-level controls:

from ml4t.backtest.risk.portfolio.limits import MaxDrawdownLimit, DailyLossLimit

Framework Profiles

Built-in profiles configure the behavioral semantics used by major backtesting frameworks:

from ml4t.backtest import BacktestConfig

# Match VectorBT behavior (same-bar close fills, fractional shares)
config = BacktestConfig.from_preset("vectorbt")

# Match Backtrader behavior (next-bar open fills, integer shares)
config = BacktestConfig.from_preset("backtrader")

# Match Zipline behavior (next-bar open fills, integer shares, per-share commission)
config = BacktestConfig.from_preset("zipline")

# Match QuantConnect LEAN behavior (same-bar close fills, integer shares)
config = BacktestConfig.from_preset("lean")

# Conservative production settings (higher costs, cash buffer)
config = BacktestConfig.from_preset("realistic")

Each profile sets 40+ behavioral knobs, including fill timing, execution price, share type, commission model, and order processing. Current exact-match evidence appears below.

Execution Modes

from ml4t.backtest import ExecutionMode, StopFillMode

# Same-bar fills (VectorBT style)
config = BacktestConfig(
    execution_mode=ExecutionMode.SAME_BAR,
    stop_fill_mode=StopFillMode.STOP_PRICE,
)

# Next-bar fills (Backtrader style)
config = BacktestConfig(
    execution_mode=ExecutionMode.NEXT_BAR,
    stop_fill_mode=StopFillMode.STOP_PRICE,
)

Quote-Aware Execution

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

feed = DataFeed(
    prices_df=quotes,
    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,
)

With QUOTE_SIDE, buys fill at the ask and sells fill at the bid when quotes are present. mark_price is configured separately, so you can trade on one source and mark the book on another.

Quote-aware runs also preserve the microstructure context in the result surface:

  • result.to_fills_dataframe() includes bid/ask/midpoint/spread/size context
  • result.to_trades_dataframe() includes nullable entry/exit quote summaries
  • result.to_portfolio_state_dataframe() reflects the configured mark source over time
  • result.to_predictions_dataframe() preserves the raw model/input surface for downstream diagnostics

Reproducible Config Snapshots

BacktestConfig is also the serializable backtest preset surface. You can keep input configs sparse, then persist the fully resolved config that actually ran.

config = BacktestConfig.from_yaml("config/my_backtest.yaml")
result = Engine(feed, strategy, config).run()

resolved_config = result.config.to_dict()
runtime_spec = result.to_spec_dict()
written = result.to_parquet("results/run_001")

The exported result directory includes:

  • config.yaml for the replayable resolved config payload
  • spec.yaml for the richer runtime snapshot with library version and realized run window

Use top-level feed in BacktestConfig for generic feed semantics and top-level metadata for user-defined provenance like input paths or strategy ids.

Commission and Slippage

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

config = BacktestConfig(
    commission_rate=0.001,         # 10 bps percentage
    slippage_rate=0.0005,          # 5 bps slippage
    stop_slippage_rate=0.001,      # Additional slippage for stop exits
)

# Or per-share (Interactive Brokers style)
config = BacktestConfig(
    commission_type=CommissionType.PER_SHARE,
    commission_per_share=0.005,
    commission_minimum=1.0,
)

# Or bar-only spread approximation in currency units
config = BacktestConfig(
    slippage_type=SlippageType.SPREAD,
    slippage_spread=0.02,
    slippage_spread_convention=SpreadConvention.FULL_SPREAD,
)

Multi-Asset Rebalancing

from ml4t.backtest import Strategy, TargetWeightExecutor, RebalanceConfig

class WeightStrategy(Strategy):
    def __init__(self):
        self.executor = TargetWeightExecutor(RebalanceConfig(
            min_trade_value=100,    # Optional: skip tiny dollar trades
            min_weight_change=0.01, # Optional: skip tiny weight changes
        ))
        self.bar_count = 0

    def on_data(self, timestamp, data, context, broker):
        self.bar_count += 1
        if self.bar_count % 21 != 1:  # Monthly rebalance
            return

        # ML predictions → portfolio weights
        weights = {}
        for asset, bar in data.items():
            signal = bar.get("signals", {}).get("prediction", 0)
            if signal and signal > 0:
                weights[asset] = signal
        if weights:
            total = sum(weights.values())
            weights = {a: w / total for a, w in weights.items()}
            self.executor.execute(weights, data, broker)

RebalanceConfig defaults both min_trade_value and min_weight_change to 0.0, so these filters are opt-in.

BacktestConfig() defaults to neutral costs: commission_type=NONE and slippage_type=NONE. Broker-specific fee models and synthetic slippage are opt-in.

Cross-Framework Validation

Profiles configure framework-specific execution behavior. The generated table below reports only claims supported by the retained release-candidate evidence.

Scenario claims use the retained release-candidate matrix. "Exact" appears only when every required scenario has zero canonical gap.

Profile Pinned framework Required scenarios Evidence
vectorbt_strict VectorBT Pro 2025.12.31 16/16 exact scenario evidence
vectorbt VectorBT OSS 0.28.2 15/15 exact scenario evidence
backtrader_strict Backtrader 1.9.78.123 16/16 exact scenario evidence
zipline_strict Zipline Reloaded 3.1.1 15/15 exact scenario evidence

Large-scale claims are published only when a retained workload has zero canonical gap.

Profile Pinned framework Compared Trade gap Terminal value Evidence
vectorbt_strict VectorBT Pro 2025.12.31 (1305a1e19743) 225,844 trades 0 685179.007330 large-scale evidence

No large-scale claim is published for Backtrader, Zipline, VectorBT OSS, or LEAN without a passing retained artifact.

See validation/README.md for methodology and detailed results.

Release-gate commands:

# Fast parity contract gate (scenario 01 across vectorbt/backtrader/zipline)
ML4T_COMPARISON_INPROC=1 uv run pytest tests/contracts/test_cross_engine_contracts.py -q

# Full correctness runner (selected scenarios)
python validation/run_all_correctness.py --framework vectorbt_oss --scenarios 01,03,05,09
python validation/run_all_correctness.py --framework backtrader --scenarios 01,03,05,09
python validation/run_all_correctness.py --framework zipline --scenarios 01,03,05,09

Performance

Release performance evidence covers deterministic single-asset, 250-asset daily, quote-aware, rebalance, and partial-fill workloads. Each workload runs three times in a fresh child process. The 250-asset workload periodically enters and exits a 50-position portfolio. The evidence separates setup from Engine.run(), measures peak RSS over the whole child process, reports runtime and memory sample spread, and verifies retained financial-output checksums and counts. The dedicated instrument-free hotpath benchmark enforces the runtime regression limit.

Run the release baselines and the instrument-free feed regression check locally:

uv run python validation/performance_baseline.py --output release-performance-evidence.json
uv run pytest tests/benchmark/test_hotpath_benchmarks.py::test_optimized_feed_runtime_vs_legacy_baseline --no-cov

Workload definitions and expected checksums are retained in validation/performance_baselines.json. The project does not publish hardware-dependent runtime, throughput, memory, or cross-framework ratios as stable claims.

Documentation

Technical Characteristics

  • Event-driven: Each bar processes sequentially with configurable order sequencing
  • Causal lifecycle: Per-bar callbacks receive the current bar; on_prepare receives configuration but no future feed timestamps
  • Configurable fills: Match behavior of different backtesting frameworks
  • Quote-aware: Optional bid/ask/mid/size caches with side-aware market fills
  • Parquet export: Trades, fills, equity, daily P&L, and config are serializable
  • Type-safe: 0 type diagnostics (ty/Astral), full type annotations

Related Libraries

  • ml4t-data: Market data acquisition and storage
  • ml4t-engineer: Feature engineering and technical indicators
  • ml4t-diagnostic: Signal evaluation and statistical validation
  • ml4t-live: Live trading with broker integration

Development

git clone https://github.com/ml4t/backtest.git
cd backtest
uv sync
uv run pytest tests/ -q
uv run ty check

Known Limitations

See LIMITATIONS.md for documented assumptions:

  • Bar data cannot identify the path or queue order of intrabar events
  • Corporate actions, borrow costs, taxes, and currency conversion are not modeled
  • The pre-stable strategy lifecycle still depends on the shared ml4t-live contract

License

MIT License - see LICENSE for details.

Release files for ml4t-backtest 0.1.4

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for ml4t-backtest 0.1.4
File Size Uploaded
ml4t_backtest-0.1.4.tar.gz 449.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for ml4t-backtest 0.1.4
File Interpreter ABI Platform
ml4t_backtest-0.1.4-py3-none-any.whl Python 3 none any Details

Total release size: 673.3 kB

Release files / ml4t_backtest-0.1.4.tar.gz

Download URL ml4t_backtest-0.1.4.tar.gz
Size 449.7 kB
Tags Source
SHA-256 checksum
How to use checksums
154e383056434e4949ad66d42370a0aecdef9328acaa638155c1ad03138caff0
BLAKE2b-256 checksum
How to use checksums
ce77d7729ad835d1d9ace18343722562830ccacbbb6ee34324e9d277c7348f00
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 3, 2026.

Transparency log

Release files / ml4t_backtest-0.1.4-py3-none-any.whl

Download URL ml4t_backtest-0.1.4-py3-none-any.whl
Size 223.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
51f919b139e5e8f8d1feb4af2d6fb1db51cc80353a39cb6ae6fb4d48333e2576
BLAKE2b-256 checksum
How to use checksums
7973141123a20ccd1c4eec281d060fb07ca7ca6707f646095214cc921f16b70d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 3, 2026.

Transparency log

Release history Release notifications | RSS feed

0.1.12

2 release files

0.1.11

2 release files

0.1.10

2 release files

0.1.9

2 release files

0.1.8

2 release files

0.1.7

2 release files

0.1.6

2 release files

0.1.5

2 release files

This release

0.1.4 This release

2 release files

0.1.3

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page