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()andexit()methods returning composable ConditionsConditions 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=Trueforce-closes positions at session end
Return Model
Bar type |
Formula |
|---|---|
Entry bar (t+1) |
|
Middle bars |
|
Exit bar |
|
Session-forced 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:
Run a vectorized backtest with fill-at-next-open semantics.
- Parameters:
df (
DataFrame) – Must containdate,open,close, and any indicator columns referenced by the strategy.strategy (
Strategy) – Object withentry()andexit()returning Conditions. May optionally defineinit(df) -> pl.DataFrameto 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’strade_sideif 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 aMultiBacktestResultthat stores per-symbol results for O(1) access (result["AAPL"]). Combined DataFrames with the symbol column prepended are available via.returns,.trades,.signalsproperties.instrument_weights (
Mapping[str,float] |DataFrame|None) – Optional portfolio weights for multi-instrument runs. Accepts either aMapping[str, float]({"TQQQ": 0.5, "AAPL": 0.1}) or apl.DataFramewith columns(instrument, weight). When supplied,MultiBacktestResult.returnsis the weighted portfolio time series ((date, return)) instead of the per-symbol concatenation. Requires instrument_col. Seemktlib.backtest._weightsfor 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:
Notes
Signal at bar t → market order fills at bar t+1’s open.
Entry bar (t+1): return =
(close - open) / openMiddle bars: return =
close / prev_close - 1Exit 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 apl.DataFramewith 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
weightall weights
>= 0no duplicate
instrumentvaluessum(weight) > 0
- Raises:
InvalidPortfolioWeights – On any schema or invariant violation.
TypeError – When weights is neither a
Mappingnor apl.DataFrame.
- Return type:
DataFrame- Parameters:
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.sideis1(long) or-1(short), extracted from the entry bar.
- signals: DataFrame
Full frame with
_entry,_exit,_position,_sidecolumns._sideis1(long),-1(short), or0(flat).
- class mktlib.backtest.MultiBacktestResult(by_instrument, *, instrument_col, weights=None)[source]
Result of a multi-symbol backtest run.
Stores per-symbol
BacktestResultinstances for O(1) access viaresult["AAPL"]. Combined DataFrames with the symbol column prepended are available viareturns,trades, andsignalsproperties (lazy-cached on first access).When weights is supplied,
returnsinstead 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 bymktlib.backtest.to_portfolio_weights_df().- Parameters:
by_instrument (dict[str, BacktestResult])
instrument_col (str)
weights (pl.DataFrame | None)
- 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.
- class mktlib.backtest.Strategy(*args, **kwargs)[source]
Any object with
entry()andexit()returning Conditions.Strategies may optionally define an
init(self, df) -> pl.DataFramemethod to enrich the DataFrame with indicator columns before signal evaluation. SeeInitStrategyfor the typed variant.Note
Strategies may optionally define an
init(self, df) -> pl.DataFramemethod to enrich the DataFrame with indicator columns before signal evaluation. This hook is called after calendar filtering (if any) and beforeentry()/exit()resolution. It is not part of the Protocol — existing strategies withoutinitcontinue to work unchanged.
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]
acrosses aboveb(column name or constant).
- class mktlib.backtest.Crossunder(a, b, trade_side=None)[source]
acrosses belowb(column name or constant).
- class mktlib.backtest.ValueGT(a, b, trade_side=None)[source]
a > b(column name, constant, or ColExpr).
- class mktlib.backtest.ValueGTE(a, b, trade_side=None)[source]
a >= b(column name, constant, or ColExpr).
- class mktlib.backtest.ValueLT(a, b, trade_side=None)[source]
a < b(column name, constant, or ColExpr).
- class mktlib.backtest.ValueLTE(a, b, trade_side=None)[source]
a <= b(column name, constant, or ColExpr).
- class mktlib.backtest.IsRising(col, period=1, trade_side=None)[source]
Column value is greater than its value
periodbars ago.
- class mktlib.backtest.IsFalling(col, period=1, trade_side=None)[source]
Column value is less than its value
periodbars ago.
- class mktlib.backtest.Custom(expr, trade_side=None)[source]
User-supplied polars expression — must evaluate to a boolean column.
- Parameters:
expr (Expr)
trade_side (TradeSide | None)
Combinators
- class mktlib.backtest.All(left, right, trade_side=None)[source]
Both conditions must be true (
a & b).
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 bart, 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 atTP. 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 insideAll/Any_/Notis treated as a plain boolean condition with no same-bar semantics. Bracket patterns (Any_(TP, SL)) are planned for a later release.
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)
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:
Detects
EntryRefnodes in the exit condition treeComputes
_entrysignals (pass 1)Creates
_entry_{col}snapshot columns: the column value where_entryis true,nullelsewhere, thenforward_fill()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)
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 |
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, |
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.