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 constantsfee_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.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

explicit_backtest-0.1.0.tar.gz (36.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

explicit_backtest-0.1.0-py3-none-any.whl (27.2 kB view details)

Uploaded Python 3

File details

Details for the file explicit_backtest-0.1.0.tar.gz.

File metadata

  • Download URL: explicit_backtest-0.1.0.tar.gz
  • Upload date:
  • Size: 36.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for explicit_backtest-0.1.0.tar.gz
Algorithm Hash digest
SHA256 bfc3231265ff49cf04294e2fee6c026e2ae9879f2994f402bd60b25846a4cd48
MD5 6cadb8cf350b973cdcab981339ca05a1
BLAKE2b-256 ca1d2deef7d56dd75b7068e9d5e44cadd8a95311c81beecbb3dc488dda265e4c

See more details on using hashes here.

Provenance

The following attestation bundles were made for explicit_backtest-0.1.0.tar.gz:

Publisher: release.yml on bond-labs-dev/explicit-backtest

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file explicit_backtest-0.1.0-py3-none-any.whl.

File metadata

File hashes

Hashes for explicit_backtest-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 ba2c6140bd86f807ad1d926514b8d648448ff4ad96299cbfd33145a9578024e9
MD5 3da64e916f0407d0c566cfa4d3c84596
BLAKE2b-256 ab2ac5e33a5a6ffae2db69bea7001699a9f4195ffc2adfca57b6773397767744

See more details on using hashes here.

Provenance

The following attestation bundles were made for explicit_backtest-0.1.0-py3-none-any.whl:

Publisher: release.yml on bond-labs-dev/explicit-backtest

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page