Metadata-Version: 2.5
Name: explicit-backtest
Version: 0.1.0
Summary: Candle-driven backtest engine whose execution assumptions are stated rather than implied
Project-URL: Homepage, https://github.com/bond-labs-dev/explicit-backtest
Project-URL: Repository, https://github.com/bond-labs-dev/explicit-backtest
Project-URL: Changelog, https://github.com/bond-labs-dev/explicit-backtest/blob/main/CHANGELOG.md
Project-URL: Issues, https://github.com/bond-labs-dev/explicit-backtest/issues
Author-email: bondlabs <hello@bondlabs.dev>
License-Expression: MIT
License-File: LICENSE
Keywords: backtest,backtesting,funding-rate,perpetual-futures,quantitative-finance,trading
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Financial and Insurance Industry
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Office/Business :: Financial :: Investment
Classifier: Typing :: Typed
Requires-Python: >=3.10
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: ruff==0.16.*; extra == 'dev'
Description-Content-Type: text/markdown

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

```python
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:

```python
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

```bash
pip install explicit-backtest
```

## Development

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

## License

MIT.
