Skip to main content

explicit-backtest

A candle-driven backtest engine where the assumptions the engine cannot derive from the data are stated rather than implied.

Zero runtime dependencies — pure standard library. Python ≥3.10.

Quickstart

A strategy is any object with three methods. The engine owns everything else: order management, intrabar fills, fees and slippage, funding, equity tracking.

from datetime import datetime, timedelta, timezone

from explicit_backtest import (
    BacktestConfig,
    Candle,
    StrategySignal,
    run_backtest,
)


class BreakoutStrategy:
    """Long a 20-bar high, flat on a 10-bar low."""

    name = "breakout_20_10"

    def signal(self, candles, index, position_side):
        prior = candles[max(0, index - 20) : index]  # never includes this bar
        if len(prior) < 20:
            return StrategySignal(candles[index].open_time, "flat", "warmup")
        close = candles[index].close
        if position_side is None and close > max(c.high for c in prior):
            side = "long"
        elif position_side == "long" and close < min(c.low for c in prior[-10:]):
            side = "flat"
        else:
            side = position_side or "flat"
        return StrategySignal(candles[index].open_time, side, reason="breakout")

    def atr_at(self, candles, index):
        """Volatility estimate that sizes the position and places the stop."""
        window = candles[max(0, index - 13) : index + 1]
        if len(window) < 14:
            return None  # warming up — the engine declines to open a position
        return sum(c.high - c.low for c in window) / len(window)

    def trailing_stop(self, side, candles, index):
        return None  # this strategy exits on its signal, not on a trail


start = datetime(2024, 1, 1, tzinfo=timezone.utc)  # aware timestamps required
prices = [100 + i * 0.5 if i < 60 else 130 - (i - 60) * 0.8 for i in range(100)]
candles = [
    Candle("BTC-PERP", "1h", start + timedelta(hours=i), p, p + 0.2, p - 0.2, p)
    for i, p in enumerate(prices)
]

result = run_backtest(
    candles, BacktestConfig(initial_equity=10_000), BreakoutStrategy()
)

print(f"final equity: {result.final_equity:,.2f}")
for t in result.trades:
    print(
        f"{t.side} {t.entry_price:.2f} -> {t.exit_price:.2f}  "
        f"net {t.net_pnl_usd:+.2f}  fees {t.fee_usd:.2f}  {t.exit_reason}"
    )
final equity: 11,368.18
long 110.50 -> 125.20  net +1368.18  fees 9.94  signal_exit

Position size comes from the ATR estimate: size = (risk_per_trade × equity) / (ATR × atr_stop_multiplier), with the initial stop the same distance from entry. Both are frozen at entry.

What is stated rather than implied

Intrabar ordering is a deliberate, documented choice, not a side effect of code order. Several things can be true within one bar and the data cannot say which happened first. The engine picks one resolution, always the same one, and says which at the point where it happens:

  • A signal is decided at bar i's close and is actionable at bar i+1's open — never at the close that produced it.
  • On that bar an open-time (flat) signal exit resolves before the intrabar stop: live, it fills at the open, which precedes any intrabar touch of the stop on the same bar. Checking the stop first would take a later, worse fill and mislabel a signal exit as a stop-out.
  • The stop is checked against the entry bar itself. A position fills at that bar's open and the same bar's range can breach the stop before the next iteration; skipping it gives every trade a free look at its entry bar, and the realized loss can then exceed risk_per_trade.
  • A stop or liquidation that gaps takes the worse of open or level: a long gapping below its stop fills at the open, not at the stop it jumped over.

The full per-bar order is in engine.run_backtest's module docstring.

The funding leg is off by default and refuses to run on thin coverage. No funding_history means no funding. With one supplied, the engine measures the history's coverage of the candle span at its own settlement cadence and raises when it falls below funding_coverage_min (default 0.9) rather than silently taking a default rate on the uncovered hours. Set funding_coverage_min=None for a deliberate partial-coverage run.

Funding keys must be timezone-aware and on a UTC hour boundary, and candle timestamps must be timezone-aware. Both are checked, because both failure modes are silent: an unreachable key returns the default rate on every bar forever.

Fees and slippage are declared models, not baked-in constants — fee_bps and slippage_bps on the config, plus a standalone orderbook-walking estimator in slippage.simulate_fill for sizing a realistic slippage_bps.

The risk defaults are opinionated, and two of them act on a bare config: max_leverage=2.0 caps notional at entry, and max_drawdown_kill=0.25 stops the run at -25% from peak (BacktestResult.halted says so). For an engine that only does what it is told:

BacktestConfig(risk=RiskConfig(max_leverage=float("inf"), max_drawdown_kill=None))

Not perp-only: funding defaults to zero, so the engine runs on any candle series.

What it deliberately does not do

  • No metrics. The result is trades and an equity curve; Sharpe, drawdown and the rest belong to whatever consumes it. timeframe.py supplies the bar duration those calculations annualize against.
  • No re-entry on an exit bar. Every exit ends that bar's work, so the earliest re-entry is the next bar's open.
  • The last bar never opens a position. Signals come from bars 0..n-2 and fill on the following bar, so the final candle can only close one.
  • One position at a time, one instrument at a time. No pyramiding, no portfolio, no cross-instrument margin.
  • Equity points are stamped at a bar's open_time but valued at its close. The mark is the bar's outcome; the timestamp names the bar it belongs to.
  • No data loading. Candles come from the caller. That is why the dependency list is empty and stays empty.

Install

pip install explicit-backtest

Development

pip install -e ".[dev]"
ruff check src tests && ruff format --check src tests
pytest -q

License

MIT.

Metadata

Release files for explicit-backtest 0.1.0

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

Source distribution (sdist)

Source distribution for explicit-backtest 0.1.0
File Size Uploaded
explicit_backtest-0.1.0.tar.gz 36.6 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for explicit-backtest 0.1.0
File Interpreter ABI Platform
explicit_backtest-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 63.8 kB

Release files / explicit_backtest-0.1.0.tar.gz

Download URL explicit_backtest-0.1.0.tar.gz
Size 36.6 kB
Tags Source
SHA-256 checksum
How to use checksums
bfc3231265ff49cf04294e2fee6c026e2ae9879f2994f402bd60b25846a4cd48
BLAKE2b-256 checksum
How to use checksums
ca1d2deef7d56dd75b7068e9d5e44cadd8a95311c81beecbb3dc488dda265e4c
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 Aug 13, 2026.

Transparency log

Release files / explicit_backtest-0.1.0-py3-none-any.whl

Download URL explicit_backtest-0.1.0-py3-none-any.whl
Size 27.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
ba2c6140bd86f807ad1d926514b8d648448ff4ad96299cbfd33145a9578024e9
BLAKE2b-256 checksum
How to use checksums
ab2ac5e33a5a6ffae2db69bea7001699a9f4195ffc2adfca57b6773397767744
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 Aug 13, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.1.0 This release

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