polars_bt
polars_bt is a Rust-backed Polars expression plugin with three deliberately
separate backtesting engines.
| Engine | Model | State axis | Output |
|---|---|---|---|
pulse |
T0 quote/signal matching | time rows | scalar summary |
mosaic |
cross-sectional portfolio | dense daily panels | daily portfolio rows |
tempo |
multi-time cross-sectional portfolio | dense datetime panels | timestamp portfolio rows |
All three engines execute inside the Polars process. They do not serialize a DataFrame through Arrow IPC to call Rust.
Requirements and installation
- CPython 3.10, 3.11, or 3.12
- Python 3.10: Polars >=2.0.0,<2.1
- Python 3.11–3.12: Polars >=2.0.0,<2.2 (target release: 2.1.0)
- Prebuilt wheels: Linux x86_64; other platforms require a source build and are not covered by the release test matrix
pip install --upgrade polars_bt
| polars_bt version | Python Polars |
|---|---|
| 0.2.4 | Python 3.10: >=2.0.0,<2.1; Python 3.11–3.12: >=2.0.0,<2.2 |
| 0.2.3 | >=2.0.0,<2.1 |
| 0.2.2 | >=1.44.2,<1.45 |
| 0.2.1 | >=1.43,<1.44 |
Version 0.2.4 adds Polars 2.1 support without changing the backtest API.
Polars 2.1 requires Python >=3.11; dependency markers keep Python 3.10 on
Polars 2.0.x. Upgrade polars_bt and Polars together; environments retaining
Polars 1.44 should retain polars_bt==0.2.2.
For a local build, install the Rust toolchain specified in
rust-toolchain.toml (1.95), then run from the repository root:
uv venv --python 3.12 .venv
uv pip install --python .venv/bin/python -r requirements.txt
make install-release
The native plugin is built with Rust Polars 0.55.2, pyo3-polars 0.28, and
PyO3 0.29. Upgrading the Python Polars major or minor version requires
rebuilding and testing the plugin with the corresponding Rust dependencies.
CPython's abi3 wheel tag does not guarantee compatibility with a different
Polars version.
See the upgrade analysis for the 2.0/2.1 verification
results and historical 1.44 migration, and ARCHITECTURE.md
for current engine contracts.
Polars 2.0 defaults lazy collection to streaming. Prepare and explicitly sort
your complete panel before calling a backtest through eager DataFrame.select.
For example, use panel.lazy().sort(["datetime", "asset"]).collect() before
Tempo; use your asset key with date for Mosaic. Do not rely on join or
group-by output order. Direct lazy/streaming backtest execution is outside
the supported contract. See the official 2.0 migration guide.
Pulse: T0 quote matching
pulse retains the original quote-by-quote T0 matcher and returns one Struct
summary.
import polars as pl
from polars_bt import pulse
quotes = pl.DataFrame(
{
"ask": [100.0, 101.0, 102.0],
"bid": [99.5, 100.5, 101.5],
"long": [1, 0, 0],
"short": [0, 1, 0],
"close_long": [0, 0, 0],
"close_short": [0, 0, 0],
"time": [1000, 2000, 3000],
"limit_down": [90.0] * 3,
"limit_up": [110.0] * 3,
}
)
summary = quotes.select(
pulse(
"ask",
"bid",
"long",
"short",
"close_long",
"close_short",
"time",
"limit_down",
"limit_up",
).alias("pulse")
)
Set LOFIEX_MATCHER=easy to use the relaxed matcher; the default matcher keeps
the original limit-price checks.
Mosaic: cross-sectional portfolios
mosaic scans a dense, date-major panel in fixed asset_num row blocks. It
returns one daily Struct row containing date, cash, nav, turnover, and
holding_count.
import polars as pl
from polars_bt import mosaic
panel = pl.DataFrame(
{
"date": ["2024-01-02", "2024-01-02", "2024-01-03", "2024-01-03"],
"weight": [0.4, 0.4, 0.0, 0.5],
"ovn_ret": [0.0, 0.0, 0.01, -0.01],
"ind_ret": [0.0, 0.0, 0.0, 0.0],
"buyable": [True] * 4,
"sellable": [True] * 4,
"prev_close": [10.0] * 4,
"vwap": [10.0] * 4,
"is_rebalance": [True] * 4,
}
)
daily = panel.select(
mosaic(
date="date",
weight="weight",
ovn_ret="ovn_ret",
ind_ret="ind_ret",
buyable="buyable",
sellable="sellable",
prev_close="prev_close",
vwap="vwap",
is_rebalance="is_rebalance",
asset_num=2,
).alias("daily")
).unnest("daily")
Mosaic's input contract is intentionally narrow:
- rows are already sorted by
(date, asset)and every date has exactlyasset_numrows; - the asset row order is stable across dates, so the engine uses row offsets and performs no joins or asset hashing;
- callers materialize a complete panel before the call; the wrapper does not sort or fill missing assets;
- numeric nulls in
weight,ovn_ret,ind_ret,prev_close, andvwapare preserved as NaN semantics rather than silently filled with zero; - use it as an eager whole-table expression; it changes the output length;
- fees default to
st_fee=6e-4andlg_fee=1e-4.
Mosaic diagnostics
Enable Polars verbose mode to see bounded Rust-side diagnostics on stderr:
with pl.Config(verbose=True):
daily = panel.select(
mosaic(
date="date",
weight="weight",
ovn_ret="ovn_ret",
ind_ret="ind_ret",
buyable="buyable",
sellable="sellable",
prev_close="prev_close",
vwap="vwap",
is_rebalance="is_rebalance",
asset_num=2,
).alias("daily")
).unnest("daily")
pl.Config.set_verbose(True) and the process-level POLARS_VERBOSE=1 switch
enable the same plugin diagnostics. Records use a stable prefix and compact
key/value format:
[polars-bt][mosaic][INFO] event=start rows=12500000 days=2500 assets=5000
[polars-bt][mosaic][WARN] event=input_summary nan_weight=32 mixed_date_blocks=1
[polars-bt][mosaic][WARN] event=halt reason=NEGATIVE_CASH day_index=1902 cash=-0.0021
[polars-bt][mosaic][INFO] event=finish completed_days=1903 expected_days=2500
Verbose diagnostics add no result fields and do not change tolerated-input
semantics. Non-finite portfolio state is always a hard error with day, asset,
and calculation-stage context. Diagnostic reports retain only counts and the
first location for each category, so memory use does not grow with the number
of anomalies. Nullable returns remain visible as NAN_OVN_RET or NAN_IND_RET,
and nullable prices remain visible as INVALID_PREV_CLOSE or INVALID_VWAP.
Mosaic cannot detect cross-day asset-order changes because asset identifiers are intentionally absent from its row-offset protocol. Callers must continue to provide a stable asset order for every date.
Tempo: intraday and cross-day portfolios
tempo extends the dense row-offset model to arbitrary timestamps. Every
datetime contains a complete target cross-section, while is_rebalance
controls whether that timestamp only marks the existing portfolio or also
trades toward the supplied weights.
from datetime import datetime
import polars as pl
from polars_bt import tempo
panel = pl.DataFrame(
{
"datetime": [
datetime(2024, 1, 2, 9, 31),
datetime(2024, 1, 2, 9, 31),
datetime(2024, 1, 2, 14, 30),
datetime(2024, 1, 2, 14, 30),
],
"asset": ["A", "B", "A", "B"],
"weight": [0.4, 0.4, 0.0, 0.8],
"period_ret": [0.0, 0.0, 0.01, -0.01],
"buyable": [True] * 4,
"sellable": [True] * 4,
"is_rebalance": [True] * 4,
}
)
path = panel.select(
tempo(
"datetime",
"asset",
"weight",
"period_ret",
"buyable",
"sellable",
"is_rebalance",
t1=True,
).alias("path")
).unnest("path")
Tempo's contract is:
- rows are sorted by
(datetime, asset)and every datetime contains the same assets in the same order; - the first timestamp defines the canonical asset vector; Rust validates every later timestamp before running the backtest and never sorts or joins;
period_retis the return from the preceding timestamp to the current one, and is applied before the current rebalance;is_rebalance=Falsestill marks holdings and emits a snapshot but does not trade;- use it as an eager whole-table expression; it changes the output length;
- numeric nulls become NaN; NaN weights mean zero target and NaN returns mean zero return, with bounded warnings available through Polars verbose mode;
t1=Truefreezes same-day purchases until the date derived fromdatetimechanges, whilet1=Falseallows same-day sales;- output contains one row per datetime with
datetime,cash,nav, timestamp turnover, andholding_count.
Tempo supports long-only weights. buyable and sellable describe market
constraints at the current timestamp; Rust separately tracks the partially
sellable quantity required by T+1.
Development
make install-release
make fmt
make pre-commit
.venv/bin/python examples/basic_usage.py
.venv/bin/python benchmarks/benchmark_mosaic.py
.venv/bin/python benchmarks/benchmark_tempo.py
The accepted benchmark scale is 12.5 million rows. Mosaic uses 2,500 days by 5,000 assets; Tempo uses 500 days by five timestamps by 5,000 assets. Both have a five-second hard limit measured only around the expression call.
make fmt formats Rust and Python sources; make pre-commit checks formatting,
Clippy, Rust tests, Python tests, and Ruff. CI repeats the quality checks and
tests the installed wheel outside the source checkout on Python 3.10–3.12.
See CONTRIBUTING.md for the release process and
CHANGELOG.md for version history.
License
MIT. See LICENSE.
Metadata
Release files for polars-bt 0.2.4
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| polars_bt-0.2.4.tar.gz | 116.0 kB | With provenance | Details for polars_bt-0.2.4.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| polars_bt-0.2.4-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl | CPython 3.10 | abi3 | Linux glibc 2.17+ x86-64 | Details for polars_bt-0.2.4-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl |
Total release size: 5.9 MB
Release files / polars_bt-0.2.4.tar.gz
| Download URL | polars_bt-0.2.4.tar.gz |
|---|---|
| Size | 116.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
0737430aca2a2431f98ab2f943b7869551b43a4098eaf9798bc53b569bbd5a70
|
|
BLAKE2b-256 checksum How to use checksums |
e78ec653e60444b705d405a2d419253c5252242c8c69c1d21249c50d76fd60ec
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.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 Oct 30, 2026.
Transparency logRelease files / polars_bt-0.2.4-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
| Download URL | polars_bt-0.2.4-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl |
|---|---|
| Size | 5.7 MB |
| Tags | CPython 3.10 Linux glibc 2.17+ x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
bf3aa3cd28245adc8b635223141a6c96c9404fb9d7225417a42b447361c00067
|
|
BLAKE2b-256 checksum How to use checksums |
9f3d6fede887c8b5e12d2381cf12c4473b0fe42e518fc4c78e8baabe738a7a70
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.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 Oct 30, 2026.
Transparency logSecurity for polars-bt 0.2.4
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.
Full provenance
Both files in this release have been uploaded with provenance metadata.
Attestation for polars_bt-0.2.4.tar.gz
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 Oct 30, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.