Backtest

Vectorized backtesting engine with fill-at-next-open semantics, exchange calendar integration, and session-boundary position management.

Included in the base pip install mktlib — no extra dependencies.

Overview

The engine runs a signal-driven backtest where:

  • A Strategy defines entry() and exit() methods returning composable Conditions

  • Conditions resolve to boolean Polars expressions evaluated over the full DataFrame

  • Fills use next-bar-open semantics: signal at bar t → market order fills at bar t+1’s open

  • Optional calendar filters to market hours; flatten_eod=True force-closes positions at session end

Return Model

Bar type

Formula

Entry bar (t+1)

(close - open) / open

Middle bars

close / prev_close - 1

Exit bar

(open - prev_close) / prev_close

Session-forced exit (flatten_eod)

(open - prev_close) / prev_close for held positions; 0 for same-bar entry+exit

Engine

mktlib.backtest.run(df, strategy, *, short_strategy=None, trade_side=TradeSide.LONG, calendar=None, flatten_eod=False, instrument_col=None, instrument_weights=None)[source]
Overloads:
  • df (pl.DataFrame), strategy (Strategy), short_strategy (Strategy), calendar (ExchangeCalendar | None), flatten_eod (bool), instrument_col (str), instrument_weights (Mapping[str, float] | pl.DataFrame | None) → MultiBacktestResult

  • df (pl.DataFrame), strategy (Strategy), short_strategy (Strategy), calendar (ExchangeCalendar | None), flatten_eod (bool), instrument_col (None) → BacktestResult

  • df (pl.DataFrame), strategy (Strategy), trade_side (TradeSide), calendar (ExchangeCalendar | None), flatten_eod (bool), instrument_col (str), instrument_weights (Mapping[str, float] | pl.DataFrame | None) → MultiBacktestResult

  • df (pl.DataFrame), strategy (Strategy), trade_side (TradeSide), calendar (ExchangeCalendar | None), flatten_eod (bool), instrument_col (None) → BacktestResult

  • df (pl.DataFrame), strategy (Strategy), trade_side (TradeSide), calendar (ExchangeCalendar | None), flatten_eod (bool), instrument_col (None), instrument_weights (Mapping[str, float] | pl.DataFrame) → MultiBacktestResult

Parameters:
Return type:

BacktestResult | MultiBacktestResult

Run a vectorized backtest with fill-at-next-open semantics.

Parameters:
  • df (DataFrame) – Must contain date, open, close, and any indicator columns referenced by the strategy.

  • strategy (Strategy) – Object with entry() and exit() returning Conditions. May optionally define init(df) -> pl.DataFrame to enrich the DataFrame with indicator columns before signal evaluation.

  • short_strategy (Strategy | None) – When provided, strategy is used as the long strategy and short_strategy as the short strategy. They are run independently, validated for mutual exclusivity (no overlapping positions), and merged into a single result.

  • trade_side (TradeSide) – Trade direction (single-strategy mode only). Overridden by the entry condition’s trade_side if set. Ignored when short_strategy is provided.

  • calendar (ExchangeCalendar | None) – Exchange calendar for market-hours filtering. When provided, the DataFrame is filtered to market hours before signal computation.

  • flatten_eod (bool) – Force-close positions at each session’s last bar, eliminating overnight exposure. Requires calendar.

  • instrument_col (str | None) – Column name identifying the symbol/ticker in a multi-symbol DataFrame. When provided, returns a MultiBacktestResult that stores per-symbol results for O(1) access (result["AAPL"]). Combined DataFrames with the symbol column prepended are available via .returns, .trades, .signals properties.

  • instrument_weights (Mapping[str, float] | DataFrame | None) – Optional portfolio weights for multi-instrument runs. Accepts either a Mapping[str, float] ({"TQQQ": 0.5, "AAPL": 0.1}) or a pl.DataFrame with columns (instrument, weight). When supplied, MultiBacktestResult.returns is the weighted portfolio time series ((date, return)) instead of the per-symbol concatenation. Requires instrument_col. See mktlib.backtest._weights for schema and renormalization semantics.

Returns:

  • BacktestResult – When instrument_col is None (default).

  • MultiBacktestResult – When instrument_col is set. Supports result[symbol] for O(1) per-symbol access, iteration, and lazy-cached combined views.

Return type:

BacktestResult | MultiBacktestResult

Notes

Signal at bar t → market order fills at bar t+1’s open.

  • Entry bar (t+1): return = (close - open) / open

  • Middle bars: return = close / prev_close - 1

  • Exit bar (first bar where position drops to 0): return = (open - prev_close) / prev_close (gap to fill price only)

When instrument_col is set, each symbol is backtested independently — indicators (e.g. rolling SMA) do not bleed across symbols. Calendar filtering is applied once on the full DataFrame before partitioning.

If instrument_weights is omitted, aggregation stays per-symbol and the caller decides how to combine:

result.returns.group_by("date").agg(pl.col("return").mean())

Multi-Symbol Backtesting

Pass instrument_col to run() to backtest multiple instruments in a single call. Returns a MultiBacktestResult with O(1) per-instrument access:

# df has columns: symbol, date, open, close
result = run(df, SmaCross(), instrument_col="symbol")

# O(1) per-symbol access — returns a BacktestResult
aapl = result["AAPL"]
aapl.returns.columns   # ["date", "return"]

# Iterate over symbols
for symbol, bt in result.items():
    print(symbol, bt.trades.height)

# Combined views (lazy-cached, symbol column first)
result.returns.columns   # ["symbol", "date", "return"]

# Equal-weight portfolio
portfolio = result.returns.group_by("date").agg(pl.col("return").mean())

Portfolio Weights

Pass instrument_weights to collapse per-symbol results into a single weighted (date, return) portfolio series:

result = run(
    df_multi, strategy,
    instrument_weights={"TQQQ": 0.5, "MSFT": 0.1, "AAPL": 0.1, ...},
)
result.returns   # (date, return) — weighted portfolio series

Weights accept either a Mapping[str, float] or a pl.DataFrame with columns (instrument, weight). Proportional and normalized inputs are equivalent — mktlib renormalizes at aggregation. When a symbol is missing on a given date, its weight drops from that date’s denominator (dynamic renormalization), keeping the portfolio series continuous across alignment gaps.

When instrument_weights is supplied without an explicit instrument_col, mktlib defaults to "instrument" (matching the canonical portfolio-weights schema). Public schema constants (PORTFOLIO_WEIGHTS_COLUMNS, INSTRUMENT_COLUMN, WEIGHT_COLUMN) live in mktlib.backtest._weights.

exception mktlib.backtest.InvalidPortfolioWeights[source]

Portfolio weights input failed schema or invariant validation.

mktlib.backtest.to_portfolio_weights_df(weights)[source]

Normalize a dict or DataFrame into the canonical portfolio-weights schema.

Accepts either a Mapping[str, float] or a pl.DataFrame with columns (instrument, weight). Returns a DataFrame with those exact columns and dtypes (Utf8, Float64), validated against:

  • non-empty

  • no nulls in either column

  • no NaN in weight

  • all weights >= 0

  • no duplicate instrument values

  • sum(weight) > 0

Raises:
Return type:

DataFrame

Parameters:

weights (Mapping[str, float] | DataFrame)

Types

class mktlib.backtest.BacktestResult(returns, trades, signals)[source]

Result of a single-symbol backtest run.

Parameters:
  • returns (DataFrame)

  • trades (DataFrame)

  • signals (DataFrame)

returns: DataFrame

(date, return) daily strategy returns.

trades: DataFrame

(entry_date, exit_date, side, pnl, bars_held) per-trade log.

side is 1 (long) or -1 (short), extracted from the entry bar.

signals: DataFrame

Full frame with _entry, _exit, _position, _side columns.

_side is 1 (long), -1 (short), or 0 (flat).

class mktlib.backtest.MultiBacktestResult(by_instrument, *, instrument_col, weights=None)[source]

Result of a multi-symbol backtest run.

Stores per-symbol BacktestResult instances for O(1) access via result["AAPL"]. Combined DataFrames with the symbol column prepended are available via returns, trades, and signals properties (lazy-cached on first access).

When weights is supplied, returns instead produces a portfolio-weighted (date, return) time series. The weights DataFrame must conform to the canonical portfolio-weights schema ((instrument, weight)) and is expected to be pre-validated by mktlib.backtest.to_portfolio_weights_df().

Parameters:
property symbols: list[str]

Ordered list of symbol keys.

property returns: DataFrame[source]

Portfolio returns.

Without weights: (instrument_col, date, return) — all symbols concatenated, one row per (symbol, date).

With weights: (date, return) — weighted-sum portfolio returns with dynamic denominator renormalization. On any given date, only symbols that reported a return contribute; the denominator is the sum of those present symbols’ weights.

property trades: DataFrame[source]

(symbol, entry_date, exit_date, pnl, bars_held) — all symbols.

property signals: DataFrame[source]

(symbol, ..., _entry, _exit, _position) — all symbols.

class mktlib.backtest.Strategy(*args, **kwargs)[source]

Any object with entry() and exit() returning Conditions.

Strategies may optionally define an init(self, df) -> pl.DataFrame method to enrich the DataFrame with indicator columns before signal evaluation. See InitStrategy for the typed variant.

Note

Strategies may optionally define an init(self, df) -> pl.DataFrame method to enrich the DataFrame with indicator columns before signal evaluation. This hook is called after calendar filtering (if any) and before entry()/exit() resolution. It is not part of the Protocol — existing strategies without init continue to work unchanged.

class mktlib.backtest.TradeSide(*values)[source]

Trade direction: +1 for long, -1 for short.

IntEnum so it works directly as a numeric multiplier.

LONG = 1
SHORT = -1

Conditions

Conditions are frozen dataclasses that resolve to boolean pl.Expr. They compose with & (All), | (Any_), and ~ (Not) operators.

from mktlib.backtest import Crossover, ValueGT

# Compose with operators
entry = Crossover("fast", "slow") & ValueGT("close", "sma_200")
class mktlib.backtest.Condition[source]

Base class for signal conditions that resolve to boolean pl.Expr.

class mktlib.backtest.Crossover(a, b, trade_side=None)[source]

a crosses above b (column name or constant).

Parameters:
class mktlib.backtest.Crossunder(a, b, trade_side=None)[source]

a crosses below b (column name or constant).

Parameters:
class mktlib.backtest.ValueGT(a, b, trade_side=None)[source]

a > b (column name, constant, or ColExpr).

Parameters:
class mktlib.backtest.ValueGTE(a, b, trade_side=None)[source]

a >= b (column name, constant, or ColExpr).

Parameters:
class mktlib.backtest.ValueLT(a, b, trade_side=None)[source]

a < b (column name, constant, or ColExpr).

Parameters:
class mktlib.backtest.ValueLTE(a, b, trade_side=None)[source]

a <= b (column name, constant, or ColExpr).

Parameters:
class mktlib.backtest.IsRising(col, period=1, trade_side=None)[source]

Column value is greater than its value period bars ago.

Parameters:
class mktlib.backtest.IsFalling(col, period=1, trade_side=None)[source]

Column value is less than its value period bars ago.

Parameters:
class mktlib.backtest.Custom(expr, trade_side=None)[source]

User-supplied polars expression — must evaluate to a boolean column.

Parameters:

Combinators

class mktlib.backtest.All(left, right, trade_side=None)[source]

Both conditions must be true (a & b).

Parameters:
class mktlib.backtest.Any_(left, right, trade_side=None)[source]

Either condition is true (a | b).

Parameters:
class mktlib.backtest.Not(inner, trade_side=None)[source]

Invert a condition (~a).

Parameters:

Same-Bar Fills: Take-Profit / Stop-Loss

Wrap an exit condition in Limit to fill on the same bar the condition fires, at the limit price — instead of the default fill-at- next-open. Designed for TP/SL strategies where the fill price is known in advance.

from mktlib.backtest import Col, Limit, Lit, ValueGTE, ValueLTE

# Take-profit: exit when high >= 103, fill at 103
tp_exit = Limit(ValueGTE(Col("high"), Lit(103.0)))

# Stop-loss: exit when low <= 95, fill at 95
sl_exit = Limit(ValueLTE(Col("low"), Lit(95.0)))

The fill price defaults to the RHS of the wrapped comparison (TP/SL idiom high >= TP → fill at TP). Pass price= explicitly for trailing stops or decoupled trigger/fill:

trailing_exit = Limit(
    ValueLTE(Col("low"), Col("trailing_stop")),
    price=Col("trailing_stop"),
)

Note

v1 scope: only the top-level Limit wrapper is recognized. Nested use inside All / Any_ / Not behaves as a plain boolean. Any_(TP, SL) bracket patterns are planned for a later release.

class mktlib.backtest.Limit(inner, price=None, trade_side=None)[source]

Exit condition with same-bar fill at a limit price.

Wraps an exit condition (typically ValueGT/GTE/LT/LTE) so that when the inner condition fires on bar t, the position exits on that same bar at price — not at the next bar’s open (mktlib’s default fill-at-next-open semantics). Intended for take-profit / stop-loss strategies where the fill price is known in advance.

When price is omitted, the fill price is auto-extracted from the right-hand side of the wrapped comparison — the typical TP/SL idiom high >= TP → fill at TP. Pass an explicit price expression for trailing stops or decoupled trigger/fill (e.g. price=Col( "trailing_stop") with the comparison also against that column).

v1 scope: only honored at the top level of exit_cond. Nested use inside All / Any_ / Not is treated as a plain boolean condition with no same-bar semantics. Bracket patterns (Any_(TP, SL)) are planned for a later release.

Parameters:

Column Expressions

Column expressions build composable numeric pl.Expr trees for use with ValueGT, ValueLT, and their >=/<= variants. They support standard arithmetic (+, -, *, /, %, unary -), comparison operators (>, >=, <, <=), and mix freely with plain str column names and float literals.

from mktlib.backtest import (
    Col, Lit, Pct, ValueGT, ValueLT, Crossover,
)

# Take-profit / stop-loss as an OR-combined exit
tp = ValueGT("close", Pct("entry_sma", 5))   # close > sma * 1.05
sl = ValueLT("close", Col("sma") - Col("vol") * 2)  # 2x vol below SMA
exit_cond = tp | sl

# Arithmetic expressions on both sides
ValueGT(Col("fast") - Col("slow"), Lit(0.0))

# Comparison operators on ColExpr return conditions directly
entry = Col("rsi") > 70  # equivalent to ValueGT(Col("rsi"), Lit(70.0))
class mktlib.backtest.ColExpr[source]

Base for composable column expressions that resolve to pl.Expr.

class mktlib.backtest.Col(name)[source]

Column reference — resolves to pl.col(name).

Parameters:

name (str)

class mktlib.backtest.Lit(value)[source]

Literal constant — resolves to pl.lit(value).

Parameters:

value (float)

class mktlib.backtest.Pct(base, pct)[source]

Price offset by pct``% from ``base.

Positive pct -> above, negative -> below.

Pct("close", 1.0) -> close * 1.01 (1% above) Pct("close", -0.5) -> close * 0.995 (0.5% below)

Parameters:

Entry-Bar Anchoring with EntryRef

When building TP/SL exits relative to the entry price, a plain column reference doesn’t work:

# BUG: resolves to close > close * 1.05 — always false
ValueGT("close", Pct("close", 5.0))

The threshold needs to reference the entry bar’s close, not the current bar’s. EntryRef solves this by snapshotting a column at the entry signal bar and forward-filling it through the position’s lifetime:

from mktlib.backtest import EntryRef, Pct, ValueGT, ValueLT

# TP: close > entry_close * 1.05
tp = ValueGT("close", Pct(EntryRef("close"), 5.0))

# SL: close < entry_close * 0.97
sl = ValueLT("close", Pct(EntryRef("close"), -3.0))

Under the hood, the engine:

  1. Detects EntryRef nodes in the exit condition tree

  2. Computes _entry signals (pass 1)

  3. Creates _entry_{col} snapshot columns: the column value where _entry is true, null elsewhere, then forward_fill()

  4. Resolves the exit condition against the snapshot columns (pass 2)

EntryRef composes freely with other expressions:

# ATR-based stop: 2 ATR below entry close
sl = ValueLT("close", EntryRef("close") - Col("atr") * 2)

# Multiple snapshots: entry close for TP, entry ATR for SL
tp = ValueGT("close", Pct(EntryRef("close"), 5.0))
sl = ValueLT("close", EntryRef("close") - EntryRef("atr") * 2)
class mktlib.backtest.EntryRef(col)[source]

Column value snapshotted at the entry signal bar, forward-filled.

The engine creates _entry_{col} columns automatically when it detects EntryRef nodes in the exit condition tree.

EntryRef("close") resolves to pl.col("_entry_close").

Parameters:

col (str)

Performance

Benchmark results for a MACD crossover strategy on synthetic minute-resolution OHLCV data (491,400 rows / 5 years). Signal resolution uses Polars in all cases; only the position-tracking / returns computation differs.

Engine

Time

vs Polars

Polars (vectorized with_columns)

0.025s

baseline

Numpy (vectorized array ops)

0.033s

1.3x slower

Pandas (vectorized)

0.223s

8.9x slower

Python for-loop over numpy arrays

0.206s

8.2x slower

Numba JIT (warm, @njit)

0.009s

2.8x faster

Calendar filtering adds ~8ms for schedule-join market-hours masking. flatten_eod adds ~4ms on top.

Note

Numba requires ahead-of-time compilation (~0.6s on first call, cached to disk thereafter). The Polars engine is the best default — no extra dependencies and competitive performance. Benchmark scripts live in scripts/bench_*.py.