mirror of
https://github.com/manifoldbt/manifoldbt.git
synced 2026-08-25 15:08:03 +00:00
release: v0.19.0
This commit is contained in:
@@ -8,14 +8,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://discord.gg/bvU6Wjc72d"><img src="https://img.shields.io/badge/Discord-Join%20the%20community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join the ManifoldBT Discord" height="34"></a>
|
<a href="https://discord.gg/bvU6Wjc72d"><img src="https://img.shields.io/badge/Discord-join%20the%20community-5865F2?logo=discord&logoColor=white" alt="Discord"></a>
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://pypi.org/project/manifoldbt/"><img src="https://img.shields.io/pypi/v/manifoldbt?logo=pypi&logoColor=white&color=2f6fed" alt="PyPI"></a>
|
|
||||||
<img src="https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white" alt="Python 3.9+">
|
|
||||||
<img src="https://img.shields.io/badge/core-Rust-dea584?logo=rust&logoColor=white" alt="Rust core">
|
|
||||||
<a href="https://github.com/manifoldbt/manifoldbt/actions/workflows/bench-vs-vectorbt.yml"><img src="https://img.shields.io/badge/benchmarks-public%20CI-2ea44f?logo=githubactions&logoColor=white" alt="Benchmarks in public CI"></a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -33,7 +26,7 @@ sequential fill simulation with realistic fees, slippage, funding and look-ahead
|
|||||||
|
|
||||||
## Why ManifoldBT
|
## Why ManifoldBT
|
||||||
|
|
||||||
- **Fast**: 10M bars in 329 ms. 79x faster than vectorbt, and 311x once you also want drawdown and Sharpe. [Measured in public CI](#performance), every run linked.
|
- **Fast**: 10M bars in 317 ms. 78x faster than vectorbt, 308x once you also want drawdown and Sharpe, ~3,500x faster than backtrader. [Measured in public CI](#performance), every run linked.
|
||||||
- **Expressive**: fluent DSL with 30+ indicators, conditional logic, cross-asset references
|
- **Expressive**: fluent DSL with 30+ indicators, conditional logic, cross-asset references
|
||||||
- **Rigorous**: Monte Carlo, walk-forward, parameter sweeps, lookahead detection, exposure diagnostics
|
- **Rigorous**: Monte Carlo, walk-forward, parameter sweeps, lookahead detection, exposure diagnostics
|
||||||
- **Portable**: `pip install`, no Rust toolchain needed. Works on Python 3.9+.
|
- **Portable**: `pip install`, no Rust toolchain needed. Works on Python 3.9+.
|
||||||
@@ -107,13 +100,26 @@ store = mbt.import_csv("EURUSD_1m.csv", symbol="EURUSD", symbol_id=1,
|
|||||||
interval="1m", asset_class="forex")
|
interval="1m", asset_class="forex")
|
||||||
```
|
```
|
||||||
|
|
||||||
**Exchange connectors**: Binance, Bybit, Hyperliquid, dYdX, Bitstamp (free); Databento, Massive (Pro):
|
**Market data connectors**: Binance, Bybit, Hyperliquid, dYdX, Bitstamp, Yahoo Finance (free);
|
||||||
|
Databento, Massive (Pro):
|
||||||
|
|
||||||
```python
|
```python
|
||||||
store = mbt.ingest(provider="binance", symbol="BTCUSDT", symbol_id=1,
|
store = mbt.ingest(provider="binance", symbol="BTCUSDT", symbol_id=1,
|
||||||
start="2024-01-01T00:00:00Z", end="2025-01-01T00:00:00Z")
|
start="2024-01-01T00:00:00Z", end="2025-01-01T00:00:00Z")
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Yahoo Finance covers stocks, ETFs, indices (`^GSPC`), FX (`EURUSD=X`), futures
|
||||||
|
(`ES=F`) and crypto (`BTC-USD`) without an API key. Prices are dividend-adjusted,
|
||||||
|
like `yfinance`'s `auto_adjust=True`; pass `dataset="raw"` for unadjusted quotes.
|
||||||
|
Yahoo's own history limits apply: 1m over the last 30 days, 1h over ~2 years,
|
||||||
|
daily back to the listing date.
|
||||||
|
|
||||||
|
```python
|
||||||
|
store = mbt.ingest(provider="yahoo", symbol="AAPL", symbol_id=1, interval="1d",
|
||||||
|
asset_class="equity",
|
||||||
|
start="2015-01-01T00:00:00Z", end="2026-01-01T00:00:00Z")
|
||||||
|
```
|
||||||
|
|
||||||
Or from the CLI:
|
Or from the CLI:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -121,59 +127,6 @@ manifoldbt import-csv data.csv --symbol EURUSD --symbol-id 1 --interval 1m
|
|||||||
manifoldbt ingest --provider binance --symbol BTCUSDT --symbol-id 1 --start ... --end ...
|
manifoldbt ingest --provider binance --symbol BTCUSDT --symbol-id 1 --start ... --end ...
|
||||||
```
|
```
|
||||||
|
|
||||||
## Higher timeframes
|
|
||||||
|
|
||||||
Declare the timeframes you want alongside the simulation one, then read them
|
|
||||||
with `mbt.tf(...)`. Columns are forward-filled onto the simulation grid, and a
|
|
||||||
bar's value only becomes readable once that bar has closed, so there is no
|
|
||||||
look-ahead.
|
|
||||||
|
|
||||||
```python
|
|
||||||
config = mbt.BacktestConfig(
|
|
||||||
...,
|
|
||||||
bar_interval=Interval.minutes(1), # simulate on 1m
|
|
||||||
extra_timeframes={"1h": Interval.hours(1)}, # also resample to 1h
|
|
||||||
)
|
|
||||||
|
|
||||||
h1 = mbt.tf("1h")
|
|
||||||
h1.close # the last closed hourly close, held across the minute bars
|
|
||||||
```
|
|
||||||
|
|
||||||
For an **indicator** on a higher timeframe, use `.apply(...)`. It evaluates the
|
|
||||||
expression on that timeframe's own grid, so the period counts in *its* bars:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from manifoldbt.indicators import close, sma
|
|
||||||
|
|
||||||
band = mbt.tf("1h").apply(sma(close, 20)) # mean of 20 HOURLY closes
|
|
||||||
```
|
|
||||||
|
|
||||||
> Careful: `sma(mbt.tf("1h").close, 20)` is **not** the same thing. That reads
|
|
||||||
> the step-held hourly series on the simulation grid, so the period counts in
|
|
||||||
> simulation bars: on a 1m simulation it is a 20-*minute* smoothing of an hourly
|
|
||||||
> staircase. Use `.apply(...)` whenever you want an indicator *of* the higher
|
|
||||||
> timeframe.
|
|
||||||
|
|
||||||
## Sweeping a choice, not just a number
|
|
||||||
|
|
||||||
`mbt.param(...)` sweeps numbers. `mbt.choice(...)` sweeps *expressions*: the
|
|
||||||
selector becomes a grid axis, and each combination resolves to its branch before
|
|
||||||
the simulation runs, so the branches it did not pick cost nothing.
|
|
||||||
|
|
||||||
```python
|
|
||||||
band = mbt.choice("band", {
|
|
||||||
"30m": mbt.tf("30m").apply(sma(close, mbt.param("len"))),
|
|
||||||
"1h": mbt.tf("1h").apply(sma(close, mbt.param("len"))),
|
|
||||||
"2h": mbt.tf("2h").apply(sma(close, mbt.param("len"))),
|
|
||||||
})
|
|
||||||
|
|
||||||
sweep = mbt.run_sweep(strategy, {"band": ["30m", "1h", "2h"],
|
|
||||||
"len": range(10, 210, 10)}, config, store)
|
|
||||||
```
|
|
||||||
|
|
||||||
The branches can hold any expression, so the same mechanism sweeps which
|
|
||||||
exogenous column to use, which asset to reference, or which indicator to apply.
|
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
| # | Example | What it shows |
|
| # | Example | What it shows |
|
||||||
@@ -206,30 +159,26 @@ engine from PyPI the way a user would, generates its own data, checks that the
|
|||||||
engines produced the **same result**, and only then reports how long each took:
|
engines produced the **same result**, and only then reports how long each took:
|
||||||
a workload they disagree on gets no published timing at all.
|
a workload they disagree on gets no published timing at all.
|
||||||
|
|
||||||
**Latest run: [#13](https://github.com/manifoldbt/manifoldbt/actions/runs/32469701489)**
|
**Latest run: [#11](https://github.com/manifoldbt/manifoldbt/actions/runs/32396472073)**
|
||||||
ran on Linux x86_64, 4 vCPU (AMD EPYC 7763), Python 3.12, manifoldbt 0.18.0 /
|
ran on Linux x86_64, 4 vCPU, Python 3.12, manifoldbt 0.17.3 / vectorbt 0.28.4 /
|
||||||
vectorbt 0.28.4 / raptorbt 0.9.0, 3 interleaved repetitions, medians reported.
|
raptorbt 0.9.0, 3 interleaved repetitions.
|
||||||
|
|
||||||
| Workload | Bars | ManifoldBT | vectorbt | raptorbt |
|
| Workload | Bars | ManifoldBT | vectorbt | raptorbt |
|
||||||
|---|---:|---:|---:|---:|
|
|---|---:|---:|---:|---:|
|
||||||
| SMA crossover | 10M | **327 ms** | 26.12 s (x79) | 913 ms (x2.8) |
|
| SMA crossover | 10M | **317 ms** | 24.75 s (x78) | 878 ms (x2.8) |
|
||||||
| ...with drawdown, Sharpe, Sortino, volatility | 10M | **329 ms** | 102.38 s (**x311**) | 909 ms (x2.8) |
|
| ...with drawdown, Sharpe, Sortino, volatility | 10M | **317 ms** | 97.46 s (**x308**) | 894 ms (x2.8) |
|
||||||
| ...with a 5 bps fee and 2 bps slippage | 10M | **337 ms** | 26.08 s (x79) | not supported |
|
| ...with a 5 bps fee and 2 bps slippage | 10M | **316 ms** | 24.53 s (x78) | not supported |
|
||||||
| EMA + RSI filter, 5 bps fee | 1M | **57 ms** | 2.35 s (x40) | not supported |
|
| EMA + RSI filter, 5 bps fee | 1M | **52 ms** | 2.21 s (x41) | not supported |
|
||||||
| Five assets in one book | 1M | **148 ms** | 2.54 s (x17) | not supported |
|
| Five assets in one book | 1M | **140 ms** | 2.34 s (x17) | not supported |
|
||||||
| Stop-loss and take-profit bracket | 10M | **934 ms** | 26.24 s (x28) | 916 ms (**x1.0**) |
|
|
||||||
|
|
||||||
The second row is the one worth reading twice. Asking for a performance summary
|
The second row is the one worth reading twice. Asking for a performance summary
|
||||||
costs ManifoldBT nothing measurable, because it computes one during the run
|
costs ManifoldBT nothing measurable, because it computes one during the run
|
||||||
whether you read it or not, and costs vectorbt 102 seconds, because it defers
|
whether you read it or not, and costs vectorbt 73 seconds, because it defers the
|
||||||
the equity curve until a risk metric needs it and then has to build one.
|
equity curve until a risk metric needs it and then has to build one.
|
||||||
|
|
||||||
The last two rows are the ones where ManifoldBT does worst, and they are
|
The fifth row is the one where ManifoldBT does worst, and it is published for
|
||||||
published for that reason. Broadcasting a column per asset is close to free for
|
that reason: broadcasting a column per asset is close to free for vectorbt,
|
||||||
vectorbt, while walking five books is not free for anything. And on a
|
while walking five books is not free for anything.
|
||||||
stop-loss/take-profit bracket, raptorbt is level with us: the intra-bar check
|
|
||||||
that decides which of the two triggers first is a sequential walk in both
|
|
||||||
engines, so there is no vectorization left to win with.
|
|
||||||
|
|
||||||
### Parameter sweeps
|
### Parameter sweeps
|
||||||
|
|
||||||
@@ -254,14 +203,9 @@ The method, the parity gate and the known divergences are written up in
|
|||||||
|
|
||||||
backtrader runs the same EMA(12/26) + RSI(14) strategy on 500K 1-minute bars in
|
backtrader runs the same EMA(12/26) + RSI(14) strategy on 500K 1-minute bars in
|
||||||
**46,944 ms**, against **13 ms** for ManifoldBT: a factor of **3,556**. Measured
|
**46,944 ms**, against **13 ms** for ManifoldBT: a factor of **3,556**. Measured
|
||||||
with `benchmarks/bench_vs_competitors.py`, median of 3 runs, on a developer
|
with `benchmarks/bench_vs_competitors.py`, median of 3 runs. It sits outside the
|
||||||
machine and not the CI runner, so it is not comparable line-for-line with the
|
CI suite because its event-driven fills produce a different PnL, and the parity
|
||||||
table above.
|
gate publishes no timing for engines that did not do the same work.
|
||||||
|
|
||||||
It sits outside the CI suite because its event-driven fills produce a different
|
|
||||||
PnL, and the parity gate publishes no timing for engines that did not do the
|
|
||||||
same work. Treat it as an order of magnitude, not a benchmark: the two engines
|
|
||||||
are not doing the same thing.
|
|
||||||
|
|
||||||
### How it compares
|
### How it compares
|
||||||
|
|
||||||
@@ -289,7 +233,7 @@ Full API reference, indicator list, configuration guide, and best practices:
|
|||||||
| Monte Carlo | 1K sims | Unlimited |
|
| Monte Carlo | 1K sims | Unlimited |
|
||||||
| Walk-Forward | - | Anchored + Rolling |
|
| Walk-Forward | - | Anchored + Rolling |
|
||||||
| Parameter Stability | - | Yes |
|
| Parameter Stability | - | Yes |
|
||||||
| Crypto connectors (Binance, Bybit, Hyperliquid) | Yes | Yes |
|
| Free connectors (Binance, Bybit, Hyperliquid, dYdX, Bitstamp, Yahoo) | Yes | Yes |
|
||||||
| Databento & Massive connectors | - | Yes |
|
| Databento & Massive connectors | - | Yes |
|
||||||
| Safety checks (lookahead, exposure) | - | Yes |
|
| Safety checks (lookahead, exposure) | - | Yes |
|
||||||
| Tearsheets & export | - | Yes |
|
| Tearsheets & export | - | Yes |
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
"""Strategy template — copy this file and modify.
|
"""Strategy template — copy this file and modify.
|
||||||
|
|
||||||
|
Demonstrates:
|
||||||
|
- the minimal shape of a backtest: strategy, config, store, run
|
||||||
|
|
||||||
|
Data: shared store — real market data from `data/` (see examples/README.md)
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python examples/00_template.py
|
python examples/00_template.py
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ Demonstrates:
|
|||||||
- Diagnostics (lookahead, exposure stability, risk)
|
- Diagnostics (lookahead, exposure stability, risk)
|
||||||
- result.summary() rich output
|
- result.summary() rich output
|
||||||
|
|
||||||
|
Data: shared store — real market data from `data/` (see examples/README.md)
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python examples/01_trend_following.py
|
python examples/01_trend_following.py
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ Demonstrates:
|
|||||||
- Long and short positions
|
- Long and short positions
|
||||||
- Continuous sizing (signal * 0.25)
|
- Continuous sizing (signal * 0.25)
|
||||||
|
|
||||||
|
Data: shared store — real market data from `data/` (see examples/README.md)
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python examples/02_mean_reversion.py
|
python examples/02_mean_reversion.py
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ Demonstrates:
|
|||||||
- Momentum via smoothed ROC on 12h bars
|
- Momentum via smoothed ROC on 12h bars
|
||||||
- Volatility-adjusted sizing
|
- Volatility-adjusted sizing
|
||||||
|
|
||||||
|
Data: shared store — real market data from `data/` (see examples/README.md)
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python examples/03_multi_asset_momentum.py
|
python examples/03_multi_asset_momentum.py
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ The idea: fit a rolling OLS regression on price. When the slope is steep
|
|||||||
and the R² is high (price moves in a straight line), we have a strong trend.
|
and the R² is high (price moves in a straight line), we have a strong trend.
|
||||||
Size proportionally to slope strength * R² confidence.
|
Size proportionally to slope strength * R² confidence.
|
||||||
|
|
||||||
|
Data: shared store — real market data from `data/` (see examples/README.md)
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python examples/04_linear_regression.py
|
python examples/04_linear_regression.py
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ Demonstrates:
|
|||||||
- Kalman filter for spread equilibrium
|
- Kalman filter for spread equilibrium
|
||||||
- Z-score mean-reversion sizing
|
- Z-score mean-reversion sizing
|
||||||
|
|
||||||
|
Data: shared store — real market data from `data/` (see examples/README.md)
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python examples/05_stat_arb.py
|
python examples/05_stat_arb.py
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ Strategy:
|
|||||||
|
|
||||||
Demonstrates every plotting function available in manifoldbt.
|
Demonstrates every plotting function available in manifoldbt.
|
||||||
|
|
||||||
|
Demonstrates:
|
||||||
|
- every plotting function in manifoldbt, on one result
|
||||||
|
- tearsheet, equity, drawdown, monthly and annual returns
|
||||||
|
- rolling Sharpe and volatility, returns histogram
|
||||||
|
|
||||||
|
Data: shared store — real market data from `data/` (see examples/README.md)
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python examples/06_full_visualization.py
|
python examples/06_full_visualization.py
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ Demonstrates:
|
|||||||
- param() for sweep-able parameters
|
- param() for sweep-able parameters
|
||||||
- Walk-forward fold results inspection
|
- Walk-forward fold results inspection
|
||||||
|
|
||||||
|
Data: shared store — real market data from `data/` (see examples/README.md)
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python examples/07_walk_forward.py
|
python examples/07_walk_forward.py
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ Demonstrates:
|
|||||||
- run_sweep() for Cartesian grid search
|
- run_sweep() for Cartesian grid search
|
||||||
- Heatmap visualization with mbt.plot.heatmap_2d()
|
- Heatmap visualization with mbt.plot.heatmap_2d()
|
||||||
|
|
||||||
|
Data: shared store — real market data from `data/` (see examples/README.md)
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python examples/08_sweep_2d_heatmap.py
|
python examples/08_sweep_2d_heatmap.py
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ Demonstrates:
|
|||||||
- run_sweep_lite() for fast parameter grid search
|
- run_sweep_lite() for fast parameter grid search
|
||||||
- 3D surface visualization with mbt.plot.surface_3d()
|
- 3D surface visualization with mbt.plot.surface_3d()
|
||||||
|
|
||||||
|
Data: shared store — real market data from `data/` (see examples/README.md)
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python examples/09_surface_3d.py
|
python examples/09_surface_3d.py
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ Demonstrates:
|
|||||||
- Monte Carlo fan chart visualization
|
- Monte Carlo fan chart visualization
|
||||||
- Risk metrics from simulated distributions
|
- Risk metrics from simulated distributions
|
||||||
|
|
||||||
|
Data: shared store — real market data from `data/` (see examples/README.md)
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python examples/10_monte_carlo.py
|
python examples/10_monte_carlo.py
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ Demonstrates:
|
|||||||
- Periodic rebalancing
|
- Periodic rebalancing
|
||||||
- Per-strategy breakdown
|
- Per-strategy breakdown
|
||||||
|
|
||||||
|
Data: shared store — real market data from `data/` (see examples/README.md)
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python examples/11_portfolio.py
|
python examples/11_portfolio.py
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ Demonstrates:
|
|||||||
- check_exposure_stability(): verify positions are consistent across time windows
|
- check_exposure_stability(): verify positions are consistent across time windows
|
||||||
- risk_check(): post-run risk metrics validation
|
- risk_check(): post-run risk metrics validation
|
||||||
|
|
||||||
|
Data: shared store — real market data from `data/` (see examples/README.md)
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python examples/12_diagnostics.py
|
python examples/12_diagnostics.py
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ Demonstrates:
|
|||||||
- CUDA GPU acceleration (device="cuda")
|
- CUDA GPU acceleration (device="cuda")
|
||||||
- All expressions compile to native Rust — full Rayon / CUDA parallelism
|
- All expressions compile to native Rust — full Rayon / CUDA parallelism
|
||||||
|
|
||||||
|
Data: none — this file simulates price paths, it does not backtest on any
|
||||||
|
series. The paths are its output, not its input.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python examples/13_stochastic_simulation.py
|
python examples/13_stochastic_simulation.py
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ Logic:
|
|||||||
- 1h entry: RSI(14) < 35 during bullish regime → buy the dip
|
- 1h entry: RSI(14) < 35 during bullish regime → buy the dip
|
||||||
- Size: 50% of initial capital when conditions met, else flat
|
- Size: 50% of initial capital when conditions met, else flat
|
||||||
|
|
||||||
|
Data: shared store — real market data from `data/` (see examples/README.md)
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python examples/14_multi_timeframe.py
|
python examples/14_multi_timeframe.py
|
||||||
"""
|
"""
|
||||||
@@ -23,9 +25,12 @@ from manifoldbt.helpers import time_range, Slippage, Interval
|
|||||||
h12 = mbt.tf("12h") # references columns like "12h.close"
|
h12 = mbt.tf("12h") # references columns like "12h.close"
|
||||||
|
|
||||||
# -- Indicators ---------------------------------------------------------------
|
# -- Indicators ---------------------------------------------------------------
|
||||||
# Trend filter on 12-hour bars (forward-filled onto 1h grid)
|
# Trend filter on 12-hour bars. `apply()` evaluates the EMA on the 12h grid, so
|
||||||
trend_fast = ema(h12.close, 20)
|
# 20 and 50 count 12-HOUR candles. Written `ema(h12.close, 20)` they would count
|
||||||
trend_slow = ema(h12.close, 50)
|
# 20 rows of the 1h simulation grid over a step-held column -- under two 12h
|
||||||
|
# candles, not twenty. See `bt.tf`.
|
||||||
|
trend_fast = h12.apply(ema(close, 20))
|
||||||
|
trend_slow = h12.apply(ema(close, 50))
|
||||||
bullish = trend_fast > trend_slow
|
bullish = trend_fast > trend_slow
|
||||||
|
|
||||||
# Entry signal on 1-hour bars (native resolution)
|
# Entry signal on 1-hour bars (native resolution)
|
||||||
@@ -59,7 +64,7 @@ config = mbt.BacktestConfig(
|
|||||||
),
|
),
|
||||||
fees=mbt.FeeConfig.binance_perps(),
|
fees=mbt.FeeConfig.binance_perps(),
|
||||||
slippage=Slippage.fixed_bps(2),
|
slippage=Slippage.fixed_bps(2),
|
||||||
warmup_bars=50,
|
warmup_bars=50 * 12, # 50 twelve-hour candles, counted in 1h simulation bars
|
||||||
extra_timeframes={
|
extra_timeframes={
|
||||||
"12h": Interval.hours(12),
|
"12h": Interval.hours(12),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Example 15: Cross-Exchange — Signal Binance, Execution dYdX.
|
"""Cross-exchange — signals on Binance, execution on dYdX.
|
||||||
|
|
||||||
Simple RSI mean-reversion:
|
Simple RSI mean-reversion:
|
||||||
- RSI computed on Binance BTC perp data
|
- RSI computed on Binance BTC perp data
|
||||||
@@ -7,8 +7,18 @@ Simple RSI mean-reversion:
|
|||||||
- Per-venue fees: each symbol is charged its own exchange's fee schedule
|
- Per-venue fees: each symbol is charged its own exchange's fee schedule
|
||||||
(see FeeConfig.multi_venue below)
|
(see FeeConfig.multi_venue below)
|
||||||
|
|
||||||
|
Demonstrates:
|
||||||
|
- signals computed on one venue, orders filled on another
|
||||||
|
- a dict universe spanning two providers, with no special config
|
||||||
|
- per-venue fees: each symbol charged its own exchange's schedule
|
||||||
|
|
||||||
|
Data: shared store — real market data from `data/` (see examples/README.md)
|
||||||
|
|
||||||
Prerequisite:
|
Prerequisite:
|
||||||
Binance perp data (bars_1m/201.arrow) + dYdX data (dydx/1h/BTC-USD.arrow)
|
Binance perp data (bars_1m/201.arrow) + dYdX data (dydx/1h/BTC-USD.arrow)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python examples/15_cross_exchange.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Example 16: BTC-Hashrate Spread — Exogenous Data Strategy.
|
"""Exogenous data — a BTC/hashrate spread as a signal.
|
||||||
|
|
||||||
Thesis: Bitcoin hashrate is a proxy for miner commitment and network
|
Thesis: Bitcoin hashrate is a proxy for miner commitment and network
|
||||||
security. When BTC price drops but hashrate holds (or rises), miners
|
security. When BTC price drops but hashrate holds (or rises), miners
|
||||||
@@ -10,6 +10,15 @@ The strategy normalizes both BTC price and hashrate via EMA ratios
|
|||||||
A rolling z-score of the spread generates the signal: negative z means
|
A rolling z-score of the spread generates the signal: negative z means
|
||||||
price is cheap relative to hashrate (long), positive means expensive.
|
price is cheap relative to hashrate (long), positive means expensive.
|
||||||
|
|
||||||
|
Demonstrates:
|
||||||
|
- an exogenous series ASOF-joined onto the bar grid
|
||||||
|
- a spread between two EMA-normalised series as a signal
|
||||||
|
- a rolling z-score turning that spread into a position
|
||||||
|
|
||||||
|
Data: shared store — real BTC bars from `data/`, plus a hashrate series
|
||||||
|
(fetched, or generated by the sample generator below when absent).
|
||||||
|
See examples/README.md.
|
||||||
|
|
||||||
Exogenous data flow:
|
Exogenous data flow:
|
||||||
1. Fetch hashrate CSV (or use sample generator below)
|
1. Fetch hashrate CSV (or use sample generator below)
|
||||||
2. Register via mbt.register_exo("hashrate", df)
|
2. Register via mbt.register_exo("hashrate", df)
|
||||||
@@ -18,6 +27,9 @@ Exogenous data flow:
|
|||||||
|
|
||||||
Prerequisite:
|
Prerequisite:
|
||||||
Binance BTC perp data + hashrate exo registered in data/mega/exo/
|
Binance BTC perp data + hashrate exo registered in data/mega/exo/
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python examples/16_hashrate_exogene.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Example 17: Per-Venue Fees — charge each symbol its own fee schedule.
|
"""Per-venue fees — charge each symbol its own fee schedule.
|
||||||
|
|
||||||
Real desks route different assets to different exchanges (or liquidity tiers),
|
Real desks route different assets to different exchanges (or liquidity tiers),
|
||||||
each with its own maker/taker fees, funding column and borrow rate. ``FeeConfig``
|
each with its own maker/taker fees, funding column and borrow rate. ``FeeConfig``
|
||||||
@@ -9,6 +9,13 @@ Here a 4-asset momentum portfolio executes the majors (BTC, ETH) on a cheap
|
|||||||
venue and the alts (XRP, DOT) on a more expensive one. Single-provider universe,
|
venue and the alts (XRP, DOT) on a more expensive one. Single-provider universe,
|
||||||
so it runs without Pro.
|
so it runs without Pro.
|
||||||
|
|
||||||
|
Demonstrates:
|
||||||
|
- FeeConfig.per_venue: named fee schedules in one book
|
||||||
|
- symbol_venue: which symbol trades where
|
||||||
|
- the cost gap between routing majors and alts differently
|
||||||
|
|
||||||
|
Data: shared store — real market data from `data/` (see examples/README.md)
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python examples/17_per_venue_fees.py
|
python examples/17_per_venue_fees.py
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ Demonstrates:
|
|||||||
The standard format is a header row + `timestamp,open,high,low,close,volume`
|
The standard format is a header row + `timestamp,open,high,low,close,volume`
|
||||||
where timestamp is Unix milliseconds. MT4/MT5 exports are auto-detected.
|
where timestamp is Unix milliseconds. MT4/MT5 exports are auto-detected.
|
||||||
|
|
||||||
|
Data: synthetic — a sample generated by this file, reproducible
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python examples/18_csv_import.py
|
python examples/18_csv_import.py
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,109 +1,115 @@
|
|||||||
"""Créer ses propres indicateurs (indicateurs absents de la base).
|
"""Writing your own indicators — the ones the library does not ship.
|
||||||
|
|
||||||
|
Demonstrates:
|
||||||
|
- an indicator as a plain function returning an `Expr`
|
||||||
|
- `scan` for stateful indicators no rolling window can express
|
||||||
|
- `param(...)` to make a custom indicator sweepable
|
||||||
|
|
||||||
|
Data: shared store — real market data from `data/` (see examples/README.md)
|
||||||
|
|
||||||
|
Usage:
|
||||||
python examples/19_custom_indicators.py
|
python examples/19_custom_indicators.py
|
||||||
|
|
||||||
────────────────────────────────────────────────────────────────────────────
|
────────────────────────────────────────────────────────────────────────────
|
||||||
LE MODÈLE MENTAL
|
THE MENTAL MODEL
|
||||||
────────────────────────────────────────────────────────────────────────────
|
────────────────────────────────────────────────────────────────────────────
|
||||||
Un indicateur, ici, n'est RIEN d'autre qu'une fonction Python qui renvoie un
|
An indicator here is NOTHING but a Python function returning an `Expr`. An
|
||||||
`Expr`. Un `Expr` est un *nœud dans un graphe de calcul* : quand vous écrivez
|
`Expr` is a *node in a computation graph*: writing `(high + low) / 2` touches
|
||||||
`(high + low) / 2`, aucune donnée n'est touchée — vous décrivez une opération.
|
no data — it describes an operation. The whole graph is then compiled and
|
||||||
Le graphe complet est ensuite compilé et évalué **en Rust**, en une passe,
|
evaluated **in Rust**, in one vectorised pass. That is why your own indicators
|
||||||
vectorisé. C'est pour ça que vos indicateurs maison tournent à la vitesse des
|
run at the speed of the built-in ones: they end up in the same engine.
|
||||||
indicateurs natifs : ils finissent dans le même moteur.
|
|
||||||
|
|
||||||
Toute la lib `manifoldbt.indicators` est écrite comme ça (`sma` ==
|
The whole `manifoldbt.indicators` library is written this way (`sma` ==
|
||||||
`source.rolling_mean(period)`). Donc « ajouter un indicateur » = « écrire une
|
`source.rolling_mean(period)`). So "adding an indicator" means "writing a
|
||||||
fonction qui compose des `Expr` ». Trois niveaux, du plus simple au plus rare.
|
function that composes `Expr`s". Three levels, from the common to the rare.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from time import perf_counter
|
from time import perf_counter
|
||||||
|
|
||||||
import manifoldbt as mbt
|
import manifoldbt as mbt
|
||||||
# Colonnes de base (ce sont déjà des Expr) + quelques helpers.
|
# Base columns (already Exprs) plus a few helpers.
|
||||||
from manifoldbt.indicators import open, high, low, close, volume, sma, rsi, ema
|
from manifoldbt.indicators import open, high, low, close, volume, sma, rsi, ema
|
||||||
# Briques bas niveau : lit (constante), col (colonne par nom), when (if/else),
|
# Low-level bricks: lit (constant), col (column by name), when (if/else),
|
||||||
# scan/s (état récursif), param (paramètre balayable).
|
# scan/s (recursive state), param (sweepable parameter).
|
||||||
from manifoldbt.expr import lit, col, when, scan, s, param
|
from manifoldbt.expr import lit, col, when, scan, s, param
|
||||||
from manifoldbt.helpers import time_range, Slippage, Interval
|
from manifoldbt.helpers import time_range, Slippage, Interval
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
# NIVEAU 1 — COMPOSER LES PRIMITIVES (99 % des cas)
|
# LEVEL 1 — COMPOSING PRIMITIVES (99% of cases)
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
# On combine colonnes + opérateurs (+ - * /, > < >= & | ~) + méthodes d'Expr
|
# Combine columns + operators (+ - * /, > < >= & | ~) + Expr methods
|
||||||
# (rolling_mean/std/min/max/median, ewm_mean, zscore, pct_change, diff, lag,
|
# (rolling_mean/std/min/max/median, ewm_mean, zscore, pct_change, diff, lag,
|
||||||
# rsi, linreg_*, cross_above/below, cumsum, rank, ...). Chaque appel renvoie
|
# rsi, linreg_*, cross_above/below, cumsum, rank, ...). Every call returns
|
||||||
# un Expr, donc tout se chaîne.
|
# an Expr, so everything chains.
|
||||||
|
|
||||||
def awesome_oscillator(fast=5, slow=34):
|
def awesome_oscillator(fast=5, slow=34):
|
||||||
"""Awesome Oscillator (Bill Williams) — ABSENT de la base.
|
"""Awesome Oscillator (Bill Williams) — NOT in the library.
|
||||||
|
|
||||||
AO = SMA(prix médian, 5) − SMA(prix médian, 34), prix médian = (H+L)/2
|
AO = SMA(median price, 5) − SMA(median price, 34), median = (H+L)/2
|
||||||
|
|
||||||
Momentum : positif = pression acheteuse, négatif = vendeuse.
|
Momentum: positive means buying pressure, negative means selling.
|
||||||
"""
|
"""
|
||||||
median_price = (high + low) / 2 # Expr : opération sur 2 colonnes
|
median_price = (high + low) / 2 # Expr: an operation on 2 columns
|
||||||
return sma(median_price, fast) - sma(median_price, slow) # Expr résultat
|
return sma(median_price, fast) - sma(median_price, slow) # the result Expr
|
||||||
|
|
||||||
|
|
||||||
def dist_to_ma_pct(period=20):
|
def dist_to_ma_pct(period=20):
|
||||||
"""Écart en % du prix à sa moyenne mobile — ABSENT de la base.
|
"""Distance from price to its moving average, in % — NOT in the library.
|
||||||
|
|
||||||
Négatif = le prix est SOUS sa moyenne (survendu) → brique idéale pour du
|
Negative means the price sits BELOW its average (oversold), which makes it
|
||||||
retour à la moyenne. Une seule ligne de composition.
|
a natural building block for mean reversion. One line of composition.
|
||||||
"""
|
"""
|
||||||
ma = sma(close, period)
|
ma = sma(close, period)
|
||||||
return (close - ma) / ma * 100.0
|
return (close - ma) / ma * 100.0
|
||||||
|
|
||||||
|
|
||||||
def intraday_range_pct():
|
def intraday_range_pct():
|
||||||
"""Amplitude de la bougie en % du close — ABSENT de la base.
|
"""Bar range as a % of the close — NOT in the library.
|
||||||
|
|
||||||
Un proxy de volatilité instantané. Montre qu'on mélange librement les
|
An instant volatility proxy. Shows that OHLC columns mix freely.
|
||||||
colonnes OHLC.
|
|
||||||
"""
|
"""
|
||||||
return (high - low) / close * 100.0
|
return (high - low) / close * 100.0
|
||||||
|
|
||||||
|
|
||||||
def rsi_zscore(period=14, lookback=365):
|
def rsi_zscore(period=14, lookback=365):
|
||||||
"""RSI standardisé : à quel point le RSI est extrême vs SA PROPRE histoire.
|
"""Standardised RSI: how extreme the RSI is against ITS OWN history.
|
||||||
|
|
||||||
Compose un indicateur natif (rsi) avec des stats roulantes. C'est
|
Composes a built-in indicator (rsi) with rolling statistics — the same
|
||||||
exactement le motif utilisé dans strategies/rsi_dynamic_alloc.py.
|
pattern used in strategies/rsi_dynamic_alloc.py.
|
||||||
"""
|
"""
|
||||||
r = rsi(close, period)
|
r = rsi(close, period)
|
||||||
return (r - r.rolling_mean(lookback)) / r.rolling_std(lookback)
|
return (r - r.rolling_mean(lookback)) / r.rolling_std(lookback)
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
# NIVEAU 2 — `scan` : INDICATEURS À ÉTAT / RÉCURSIFS
|
# LEVEL 2 — `scan`: STATEFUL / RECURSIVE INDICATORS
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
# Quand la valeur d'aujourd'hui dépend de celle d'HIER (récursion) et qu'aucun
|
# When today's value depends on YESTERDAY's (recursion) and no rolling window
|
||||||
# rolling ne suffit, on utilise `scan`. Il tourne comme une petite VM scalaire,
|
# suffices, reach for `scan`. It runs as a small scalar VM, entirely in Rust
|
||||||
# entièrement en Rust (pas de callback Python par barre).
|
# (no Python callback per bar).
|
||||||
#
|
#
|
||||||
# scan(state=..., update=..., output=...)
|
# scan(state=..., update=..., output=...)
|
||||||
# • state : variables d'état + leur valeur initiale (1re ligne)
|
# • state : state variables and their initial value (first row)
|
||||||
# • update : expressions évaluées à chaque barre, DANS L'ORDRE
|
# • update : expressions evaluated on every bar, IN ORDER
|
||||||
# - s.prev("x") = valeur de "x" à la barre précédente
|
# - s.prev("x") = value of "x" on the previous bar
|
||||||
# - s.var("k") = valeur calculée plus tôt DANS LE MÊME pas
|
# - s.var("k") = value computed earlier WITHIN THE SAME step
|
||||||
# - si un nom d'update == un nom d'état, on réécrit cet état
|
# - an update name matching a state name rewrites that state
|
||||||
# • output : quelle variable émettre comme résultat
|
# • output : which variable to emit as the result
|
||||||
#
|
#
|
||||||
# Preuve que c'est puissant : le Kalman et le GARCH livrés sont écrits
|
# Proof that it is enough: the shipped Kalman and GARCH are written with scan
|
||||||
# UNIQUEMENT avec scan (voir manifoldbt/indicators.py).
|
# ALONE (see manifoldbt/indicators.py).
|
||||||
|
|
||||||
def up_streak():
|
def up_streak():
|
||||||
"""Nombre de bougies HAUSSIÈRES consécutives — ABSENT de la base, et
|
"""Count of consecutive UP bars — NOT in the library, and impossible with
|
||||||
impossible avec un simple rolling (il faut un compteur qui se réinitialise).
|
a plain rolling window (it needs a counter that resets).
|
||||||
|
|
||||||
streak = streak_précédent + 1 si close > close(-1), sinon 0
|
streak = previous streak + 1 if close > close(-1), else 0
|
||||||
"""
|
"""
|
||||||
is_up = close > close.lag(1) # Expr booléen (1.0 / 0.0) par barre
|
is_up = close > close.lag(1) # boolean Expr (1.0 / 0.0) per bar
|
||||||
return scan(
|
return scan(
|
||||||
state={"n": lit(0.0)}, # compteur initialisé à 0
|
state={"n": lit(0.0)}, # counter seeded at 0
|
||||||
update={
|
update={
|
||||||
# if is_up: prev(n) + 1 else: 0
|
# if is_up: prev(n) + 1 else: 0
|
||||||
"n": when(is_up, s.prev("n") + lit(1.0), lit(0.0)),
|
"n": when(is_up, s.prev("n") + lit(1.0), lit(0.0)),
|
||||||
@@ -113,66 +119,66 @@ def up_streak():
|
|||||||
|
|
||||||
|
|
||||||
def ema_from_scratch(alpha=0.1):
|
def ema_from_scratch(alpha=0.1):
|
||||||
"""EMA « à la main » via scan — juste pour illustrer le mécanisme.
|
"""A hand-rolled EMA via scan — purely to show the mechanism.
|
||||||
(L'EMA existe en natif : `ema(close, span)`. Ici c'est pédagogique.)
|
(EMA is built in: `ema(close, span)`. This one is pedagogical.)
|
||||||
|
|
||||||
ema = alpha * close + (1 - alpha) * ema_précédent
|
ema = alpha * close + (1 - alpha) * previous ema
|
||||||
"""
|
"""
|
||||||
return scan(
|
return scan(
|
||||||
state={"ema": close}, # graine = 1er close
|
state={"ema": close}, # seeded with the first close
|
||||||
update={"ema": lit(alpha) * close + lit(1.0 - alpha) * s.prev("ema")},
|
update={"ema": lit(alpha) * close + lit(1.0 - alpha) * s.prev("ema")},
|
||||||
output="ema",
|
output="ema",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
# NIVEAU 3 — LES LIMITES (À CONNAÎTRE)
|
# LEVEL 3 — THE LIMITS (WORTH KNOWING)
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
# • PAS de callback Python par barre : `scan` s'exécute en Rust, on ne peut pas
|
# • NO Python callback per bar: `scan` runs in Rust, and you cannot inject a
|
||||||
# y injecter une fonction Python appelée sur chaque bougie (ce serait lent).
|
# Python function called on every candle (it would be slow). As long as the
|
||||||
# Tant que la logique s'exprime avec Expr + when + scan, ça passe.
|
# logic expresses in Expr + when + scan, it works.
|
||||||
# • Un indicateur VRAIMENT nouveau, non exprimable ainsi, demande d'ajouter un
|
# • A GENUINELY new indicator, not expressible that way, needs a new `Expr`
|
||||||
# variant `Expr` + son kernel côté Rust — chemin contributeur, pas utilisateur.
|
# variant and its Rust kernel — the contributor path, not the user path.
|
||||||
# • Données externes (hashrate, funding, sentiment…) : `mbt.register_exo(...)`
|
# • External data (hashrate, funding, sentiment…): `mbt.register_exo(...)`,
|
||||||
# puis `exo("nom")` renvoie un Expr utilisable comme n'importe quelle colonne.
|
# then `exo("name")` returns an Expr usable like any other column.
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
# BONUS — RENDRE SON INDICATEUR BALAYABLE (sweep)
|
# BONUS — MAKING YOUR INDICATOR SWEEPABLE
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
# Les périodes acceptent `param(...)` à la place d'un entier. Le moteur
|
# Periods accept `param(...)` in place of an integer. The engine then
|
||||||
# recompile alors une fois par combinaison et balaie la grille en parallèle,
|
# recompiles once per combination and sweeps the grid in parallel, without
|
||||||
# sans changer une ligne de l'indicateur :
|
# changing a line of the indicator:
|
||||||
#
|
#
|
||||||
# ao = awesome_oscillator(fast=param("fast"), slow=param("slow"))
|
# ao = awesome_oscillator(fast=param("fast"), slow=param("slow"))
|
||||||
# # puis, avec la grille passée séparément (l'indicateur ne change pas) :
|
# # then, with the grid passed separately (the indicator is unchanged):
|
||||||
# # batch = mbt.run_sweep_lite(
|
# # batch = mbt.run_sweep_lite(
|
||||||
# # strategy,
|
# # strategy,
|
||||||
# # {"fast": [3, 5, 8], "slow": [21, 34, 55]},
|
# # {"fast": [3, 5, 8], "slow": [21, 34, 55]},
|
||||||
# # config, store,
|
# # config, store,
|
||||||
# # )
|
# # )
|
||||||
#
|
#
|
||||||
# (voir examples/08_sweep_2d_heatmap.py pour le sweep complet.)
|
# (see examples/08_sweep_2d_heatmap.py for the full sweep.)
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
# METTRE UN INDICATEUR MAISON DANS UNE STRATÉGIE + BACKTEST
|
# PUTTING A CUSTOM INDICATOR IN A STRATEGY AND BACKTESTING IT
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
# On utilise `dist_to_ma_pct` (retour à la moyenne) : long quand le prix est
|
# Using `dist_to_ma_pct` (mean reversion): long when the price sits well below
|
||||||
# nettement sous sa moyenne, on sort quand il l'a rejointe.
|
# its average, out when it has caught up.
|
||||||
|
|
||||||
dist = dist_to_ma_pct(period=48) # notre indicateur maison
|
dist = dist_to_ma_pct(period=48) # our custom indicator
|
||||||
streak = up_streak() # et un second, pour l'exposer aussi
|
streak = up_streak() # a second one, exposed too
|
||||||
|
|
||||||
signal = when(dist < -5.0, 1.0, # >5 % sous la MM → achat du creux
|
signal = when(dist < -5.0, 1.0, # >5% below the MA -> buy the dip
|
||||||
when(dist > 0.0, 0.0)) # revenu à la MM → sortie, sinon hold
|
when(dist > 0.0, 0.0)) # back at the MA -> exit, else hold
|
||||||
|
|
||||||
strategy = (
|
strategy = (
|
||||||
mbt.Strategy.create("custom_indicator_demo")
|
mbt.Strategy.create("custom_indicator_demo")
|
||||||
.signal("dist_to_ma_%", dist) # .signal() = exposer pour le rapport
|
.signal("dist_to_ma_%", dist) # .signal() exposes it in the report
|
||||||
.signal("up_streak", streak)
|
.signal("up_streak", streak)
|
||||||
.size(signal)
|
.size(signal)
|
||||||
.describe("Retour à la moyenne piloté par un indicateur maison (écart à la MM)")
|
.describe("Mean reversion driven by a custom indicator (distance to the MA)")
|
||||||
)
|
)
|
||||||
|
|
||||||
# -- Config -------------------------------------------------------------------
|
# -- Config -------------------------------------------------------------------
|
||||||
@@ -185,9 +191,9 @@ config = mbt.BacktestConfig(
|
|||||||
bar_interval=Interval.hours(1),
|
bar_interval=Interval.hours(1),
|
||||||
initial_capital=10_000,
|
initial_capital=10_000,
|
||||||
execution=mbt.ExecutionConfig(allow_short=False, max_position_pct=1.0),
|
execution=mbt.ExecutionConfig(allow_short=False, max_position_pct=1.0),
|
||||||
fees=mbt.FeeConfig.zero(), # sans frais, pour l'exemple
|
fees=mbt.FeeConfig.zero(), # fee-free, for the example
|
||||||
slippage=Slippage.fixed_bps(2),
|
slippage=Slippage.fixed_bps(2),
|
||||||
warmup_bars=60, # >= la plus longue fenêtre utilisée
|
warmup_bars=60, # >= the longest window used
|
||||||
)
|
)
|
||||||
|
|
||||||
# -- Run ----------------------------------------------------------------------
|
# -- Run ----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -8,6 +8,13 @@ the same signal four ways so the difference is visible in one place:
|
|||||||
stop wait for a breakout, fill through the level (taker + gap)
|
stop wait for a breakout, fill through the level (taker + gap)
|
||||||
limit on a signal rest on a level the DSL computes (here: 1 ATR below close)
|
limit on a signal rest on a level the DSL computes (here: 1 ATR below close)
|
||||||
|
|
||||||
|
Demonstrates:
|
||||||
|
- market, limit, stop and signal-priced entries, side by side
|
||||||
|
- passive fills (maker, no slippage) against aggressive ones
|
||||||
|
- an entry resting on a level the DSL computes
|
||||||
|
|
||||||
|
Data: shared store — real market data from `data/` (see examples/README.md)
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python examples/20_entry_orders.py
|
python examples/20_entry_orders.py
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,17 +1,36 @@
|
|||||||
"""Filling at a computed level — ExecutionPrice.custom(<signal name>).
|
"""Filling at a computed level — ExecutionPrice.custom(<signal name>).
|
||||||
|
|
||||||
A mean-reversion band strategy on native 1-minute bars: short at the touch of
|
A mean-reversion band strategy on native 1-minute bars: short at the touch of
|
||||||
an upper band around an hourly SMA, cover at the lower band. The engine always
|
an upper band, cover at the lower one. The engine always knew how to COMPUTE
|
||||||
knew how to COMPUTE the band; this example shows the fill landing ON it.
|
the band; this example shows the fill landing ON it.
|
||||||
|
|
||||||
The touch bar itself proves the level traded: it opens below the band and its
|
**This is a feature demo, not a claim about markets.** The data is synthetic and
|
||||||
high crosses it, so the band price sits inside [open, high]. Yet with
|
detrended by construction, so mean reversion cannot lose on it whatever the
|
||||||
``AtClose`` the only reachable fill is the bar's close — on a mean-reverting
|
parameters — the returns printed below describe the fixture, nothing else. The
|
||||||
touch, systematically on the wrong side of the level. The same run is done
|
same run is done both ways only so you can see that the setting takes effect.
|
||||||
both ways so the difference is visible in one place.
|
|
||||||
|
Which of the two fills is the better one depends entirely on what the market
|
||||||
|
does after the touch, and this fixture reverts by construction. Do not read a
|
||||||
|
rule into the sign of the gap.
|
||||||
|
|
||||||
|
The touch bar proves the level traded: it opens below the band and its high
|
||||||
|
crosses it, so the band price sits inside [open, high]. On this data all 248
|
||||||
|
intended levels land inside their bar. A level that did not would be clamped
|
||||||
|
back into [low, high] and reported in ``result.warnings`` — the band comes from
|
||||||
|
the previous closed hour, so it can sit outside a bar that never reached it.
|
||||||
|
|
||||||
|
Look-ahead was tested for and ruled out: the fill price does not move when the
|
||||||
|
triggering bar's high is inflated by 3%.
|
||||||
|
|
||||||
Self-contained: generates its own synthetic data in a temp store.
|
Self-contained: generates its own synthetic data in a temp store.
|
||||||
|
|
||||||
|
Demonstrates:
|
||||||
|
- ExecutionPrice.custom(<signal name>): the fill price as a strategy signal
|
||||||
|
- tf("1h").apply(...): an indicator whose period counts hourly candles
|
||||||
|
- a level computed from a higher timeframe, known before the bar opens
|
||||||
|
|
||||||
|
Data: synthetic (seed 7) — generated by this file, reproducible
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python examples/21_fill_at_computed_level.py
|
python examples/21_fill_at_computed_level.py
|
||||||
"""
|
"""
|
||||||
@@ -26,11 +45,21 @@ import manifoldbt as mbt
|
|||||||
from manifoldbt.indicators import close, high, low, open as open_px, sma
|
from manifoldbt.indicators import close, high, low, open as open_px, sma
|
||||||
from manifoldbt.helpers import ExecutionPrice, Interval, Slippage
|
from manifoldbt.helpers import ExecutionPrice, Interval, Slippage
|
||||||
|
|
||||||
# -- Synthetic 1m data: a mean-reverting walk ---------------------------------
|
# -- Synthetic 1m data: a walk with its drift REMOVED -------------------------
|
||||||
|
# `- np.linspace(0, level[-1], N)` subtracts the entire trend, forcing the
|
||||||
|
# series to end exactly where it started: the raw walk loses 25.6%, this one
|
||||||
|
# returns -0.0%. Mean reversion is therefore GUARANTEED here, by construction.
|
||||||
|
#
|
||||||
|
# That is deliberate, and it is why the absolute returns printed below mean
|
||||||
|
# nothing. Measured over nine parameter sets (bands 0.002 to 0.008, periods 4h
|
||||||
|
# to 20h), the custom fill returns +2.2% to +9.1% and AtClose -25.5% to +4.2%:
|
||||||
|
# neither range is a property of the strategy, and AtClose even turns positive
|
||||||
|
# on the widest band. What holds across all nine is only the ORDER — the custom
|
||||||
|
# fill is ahead every time — and that gap is the whole subject.
|
||||||
N = 30 * 1440 # 30 days of 1-minute bars
|
N = 30 * 1440 # 30 days of 1-minute bars
|
||||||
rng = np.random.default_rng(7)
|
rng = np.random.default_rng(7)
|
||||||
steps = rng.normal(0.0, 0.0010, N)
|
steps = rng.normal(0.0, 0.0010, N)
|
||||||
level = np.cumsum(steps) * 0.85 # pull the walk back toward its mean
|
level = np.cumsum(steps) * 0.85
|
||||||
px = 100.0 * np.exp(level - np.linspace(0, level[-1], N))
|
px = 100.0 * np.exp(level - np.linspace(0, level[-1], N))
|
||||||
o, c = px, np.roll(px, -1)
|
o, c = px, np.roll(px, -1)
|
||||||
c[-1] = px[-1]
|
c[-1] = px[-1]
|
||||||
@@ -42,12 +71,16 @@ frame = pd.DataFrame(
|
|||||||
"close": c, "volume": rng.uniform(1_000, 5_000, N)}
|
"close": c, "volume": rng.uniform(1_000, 5_000, N)}
|
||||||
)
|
)
|
||||||
|
|
||||||
# -- Bands around an hourly SMA, evaluated on 1m native bars ------------------
|
# -- Bands around an 8-hour mean, on 1m native bars ---------------------------
|
||||||
|
# `apply()` evaluates the SMA on the hourly grid, so its period counts hourly
|
||||||
|
# candles. Written `sma(h1.close, 8)` the period would count 8 SIMULATION bars
|
||||||
|
# over a step-held column, which is 8 minutes, not 8 hours. See `bt.tf`.
|
||||||
DEV_UP, DEV_DN = 0.004, 0.003
|
DEV_UP, DEV_DN = 0.004, 0.003
|
||||||
|
|
||||||
h1 = mbt.tf("1h") # hourly columns, as of the last closed hour
|
h1 = mbt.tf("1h")
|
||||||
band_up = sma(h1.close, 8) * (1 + DEV_UP)
|
hourly_mean = h1.apply(sma(close, 8)) # mean of the last 8 hourly closes
|
||||||
band_dn = sma(h1.close, 8) * (1 - DEV_DN)
|
band_up = hourly_mean * (1 + DEV_UP)
|
||||||
|
band_dn = hourly_mean * (1 - DEV_DN)
|
||||||
|
|
||||||
touch_up = high >= band_up # entry: short at the touch of the upper band
|
touch_up = high >= band_up # entry: short at the touch of the upper band
|
||||||
touch_dn = low <= band_dn # exit: cover at the touch of the lower band
|
touch_dn = low <= band_dn # exit: cover at the touch of the lower band
|
||||||
@@ -98,17 +131,34 @@ if __name__ == "__main__":
|
|||||||
)
|
)
|
||||||
return mbt.run(strategy, config, store)
|
return mbt.run(strategy, config, store)
|
||||||
|
|
||||||
print(f"{'execution price':<22} {'trades':>7} {'return':>9} first entry fills")
|
print("Synthetic detrended data: the LEVELS below are an artifact of the")
|
||||||
|
print("fixture, not a result. Only the gap between the two rows is real.\n")
|
||||||
|
print(f"{'execution price':<22} {'trades':>7} {'return*':>9} first entry fills")
|
||||||
print("-" * 78)
|
print("-" * 78)
|
||||||
|
|
||||||
|
returns = {}
|
||||||
|
n_trades = 0
|
||||||
for label, price in (("AtClose", "AtClose"),
|
for label, price in (("AtClose", "AtClose"),
|
||||||
("custom('exec_level')", ExecutionPrice.custom("exec_level"))):
|
("custom('exec_level')", ExecutionPrice.custom("exec_level"))):
|
||||||
result = run(price)
|
result = run(price)
|
||||||
tr = result.trades_df()
|
tr = result.trades_df()
|
||||||
entries = tr[tr["fill_price"] > 0].head(3)["fill_price"].round(4).tolist()
|
entries = tr[tr["fill_price"] > 0].head(3)["fill_price"].round(4).tolist()
|
||||||
print(f"{label:<22} {len(tr):>7} {result.metrics['total_return']:>8.2%} {entries}")
|
returns[label] = result.metrics["total_return"]
|
||||||
|
n_trades = len(tr)
|
||||||
|
print(f"{label:<22} {len(tr):>7} {returns[label]:>8.2%} {entries}")
|
||||||
|
|
||||||
|
gap = returns["custom('exec_level')"] - returns["AtClose"]
|
||||||
|
print(f"\n GAP: {gap:.1%} on identical signals and identical bars.")
|
||||||
print(
|
print(
|
||||||
"\nSame signals, same bars: only WHERE the order fills changed. The"
|
"\n* Both figures describe the fixture, not a market: the series is"
|
||||||
"\ncustom fills land on the band level (inside the touch bar's range),"
|
"\n detrended, so reversion wins on it by construction. What the two"
|
||||||
"\nnot on its close. A fill outside [low, high] would be warned about."
|
"\n rows show is that the setting takes effect -- the custom fills land"
|
||||||
|
"\n on the band, computed from the previous closed hour, where AtClose"
|
||||||
|
"\n can only reach the bar's close. Which of the two is the better"
|
||||||
|
"\n price depends on what the market does next, and this series was"
|
||||||
|
"\n built to revert."
|
||||||
|
"\n"
|
||||||
|
f"\n All {n_trades} intended levels land inside their own bar here. One"
|
||||||
|
f"\n that did not would be clamped into [low, high] and reported in"
|
||||||
|
f"\n result.warnings."
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Yahoo Finance -- stocks, ETFs, indices, FX and futures, free on all tiers.
|
||||||
|
|
||||||
|
Demonstrates:
|
||||||
|
- mbt.ingest(provider="yahoo") -- no API key, no license required
|
||||||
|
- Backtesting daily equity bars, exactly like a crypto connector
|
||||||
|
- Dividend-adjusted prices (same convention as yfinance's auto_adjust=True)
|
||||||
|
|
||||||
|
Yahoo imposes its own history limits: 1m bars go back 30 days, 1h about two
|
||||||
|
years, daily bars back to the listing date. Tickers follow Yahoo's own
|
||||||
|
notation: AAPL, SPY, ^GSPC (index), EURUSD=X (FX), ES=F (future),
|
||||||
|
BTC-USD (crypto), AIR.PA (Euronext).
|
||||||
|
|
||||||
|
Pass `dataset="raw"` to keep unadjusted quotes.
|
||||||
|
|
||||||
|
Data: self-contained (network) — ingested on each run from a free connector
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python examples/22_yahoo_equities.py
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import manifoldbt as mbt
|
||||||
|
from manifoldbt.indicators import close, ema
|
||||||
|
from manifoldbt.helpers import time_range, Interval
|
||||||
|
|
||||||
|
# -- 1. Pull daily bars from Yahoo (free, all tiers) --------------------------
|
||||||
|
tmp = tempfile.mkdtemp()
|
||||||
|
store = mbt.ingest(
|
||||||
|
provider="yahoo",
|
||||||
|
symbol="AAPL",
|
||||||
|
symbol_id=1,
|
||||||
|
start="2020-01-01T00:00:00Z",
|
||||||
|
end="2024-01-01T00:00:00Z",
|
||||||
|
interval="1d",
|
||||||
|
asset_class="equity",
|
||||||
|
data_root=os.path.join(tmp, "data"),
|
||||||
|
metadata_db=os.path.join(tmp, "meta.sqlite"),
|
||||||
|
)
|
||||||
|
print("Ingested:", store.list_symbols())
|
||||||
|
|
||||||
|
# -- 2. Backtest on it like any other data ------------------------------------
|
||||||
|
strategy = (
|
||||||
|
mbt.Strategy.create("ema_cross")
|
||||||
|
.signal("fast", ema(close, 20))
|
||||||
|
.signal("slow", ema(close, 50))
|
||||||
|
.size(mbt.when(ema(close, 20) > ema(close, 50), 1.0, 0.0))
|
||||||
|
.describe("EMA(20/50) crossover on daily AAPL bars from Yahoo Finance")
|
||||||
|
)
|
||||||
|
|
||||||
|
start, end = time_range("2020-01-01", "2024-01-01")
|
||||||
|
config = mbt.BacktestConfig(
|
||||||
|
universe=[1],
|
||||||
|
time_range_start=start,
|
||||||
|
time_range_end=end,
|
||||||
|
bar_interval=Interval.days(1),
|
||||||
|
initial_capital=10_000,
|
||||||
|
warmup_bars=60,
|
||||||
|
)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
result = mbt.run(strategy, config, store)
|
||||||
|
print(result.summary())
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""Crypto options -- Deribit contracts that actually expire.
|
||||||
|
|
||||||
|
Demonstrates:
|
||||||
|
- mbt.ingest(provider="deribit", ...) -- no API key, expired contracts included
|
||||||
|
- Contract terms (strike, expiry, side, settlement) recorded alongside the bars
|
||||||
|
- Cash settlement at expiration, at intrinsic value
|
||||||
|
- config.option_underlyings -- which price series settles the contract
|
||||||
|
- Per-leg sizing with col("symbol_id"), for multi-leg structures
|
||||||
|
|
||||||
|
Why Deribit and not Binance: Deribit serves the history of contracts that have
|
||||||
|
already expired, which is the only data an option backtest can run on. Binance's
|
||||||
|
options API answers HTTP 400 for anything past its expiration date.
|
||||||
|
|
||||||
|
Three things worth knowing before reading the numbers:
|
||||||
|
|
||||||
|
- **Everything is in BTC.** A Deribit BTC option is quoted, margined and
|
||||||
|
settled in BTC, so `initial_capital` below is 10 BTC, not 10 dollars. The
|
||||||
|
payoff of a call is `max(0, S - K) / S` BTC per contract.
|
||||||
|
|
||||||
|
- **You choose the settlement reference.** Deribit settles against its own
|
||||||
|
`BTC_USD` index, whose ticker matches no series you can ingest.
|
||||||
|
`BTC-PERPETUAL` stands in for it here; the basis between the two is small
|
||||||
|
but real, and it is not modelled.
|
||||||
|
|
||||||
|
- **Positions are counted in units of the underlying.** On Deribit a contract
|
||||||
|
is one unit, so the two are the same thing. On a 100-multiplier listed
|
||||||
|
option, holding one contract means a position of 100.
|
||||||
|
|
||||||
|
Data: self-contained (network) — ingested on each run from a free connector
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python examples/23_deribit_options.py
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import manifoldbt as mbt
|
||||||
|
from manifoldbt.indicators import col
|
||||||
|
from manifoldbt.helpers import time_range, Interval
|
||||||
|
|
||||||
|
# Two contracts that expired on 2025-06-27, and the series that settles them.
|
||||||
|
UNDERLYING, UNDERLYING_ID = "BTC-PERPETUAL", 1
|
||||||
|
CALL_100K, CALL_ID = "BTC-27JUN25-100000-C", 2
|
||||||
|
PUT_90K, PUT_ID = "BTC-27JUN25-90000-P", 3
|
||||||
|
START, END = "2025-05-01T00:00:00Z", "2025-07-01T00:00:00Z"
|
||||||
|
|
||||||
|
tmp = tempfile.mkdtemp()
|
||||||
|
common = dict(
|
||||||
|
start=START,
|
||||||
|
end=END,
|
||||||
|
interval="1d",
|
||||||
|
data_root=os.path.join(tmp, "data"),
|
||||||
|
metadata_db=os.path.join(tmp, "meta.sqlite"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- 1. The settlement reference, then the contracts ---------------------------
|
||||||
|
store = mbt.ingest(
|
||||||
|
provider="deribit", symbol=UNDERLYING, symbol_id=UNDERLYING_ID,
|
||||||
|
asset_class="crypto_perp", **common
|
||||||
|
)
|
||||||
|
store = mbt.ingest(
|
||||||
|
provider="deribit", symbol=CALL_100K, symbol_id=CALL_ID,
|
||||||
|
asset_class="option", **common
|
||||||
|
)
|
||||||
|
store = mbt.ingest(
|
||||||
|
provider="deribit", symbol=PUT_90K, symbol_id=PUT_ID,
|
||||||
|
asset_class="option", **common
|
||||||
|
)
|
||||||
|
|
||||||
|
# The connector asked Deribit for the terms and the store kept them; nothing
|
||||||
|
# below is inferred from the instrument name.
|
||||||
|
for symbol_id, terms in sorted(store.option_contracts().items()):
|
||||||
|
print(f"{symbol_id}: {terms['option_type']} {terms['strike']:.0f}, "
|
||||||
|
f"settles {terms['settlement']}")
|
||||||
|
|
||||||
|
# -- 2. A risk reversal: long the 100k call, short the 90k put -----------------
|
||||||
|
# Legs are told apart by `col("symbol_id")`. Do NOT discriminate on price level
|
||||||
|
# (e.g. "close < 100"): a premium that crosses the threshold silently flips the
|
||||||
|
# leg to zero and the strategy closes its own position.
|
||||||
|
size = (
|
||||||
|
mbt.when(col("symbol_id") == float(CALL_ID), 1.0, 0.0)
|
||||||
|
+ mbt.when(col("symbol_id") == float(PUT_ID), -1.0, 0.0)
|
||||||
|
)
|
||||||
|
strategy = (
|
||||||
|
mbt.Strategy.create("risk_reversal")
|
||||||
|
.signal("leg", col("symbol_id"))
|
||||||
|
.size(size)
|
||||||
|
.describe("Long the 100k call, short the 90k put, both held to expiration")
|
||||||
|
)
|
||||||
|
|
||||||
|
start, end = time_range("2025-05-01", "2025-07-01")
|
||||||
|
config = mbt.BacktestConfig(
|
||||||
|
universe=[UNDERLYING_ID, CALL_ID, PUT_ID],
|
||||||
|
time_range_start=start,
|
||||||
|
time_range_end=end,
|
||||||
|
bar_interval=Interval.days(1),
|
||||||
|
initial_capital=10.0, # 10 BTC
|
||||||
|
currency="BTC",
|
||||||
|
option_underlyings={CALL_ID: UNDERLYING_ID, PUT_ID: UNDERLYING_ID},
|
||||||
|
option_margin_model="deribit", # the short put pays margin
|
||||||
|
execution=mbt.ExecutionConfig(position_sizing_mode="Units", allow_short=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
result = mbt.run(strategy, config, store)
|
||||||
|
trades = result.trades.to_pandas()
|
||||||
|
|
||||||
|
print("\nTrades on the option legs:")
|
||||||
|
print(
|
||||||
|
trades[trades.symbol_id != UNDERLYING_ID][
|
||||||
|
["symbol_id", "side", "quantity", "fill_price", "exit_reason"]
|
||||||
|
].to_string(index=False)
|
||||||
|
)
|
||||||
|
# exit_reason 5 is OptionExpiry: the venue settled the contract, the
|
||||||
|
# strategy did not sell it. The put settles at 0 because BTC finished far
|
||||||
|
# above its 90k strike.
|
||||||
|
print("\nFinal equity: %.6f BTC" % float(result.equity_curve[-1]))
|
||||||
|
if result.warnings:
|
||||||
|
print("Warnings:", result.warnings)
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""Option strategy -- a bull call spread, held to expiration.
|
||||||
|
|
||||||
|
Demonstrates:
|
||||||
|
- A two-leg option structure: long a low strike, short a higher one
|
||||||
|
- Per-leg sizing with col("symbol_id")
|
||||||
|
- A SHORT option paying margin under the venue's own formula
|
||||||
|
- Both legs cash-settled at expiry, which is what caps the payoff
|
||||||
|
|
||||||
|
The structure: buy the 100k call, sell the 110k call, same expiration. The
|
||||||
|
short leg pays for part of the long one, and in exchange it caps the gain at
|
||||||
|
the distance between the strikes. Classic, and the cheapest way to see the
|
||||||
|
engine settle two contracts on the same day with different outcomes.
|
||||||
|
|
||||||
|
**A currency trap worth knowing.** Every leg of a strategy has to be quoted in
|
||||||
|
the same currency, because the engine carries one cash balance. On Deribit an
|
||||||
|
option is quoted in BTC, but `BTC-PERPETUAL` is quoted in USD. So a covered
|
||||||
|
call (long the perpetual, short a call) would add dollars to bitcoin in a single
|
||||||
|
number and produce a meaningless equity curve. A spread has both legs in BTC,
|
||||||
|
which is why this example is a spread. The perpetual appears below only as the
|
||||||
|
settlement reference, never as a position.
|
||||||
|
|
||||||
|
Data: self-contained (network) — ingested on each run from a free connector
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python examples/24_option_spread.py
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import manifoldbt as mbt
|
||||||
|
from manifoldbt.indicators import col
|
||||||
|
from manifoldbt.helpers import time_range, Interval
|
||||||
|
|
||||||
|
# Both legs expired on 2025-06-27, so the whole life of the trade is history.
|
||||||
|
UNDERLYING, UNDERLYING_ID = "BTC-PERPETUAL", 1
|
||||||
|
LONG_LEG, LONG_ID = "BTC-27JUN25-100000-C", 2
|
||||||
|
SHORT_LEG, SHORT_ID = "BTC-27JUN25-110000-C", 3
|
||||||
|
START, END = "2025-05-01T00:00:00Z", "2025-07-01T00:00:00Z"
|
||||||
|
|
||||||
|
tmp = tempfile.mkdtemp()
|
||||||
|
common = dict(
|
||||||
|
start=START,
|
||||||
|
end=END,
|
||||||
|
interval="1d",
|
||||||
|
data_root=os.path.join(tmp, "data"),
|
||||||
|
metadata_db=os.path.join(tmp, "meta.sqlite"),
|
||||||
|
)
|
||||||
|
|
||||||
|
store = mbt.ingest(
|
||||||
|
provider="deribit", symbol=UNDERLYING, symbol_id=UNDERLYING_ID,
|
||||||
|
asset_class="crypto_perp", **common
|
||||||
|
)
|
||||||
|
for symbol, symbol_id in ((LONG_LEG, LONG_ID), (SHORT_LEG, SHORT_ID)):
|
||||||
|
store = mbt.ingest(
|
||||||
|
provider="deribit", symbol=symbol, symbol_id=symbol_id,
|
||||||
|
asset_class="option", **common
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- The strategy --------------------------------------------------------------
|
||||||
|
# Legs are told apart by symbol id. Never discriminate on price level: a premium
|
||||||
|
# crossing the threshold would flip its own leg to zero and close the position.
|
||||||
|
size = (
|
||||||
|
mbt.when(col("symbol_id") == float(LONG_ID), 1.0, 0.0) # buy the 100k call
|
||||||
|
+ mbt.when(col("symbol_id") == float(SHORT_ID), -1.0, 0.0) # sell the 110k call
|
||||||
|
)
|
||||||
|
strategy = (
|
||||||
|
mbt.Strategy.create("bull_call_spread")
|
||||||
|
.signal("leg", col("symbol_id"))
|
||||||
|
.size(size)
|
||||||
|
.describe("Long the 100k call, short the 110k call, held to expiration")
|
||||||
|
)
|
||||||
|
|
||||||
|
start, end = time_range("2025-05-01", "2025-07-01")
|
||||||
|
config = mbt.BacktestConfig(
|
||||||
|
universe=[UNDERLYING_ID, LONG_ID, SHORT_ID],
|
||||||
|
time_range_start=start,
|
||||||
|
time_range_end=end,
|
||||||
|
bar_interval=Interval.days(1),
|
||||||
|
initial_capital=10.0, # 10 BTC: everything here is in BTC
|
||||||
|
currency="BTC",
|
||||||
|
option_underlyings={LONG_ID: UNDERLYING_ID, SHORT_ID: UNDERLYING_ID},
|
||||||
|
option_margin_model="deribit", # the short leg posts margin
|
||||||
|
execution=mbt.ExecutionConfig(position_sizing_mode="Units", allow_short=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
result = mbt.run(strategy, config, store)
|
||||||
|
trades = result.trades.to_pandas()
|
||||||
|
|
||||||
|
names = {LONG_ID: "long 100k", SHORT_ID: "short 110k"}
|
||||||
|
print("\nTrades, by leg:")
|
||||||
|
for _, t in trades[trades.symbol_id != UNDERLYING_ID].iterrows():
|
||||||
|
what = "settled" if t.exit_reason == 5 else "traded"
|
||||||
|
print(f" {names[t.symbol_id]:<12} {what:<8} {t.quantity:>4.1f} @ {t.fill_price:.6f} BTC")
|
||||||
|
|
||||||
|
equity = float(result.equity_curve[-1])
|
||||||
|
print(f"\nFinal equity: {equity:.6f} BTC ({equity - 10.0:+.6f})")
|
||||||
|
# The short leg expiring worthless is what the spread pays for: it financed
|
||||||
|
# part of the long call, and capped the gain at the strike distance.
|
||||||
|
if result.warnings:
|
||||||
|
print("Warnings:", result.warnings)
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
"""Look-ahead — the leak the detector cannot see.
|
||||||
|
|
||||||
|
The classic beginner's leak, and the one that survives review: computing a
|
||||||
|
statistic over the *whole* dataset in the notebook, then using it as a strategy
|
||||||
|
parameter. Here it is the mean price of the entire asset, used from bar 0 —
|
||||||
|
where 749 of the 750 days it summarises are still in the future.
|
||||||
|
|
||||||
|
**There IS a leak in this strategy. It is deliberate.** The question the
|
||||||
|
example answers is not whether the leak exists, but which audit methods can
|
||||||
|
see it — and two of the three cannot. Read every "sees nothing" below as a
|
||||||
|
statement about the *method*, never as a clean bill of health for the
|
||||||
|
strategy.
|
||||||
|
|
||||||
|
What this example shows, in order:
|
||||||
|
|
||||||
|
1. the leak triples the return and doubles the Sharpe;
|
||||||
|
2. `detect_lookahead` sees nothing;
|
||||||
|
3. perturbing every future bar sees nothing either;
|
||||||
|
4. the one method that catches it: re-derive the parameter on truncated data.
|
||||||
|
|
||||||
|
**Why the first two are blind.** Both re-run the *same strategy* on shorter or
|
||||||
|
altered data. The mean is not recomputed by the engine: it is a number baked
|
||||||
|
into the strategy at research time. Re-running cannot see it, because the leak
|
||||||
|
already happened, in the notebook, before the backtest existed.
|
||||||
|
|
||||||
|
That is not a bug to fix in the detector, it is the shape of the problem. No
|
||||||
|
re-run method can audit a constant. The only defence is to treat every
|
||||||
|
parameter derived from data as part of the pipeline, and re-derive it on
|
||||||
|
whatever window you are testing.
|
||||||
|
|
||||||
|
Self-contained: generates its own synthetic data in a temp store.
|
||||||
|
|
||||||
|
Demonstrates:
|
||||||
|
- a parameter computed over the whole dataset, used from bar 0
|
||||||
|
- two audit methods that do not see it, and why
|
||||||
|
- the one that does: re-deriving the parameter per window
|
||||||
|
|
||||||
|
Data: synthetic (seed 11) — generated by this file, reproducible.
|
||||||
|
The fixture DETERMINES the outcome: see examples/README.md.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python examples/25_lookahead_trap.py
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
import manifoldbt as mbt
|
||||||
|
from manifoldbt.indicators import close
|
||||||
|
from manifoldbt.helpers import Interval, Slippage
|
||||||
|
|
||||||
|
# -- A mean-reverting series: exactly where knowing the mean is gold ----------
|
||||||
|
N_DAYS = 750
|
||||||
|
rng = np.random.default_rng(11)
|
||||||
|
level = np.cumsum(rng.normal(0.0, 0.018, N_DAYS))
|
||||||
|
px = 100.0 * np.exp(level - np.linspace(0, level[-1], N_DAYS))
|
||||||
|
o = px
|
||||||
|
c = np.roll(px, -1)
|
||||||
|
c[-1] = px[-1]
|
||||||
|
amp = np.abs(rng.normal(0.0, 0.004, N_DAYS))
|
||||||
|
frame = pd.DataFrame({
|
||||||
|
"timestamp": pd.date_range("2022-01-01", periods=N_DAYS, freq="1D", tz="UTC"),
|
||||||
|
"open": o,
|
||||||
|
"high": np.maximum(o, c) * (1 + amp),
|
||||||
|
"low": np.minimum(o, c) * (1 - amp),
|
||||||
|
"close": c,
|
||||||
|
"volume": rng.uniform(1_000, 5_000, N_DAYS),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def store_of(df, tag):
|
||||||
|
root = tempfile.mkdtemp(prefix=f"mbt_ex25_{tag}_")
|
||||||
|
return mbt.import_dataframe(
|
||||||
|
df, symbol="SYNTH", symbol_id=1, interval="1d",
|
||||||
|
data_root=os.path.join(root, "data"),
|
||||||
|
metadata_db=os.path.join(root, "meta.sqlite"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def config_of(df):
|
||||||
|
ts = pd.DatetimeIndex(df["timestamp"])
|
||||||
|
return mbt.BacktestConfig(
|
||||||
|
universe=[1],
|
||||||
|
time_range_start=int(ts[0].value),
|
||||||
|
time_range_end=int(ts[-1].value) + 86_400_000_000_000,
|
||||||
|
bar_interval=Interval.days(1),
|
||||||
|
initial_capital=10_000,
|
||||||
|
execution=mbt.ExecutionConfig(
|
||||||
|
signal_delay=1, max_position_pct=1.0,
|
||||||
|
allow_short=True, position_sizing_mode="FractionOfEquity",
|
||||||
|
),
|
||||||
|
slippage=Slippage.fixed_bps(0),
|
||||||
|
warmup_bars=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def leaky(mean_price):
|
||||||
|
"""THE LEAK: `mean_price` comes from `df.close.mean()` over everything."""
|
||||||
|
return (
|
||||||
|
mbt.Strategy.create("global_mean_leak")
|
||||||
|
.signal("edge", close)
|
||||||
|
.size(mbt.when(close < mean_price, 1.0, -1.0))
|
||||||
|
.describe("Long below the global mean, short above")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def causal(window):
|
||||||
|
"""The honest twin: a rolling mean knows only the past."""
|
||||||
|
return (
|
||||||
|
mbt.Strategy.create("rolling_mean")
|
||||||
|
.signal("edge", close)
|
||||||
|
.size(mbt.when(close < close.rolling_mean(window), 1.0, -1.0))
|
||||||
|
.describe("Long below the rolling mean")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def equity(result):
|
||||||
|
return np.array([float(x) for x in result.equity_curve])
|
||||||
|
|
||||||
|
|
||||||
|
# The two words this example turns on. "SEES NOTHING" describes the METHOD,
|
||||||
|
# never the strategy: the leak below is there whatever any audit reports.
|
||||||
|
BLIND, CAUGHT = "SEES NOTHING", "CATCHES THE LEAK"
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
global_mean = float(frame["close"].mean())
|
||||||
|
store = store_of(frame, "full")
|
||||||
|
|
||||||
|
# -- 1. The seduction -----------------------------------------------------
|
||||||
|
leaked = mbt.run(leaky(global_mean), config_of(frame), store)
|
||||||
|
honest = mbt.run(causal(60), config_of(frame), store)
|
||||||
|
|
||||||
|
print(f"Global mean over all {N_DAYS} days: {global_mean:.4f}")
|
||||||
|
print("(at bar 0, 749 of those days have not happened yet)")
|
||||||
|
print("\nThis strategy IS leaking. Below: which audits notice.\n")
|
||||||
|
for label, r in (("with the leak ", leaked), ("rolling mean ", honest)):
|
||||||
|
m = r.metrics
|
||||||
|
print(f" {label} return {100 * m['total_return']:+8.2f}% "
|
||||||
|
f"sharpe {m['sharpe']:5.2f} trades {r.trade_count}")
|
||||||
|
|
||||||
|
# -- 2. The detector -------------------------------------------------------
|
||||||
|
from manifoldbt.diagnostics import detect_lookahead
|
||||||
|
|
||||||
|
report = detect_lookahead(leaky(global_mean), config_of(frame), store, mode="all")
|
||||||
|
compared = sum(r.total_trades_overlap for r in report.reports)
|
||||||
|
verdict = BLIND if report.passed else CAUGHT
|
||||||
|
print(f"\n [1] detect_lookahead .......... {verdict}")
|
||||||
|
print(f" ({compared} trades compared, so the verdict is not empty)")
|
||||||
|
|
||||||
|
# -- 3. Perturbing the future ---------------------------------------------
|
||||||
|
split = 500
|
||||||
|
reference = equity(mbt.run(leaky(global_mean), config_of(frame), store))
|
||||||
|
corrupted = frame.copy()
|
||||||
|
tail = slice(split + 1, None)
|
||||||
|
factor = 1.0 + np.random.default_rng(99).uniform(-0.05, 0.05, N_DAYS - split - 1)
|
||||||
|
for col in ("open", "high", "low", "close"):
|
||||||
|
corrupted.loc[corrupted.index[tail], col] = corrupted[col].to_numpy()[tail] * factor
|
||||||
|
perturbed = equity(mbt.run(leaky(global_mean), config_of(frame),
|
||||||
|
store_of(corrupted, "pert")))
|
||||||
|
n = min(len(reference), len(perturbed), split + 1)
|
||||||
|
drift = float(np.abs(reference[:n] - perturbed[:n]).max())
|
||||||
|
verdict = CAUGHT if drift > 0 else BLIND
|
||||||
|
print(f" [2] future perturbation ....... {verdict}")
|
||||||
|
print(f" (past moved by {drift:.3e} while the future moved by "
|
||||||
|
f"{abs(reference[-1] - perturbed[-1]):.0f})")
|
||||||
|
|
||||||
|
# -- 4. The method that works ---------------------------------------------
|
||||||
|
# Treat the mean as what it is: a pipeline step, not a constant. A
|
||||||
|
# researcher standing at bar 500 only has the first 500 bars.
|
||||||
|
truncated = frame.iloc[:split + 1]
|
||||||
|
honest_mean = float(truncated["close"].mean())
|
||||||
|
with_future = equity(mbt.run(leaky(global_mean), config_of(truncated),
|
||||||
|
store_of(truncated, "t1")))
|
||||||
|
with_past = equity(mbt.run(leaky(honest_mean), config_of(truncated),
|
||||||
|
store_of(truncated, "t2")))
|
||||||
|
m = min(len(with_future), len(with_past))
|
||||||
|
gap = float(np.abs(with_future[:m] - with_past[:m]).max())
|
||||||
|
verdict = CAUGHT if gap > 0 else BLIND
|
||||||
|
print(f" [3] re-deriving the parameter . {verdict}")
|
||||||
|
print(f" mean from the whole set : {global_mean:.4f} -> "
|
||||||
|
f"final equity {with_future[-1]:,.0f}")
|
||||||
|
print(f" mean from the past only : {honest_mean:.4f} -> "
|
||||||
|
f"final equity {with_past[-1]:,.0f}")
|
||||||
|
print(f" same window, same bars, equity differs by {gap:.0f}")
|
||||||
|
print("\n Verdict: the leak is real, and only [3] found it. A")
|
||||||
|
print(" 'SEES NOTHING' is a statement about the method, not about")
|
||||||
|
print(" the strategy.")
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# Examples
|
||||||
|
|
||||||
|
Each file demonstrates **one mechanism** of the engine and stops there. They are
|
||||||
|
documentation that happens to execute, not strategies: several would lose money
|
||||||
|
traded as written, and that is not a defect.
|
||||||
|
|
||||||
|
## The contract
|
||||||
|
|
||||||
|
Every example opens with the same four blocks, in this order:
|
||||||
|
|
||||||
|
```
|
||||||
|
"""One-line title — the mechanism, named.
|
||||||
|
|
||||||
|
Demonstrates:
|
||||||
|
- the two or three API surfaces the file exists to show
|
||||||
|
|
||||||
|
Data: <provenance>
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python examples/NN_name.py
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
`Data:` is the block that matters most, and it is not optional. An example is
|
||||||
|
read by someone deciding whether to trust a number, so where the number came
|
||||||
|
from is part of the example.
|
||||||
|
|
||||||
|
## The three data provenances
|
||||||
|
|
||||||
|
**`Data: shared store`** — real market data from `data/`, ingested once and
|
||||||
|
reused. These examples need that store to exist (see below). Their results are
|
||||||
|
real in the sense that the prices are real; they are still not investment
|
||||||
|
advice, and none of the strategies is tuned.
|
||||||
|
|
||||||
|
**`Data: self-contained (network)`** — the file ingests what it needs on each
|
||||||
|
run, from a free connector. Runs on a fresh clone, needs a network.
|
||||||
|
|
||||||
|
**`Data: synthetic (seed N)`** — the file generates its own series. Always
|
||||||
|
reproducible, never a market claim.
|
||||||
|
|
||||||
|
## The rule on synthetic data
|
||||||
|
|
||||||
|
Synthetic data is legitimate and often the right choice: it isolates a
|
||||||
|
mechanism from market noise, and it lets an example run anywhere with no
|
||||||
|
dependency. It is used deliberately here, not as a fallback.
|
||||||
|
|
||||||
|
The one thing it must never do is let a reader mistake a fixture for a result.
|
||||||
|
So when the construction of the series **determines the outcome**, the file says
|
||||||
|
so before printing any figure, and marks the affected numbers. Two examples are
|
||||||
|
in that case:
|
||||||
|
|
||||||
|
| | What the fixture determines |
|
||||||
|
|---|---|
|
||||||
|
| `21_fill_at_computed_level.py` | the series is detrended, so mean reversion cannot lose on it — only the *gap* between two execution prices is meaningful |
|
||||||
|
| `25_lookahead_trap.py` | the leak is deliberate; the file exists to show which audits fail to see it |
|
||||||
|
|
||||||
|
A synthetic example whose fixture does **not** predetermine the outcome carries
|
||||||
|
no such warning, because there is nothing to warn about.
|
||||||
|
|
||||||
|
## The rule on performance
|
||||||
|
|
||||||
|
No example is selected, tuned, or presented for its returns. Where a figure
|
||||||
|
appears it illustrates the mechanism under discussion. If an example ever
|
||||||
|
produced a reproducible edge on real data, it would belong in a private
|
||||||
|
repository rather than in documentation.
|
||||||
|
|
||||||
|
## The shared store
|
||||||
|
|
||||||
|
Examples marked `Data: shared store` read `data/` and `metadata/`, which are not
|
||||||
|
in the repository. Populate them once:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import manifoldbt as mbt
|
||||||
|
mbt.ingest(provider="binance", symbol="BTCUSDT", symbol_id=1,
|
||||||
|
start="2021-01-01T00:00:00Z", end="2025-01-01T00:00:00Z")
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the symbols an example asks for; each names them in its `universe`. The
|
||||||
|
self-contained and synthetic examples need none of this.
|
||||||
|
|
||||||
|
## Index
|
||||||
|
|
||||||
|
| | Example | Mechanism | Data |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 00 | `00_template.py` | the minimal shape of a backtest | shared store |
|
||||||
|
| 01 | `01_trend_following.py` | fluent builder, EMA, stop-loss, diagnostics | shared store |
|
||||||
|
| 02 | `02_mean_reversion.py` | bands, z-score, conditional sizing | shared store |
|
||||||
|
| 03 | `03_multi_asset_momentum.py` | cross-sectional ranking over a universe | shared store |
|
||||||
|
| 04 | `04_linear_regression.py` | rolling regression as a signal | shared store |
|
||||||
|
| 05 | `05_stat_arb.py` | pair spread, cross-asset references | shared store |
|
||||||
|
| 06 | `06_full_visualization.py` | the whole plotting surface | shared store |
|
||||||
|
| 07 | `07_walk_forward.py` | fold geometries, out-of-sample selection | shared store |
|
||||||
|
| 08 | `08_sweep_2d_heatmap.py` | a two-parameter sweep, read as a surface | shared store |
|
||||||
|
| 09 | `09_surface_3d.py` | the same surface in three dimensions | shared store |
|
||||||
|
| 10 | `10_monte_carlo.py` | bootstrap resampling, rare-event metrics | shared store |
|
||||||
|
| 11 | `11_portfolio.py` | several strategies in one book, rebalancing | shared store |
|
||||||
|
| 12 | `12_diagnostics.py` | look-ahead, exposure, risk checks | shared store |
|
||||||
|
| 13 | `13_stochastic_simulation.py` | SDE paths from the expression DSL | synthetic |
|
||||||
|
| 14 | `14_multi_timeframe.py` | higher timeframes, and how periods count | shared store |
|
||||||
|
| 15 | `15_cross_exchange.py` | signal on one venue, execution on another | shared store |
|
||||||
|
| 16 | `16_hashrate_exogene.py` | an exogenous series joined onto the bars | shared store |
|
||||||
|
| 17 | `17_per_venue_fees.py` | per-venue fee schedules in one book | shared store |
|
||||||
|
| 18 | `18_csv_import.py` | CSV / MT4 / MT5 import | synthetic |
|
||||||
|
| 19 | `19_custom_indicators.py` | composing an indicator from primitives | shared store |
|
||||||
|
| 20 | `20_entry_orders.py` | limit, stop and market-if-touched entries | shared store |
|
||||||
|
| 21 | `21_fill_at_computed_level.py` | filling at a level computed in advance | synthetic ⚠ |
|
||||||
|
| 22 | `22_yahoo_equities.py` | stocks, ETFs, indices, FX via Yahoo | network |
|
||||||
|
| 23 | `23_deribit_options.py` | option contracts that expire and settle | network |
|
||||||
|
| 24 | `24_option_spread.py` | a two-leg option structure | network |
|
||||||
|
| 25 | `25_lookahead_trap.py` | the look-ahead no re-run can detect | synthetic ⚠ |
|
||||||
|
|
||||||
|
⚠ marks the two files whose fixture determines the outcome, as described above.
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "manifoldbt"
|
name = "manifoldbt"
|
||||||
version = "0.18.0"
|
version = "0.19.0"
|
||||||
description = "Rust-powered backtesting engine for quantitative research"
|
description = "Rust-powered backtesting engine for quantitative research"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ from manifoldbt.exceptions import (
|
|||||||
LicenseError,
|
LicenseError,
|
||||||
StrategyError,
|
StrategyError,
|
||||||
)
|
)
|
||||||
from manifoldbt.expr import AssetRef, Expr, TimeframeRef, asset, col, exo, hold, lit, param, s, scan, symbol_ref, tf, when
|
from manifoldbt.expr import AssetRef, Expr, TimeframeRef, asset, choice, col, exo, hold, lit, param, s, scan, symbol_ref, tf, when
|
||||||
from manifoldbt.helpers import (
|
from manifoldbt.helpers import (
|
||||||
ExecutionPrice,
|
ExecutionPrice,
|
||||||
FillModel,
|
FillModel,
|
||||||
@@ -452,9 +452,78 @@ def _prepare_config(config: BacktestConfig, strategy, store: DataStore) -> Backt
|
|||||||
# so the engine applies them per-strategy. This lets one batch/sweep call run
|
# so the engine applies them per-strategy. This lets one batch/sweep call run
|
||||||
# strategies carrying different brackets over a single data load. A bracket
|
# strategies carrying different brackets over a single data load. A bracket
|
||||||
# set directly on config.execution.orders still applies as the fallback.
|
# set directly on config.execution.orders still applies as the fallback.
|
||||||
|
|
||||||
|
_attach_option_contracts(cfg, store)
|
||||||
return cfg
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def _attach_option_contracts(cfg: BacktestConfig, store: DataStore) -> None:
|
||||||
|
"""Fill ``cfg.option_contracts`` from what the store recorded at ingest.
|
||||||
|
|
||||||
|
The terms come from the venue, so nothing here is guessed. The one thing the
|
||||||
|
caller must supply is ``option_underlyings``: Deribit settles against its own
|
||||||
|
index, whose ticker matches no series anyone can ingest, so which price
|
||||||
|
stands in for it is a decision, not a lookup. Getting it wrong silently would
|
||||||
|
settle every contract against the wrong number, so a missing entry raises.
|
||||||
|
"""
|
||||||
|
if cfg.option_contracts:
|
||||||
|
return # explicitly overridden by the caller
|
||||||
|
try:
|
||||||
|
available = store.option_contracts()
|
||||||
|
except AttributeError:
|
||||||
|
return # store predates option support (mock stores in tests)
|
||||||
|
|
||||||
|
universe = cfg.universe if isinstance(cfg.universe, list) else []
|
||||||
|
in_universe = {int(sid) for sid in universe if isinstance(sid, int)}
|
||||||
|
underlyings = {int(k): int(v) for k, v in (cfg.option_underlyings or {}).items()}
|
||||||
|
|
||||||
|
# An option whose terms were never recorded is the dangerous case: it
|
||||||
|
# prices, it trades, it never expires, and nothing looks wrong. Catch it
|
||||||
|
# before the engine sees a plain price series.
|
||||||
|
try:
|
||||||
|
classes = store.asset_classes()
|
||||||
|
except AttributeError:
|
||||||
|
classes = {}
|
||||||
|
untermed = [
|
||||||
|
int(sid)
|
||||||
|
for sid, klass in classes.items()
|
||||||
|
if klass == "EquityOption"
|
||||||
|
and int(sid) in in_universe
|
||||||
|
and int(sid) not in {int(k) for k in available}
|
||||||
|
]
|
||||||
|
if untermed:
|
||||||
|
names = {int(i): t for i, t in store.list_symbols()}
|
||||||
|
listed = ", ".join(f"{sid} ({names.get(sid, '?')})" for sid in sorted(untermed))
|
||||||
|
raise ValueError(
|
||||||
|
f"symbol(s) {listed} are recorded as options but carry no contract terms. "
|
||||||
|
"The connector that ingested them does not report a strike and an expiration, "
|
||||||
|
"so the engine would hold them forever at their last quoted premium instead of "
|
||||||
|
"settling them. Re-ingest from a connector that reports contract terms "
|
||||||
|
"(deribit, databento), or set config.option_contracts by hand."
|
||||||
|
)
|
||||||
|
|
||||||
|
missing = []
|
||||||
|
contracts = {}
|
||||||
|
for sid, terms in available.items():
|
||||||
|
sid = int(sid)
|
||||||
|
if sid not in in_universe:
|
||||||
|
continue
|
||||||
|
if sid not in underlyings:
|
||||||
|
missing.append(sid)
|
||||||
|
continue
|
||||||
|
contracts[sid] = dict(terms, underlying_id=underlyings[sid])
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
names = {int(i): t for i, t in store.list_symbols()}
|
||||||
|
listed = ", ".join(f"{sid} ({names.get(sid, '?')})" for sid in sorted(missing))
|
||||||
|
raise ValueError(
|
||||||
|
f"option symbol(s) {listed} have contract terms but no settlement "
|
||||||
|
"underlying. Set config.option_underlyings = {option_id: underlying_id}; "
|
||||||
|
"an option cannot be settled against its own last traded premium."
|
||||||
|
)
|
||||||
|
cfg.option_contracts = contracts
|
||||||
|
|
||||||
|
|
||||||
def _is_sub_daily(res: Any) -> bool:
|
def _is_sub_daily(res: Any) -> bool:
|
||||||
"""Return True if an Interval dict represents sub-daily resolution."""
|
"""Return True if an Interval dict represents sub-daily resolution."""
|
||||||
if not isinstance(res, dict):
|
if not isinstance(res, dict):
|
||||||
@@ -612,7 +681,8 @@ def ingest(
|
|||||||
"""Ingest bars from a data provider into the Arrow IPC store.
|
"""Ingest bars from a data provider into the Arrow IPC store.
|
||||||
|
|
||||||
Providers (free): ``"binance"``, ``"bybit"``, ``"hyperliquid"``, ``"dydx"``,
|
Providers (free): ``"binance"``, ``"bybit"``, ``"hyperliquid"``, ``"dydx"``,
|
||||||
``"bitstamp"``. Pro: ``"databento"``, ``"massive"``.
|
``"bitstamp"``, ``"deribit"``, ``"yahoo"`` (alias ``"yfinance"``).
|
||||||
|
Pro: ``"databento"``, ``"massive"``.
|
||||||
|
|
||||||
Returns a :class:`DataStore` ready for :func:`run`.
|
Returns a :class:`DataStore` ready for :func:`run`.
|
||||||
|
|
||||||
@@ -634,6 +704,50 @@ def ingest(
|
|||||||
start="2020-06-01T00:00:00Z",
|
start="2020-06-01T00:00:00Z",
|
||||||
end="2026-03-01T00:00:00Z",
|
end="2026-03-01T00:00:00Z",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Example (stocks, ETFs, indices, FX and futures via Yahoo Finance)::
|
||||||
|
|
||||||
|
store = bt.ingest(
|
||||||
|
provider="yahoo",
|
||||||
|
symbol="AAPL",
|
||||||
|
symbol_id=1,
|
||||||
|
start="2015-01-01T00:00:00Z",
|
||||||
|
end="2026-01-01T00:00:00Z",
|
||||||
|
interval="1d",
|
||||||
|
asset_class="equity",
|
||||||
|
)
|
||||||
|
|
||||||
|
Yahoo caps its own history: 1m goes back 30 days, 1h about 2 years, daily
|
||||||
|
to the listing date. Prices are dividend-adjusted like ``yfinance``'s
|
||||||
|
``auto_adjust=True``; pass ``dataset="raw"`` for unadjusted quotes.
|
||||||
|
|
||||||
|
Example (a Deribit option, including one that has already expired)::
|
||||||
|
|
||||||
|
store = bt.ingest(
|
||||||
|
provider="deribit",
|
||||||
|
symbol="BTC-27JUN25-100000-C",
|
||||||
|
symbol_id=2,
|
||||||
|
start="2025-05-01T00:00:00Z",
|
||||||
|
end="2025-07-01T00:00:00Z",
|
||||||
|
interval="1d",
|
||||||
|
asset_class="option",
|
||||||
|
)
|
||||||
|
|
||||||
|
Deribit is the only free connector here that serves expired contracts, which
|
||||||
|
is what an option backtest needs. The strike, expiration, side and settlement
|
||||||
|
style are read from the venue and stored beside the bars, so the engine can
|
||||||
|
settle the contract instead of holding it forever. Prices are quoted in the
|
||||||
|
base currency, so such a backtest is denominated in BTC, ``initial_capital``
|
||||||
|
included. Set ``config.option_underlyings`` to say which series settles it.
|
||||||
|
|
||||||
|
``databento`` and ``massive`` (both Pro) report the same terms for US listed
|
||||||
|
options: Databento from the ``definition`` schema of a dataset such as
|
||||||
|
``OPRA.PILLAR``, Massive from ``/v3/reference/options/contracts`` on an OSI
|
||||||
|
ticker like ``"O:SPY251219C00650000"``. Two things differ from Deribit.
|
||||||
|
Positions are counted in units of the underlying, so one 100-multiplier
|
||||||
|
contract is a position of 100. And US listed equity options are physically
|
||||||
|
settled, which the engine models as cash at intrinsic: exact for an index
|
||||||
|
option, an approximation for a single-stock one.
|
||||||
"""
|
"""
|
||||||
_PRO_PROVIDERS = {"databento", "massive"}
|
_PRO_PROVIDERS = {"databento", "massive"}
|
||||||
if provider in _PRO_PROVIDERS:
|
if provider in _PRO_PROVIDERS:
|
||||||
@@ -1642,6 +1756,7 @@ __all__ = [
|
|||||||
"s",
|
"s",
|
||||||
"scan",
|
"scan",
|
||||||
"symbol_ref",
|
"symbol_ref",
|
||||||
|
"choice",
|
||||||
"tf",
|
"tf",
|
||||||
"when",
|
"when",
|
||||||
# Strategy & config
|
# Strategy & config
|
||||||
|
|||||||
@@ -18,7 +18,14 @@ def main() -> None:
|
|||||||
|
|
||||||
# ── ingest ────────────────────────────────────────────────────────────
|
# ── ingest ────────────────────────────────────────────────────────────
|
||||||
ing = sub.add_parser("ingest", help="Ingest bars from a data provider")
|
ing = sub.add_parser("ingest", help="Ingest bars from a data provider")
|
||||||
ing.add_argument("--provider", required=True, help="binance | bybit | hyperliquid | databento")
|
ing.add_argument(
|
||||||
|
"--provider",
|
||||||
|
required=True,
|
||||||
|
help=(
|
||||||
|
"binance | bybit | hyperliquid | dydx | bitstamp | deribit | yahoo "
|
||||||
|
"| databento | massive"
|
||||||
|
),
|
||||||
|
)
|
||||||
ing.add_argument("--symbol", required=True, help="e.g. BTCUSDT, ESH5")
|
ing.add_argument("--symbol", required=True, help="e.g. BTCUSDT, ESH5")
|
||||||
ing.add_argument("--symbol-id", required=True, type=int, help="Unique integer ID for this symbol")
|
ing.add_argument("--symbol-id", required=True, type=int, help="Unique integer ID for this symbol")
|
||||||
ing.add_argument("--start", required=True, help="RFC3339 start (e.g. 2025-01-01T00:00:00Z)")
|
ing.add_argument("--start", required=True, help="RFC3339 start (e.g. 2025-01-01T00:00:00Z)")
|
||||||
|
|||||||
@@ -375,6 +375,32 @@ class BacktestConfig:
|
|||||||
"""Explicit mapping from signal symbol to execution symbol.
|
"""Explicit mapping from signal symbol to execution symbol.
|
||||||
Required when signal and execution have different tickers.
|
Required when signal and execution have different tickers.
|
||||||
Example: ``{"BTC-USDT:perp": "BTC-USD:perp"}``"""
|
Example: ``{"BTC-USDT:perp": "BTC-USD:perp"}``"""
|
||||||
|
option_underlyings: Dict[int, int] = field(default_factory=dict)
|
||||||
|
"""Maps an option symbol id to the symbol whose price settles it.
|
||||||
|
|
||||||
|
Required for every option in the universe. Deribit settles against its own
|
||||||
|
index, whose ticker matches no series you can ingest, so the substitute is
|
||||||
|
yours to name (``BTC-PERPETUAL`` in practice). The engine refuses to run an
|
||||||
|
option without one rather than settle it against its own last traded
|
||||||
|
premium, which on an illiquid strike is days stale.
|
||||||
|
Example: ``{2: 1}`` to settle option id 2 against symbol id 1."""
|
||||||
|
option_margin_model: str = "none"
|
||||||
|
"""Margin formula short option positions pay: ``"none"`` or ``"deribit"``.
|
||||||
|
|
||||||
|
``"none"`` charges nothing, which is only honest when the strategy never
|
||||||
|
sells an option. ``"deribit"`` applies the venue's published per-contract
|
||||||
|
formula, refuses a short that does not fit initial margin, and force-closes
|
||||||
|
the book when maintenance margin passes equity."""
|
||||||
|
option_contracts: Dict = field(default_factory=dict)
|
||||||
|
"""Contract terms per option symbol id. Filled automatically from the data
|
||||||
|
store at run time; set it by hand only to override what was ingested.
|
||||||
|
|
||||||
|
Positions on an option are counted in **units of the underlying**, not in
|
||||||
|
exchange contracts. On Deribit the two are the same thing (contract size 1).
|
||||||
|
On a listed equity option, one contract is 100 units: to hold one SPY
|
||||||
|
contract quoted at 4.70, target 100, which costs the 470 a contract costs
|
||||||
|
and settles for what a contract settles for. ``contract_size`` in the terms
|
||||||
|
is what converts a position back into contracts."""
|
||||||
# Deprecated — kept for backward compat
|
# Deprecated — kept for backward compat
|
||||||
provider: Optional[str] = None
|
provider: Optional[str] = None
|
||||||
exo_sources: Dict = field(default_factory=dict)
|
exo_sources: Dict = field(default_factory=dict)
|
||||||
@@ -417,6 +443,12 @@ class BacktestConfig:
|
|||||||
d["signal_source"] = self.signal_source
|
d["signal_source"] = self.signal_source
|
||||||
if self.execution_source:
|
if self.execution_source:
|
||||||
d["execution_source"] = self.execution_source
|
d["execution_source"] = self.execution_source
|
||||||
|
if self.option_contracts:
|
||||||
|
d["option_contracts"] = {
|
||||||
|
str(sid): spec for sid, spec in self.option_contracts.items()
|
||||||
|
}
|
||||||
|
if self.option_margin_model and self.option_margin_model != "none":
|
||||||
|
d["option_margin_model"] = self.option_margin_model
|
||||||
# Deprecated fields (backward compat)
|
# Deprecated fields (backward compat)
|
||||||
if self.provider:
|
if self.provider:
|
||||||
d["provider"] = self.provider
|
d["provider"] = self.provider
|
||||||
|
|||||||
@@ -111,11 +111,24 @@ def detect_lookahead(
|
|||||||
Data is loaded once and sliced for each sub-test (no redundant I/O).
|
Data is loaded once and sliced for each sub-test (no redundant I/O).
|
||||||
|
|
||||||
Two sub-tests:
|
Two sub-tests:
|
||||||
* **extension** — split at 2/3 of the period. Catches *global*
|
* **extension** — split at 2/3 of the period. Catches look-ahead that
|
||||||
look-ahead (e.g. ``np.mean(all_prices)`` instead of rolling).
|
depends on how much data the run was given.
|
||||||
* **truncation** — split at 1/3 of the period. Catches *rolling*
|
* **truncation** — split at 1/3 of the period. Catches *rolling*
|
||||||
look-ahead (e.g. signal at bar T using bar T+1).
|
look-ahead (e.g. signal at bar T using bar T+1).
|
||||||
|
|
||||||
|
.. warning::
|
||||||
|
**What this cannot see.** Both sub-tests re-run the *same strategy* on
|
||||||
|
a shorter window. A parameter computed from the data *before* the
|
||||||
|
backtest — ``threshold = df.close.mean()`` in a notebook, then passed in
|
||||||
|
as a number — is unchanged by re-running, so the trades match and the
|
||||||
|
verdict is PASS. The leak already happened, outside the engine.
|
||||||
|
|
||||||
|
This is a property of every re-run-based method, not a gap to be closed
|
||||||
|
here: no such test can audit a constant. The defence is to treat any
|
||||||
|
parameter derived from data as part of the pipeline and re-derive it on
|
||||||
|
the window under test. ``examples/25_lookahead_trap.py`` demonstrates
|
||||||
|
the blind spot and the technique that does catch it.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
strategy: Strategy definition.
|
strategy: Strategy definition.
|
||||||
config: BacktestConfig.
|
config: BacktestConfig.
|
||||||
|
|||||||
+127
-2
@@ -182,6 +182,17 @@ class Expr:
|
|||||||
if v == "IfElse":
|
if v == "IfElse":
|
||||||
return {v: [args[0].to_json(), args[1].to_json(), args[2].to_json()]}
|
return {v: [args[0].to_json(), args[1].to_json(), args[2].to_json()]}
|
||||||
|
|
||||||
|
if v == "Choice":
|
||||||
|
# Choice(String, Vec<(String, Expr)>) -- serde attend une liste de
|
||||||
|
# paires, pas un dict : l'ORDRE des branches est porteur (la
|
||||||
|
# premiere sert de defaut a la compilation initiale).
|
||||||
|
return {"Choice": [args[0], [[k, e.to_json()] for k, e in args[1]]]}
|
||||||
|
|
||||||
|
if v == "OnTimeframe":
|
||||||
|
# OnTimeframe(String, Box<Expr>) -- l'expression est evaluee sur la
|
||||||
|
# grille de la timeframe nommee puis etalee en escalier.
|
||||||
|
return {"OnTimeframe": [args[0], args[1].to_json()]}
|
||||||
|
|
||||||
if v == "Column":
|
if v == "Column":
|
||||||
return {"Column": args[0]}
|
return {"Column": args[0]}
|
||||||
if v == "Literal":
|
if v == "Literal":
|
||||||
@@ -518,6 +529,62 @@ def when(condition: Expr, true_value: Any = 1.0, false_value: Any = float("nan")
|
|||||||
return Expr("IfElse", condition, _coerce(true_value), _coerce(false_value))
|
return Expr("IfElse", condition, _coerce(true_value), _coerce(false_value))
|
||||||
|
|
||||||
|
|
||||||
|
def choice(name: str, branches: "dict[str, Expr]", *, description: str = "") -> Expr:
|
||||||
|
"""Balayer un CHOIX d'expression, pas seulement un nombre.
|
||||||
|
|
||||||
|
Un ``param()`` ordinaire porte une valeur numerique. ``choice()`` porte un
|
||||||
|
NOM, et chaque nom designe une sous-expression differente. Le moteur
|
||||||
|
remplace le noeud entier par la branche choisie AVANT de simuler, donc une
|
||||||
|
combinaison n'evalue que sa propre branche : les autres n'existent plus.
|
||||||
|
|
||||||
|
C'est ce qui le distingue d'un ``when()`` imbrique, qui construit toutes
|
||||||
|
les variantes et tranche barre par barre.
|
||||||
|
|
||||||
|
Usage (balayer la timeframe d'une bande, sim en 1m)::
|
||||||
|
|
||||||
|
bande = mbt.choice("band", {
|
||||||
|
"30m": mbt.tf("30m").apply(sma(close, mbt.param("len"))),
|
||||||
|
"1h": mbt.tf("1h").apply(sma(close, mbt.param("len"))),
|
||||||
|
"2h": mbt.tf("2h").apply(sma(close, mbt.param("len"))),
|
||||||
|
})
|
||||||
|
# grille : {"len": [10, 20, 30], "band": ["30m", "1h", "2h"]}
|
||||||
|
|
||||||
|
Noter le ``apply()``. Ecrit ``sma(mbt.tf("30m").close, param("len"))``, le
|
||||||
|
balayage n'aurait pas le sens attendu : la periode compterait des barres de
|
||||||
|
SIMULATION sur une colonne etalee en escalier, donc les trois branches
|
||||||
|
lisseraient le meme nombre de MINUTES au lieu de 10, 20 ou 30 bougies de
|
||||||
|
leur timeframe. Voir :func:`tf`.
|
||||||
|
|
||||||
|
Les branches acceptent n'importe quelle expression, donc le meme mecanisme
|
||||||
|
balaie une colonne exogene, un actif ou un type d'indicateur.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: nom du parametre selecteur, a mettre dans la grille.
|
||||||
|
branches: nom de branche -> expression. L'ordre compte : la premiere
|
||||||
|
sert de defaut quand le parametre est absent.
|
||||||
|
description: metadonnee libre.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: si ``branches`` est vide.
|
||||||
|
"""
|
||||||
|
if not branches:
|
||||||
|
raise ValueError(
|
||||||
|
f"choice({name!r}) needs at least one branch; an empty choice has "
|
||||||
|
f"nothing to resolve to."
|
||||||
|
)
|
||||||
|
items = [(str(k), _coerce(v)) for k, v in branches.items()]
|
||||||
|
expr = Expr("Choice", name, items)
|
||||||
|
# Declare le selecteur comme un parametre a part entiere, sans quoi le
|
||||||
|
# balayer serait refuse par la validation ("parameter not declared").
|
||||||
|
expr._param_meta = {
|
||||||
|
"name": name,
|
||||||
|
"default": items[0][0],
|
||||||
|
"range": None,
|
||||||
|
"description": description,
|
||||||
|
}
|
||||||
|
return expr
|
||||||
|
|
||||||
|
|
||||||
def exo(name: str, column: Optional[str] = None) -> Expr:
|
def exo(name: str, column: Optional[str] = None) -> Expr:
|
||||||
"""Reference an exogenous data column.
|
"""Reference an exogenous data column.
|
||||||
|
|
||||||
@@ -626,6 +693,28 @@ class TimeframeRef:
|
|||||||
"""Reference any column from this timeframe."""
|
"""Reference any column from this timeframe."""
|
||||||
return col(f"{self._tf}.{name}")
|
return col(f"{self._tf}.{name}")
|
||||||
|
|
||||||
|
def apply(self, expr: "Expr") -> Expr:
|
||||||
|
"""Evaluate *expr* ON this timeframe's own grid, then step-hold the
|
||||||
|
result back onto the simulation grid (forward-filled, no lookahead:
|
||||||
|
a completed bar's value becomes readable from the next bar on).
|
||||||
|
|
||||||
|
This is what makes higher-timeframe INDICATORS correct. Periods
|
||||||
|
inside *expr* count in THIS timeframe's bars::
|
||||||
|
|
||||||
|
h1 = bt.tf("1h")
|
||||||
|
band = h1.apply(sma(close, mbt.param("len"))) # len = HOURS
|
||||||
|
|
||||||
|
is a true SMA of ``len`` hourly closes, sweepable like any param.
|
||||||
|
By contrast ``sma(h1.close, 20)`` counts 20 SIMULATION bars over a
|
||||||
|
step-held hourly series -- on a 1m simulation that is a 20-MINUTE
|
||||||
|
smoothing of a staircase, not a 20-hour average.
|
||||||
|
|
||||||
|
Inside *expr*, ``close``/``open``/... refer to this timeframe's own
|
||||||
|
resampled columns. Requires ``extra_timeframes`` to declare the
|
||||||
|
timeframe. Nesting ``apply`` inside another ``apply`` is rejected.
|
||||||
|
"""
|
||||||
|
return Expr("OnTimeframe", self._tf, _coerce(expr))
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"TimeframeRef({self._tf!r})"
|
return f"TimeframeRef({self._tf!r})"
|
||||||
|
|
||||||
@@ -633,12 +722,48 @@ class TimeframeRef:
|
|||||||
def tf(timeframe: str) -> TimeframeRef:
|
def tf(timeframe: str) -> TimeframeRef:
|
||||||
"""Reference a higher timeframe for multi-TF strategies.
|
"""Reference a higher timeframe for multi-TF strategies.
|
||||||
|
|
||||||
Usage::
|
Two different things, and the distinction matters::
|
||||||
|
|
||||||
h1 = bt.tf("1h")
|
h1 = bt.tf("1h")
|
||||||
trend = ema(h1.close, 20) > ema(h1.close, 50)
|
|
||||||
|
h1.close # a COLUMN: the last closed hourly
|
||||||
|
# close, held across the minute bars
|
||||||
|
h1.apply(ema(close, 20)) # an INDICATOR on the hourly grid:
|
||||||
|
# 20 counts hourly candles
|
||||||
|
|
||||||
Requires ``extra_timeframes={"1h": Interval.hours(1)}`` in config.
|
Requires ``extra_timeframes={"1h": Interval.hours(1)}`` in config.
|
||||||
|
|
||||||
|
.. warning::
|
||||||
|
**An indicator applied to** ``h1.close`` **counts SIMULATION bars, not
|
||||||
|
candles of the higher timeframe.** The column is forward-filled onto the
|
||||||
|
simulation grid, so an indicator over it counts rows of that grid.
|
||||||
|
|
||||||
|
On 1-minute bars, ``sma(h1.close, 8)`` averages the last 8 *minutes* of
|
||||||
|
a step function — which is the last closed hourly close, not an 8-hour
|
||||||
|
average. Measured on a ramp of +10/hour, it lags 1.63 h where a true
|
||||||
|
8-hour mean lags 5.45 h.
|
||||||
|
|
||||||
|
Multiplying by the ratio of the two intervals does not fix it either.
|
||||||
|
``sma(h1.close, 8 * 60)`` averages 480 rows of the step function: at
|
||||||
|
every move it ramps in over 60 minutes instead of stepping, and its
|
||||||
|
window spans 9 hourly values with unequal weights rather than 8 with
|
||||||
|
equal ones. Measured against a true 8-hour mean on an impulse (one hour
|
||||||
|
at 200, base 100, so the true signal spans 12.5): the error reaches
|
||||||
|
12.29, or 98 % of that span. A ramp cannot reveal this — a box filter
|
||||||
|
leaves a straight line straight — which is why a lag measurement alone
|
||||||
|
reads correct.
|
||||||
|
|
||||||
|
Use :meth:`TimeframeRef.apply`, which evaluates on the hourly grid and
|
||||||
|
then step-holds the result. On that same impulse it matches the true
|
||||||
|
8-hour mean exactly, on every bar::
|
||||||
|
|
||||||
|
sma(h1.close, 8) # 8 minutes of a step (gap 12.29)
|
||||||
|
sma(h1.close, 8 * 60) # 480 minutes of a step (gap 12.29)
|
||||||
|
h1.apply(sma(close, 8)) # the 8-hour mean (gap 0.00)
|
||||||
|
|
||||||
|
The ~1 h of lag common to all three is the timeframe itself: an hourly
|
||||||
|
bar is only readable once closed, which is what makes it free of
|
||||||
|
look-ahead.
|
||||||
"""
|
"""
|
||||||
return TimeframeRef(timeframe)
|
return TimeframeRef(timeframe)
|
||||||
|
|
||||||
|
|||||||
@@ -101,3 +101,77 @@ def trades_arrays(result) -> dict:
|
|||||||
else:
|
else:
|
||||||
out[name] = arrow_to_numpy(col)
|
out[name] = arrow_to_numpy(col)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def run_currency(result) -> str:
|
||||||
|
"""Currency the run is denominated in, from its manifest.
|
||||||
|
|
||||||
|
The manifest embeds the full BacktestConfig, so nothing is guessed. "USD"
|
||||||
|
is only the last resort for a result that has no manifest at all (mock
|
||||||
|
objects in tests).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
code = result.manifest["config"]["currency"]
|
||||||
|
return str(code) if code else "USD"
|
||||||
|
except Exception:
|
||||||
|
return "USD"
|
||||||
|
|
||||||
|
|
||||||
|
_CURRENCY_PREFIX = {"USD": "$", "EUR": "€", "GBP": "£"}
|
||||||
|
|
||||||
|
|
||||||
|
def money_hovertemplate(values: np.ndarray, currency: str) -> str:
|
||||||
|
"""Hover template for a money series, currency- and magnitude-aware.
|
||||||
|
|
||||||
|
The old template was a hardcoded "$%{y:,.0f}": a 10-BTC equity hovered as
|
||||||
|
"$10" - wrong currency, and a precision that erased every variation the
|
||||||
|
chart existed to show. Decimals follow the magnitude of the series, and
|
||||||
|
the currency is written as a symbol when it has one, as a suffix code
|
||||||
|
(10.0443 BTC) when it does not.
|
||||||
|
"""
|
||||||
|
peak = float(np.nanmax(np.abs(values))) if len(values) else 0.0
|
||||||
|
decimals = 0 if peak >= 10_000 else 2 if peak >= 100 else 4
|
||||||
|
code = (currency or "USD").upper()
|
||||||
|
amount = "%{y:,." + str(decimals) + "f}"
|
||||||
|
prefix = _CURRENCY_PREFIX.get(code)
|
||||||
|
amount = prefix + amount if prefix else amount + " " + code
|
||||||
|
return "%{x|%d %b %Y} " + amount + "<extra></extra>"
|
||||||
|
|
||||||
|
|
||||||
|
def date_tickformat(dates: np.ndarray) -> str:
|
||||||
|
"""Date-axis tick format adapted to the span of the series.
|
||||||
|
|
||||||
|
Hardcoding "%b %Y" labelled every tick of a two-month backtest "May 2025"
|
||||||
|
(and every tick of a 30-day synthetic run "Jan 2024"): the format must
|
||||||
|
follow the span, not assume it. Thresholds are where the coarser format
|
||||||
|
stops producing distinct labels for ~6 ticks.
|
||||||
|
"""
|
||||||
|
if len(dates) < 2:
|
||||||
|
return "%b %Y"
|
||||||
|
span_days = float(
|
||||||
|
(np.datetime64(dates[-1], "ns") - np.datetime64(dates[0], "ns"))
|
||||||
|
/ np.timedelta64(1, "D")
|
||||||
|
)
|
||||||
|
if span_days <= 3:
|
||||||
|
return "%d %b %H:%M"
|
||||||
|
if span_days <= 180:
|
||||||
|
return "%d %b"
|
||||||
|
# Beyond ~6 months the historical "%b %Y" is already distinct per tick,
|
||||||
|
# whatever the span: plotly widens the tick spacing with the range. Only
|
||||||
|
# the short end was ever broken.
|
||||||
|
return "%b %Y"
|
||||||
|
|
||||||
|
|
||||||
|
def percent_tickformat(magnitude: float) -> str:
|
||||||
|
"""Percent-axis tick format adapted to the magnitude of the series.
|
||||||
|
|
||||||
|
The date-axis disease, on the value axis: ".0%" labelled every tick of a
|
||||||
|
-0.9% max-drawdown chart "0%". Decimals follow the extreme value, so the
|
||||||
|
ticks always spell out distinct numbers.
|
||||||
|
"""
|
||||||
|
m = abs(float(magnitude))
|
||||||
|
if m >= 0.05:
|
||||||
|
return ".0%"
|
||||||
|
if m >= 0.005:
|
||||||
|
return ".1%"
|
||||||
|
return ".2%"
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ from manifoldbt.plot._convert import (
|
|||||||
positions_arrays,
|
positions_arrays,
|
||||||
trades_arrays,
|
trades_arrays,
|
||||||
_ts_to_int64,
|
_ts_to_int64,
|
||||||
|
date_tickformat,
|
||||||
|
percent_tickformat,
|
||||||
|
money_hovertemplate,
|
||||||
|
run_currency,
|
||||||
)
|
)
|
||||||
from manifoldbt.plot._decimate import maybe_decimate
|
from manifoldbt.plot._decimate import maybe_decimate
|
||||||
from manifoldbt.plot._utils import finalize, format_pct, new_figure
|
from manifoldbt.plot._utils import finalize, format_pct, new_figure
|
||||||
@@ -277,10 +281,10 @@ def equity(
|
|||||||
dates, values = maybe_decimate(dates, values)
|
dates, values = maybe_decimate(dates, values)
|
||||||
fig.add_traces(_area_traces(
|
fig.add_traces(_area_traces(
|
||||||
dates, values, float(values.min()), color, width=1.5,
|
dates, values, float(values.min()), color, width=1.5,
|
||||||
hovertemplate="%{x|%d %b %Y} $%{y:,.0f}<extra></extra>",
|
hovertemplate=money_hovertemplate(values, run_currency(result)),
|
||||||
))
|
))
|
||||||
fig.update_yaxes(title_text="Equity")
|
fig.update_yaxes(title_text="Equity")
|
||||||
fig.update_xaxes(tickformat="%b %Y")
|
fig.update_xaxes(tickformat=date_tickformat(dates))
|
||||||
return finalize(fig, show=show, save=save)
|
return finalize(fig, show=show, save=save)
|
||||||
|
|
||||||
|
|
||||||
@@ -324,7 +328,7 @@ def benchmark_equity(
|
|||||||
line=dict(color=benchmark_color, width=1.0),
|
line=dict(color=benchmark_color, width=1.0),
|
||||||
))
|
))
|
||||||
fig.update_yaxes(title_text="Normalized" if normalize else "Equity")
|
fig.update_yaxes(title_text="Normalized" if normalize else "Equity")
|
||||||
fig.update_xaxes(tickformat="%b %Y")
|
fig.update_xaxes(tickformat=date_tickformat(d1))
|
||||||
fig.update_layout(legend=dict(x=0.01, y=0.99))
|
fig.update_layout(legend=dict(x=0.01, y=0.99))
|
||||||
return finalize(fig, show=show, save=save)
|
return finalize(fig, show=show, save=save)
|
||||||
|
|
||||||
@@ -357,9 +361,10 @@ def drawdown(
|
|||||||
hovertemplate="%{x|%d %b %Y} %{y:.1%}<extra></extra>",
|
hovertemplate="%{x|%d %b %Y} %{y:.1%}<extra></extra>",
|
||||||
))
|
))
|
||||||
dd_min = float(dd.min()) if len(dd) else -0.01
|
dd_min = float(dd.min()) if len(dd) else -0.01
|
||||||
fig.update_yaxes(title_text="Drawdown", tickformat=".0%",
|
fig.update_yaxes(title_text="Drawdown",
|
||||||
|
tickformat=percent_tickformat(dd_min),
|
||||||
range=[dd_min * 1.08, 0])
|
range=[dd_min * 1.08, 0])
|
||||||
fig.update_xaxes(tickformat="%b %Y")
|
fig.update_xaxes(tickformat=date_tickformat(dates))
|
||||||
return finalize(fig, show=show, save=save)
|
return finalize(fig, show=show, save=save)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""Tests for bt.choice() — sweeping a CHOICE of expression, not just a number.
|
||||||
|
|
||||||
|
The contract under test: `choice("sel", {...})` resolves to exactly one branch
|
||||||
|
per combo BEFORE simulation, so a sweep over the selector must be
|
||||||
|
bit-identical to running each branch inlined by hand. The selector must count
|
||||||
|
as a declared parameter (otherwise `_validate_swept_params` would reject the
|
||||||
|
sweep), and an unknown branch must fail with a message naming the known ones.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import manifoldbt as bt
|
||||||
|
from manifoldbt.indicators import close, sma
|
||||||
|
|
||||||
|
pd = pytest.importorskip("pandas")
|
||||||
|
np = pytest.importorskip("numpy")
|
||||||
|
|
||||||
|
N_BARS = 3_000
|
||||||
|
|
||||||
|
|
||||||
|
def _store(tmp_path):
|
||||||
|
ts = pd.date_range("2022-01-01", periods=N_BARS, freq="1min", tz="UTC")
|
||||||
|
rng = np.random.default_rng(11)
|
||||||
|
px = 100.0 + np.cumsum(np.sin(np.arange(N_BARS) / 90.0) * 0.3 + rng.normal(0, 0.2, N_BARS)) * 0.05
|
||||||
|
px = np.maximum(px, 1.0)
|
||||||
|
df = pd.DataFrame(
|
||||||
|
{
|
||||||
|
"timestamp": ts,
|
||||||
|
"open": px,
|
||||||
|
"high": px * 1.0005,
|
||||||
|
"low": px * 0.9995,
|
||||||
|
"close": px,
|
||||||
|
"volume": [1000.0] * N_BARS,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
root = tmp_path / "choice_store"
|
||||||
|
return bt.import_dataframe(
|
||||||
|
df,
|
||||||
|
symbol="ZC",
|
||||||
|
symbol_id=1,
|
||||||
|
interval="1m",
|
||||||
|
asset_class="equity",
|
||||||
|
exchange="TEST",
|
||||||
|
data_root=str(root / "data"),
|
||||||
|
metadata_db=str(root / "metadata.sqlite"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _config():
|
||||||
|
start, end = bt.time_range("2022-01-01", "2022-01-03")
|
||||||
|
cfg = bt.BacktestConfig(
|
||||||
|
universe=[1],
|
||||||
|
time_range_start=start,
|
||||||
|
time_range_end=end,
|
||||||
|
initial_capital=10_000.0,
|
||||||
|
provider="TEST",
|
||||||
|
bar_interval=bt.Interval.minutes(1),
|
||||||
|
symbol_names={"ZC": 1},
|
||||||
|
)
|
||||||
|
cfg.warmup_bars = 0
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def _strategy_with_choice():
|
||||||
|
band = bt.choice(
|
||||||
|
"pick",
|
||||||
|
{
|
||||||
|
"fast": sma(close, 5),
|
||||||
|
"slow": sma(close, 20),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
bt.Strategy.create("choice_e2e")
|
||||||
|
.signal("band", band)
|
||||||
|
.size(bt.when(close > bt.col("band"), 1.0, 0.0))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _strategy_inlined(period):
|
||||||
|
return (
|
||||||
|
bt.Strategy.create(f"inline_{period}")
|
||||||
|
.signal("band", sma(close, period))
|
||||||
|
.size(bt.when(close > bt.col("band"), 1.0, 0.0))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_serializes_as_ordered_pairs():
|
||||||
|
"""serde expects Choice(String, Vec<(String, Expr)>): a list of pairs,
|
||||||
|
order preserved — the first branch is the compile-time default."""
|
||||||
|
e = bt.choice("pick", {"a": close, "b": sma(close, 3)})
|
||||||
|
payload = json.loads(json.dumps(e.to_json()))
|
||||||
|
assert list(payload) == ["Choice"]
|
||||||
|
name, branches = payload["Choice"]
|
||||||
|
assert name == "pick"
|
||||||
|
assert [k for k, _ in branches] == ["a", "b"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_branches_rejected():
|
||||||
|
with pytest.raises(ValueError, match="at least one branch"):
|
||||||
|
bt.choice("pick", {})
|
||||||
|
|
||||||
|
|
||||||
|
def test_selector_counts_as_declared_parameter():
|
||||||
|
"""Sweeping the selector must pass strategy-side validation: choice()
|
||||||
|
declares it via _param_meta exactly like param() does."""
|
||||||
|
strat = _strategy_with_choice()
|
||||||
|
assert "pick" in (strat.to_json_dict().get("parameters") or {})
|
||||||
|
|
||||||
|
|
||||||
|
def test_sweep_over_choice_matches_inlined_branches(tmp_path):
|
||||||
|
"""The money test: each combo of the selector sweep is bit-identical to
|
||||||
|
the strategy with that branch written directly."""
|
||||||
|
store = _store(tmp_path)
|
||||||
|
cfg = _config()
|
||||||
|
|
||||||
|
sweep = bt.run_sweep_lite(
|
||||||
|
_strategy_with_choice(), {"pick": ["fast", "slow"]}, cfg, store, device="cpu"
|
||||||
|
)
|
||||||
|
assert len(sweep) == 2
|
||||||
|
|
||||||
|
by_branch = dict(zip(["fast", "slow"], sweep))
|
||||||
|
for name, period in (("fast", 5), ("slow", 20)):
|
||||||
|
ref = bt.run_sweep_lite(
|
||||||
|
_strategy_inlined(period), {}, cfg, store, device="cpu"
|
||||||
|
)[0]
|
||||||
|
got, want = by_branch[name].metrics, ref.metrics
|
||||||
|
for key in ("total_return", "sharpe", "max_drawdown"):
|
||||||
|
assert got.get(key) == want.get(key), (
|
||||||
|
f"branch {name!r}: {key} diverged ({got.get(key)} vs {want.get(key)})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_branch_names_the_known_ones(tmp_path):
|
||||||
|
store = _store(tmp_path)
|
||||||
|
with pytest.raises(Exception, match="fast"):
|
||||||
|
bt.run_sweep_lite(
|
||||||
|
_strategy_with_choice(), {"pick": ["nope"]}, _config(), store, device="cpu"
|
||||||
|
)
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"""The look-ahead the detector cannot see, pinned as a characterization test.
|
||||||
|
|
||||||
|
`detect_lookahead` used to document itself as catching global look-ahead,
|
||||||
|
"e.g. np.mean(all_prices) instead of rolling". It does not, and it cannot: both
|
||||||
|
its sub-tests re-run the *same strategy* on a shorter window, so a threshold
|
||||||
|
computed in a notebook and passed in as a number is identical in every run.
|
||||||
|
|
||||||
|
This file asserts the blind spot on purpose. A test that pins a limitation is
|
||||||
|
worth more than a docstring promising the opposite, because the docstring was
|
||||||
|
wrong for as long as nobody tried it.
|
||||||
|
|
||||||
|
It also pins the method that DOES catch it, so the boundary is not just
|
||||||
|
described but demonstrated: re-derive the parameter on the truncated window and
|
||||||
|
compare the same prefix.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import manifoldbt as bt
|
||||||
|
|
||||||
|
np = pytest.importorskip("numpy")
|
||||||
|
pd = pytest.importorskip("pandas")
|
||||||
|
|
||||||
|
from manifoldbt.helpers import Interval, Slippage # noqa: E402
|
||||||
|
|
||||||
|
N_DAYS = 400
|
||||||
|
SPLIT = 260
|
||||||
|
|
||||||
|
|
||||||
|
def _mean_reverting_daily():
|
||||||
|
"""A series that pulls back to its mean, where knowing that mean pays."""
|
||||||
|
rng = np.random.default_rng(11)
|
||||||
|
level = np.cumsum(rng.normal(0.0, 0.018, N_DAYS))
|
||||||
|
px = 100.0 * np.exp(level - np.linspace(0, level[-1], N_DAYS))
|
||||||
|
o = px
|
||||||
|
c = np.roll(px, -1)
|
||||||
|
c[-1] = px[-1]
|
||||||
|
amp = np.abs(rng.normal(0.0, 0.004, N_DAYS))
|
||||||
|
return pd.DataFrame({
|
||||||
|
"timestamp": pd.date_range("2022-01-01", periods=N_DAYS, freq="1D", tz="UTC"),
|
||||||
|
"open": o,
|
||||||
|
"high": np.maximum(o, c) * (1 + amp),
|
||||||
|
"low": np.minimum(o, c) * (1 - amp),
|
||||||
|
"close": c,
|
||||||
|
"volume": rng.uniform(1_000, 5_000, N_DAYS),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _store(frame, tmp_path, tag):
|
||||||
|
root = os.path.join(str(tmp_path), tag)
|
||||||
|
return bt.import_dataframe(
|
||||||
|
frame, symbol="SYNTH", symbol_id=1, interval="1d",
|
||||||
|
data_root=os.path.join(root, "data"),
|
||||||
|
metadata_db=os.path.join(root, "meta.sqlite"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _config(frame):
|
||||||
|
ts = pd.DatetimeIndex(frame["timestamp"])
|
||||||
|
return bt.BacktestConfig(
|
||||||
|
universe=[1],
|
||||||
|
time_range_start=int(ts[0].value),
|
||||||
|
time_range_end=int(ts[-1].value) + 86_400_000_000_000,
|
||||||
|
bar_interval=Interval.days(1),
|
||||||
|
initial_capital=10_000,
|
||||||
|
execution=bt.ExecutionConfig(
|
||||||
|
signal_delay=1, max_position_pct=1.0,
|
||||||
|
allow_short=True, position_sizing_mode="FractionOfEquity",
|
||||||
|
),
|
||||||
|
slippage=Slippage.fixed_bps(0),
|
||||||
|
warmup_bars=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _leaky(mean_price):
|
||||||
|
"""The threshold is a number the researcher computed over everything."""
|
||||||
|
from manifoldbt.indicators import close
|
||||||
|
|
||||||
|
return (
|
||||||
|
bt.Strategy.create("global_mean_leak")
|
||||||
|
.signal("edge", close)
|
||||||
|
.size(bt.when(close < mean_price, 1.0, -1.0))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _equity(result):
|
||||||
|
return np.array([float(x) for x in result.equity_curve])
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_parameter_baked_at_research_time_flatters_the_result(tmp_path):
|
||||||
|
"""First establish there IS a leak, otherwise the blind spot is moot."""
|
||||||
|
from manifoldbt.indicators import close
|
||||||
|
|
||||||
|
frame = _mean_reverting_daily()
|
||||||
|
store = _store(frame, tmp_path, "seduction")
|
||||||
|
global_mean = float(frame["close"].mean())
|
||||||
|
|
||||||
|
leaked = bt.run(_leaky(global_mean), _config(frame), store)
|
||||||
|
honest = bt.run(
|
||||||
|
bt.Strategy.create("rolling")
|
||||||
|
.signal("edge", close)
|
||||||
|
.size(bt.when(close < close.rolling_mean(60), 1.0, -1.0)),
|
||||||
|
_config(frame), store,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert leaked.metrics["total_return"] > honest.metrics["total_return"], (
|
||||||
|
"the global mean did not flatter the result, so this fixture no longer "
|
||||||
|
"demonstrates a leak worth detecting"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_detector_is_blind_to_it(tmp_path):
|
||||||
|
"""Pinned limitation: PASS here is the documented, expected answer.
|
||||||
|
|
||||||
|
If this ever starts failing, the detector gained the ability to audit a
|
||||||
|
baked parameter. That would be good news, and the warning in
|
||||||
|
`detect_lookahead`'s docstring should be revisited rather than this test
|
||||||
|
silenced.
|
||||||
|
"""
|
||||||
|
from manifoldbt.diagnostics import detect_lookahead
|
||||||
|
|
||||||
|
frame = _mean_reverting_daily()
|
||||||
|
result = detect_lookahead(
|
||||||
|
_leaky(float(frame["close"].mean())),
|
||||||
|
_config(frame), _store(frame, tmp_path, "blind"), mode="all",
|
||||||
|
)
|
||||||
|
|
||||||
|
compared = sum(r.total_trades_overlap for r in result.reports)
|
||||||
|
assert compared > 0, "empty verdict, the blind spot is not what is being shown"
|
||||||
|
assert result.passed, (
|
||||||
|
"the detector now catches a research-time constant; update the docstring "
|
||||||
|
"warning instead of deleting this test"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_re_deriving_the_parameter_catches_it(tmp_path):
|
||||||
|
"""The technique that works, and the reason the blind spot is acceptable.
|
||||||
|
|
||||||
|
Same window, same strategy shape: only the threshold differs, one computed
|
||||||
|
with the future and one without. The equity must diverge.
|
||||||
|
"""
|
||||||
|
frame = _mean_reverting_daily()
|
||||||
|
truncated = frame.iloc[:SPLIT + 1]
|
||||||
|
|
||||||
|
with_future = _equity(bt.run(
|
||||||
|
_leaky(float(frame["close"].mean())), # knows all 400 days
|
||||||
|
_config(truncated), _store(truncated, tmp_path, "future"),
|
||||||
|
))
|
||||||
|
with_past = _equity(bt.run(
|
||||||
|
_leaky(float(truncated["close"].mean())), # knows only the first 261
|
||||||
|
_config(truncated), _store(truncated, tmp_path, "past"),
|
||||||
|
))
|
||||||
|
|
||||||
|
n = min(len(with_future), len(with_past))
|
||||||
|
assert n > 100, f"only {n} bars compared, too few to conclude"
|
||||||
|
gap = float(np.abs(with_future[:n] - with_past[:n]).max())
|
||||||
|
assert gap > 0.0, (
|
||||||
|
"re-deriving the threshold changed nothing, so this method would not "
|
||||||
|
"catch the leak either"
|
||||||
|
)
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
"""Anti-look-ahead tests for `ExecutionPrice.custom(...)` with `signal_delay=0`.
|
||||||
|
|
||||||
|
This is the configuration of `examples/21_fill_at_computed_level.py`, and the
|
||||||
|
one with the most room for leakage in the whole engine: the fill price is read
|
||||||
|
from a strategy signal, the order acts on the same bar it was computed on, and
|
||||||
|
the level itself comes from a higher timeframe. Three chances for a bar to be
|
||||||
|
priced with information it could not have had.
|
||||||
|
|
||||||
|
Two independent methods, because a single one can pass for the wrong reason:
|
||||||
|
|
||||||
|
* **future perturbation** — corrupt every bar after K, re-run, and require
|
||||||
|
the equity of bars 0..K to be *bit-identical*. Any decision that read a
|
||||||
|
future bar moves the prefix.
|
||||||
|
* **the engine's own detector** — `detect_lookahead`, which compares the
|
||||||
|
trades of truncated runs against the full run.
|
||||||
|
|
||||||
|
Both carry an anti-vacuity guard. A look-ahead test that compares nothing
|
||||||
|
passes just as loudly as one that compares everything, which is exactly how
|
||||||
|
the built-in detector reports PASS when its splits fall outside the data.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import manifoldbt as bt
|
||||||
|
|
||||||
|
np = pytest.importorskip("numpy")
|
||||||
|
pd = pytest.importorskip("pandas")
|
||||||
|
|
||||||
|
from manifoldbt.helpers import ExecutionPrice, Interval, Slippage # noqa: E402
|
||||||
|
|
||||||
|
# Three days of 1-minute bars: enough for the hourly SMA to have a history,
|
||||||
|
# small enough not to weigh on the suite.
|
||||||
|
N_BARS = 3 * 1440
|
||||||
|
SPLIT = 2 * 1440 # perturb everything after this bar
|
||||||
|
|
||||||
|
|
||||||
|
def _mean_reverting_bars(seed=7):
|
||||||
|
"""The construction of example 21, at a size a test can afford."""
|
||||||
|
rng = np.random.default_rng(seed)
|
||||||
|
steps = rng.normal(0.0, 0.0010, N_BARS)
|
||||||
|
level = np.cumsum(steps) * 0.85
|
||||||
|
px = 100.0 * np.exp(level - np.linspace(0, level[-1], N_BARS))
|
||||||
|
o = px
|
||||||
|
c = np.roll(px, -1)
|
||||||
|
c[-1] = px[-1]
|
||||||
|
amp = np.abs(rng.normal(0.0, 0.0012, N_BARS))
|
||||||
|
return pd.DataFrame({
|
||||||
|
"timestamp": pd.date_range("2024-01-01", periods=N_BARS, freq="1min", tz="UTC"),
|
||||||
|
"open": o,
|
||||||
|
"high": np.maximum(o, c) * (1 + amp),
|
||||||
|
"low": np.minimum(o, c) * (1 - amp),
|
||||||
|
"close": c,
|
||||||
|
"volume": rng.uniform(1_000, 5_000, N_BARS),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _band_strategy():
|
||||||
|
"""Short the upper band, cover the lower one, filling ON the band."""
|
||||||
|
from manifoldbt.indicators import close, high, low, open as open_px, sma
|
||||||
|
|
||||||
|
h1 = bt.tf("1h")
|
||||||
|
band_up = sma(h1.close, 8) * 1.004
|
||||||
|
band_dn = sma(h1.close, 8) * 0.997
|
||||||
|
touch_up = high >= band_up
|
||||||
|
touch_dn = low <= band_dn
|
||||||
|
target = bt.when(touch_dn, 0.0, bt.when(touch_up, -1.0))
|
||||||
|
exec_level = bt.when(
|
||||||
|
touch_dn, bt.when(open_px <= band_dn, open_px, band_dn),
|
||||||
|
bt.when(touch_up, bt.when(open_px >= band_up, open_px, band_up), close),
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
bt.Strategy.create("band_touch_short")
|
||||||
|
.signal("position", target)
|
||||||
|
.signal("exec_level", exec_level)
|
||||||
|
.size(target)
|
||||||
|
.stop_loss(pct=25.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _store(frame, tmp_path, tag):
|
||||||
|
root = os.path.join(str(tmp_path), tag)
|
||||||
|
return bt.import_dataframe(
|
||||||
|
frame, symbol="SYNTH", symbol_id=1, interval="1m",
|
||||||
|
data_root=os.path.join(root, "data"),
|
||||||
|
metadata_db=os.path.join(root, "meta.sqlite"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _config(frame):
|
||||||
|
"""A time range bounded by the DATA, not by the epoch.
|
||||||
|
|
||||||
|
`time_range_start=0` would stretch the range over 54 years, which is what
|
||||||
|
makes the built-in detector split outside the data and pass vacuously.
|
||||||
|
"""
|
||||||
|
ts = pd.DatetimeIndex(frame["timestamp"])
|
||||||
|
return bt.BacktestConfig(
|
||||||
|
universe=[1],
|
||||||
|
time_range_start=int(ts[0].value),
|
||||||
|
time_range_end=int(ts[-1].value) + 60_000_000_000,
|
||||||
|
bar_interval=Interval.minutes(1),
|
||||||
|
initial_capital=10_000,
|
||||||
|
execution=bt.ExecutionConfig(
|
||||||
|
signal_delay=0,
|
||||||
|
execution_price=ExecutionPrice.custom("exec_level"),
|
||||||
|
max_position_pct=0.4,
|
||||||
|
allow_short=True,
|
||||||
|
position_sizing_mode="FractionOfEquity",
|
||||||
|
),
|
||||||
|
fees=bt.FeeConfig.binance_perps(),
|
||||||
|
slippage=Slippage.fixed_bps(2),
|
||||||
|
warmup_bars=60 * 4,
|
||||||
|
extra_timeframes={"1h": Interval.hours(1)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _equity(result):
|
||||||
|
return np.array([float(x) for x in result.equity_curve])
|
||||||
|
|
||||||
|
|
||||||
|
def test_future_bars_cannot_move_the_past(tmp_path):
|
||||||
|
"""The decisive test: corrupt the future, the past must not budge."""
|
||||||
|
frame = _mean_reverting_bars()
|
||||||
|
strategy = _band_strategy()
|
||||||
|
config = _config(frame)
|
||||||
|
|
||||||
|
reference = _equity(bt.run(strategy, _config(frame), _store(frame, tmp_path, "ref")))
|
||||||
|
|
||||||
|
# Same multiplicative factor on all four price columns, so the bars stay
|
||||||
|
# valid (high >= max(open, close), low <= min(open, close)). Small enough
|
||||||
|
# that the short strategy survives it: a 3x future turns the equity
|
||||||
|
# negative and the run refuses, which would prove nothing.
|
||||||
|
rng = np.random.default_rng(1234)
|
||||||
|
corrupted = frame.copy()
|
||||||
|
tail = slice(SPLIT + 1, None)
|
||||||
|
factor = 1.0 + rng.uniform(-0.005, 0.005, N_BARS - SPLIT - 1)
|
||||||
|
for col in ("open", "high", "low", "close"):
|
||||||
|
corrupted.loc[corrupted.index[tail], col] = corrupted[col].to_numpy()[tail] * factor
|
||||||
|
|
||||||
|
perturbed = _equity(bt.run(strategy, config, _store(corrupted, tmp_path, "pert")))
|
||||||
|
|
||||||
|
# Anti-vacuity: if the corruption changed nothing at all, an identical
|
||||||
|
# prefix would be meaningless.
|
||||||
|
assert abs(reference[-1] - perturbed[-1]) > 1e-6, (
|
||||||
|
"the perturbation left the future untouched; the test would be vacuous"
|
||||||
|
)
|
||||||
|
|
||||||
|
n = min(len(reference), len(perturbed), SPLIT + 1)
|
||||||
|
assert n > 1000, f"only {n} bars compared, too few to conclude"
|
||||||
|
delta = np.abs(reference[:n] - perturbed[:n])
|
||||||
|
first = int(np.argmax(delta > 0)) if delta.max() > 0 else -1
|
||||||
|
assert delta.max() == 0.0, (
|
||||||
|
f"future data leaked into the past: bar {first} differs by {delta.max():.3e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_builtin_detector_agrees_and_is_not_vacuous(tmp_path):
|
||||||
|
"""The engine's own detector, plus a check that it compared something.
|
||||||
|
|
||||||
|
`trades=0, mismatched=0` is reported as PASS. Asserting only on `.passed`
|
||||||
|
would accept that empty verdict.
|
||||||
|
"""
|
||||||
|
from manifoldbt.diagnostics import detect_lookahead
|
||||||
|
|
||||||
|
frame = _mean_reverting_bars()
|
||||||
|
result = detect_lookahead(
|
||||||
|
_band_strategy(), _config(frame), _store(frame, tmp_path, "det"), mode="all"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.passed, f"look-ahead reported: {result}"
|
||||||
|
compared = sum(r.total_trades_overlap for r in result.reports)
|
||||||
|
assert compared > 0, (
|
||||||
|
f"the detector compared no trade at all, its PASS is empty: {result.reports}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_fill_lands_on_a_level_known_before_the_bar(tmp_path):
|
||||||
|
"""No fill may be priced at its own bar's close.
|
||||||
|
|
||||||
|
A fill at the close is only knowable once the bar is over. It is also what
|
||||||
|
the example would produce if `custom(...)` silently fell back to AtClose,
|
||||||
|
which would make the whole feature a no-op.
|
||||||
|
"""
|
||||||
|
frame = _mean_reverting_bars()
|
||||||
|
result = bt.run(_band_strategy(), _config(frame), _store(frame, tmp_path, "fills"))
|
||||||
|
|
||||||
|
trades = result.trades_df()
|
||||||
|
assert len(trades) > 20, f"only {len(trades)} trades, too few to conclude"
|
||||||
|
|
||||||
|
bars = frame.set_index(pd.DatetimeIndex(frame["timestamp"]))
|
||||||
|
at = bars.reindex(pd.DatetimeIndex(pd.to_datetime(trades["execution_timestamp"], utc=True)))
|
||||||
|
intended = trades["intended_price"].to_numpy()
|
||||||
|
|
||||||
|
on_close = np.isclose(intended, at["close"].to_numpy(), rtol=0, atol=1e-12)
|
||||||
|
assert not on_close.any(), (
|
||||||
|
f"{int(on_close.sum())} fill(s) landed on their bar's close, "
|
||||||
|
"which is AtClose behaviour, not a computed level"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _leaking_strategy():
|
||||||
|
"""The clean strategy, with one deliberate leak: the entry reads ahead."""
|
||||||
|
from manifoldbt.indicators import close, low, open as open_px, sma
|
||||||
|
|
||||||
|
h1 = bt.tf("1h")
|
||||||
|
base = sma(h1.close, 8)
|
||||||
|
band_up = base * 1.004
|
||||||
|
band_dn = base * 0.997
|
||||||
|
touch_up = close.lead(5) >= band_up # <- the leak
|
||||||
|
touch_dn = low <= band_dn
|
||||||
|
target = bt.when(touch_dn, 0.0, bt.when(touch_up, -1.0))
|
||||||
|
exec_level = bt.when(
|
||||||
|
touch_dn, bt.when(open_px <= band_dn, open_px, band_dn),
|
||||||
|
bt.when(touch_up, bt.when(open_px >= band_up, open_px, band_up), close),
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
bt.Strategy.create("leaky")
|
||||||
|
.signal("position", target)
|
||||||
|
.signal("exec_level", exec_level)
|
||||||
|
.size(target)
|
||||||
|
.stop_loss(pct=25.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_perturbation_method_catches_a_real_leak(tmp_path):
|
||||||
|
"""A look-ahead test that cannot fail proves nothing.
|
||||||
|
|
||||||
|
Same data, same perturbation, same comparison as
|
||||||
|
:func:`test_future_bars_cannot_move_the_past` -- only the strategy reads
|
||||||
|
five bars ahead. The prefix MUST diverge, or the method above is blind and
|
||||||
|
its PASS is worthless.
|
||||||
|
"""
|
||||||
|
frame = _mean_reverting_bars()
|
||||||
|
strategy = _leaking_strategy()
|
||||||
|
config = _config(frame)
|
||||||
|
|
||||||
|
reference = _equity(bt.run(strategy, config, _store(frame, tmp_path, "leak_ref")))
|
||||||
|
|
||||||
|
rng = np.random.default_rng(1234)
|
||||||
|
corrupted = frame.copy()
|
||||||
|
tail = slice(SPLIT + 1, None)
|
||||||
|
factor = 1.0 + rng.uniform(-0.005, 0.005, N_BARS - SPLIT - 1)
|
||||||
|
for col in ("open", "high", "low", "close"):
|
||||||
|
corrupted.loc[corrupted.index[tail], col] = corrupted[col].to_numpy()[tail] * factor
|
||||||
|
|
||||||
|
perturbed = _equity(bt.run(strategy, config, _store(corrupted, tmp_path, "leak_pert")))
|
||||||
|
|
||||||
|
n = min(len(reference), len(perturbed), SPLIT + 1)
|
||||||
|
delta = np.abs(reference[:n] - perturbed[:n])
|
||||||
|
assert delta.max() > 0.0, (
|
||||||
|
"a strategy reading 5 bars ahead went undetected: the perturbation "
|
||||||
|
"method is blind and every PASS in this file is meaningless"
|
||||||
|
)
|
||||||
|
# The divergence must sit just before the split, where the lead reaches
|
||||||
|
# into the corrupted tail -- not somewhere unrelated.
|
||||||
|
first = int(np.argmax(delta > 0))
|
||||||
|
assert SPLIT - 60 <= first <= SPLIT, (
|
||||||
|
f"divergence at bar {first}, expected it near the split at {SPLIT}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_detector_splits_on_the_data_not_on_the_configured_range(tmp_path):
|
||||||
|
"""A config starting at the epoch must not empty the detector.
|
||||||
|
|
||||||
|
`time_range_start=0` is what `examples/21_fill_at_computed_level.py`
|
||||||
|
writes, and it stretches the period over five decades: both split points
|
||||||
|
used to land before the first bar, so both truncated runs saw no data and
|
||||||
|
the detector announced PASS having compared nothing.
|
||||||
|
"""
|
||||||
|
from manifoldbt.diagnostics import detect_lookahead
|
||||||
|
|
||||||
|
frame = _mean_reverting_bars()
|
||||||
|
config = _config(frame)
|
||||||
|
config.time_range_start = 0 # the epoch, as the example does
|
||||||
|
|
||||||
|
result = detect_lookahead(
|
||||||
|
_band_strategy(), config, _store(frame, tmp_path, "epoch"), mode="all"
|
||||||
|
)
|
||||||
|
|
||||||
|
compared = sum(r.total_trades_overlap for r in result.reports)
|
||||||
|
assert compared > 0, (
|
||||||
|
"the detector compared no trade: its splits fell outside the data again"
|
||||||
|
)
|
||||||
|
assert result.passed, f"look-ahead reported: {result}"
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
"""Indicator periods over a higher timeframe count SIMULATION bars.
|
||||||
|
|
||||||
|
`bt.tf("1h").close` is the last closed hourly close, forward-filled onto the
|
||||||
|
simulation grid. An indicator over it counts rows of that grid, so on 1-minute
|
||||||
|
bars `sma(h1.close, 8)` averages 8 *minutes* of a step function — it tracks the
|
||||||
|
last closed hourly close instead of averaging 8 hours.
|
||||||
|
|
||||||
|
That reading is surprising enough that `tf()`'s own usage example used to show
|
||||||
|
`ema(h1.close, 20)` as if 20 meant hours. These tests pin the real semantics
|
||||||
|
and the conversion, measured rather than argued: on a ramp of +10 per hour, the
|
||||||
|
lag of an average over K hours is (K+1)/2 hours, plus the ~1 h the timeframe
|
||||||
|
itself costs (an hourly bar is only readable once closed).
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import manifoldbt as bt
|
||||||
|
|
||||||
|
np = pytest.importorskip("numpy")
|
||||||
|
pd = pytest.importorskip("pandas")
|
||||||
|
|
||||||
|
from manifoldbt.helpers import ExecutionPrice, Interval, Slippage # noqa: E402
|
||||||
|
|
||||||
|
HOURS, PER_HOUR = 200, 60
|
||||||
|
N_BARS = HOURS * PER_HOUR
|
||||||
|
SLOPE = 10.0 # the price gains exactly this much per hour
|
||||||
|
|
||||||
|
|
||||||
|
def _ramp_bars():
|
||||||
|
"""Hourly closes of 100, 110, 120 … so a lag reads directly as hours."""
|
||||||
|
ts = pd.date_range("2024-01-01", periods=N_BARS, freq="1min", tz="UTC")
|
||||||
|
px = 100.0 + SLOPE * (np.arange(N_BARS) // PER_HOUR)
|
||||||
|
return pd.DataFrame({
|
||||||
|
"timestamp": ts, "open": px, "high": px * 1.5, "low": px * 0.5,
|
||||||
|
"close": px, "volume": np.full(N_BARS, 1000.0),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _observed_series(period_or_expr, frame, tmp_path, tag):
|
||||||
|
"""The values a higher-timeframe band actually took, by timestamp.
|
||||||
|
|
||||||
|
Takes either a period — read as `sma(tf("1h").close, period)`, the
|
||||||
|
staircase form — or a ready-made expression, so the same probe serves both
|
||||||
|
candidates.
|
||||||
|
|
||||||
|
The indicator is read back through `ExecutionPrice.custom`, which fills at
|
||||||
|
the value of a named signal: the trade log then carries the series itself.
|
||||||
|
"""
|
||||||
|
from manifoldbt.indicators import close, sma
|
||||||
|
from manifoldbt.expr import Expr
|
||||||
|
|
||||||
|
root = os.path.join(str(tmp_path), tag)
|
||||||
|
store = bt.import_dataframe(
|
||||||
|
frame, symbol="S", symbol_id=1, interval="1m",
|
||||||
|
data_root=os.path.join(root, "data"),
|
||||||
|
metadata_db=os.path.join(root, "meta.sqlite"),
|
||||||
|
)
|
||||||
|
band = (period_or_expr if isinstance(period_or_expr, Expr)
|
||||||
|
else sma(bt.tf("1h").close, period_or_expr))
|
||||||
|
strategy = (
|
||||||
|
bt.Strategy.create("probe")
|
||||||
|
.signal("band", band)
|
||||||
|
.size(bt.when(close > sma(close, 3), 1.0, -1.0))
|
||||||
|
)
|
||||||
|
ts = pd.DatetimeIndex(frame["timestamp"])
|
||||||
|
config = bt.BacktestConfig(
|
||||||
|
universe=[1],
|
||||||
|
time_range_start=int(ts[0].value),
|
||||||
|
time_range_end=int(ts[-1].value) + 60_000_000_000,
|
||||||
|
bar_interval=Interval.minutes(1),
|
||||||
|
initial_capital=1_000_000,
|
||||||
|
execution=bt.ExecutionConfig(
|
||||||
|
signal_delay=0,
|
||||||
|
execution_price=ExecutionPrice.custom("band"),
|
||||||
|
max_position_pct=0.1, allow_short=True,
|
||||||
|
position_sizing_mode="FractionOfEquity",
|
||||||
|
),
|
||||||
|
slippage=Slippage.fixed_bps(0),
|
||||||
|
warmup_bars=700, # clears the widest window under test
|
||||||
|
extra_timeframes={"1h": Interval.hours(1)},
|
||||||
|
)
|
||||||
|
result = bt.run(strategy, config, store)
|
||||||
|
trades = result.trades_df()
|
||||||
|
assert len(trades) > 50, f"only {len(trades)} trades, too few to measure a lag"
|
||||||
|
|
||||||
|
at = pd.DatetimeIndex(pd.to_datetime(trades["execution_timestamp"], utc=True))
|
||||||
|
return pd.Series(trades["intended_price"].to_numpy(), index=at)
|
||||||
|
|
||||||
|
|
||||||
|
def _observed_lag_hours(period, frame, tmp_path, tag):
|
||||||
|
"""Average lag of `sma(tf("1h").close, period)`, in hours."""
|
||||||
|
observed = _observed_series(period, frame, tmp_path, tag)
|
||||||
|
ts = pd.DatetimeIndex(frame["timestamp"])
|
||||||
|
price_now = pd.Series(frame["close"].to_numpy(), index=ts).reindex(observed.index)
|
||||||
|
return float(np.median((price_now.to_numpy() - observed.to_numpy()) / SLOPE))
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_bare_period_does_not_average_the_higher_timeframe(tmp_path):
|
||||||
|
"""`sma(h1.close, 8)` is NOT an 8-hour mean.
|
||||||
|
|
||||||
|
An 8-hour mean would lag about (8+1)/2 + 1 = 5.5 hours. This lags under 2,
|
||||||
|
which is the timeframe's own delay: it is tracking the last closed hourly
|
||||||
|
close, not averaging eight of them.
|
||||||
|
"""
|
||||||
|
lag = _observed_lag_hours(8, _ramp_bars(), tmp_path, "bare")
|
||||||
|
assert lag < 2.5, f"lag {lag:.2f} h — this would be a real multi-hour average"
|
||||||
|
assert lag > 0.5, f"lag {lag:.2f} h — the timeframe delay itself is missing"
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiplying_the_period_by_the_interval_ratio_matches_the_lag(tmp_path):
|
||||||
|
"""`sma(h1.close, K * 60)` carries the LAG of a K-hour mean.
|
||||||
|
|
||||||
|
Expected lag: (K+1)/2 from the average, plus ~1 h for the timeframe. This
|
||||||
|
is the whole of what a ramp can establish, and it is not enough to call the
|
||||||
|
result a K-hour mean: see `test_the_interval_ratio_is_not_the_hourly_mean`,
|
||||||
|
which uses an impulse to show the two series apart.
|
||||||
|
"""
|
||||||
|
frame = _ramp_bars()
|
||||||
|
for hours, expected in ((4, 1 + (4 + 1) / 2), (8, 1 + (8 + 1) / 2)):
|
||||||
|
lag = _observed_lag_hours(hours * PER_HOUR, frame, tmp_path, f"k{hours}")
|
||||||
|
assert abs(lag - expected) < 0.5, (
|
||||||
|
f"{hours}-hour mean lags {lag:.2f} h, expected about {expected:.2f} h"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _impulse_bars():
|
||||||
|
"""Hourly closes flat at 100 but for ONE hour at 200.
|
||||||
|
|
||||||
|
A ramp cannot separate the two candidates: a box filter leaves a straight
|
||||||
|
line straight, so a biased weighting still reports the right lag. An
|
||||||
|
impulse makes each hour's weight readable in the value itself.
|
||||||
|
|
||||||
|
The last minute of every hour keeps the base value, so the hourly closes --
|
||||||
|
the only rows `tf("1h")` reads -- stay exactly 100 or 200. The intra-hour
|
||||||
|
zigzag exists only to make the probe strategy trade on every bar.
|
||||||
|
"""
|
||||||
|
n = 60 * PER_HOUR
|
||||||
|
ts = pd.date_range("2024-01-01", periods=n, freq="1min", tz="UTC")
|
||||||
|
base = np.where(np.arange(n) // PER_HOUR == 30, 200.0, 100.0)
|
||||||
|
minute = np.arange(n) % PER_HOUR
|
||||||
|
px = base + np.where(minute == PER_HOUR - 1, 0.0, np.where(minute % 2 == 0, 0.5, -0.5))
|
||||||
|
return pd.DataFrame({
|
||||||
|
"timestamp": ts, "open": px, "high": px * 1.5, "low": px * 0.5,
|
||||||
|
"close": px, "volume": np.full(n, 1000.0),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_interval_ratio_is_not_the_hourly_mean(tmp_path):
|
||||||
|
"""`sma(h1.close, 8 * 60)` is not an equal-weight mean of 8 hourly closes.
|
||||||
|
|
||||||
|
It averages 480 rows of a step function, so a move ramps in over 60 minutes
|
||||||
|
instead of stepping, and the window spans 9 hourly values with unequal
|
||||||
|
weights rather than 8 with equal ones. On the impulse the true signal spans
|
||||||
|
12.5 (100 to 112.5) and the gap reaches nearly all of it.
|
||||||
|
"""
|
||||||
|
frame = _impulse_bars()
|
||||||
|
observed = _observed_series(8 * PER_HOUR, frame, tmp_path, "impulse")
|
||||||
|
|
||||||
|
ts = pd.DatetimeIndex(frame["timestamp"])
|
||||||
|
hourly = pd.Series(frame["close"].to_numpy(), index=ts).resample("1h").last()
|
||||||
|
assert set(np.round(hourly.dropna().unique(), 6)) <= {100.0, 200.0}
|
||||||
|
truth = hourly.rolling(8).mean().shift(1).reindex(ts, method="ffill")
|
||||||
|
|
||||||
|
gap = (observed - truth.reindex(observed.index)).dropna().abs()
|
||||||
|
assert gap.max() > 10.0, (
|
||||||
|
f"largest gap to a true 8-hour mean is {gap.max():.2f} on a signal "
|
||||||
|
"spanning 12.5; the two would then be the same series"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_is_the_hourly_mean_and_reads_only_closed_bars(tmp_path):
|
||||||
|
"""`h1.apply(sma(close, 8))` IS the mean of 8 hourly closes — exactly.
|
||||||
|
|
||||||
|
Same impulse that separates the two staircase forms. Two alignments are
|
||||||
|
checked against, and they disagree on 120 bars, so matching one excludes
|
||||||
|
the other: the shifted reference reads only CLOSED hours, the unshifted one
|
||||||
|
would need the hour in progress. Landing on the shifted one is what rules
|
||||||
|
out look-ahead.
|
||||||
|
"""
|
||||||
|
from manifoldbt.indicators import close, sma
|
||||||
|
|
||||||
|
frame = _impulse_bars()
|
||||||
|
band = bt.tf("1h").apply(sma(close, 8))
|
||||||
|
observed = _observed_series(band, frame, tmp_path, "apply")
|
||||||
|
|
||||||
|
ts = pd.DatetimeIndex(frame["timestamp"])
|
||||||
|
hourly = pd.Series(frame["close"].to_numpy(), index=ts).resample("1h").last()
|
||||||
|
rolled = hourly.rolling(8).mean()
|
||||||
|
safe = rolled.shift(1).reindex(ts, method="ffill") # closed hours only
|
||||||
|
leaking = rolled.reindex(ts, method="ffill") # the hour in progress
|
||||||
|
|
||||||
|
disagree = (safe - leaking).dropna().abs()
|
||||||
|
assert (disagree > 1e-9).sum() > 50, "the two alignments must differ to discriminate"
|
||||||
|
|
||||||
|
gap_safe = (observed - safe.reindex(observed.index)).dropna().abs()
|
||||||
|
gap_leak = (observed - leaking.reindex(observed.index)).dropna().abs()
|
||||||
|
assert gap_safe.max() < 1e-9, (
|
||||||
|
f"apply() is off the true 8-hour mean by {gap_safe.max():.4f}"
|
||||||
|
)
|
||||||
|
assert gap_leak.max() > 1.0, (
|
||||||
|
"apply() matches the alignment that reads the hour in progress"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_longer_period_lags_more(tmp_path):
|
||||||
|
"""The ordering alone would catch a period silently ignored."""
|
||||||
|
frame = _ramp_bars()
|
||||||
|
short = _observed_lag_hours(4 * PER_HOUR, frame, tmp_path, "ord4")
|
||||||
|
long = _observed_lag_hours(8 * PER_HOUR, frame, tmp_path, "ord8")
|
||||||
|
assert long > short + 1.0, (
|
||||||
|
f"8-hour mean lags {long:.2f} h vs {short:.2f} h for 4 hours; "
|
||||||
|
"the period is not doing what it should"
|
||||||
|
)
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
"""Tests for tf(..).apply(..) — indicators evaluated ON the higher timeframe.
|
||||||
|
|
||||||
|
The defect this feature fixes, pinned by `test_apply_differs_from_staircase`:
|
||||||
|
an indicator over a step-held `tf()` column counts its period in SIMULATION
|
||||||
|
bars, so `sma(tf("1h").close, 20)` on a 1m simulation is a 20-MINUTE smoothing
|
||||||
|
of an hourly staircase — mid-hour it equals the previous hourly close exactly.
|
||||||
|
`tf("1h").apply(sma(close, 20))` is the true 20-HOUR average.
|
||||||
|
|
||||||
|
The reference implementation (`_hand_band`) is the exo-column recipe users had
|
||||||
|
to build by hand before this feature: resample to 1h in pandas, indicator on
|
||||||
|
the 1h grid, shift(1) (a closed bar is readable from the next bar on), ffill
|
||||||
|
onto the 1m grid. `test_apply_matches_hand_rolled_exo` demands bit-identical
|
||||||
|
metrics against it.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import manifoldbt as bt
|
||||||
|
from manifoldbt.indicators import close, sma
|
||||||
|
|
||||||
|
pd = pytest.importorskip("pandas")
|
||||||
|
np = pytest.importorskip("numpy")
|
||||||
|
|
||||||
|
N_BARS = 20_000 # ~13.9 days of 1m bars -> ~333 hourly bars
|
||||||
|
PERIOD = 20
|
||||||
|
|
||||||
|
|
||||||
|
def _bars_df():
|
||||||
|
ts = pd.date_range("2022-01-01", periods=N_BARS, freq="1min", tz="UTC")
|
||||||
|
rng = np.random.default_rng(3)
|
||||||
|
px = 100.0 + np.cumsum(np.sin(np.arange(N_BARS) / 700.0) * 0.5 + rng.normal(0, 0.4, N_BARS)) * 0.01
|
||||||
|
px = np.maximum(px, 1.0)
|
||||||
|
return pd.DataFrame(
|
||||||
|
{
|
||||||
|
"timestamp": ts,
|
||||||
|
"open": px,
|
||||||
|
"high": px * 1.0005,
|
||||||
|
"low": px * 0.9995,
|
||||||
|
"close": px,
|
||||||
|
"volume": [1000.0] * N_BARS,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _store(tmp_path, df):
|
||||||
|
root = tmp_path / "otf_store"
|
||||||
|
return bt.import_dataframe(
|
||||||
|
df,
|
||||||
|
symbol="ZT",
|
||||||
|
symbol_id=1,
|
||||||
|
interval="1m",
|
||||||
|
asset_class="equity",
|
||||||
|
exchange="TEST",
|
||||||
|
data_root=str(root / "data"),
|
||||||
|
metadata_db=str(root / "metadata.sqlite"),
|
||||||
|
), str(root / "data")
|
||||||
|
|
||||||
|
|
||||||
|
def _hand_band(df, period):
|
||||||
|
"""The pre-feature recipe: hourly SMA built by hand, no lookahead."""
|
||||||
|
h1 = df.set_index("timestamp")["close"].resample("1h").last()
|
||||||
|
band = h1.rolling(period).mean().shift(1)
|
||||||
|
return band.reindex(df["timestamp"], method="ffill")
|
||||||
|
|
||||||
|
|
||||||
|
def _config(tmp_path=None, exo=False):
|
||||||
|
start, end = bt.time_range("2022-01-01", "2022-01-14")
|
||||||
|
cfg = bt.BacktestConfig(
|
||||||
|
universe=[1],
|
||||||
|
time_range_start=start,
|
||||||
|
time_range_end=end,
|
||||||
|
initial_capital=10_000.0,
|
||||||
|
provider="TEST",
|
||||||
|
bar_interval=bt.Interval.minutes(1),
|
||||||
|
symbol_names={"ZT": 1},
|
||||||
|
extra_timeframes={} if exo else {"1h": bt.Interval.hours(1)},
|
||||||
|
exo_data=["hand_band"] if exo else [],
|
||||||
|
)
|
||||||
|
cfg.warmup_bars = 0
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def _strategy(band_expr, name):
|
||||||
|
return (
|
||||||
|
bt.Strategy.create(name)
|
||||||
|
.signal("band", band_expr)
|
||||||
|
.size(bt.when(close > bt.col("band"), 1.0, 0.0))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_serializes_as_on_timeframe():
|
||||||
|
e = bt.tf("1h").apply(sma(close, PERIOD))
|
||||||
|
payload = e.to_json()
|
||||||
|
assert list(payload) == ["OnTimeframe"]
|
||||||
|
label, inner = payload["OnTimeframe"]
|
||||||
|
assert label == "1h"
|
||||||
|
assert list(inner) == ["RollingMean"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_matches_hand_rolled_exo(tmp_path):
|
||||||
|
"""The money test: tf("1h").apply(sma(close, 20)) must be bit-identical to
|
||||||
|
the hand-precomputed hourly-SMA exo column it replaces."""
|
||||||
|
df = _bars_df()
|
||||||
|
store, data_root = _store(tmp_path, df)
|
||||||
|
|
||||||
|
band = _hand_band(df, PERIOD)
|
||||||
|
bt.register_exo(
|
||||||
|
"hand_band",
|
||||||
|
pd.DataFrame({"timestamp": df["timestamp"], "value": band.values}),
|
||||||
|
data_root=data_root,
|
||||||
|
)
|
||||||
|
|
||||||
|
native = bt.run(
|
||||||
|
_strategy(bt.tf("1h").apply(sma(close, PERIOD)), "native"),
|
||||||
|
_config(),
|
||||||
|
store,
|
||||||
|
)
|
||||||
|
hand = bt.run(
|
||||||
|
_strategy(bt.exo("hand_band", "value"), "hand"),
|
||||||
|
_config(exo=True),
|
||||||
|
store,
|
||||||
|
)
|
||||||
|
|
||||||
|
for key in ("total_return", "sharpe", "max_drawdown", "volatility"):
|
||||||
|
assert native.metrics.get(key) == hand.metrics.get(key), (
|
||||||
|
f"{key}: native {native.metrics.get(key)} != hand {hand.metrics.get(key)}"
|
||||||
|
)
|
||||||
|
assert len(native.trades_df()) == len(hand.trades_df())
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_differs_from_staircase(tmp_path):
|
||||||
|
"""Guard against regressing to the old semantics: the staircase version
|
||||||
|
(indicator over the step-held tf() column) must NOT equal apply()."""
|
||||||
|
df = _bars_df()
|
||||||
|
store, _ = _store(tmp_path, df)
|
||||||
|
|
||||||
|
applied = bt.run(
|
||||||
|
_strategy(bt.tf("1h").apply(sma(close, PERIOD)), "applied"),
|
||||||
|
_config(),
|
||||||
|
store,
|
||||||
|
)
|
||||||
|
staircase = bt.run(
|
||||||
|
_strategy(sma(bt.tf("1h").close, PERIOD), "staircase"),
|
||||||
|
_config(),
|
||||||
|
store,
|
||||||
|
)
|
||||||
|
assert applied.metrics.get("total_return") != staircase.metrics.get("total_return"), (
|
||||||
|
"apply() and the staircase smoothing agreed; the coarse-grid evaluation "
|
||||||
|
"is not actually happening"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_swept_period_matches_fixed_runs(tmp_path):
|
||||||
|
"""param() INSIDE apply(): each combo must equal the fixed-period run."""
|
||||||
|
df = _bars_df()
|
||||||
|
store, _ = _store(tmp_path, df)
|
||||||
|
periods = [10, 20, 40]
|
||||||
|
|
||||||
|
sweep = bt.run_sweep_lite(
|
||||||
|
_strategy(bt.tf("1h").apply(sma(close, bt.param("len"))), "swept"),
|
||||||
|
{"len": periods},
|
||||||
|
_config(),
|
||||||
|
store,
|
||||||
|
device="cpu",
|
||||||
|
)
|
||||||
|
assert len(sweep) == len(periods)
|
||||||
|
|
||||||
|
for got, period in zip(sweep, periods):
|
||||||
|
ref = bt.run_sweep_lite(
|
||||||
|
_strategy(bt.tf("1h").apply(sma(close, period)), f"fixed_{period}"),
|
||||||
|
{},
|
||||||
|
_config(),
|
||||||
|
store,
|
||||||
|
device="cpu",
|
||||||
|
)[0]
|
||||||
|
for key in ("total_return", "sharpe", "max_drawdown"):
|
||||||
|
assert got.metrics.get(key) == ref.metrics.get(key), (
|
||||||
|
f"len={period}: {key} diverged"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_lite_matches_run(tmp_path):
|
||||||
|
df = _bars_df()
|
||||||
|
store, _ = _store(tmp_path, df)
|
||||||
|
strat = _strategy(bt.tf("1h").apply(sma(close, PERIOD)), "parity")
|
||||||
|
|
||||||
|
full = bt.run(strat, _config(), store)
|
||||||
|
lite = bt.run_sweep_lite(strat, {}, _config(), store, device="cpu")[0]
|
||||||
|
for key in ("total_return", "sharpe"):
|
||||||
|
assert full.metrics.get(key) == lite.metrics.get(key), f"{key} diverged"
|
||||||
|
# max_drawdown carries a pre-existing ~1e-16 run-vs-lite float-noise gap
|
||||||
|
# (measured on a plain sma(close, 20) strategy with no OnTimeframe on this
|
||||||
|
# same data), so exact equality would pin the wrong thing here.
|
||||||
|
a, b = full.metrics["max_drawdown"], lite.metrics["max_drawdown"]
|
||||||
|
assert a == pytest.approx(b, rel=1e-12), f"max_drawdown diverged: {a} vs {b}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_extra_timeframe_is_a_clear_error(tmp_path):
|
||||||
|
df = _bars_df()
|
||||||
|
store, _ = _store(tmp_path, df)
|
||||||
|
cfg = _config()
|
||||||
|
cfg.extra_timeframes = {}
|
||||||
|
with pytest.raises(Exception, match="extra_timeframes"):
|
||||||
|
bt.run(_strategy(bt.tf("1h").apply(sma(close, PERIOD)), "no_tf"), cfg, store)
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
"""Tests for the option path from Python: contract terms in, settlement out.
|
||||||
|
|
||||||
|
The Rust side already proves the payoff arithmetic and the simulation loop.
|
||||||
|
What is under test here is the bridge: terms recorded at ingest must reach the
|
||||||
|
engine, and the one thing the user has to decide (which price series settles the
|
||||||
|
contract) must fail loudly when it is missing rather than be guessed.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import manifoldbt as bt
|
||||||
|
|
||||||
|
pd = pytest.importorskip("pandas")
|
||||||
|
|
||||||
|
OPTION_ID = 2
|
||||||
|
UNDERLYING_ID = 1
|
||||||
|
STRIKE = 50_000.0
|
||||||
|
N_BARS = 40
|
||||||
|
# Expiry lands on bar 30 of a 40-bar daily series starting 2020-01-01.
|
||||||
|
EXPIRY_MS = 1_577_836_800_000 + 30 * 86_400_000
|
||||||
|
|
||||||
|
|
||||||
|
def _daily(prices):
|
||||||
|
ts = pd.date_range("2020-01-01", periods=len(prices), freq="1D", tz="UTC")
|
||||||
|
return pd.DataFrame(
|
||||||
|
{
|
||||||
|
"timestamp": ts,
|
||||||
|
"open": prices,
|
||||||
|
"high": prices,
|
||||||
|
"low": prices,
|
||||||
|
"close": prices,
|
||||||
|
"volume": [100.0] * len(prices),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _store(tmp_path, underlying_price, premium, option_class="option"):
|
||||||
|
"""A two-symbol store: a perpetual and a call written against it.
|
||||||
|
|
||||||
|
``option_class`` exists so a test can write the same series as a plain
|
||||||
|
linear instrument, which is a different thing from an option missing its
|
||||||
|
terms.
|
||||||
|
"""
|
||||||
|
root = os.path.join(str(tmp_path), "data")
|
||||||
|
meta = os.path.join(str(tmp_path), "m.sqlite")
|
||||||
|
store = bt.import_dataframe(
|
||||||
|
_daily([underlying_price] * N_BARS),
|
||||||
|
symbol="BTC-PERPETUAL",
|
||||||
|
symbol_id=UNDERLYING_ID,
|
||||||
|
interval="1d",
|
||||||
|
data_root=root,
|
||||||
|
metadata_db=meta,
|
||||||
|
asset_class="crypto_perp",
|
||||||
|
)
|
||||||
|
store = bt.import_dataframe(
|
||||||
|
_daily([premium] * N_BARS),
|
||||||
|
symbol="BTC-CALL",
|
||||||
|
symbol_id=OPTION_ID,
|
||||||
|
interval="1d",
|
||||||
|
data_root=root,
|
||||||
|
metadata_db=meta,
|
||||||
|
asset_class=option_class,
|
||||||
|
)
|
||||||
|
return store, root, meta
|
||||||
|
|
||||||
|
|
||||||
|
def _write_terms(meta_db, settlement="cash_inverse"):
|
||||||
|
"""Record contract terms the way an option connector would."""
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
conn = sqlite3.connect(meta_db)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE symbols SET option_underlying = ?, option_type = ?, option_strike = ?,"
|
||||||
|
" option_expiry = ?, option_contract_size = ?, option_settlement = ?"
|
||||||
|
" WHERE id = ?",
|
||||||
|
(
|
||||||
|
"BTC_USD index",
|
||||||
|
"call",
|
||||||
|
STRIKE,
|
||||||
|
pd.Timestamp(EXPIRY_MS, unit="ms", tz="UTC").isoformat().replace("+00:00", "Z"),
|
||||||
|
1.0,
|
||||||
|
settlement,
|
||||||
|
OPTION_ID,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _config(**kwargs):
|
||||||
|
from manifoldbt.helpers import time_range, Interval
|
||||||
|
|
||||||
|
start, end = time_range("2020-01-01", "2020-03-01")
|
||||||
|
base = dict(
|
||||||
|
universe=[UNDERLYING_ID, OPTION_ID],
|
||||||
|
time_range_start=start,
|
||||||
|
time_range_end=end,
|
||||||
|
bar_interval=Interval.days(1),
|
||||||
|
initial_capital=10.0,
|
||||||
|
currency="BTC",
|
||||||
|
execution=bt.ExecutionConfig(position_sizing_mode="Units"),
|
||||||
|
)
|
||||||
|
base.update(kwargs)
|
||||||
|
return bt.BacktestConfig(**base)
|
||||||
|
|
||||||
|
|
||||||
|
def _hold(**per_symbol):
|
||||||
|
"""Hold a fixed number of units of each named symbol id, every bar.
|
||||||
|
|
||||||
|
Legs are told apart by `col("symbol_id")` rather than by price level. A
|
||||||
|
price threshold is a trap: a premium crossing it flips the leg to zero and
|
||||||
|
the strategy closes its own position, which is exactly how an earlier
|
||||||
|
version of this file broke.
|
||||||
|
"""
|
||||||
|
from manifoldbt.indicators import col
|
||||||
|
|
||||||
|
size = bt.when(col("symbol_id") < 0.0, 0.0, 0.0) # a typed zero to fold onto
|
||||||
|
for symbol_id, units in per_symbol.items():
|
||||||
|
size = size + bt.when(col("symbol_id") == float(symbol_id), float(units), 0.0)
|
||||||
|
return (
|
||||||
|
bt.Strategy.create("hold")
|
||||||
|
.signal("leg", col("symbol_id"))
|
||||||
|
.size(size)
|
||||||
|
.describe("Fixed units per leg, held into expiry")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _long_one_option():
|
||||||
|
return _hold(**{str(OPTION_ID): 1.0})
|
||||||
|
|
||||||
|
|
||||||
|
def test_contract_terms_round_trip_to_python(tmp_path):
|
||||||
|
_, _, meta = _store(tmp_path, 60_000.0, 0.05)
|
||||||
|
_write_terms(meta)
|
||||||
|
store = bt.DataStore(os.path.join(str(tmp_path), "data"), meta)
|
||||||
|
|
||||||
|
terms = store.option_contracts()
|
||||||
|
assert OPTION_ID in terms
|
||||||
|
assert terms[OPTION_ID]["option_type"] == "call"
|
||||||
|
assert terms[OPTION_ID]["strike"] == STRIKE
|
||||||
|
assert terms[OPTION_ID]["settlement"] == "cash_inverse"
|
||||||
|
assert UNDERLYING_ID not in terms, "a perpetual has no contract terms"
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_option_without_a_declared_underlying_is_refused(tmp_path):
|
||||||
|
store, _, meta = _store(tmp_path, 60_000.0, 0.05)
|
||||||
|
_write_terms(meta)
|
||||||
|
|
||||||
|
# The public API re-classifies the failure, so catch what a user catches.
|
||||||
|
from manifoldbt.exceptions import DataError
|
||||||
|
|
||||||
|
with pytest.raises(DataError) as excinfo:
|
||||||
|
bt.run(_long_one_option(), _config(), store)
|
||||||
|
|
||||||
|
message = str(excinfo.value)
|
||||||
|
assert "option_underlyings" in message
|
||||||
|
assert "own last traded premium" in message
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_call_expiring_in_the_money_settles_at_intrinsic(tmp_path):
|
||||||
|
# S = 60k against a 50k strike, inverse settlement: 10000/60000 BTC.
|
||||||
|
store, _, meta = _store(tmp_path, 60_000.0, 0.05)
|
||||||
|
_write_terms(meta)
|
||||||
|
|
||||||
|
result = bt.run(
|
||||||
|
_long_one_option(),
|
||||||
|
_config(option_underlyings={OPTION_ID: UNDERLYING_ID}),
|
||||||
|
store,
|
||||||
|
)
|
||||||
|
|
||||||
|
trades = result.trades.to_pandas()
|
||||||
|
settlements = trades[(trades.symbol_id == OPTION_ID) & (trades.exit_reason == 5)]
|
||||||
|
assert len(settlements) == 1, f"expected one settlement, got:\n{trades}"
|
||||||
|
assert settlements.iloc[0].fill_price == pytest.approx(10_000.0 / 60_000.0, abs=1e-12)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_call_expiring_out_of_the_money_settles_at_zero(tmp_path):
|
||||||
|
store, _, meta = _store(tmp_path, 40_000.0, 0.05)
|
||||||
|
_write_terms(meta)
|
||||||
|
|
||||||
|
result = bt.run(
|
||||||
|
_long_one_option(),
|
||||||
|
_config(option_underlyings={OPTION_ID: UNDERLYING_ID}),
|
||||||
|
store,
|
||||||
|
)
|
||||||
|
|
||||||
|
trades = result.trades.to_pandas()
|
||||||
|
settlements = trades[(trades.symbol_id == OPTION_ID) & (trades.exit_reason == 5)]
|
||||||
|
assert len(settlements) == 1
|
||||||
|
assert settlements.iloc[0].fill_price == 0.0
|
||||||
|
# The premium paid is the whole loss, and it is a loss.
|
||||||
|
assert float(result.equity_curve[-1]) < float(result.equity_curve[0])
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_linear_universe_is_untouched_by_the_option_path(tmp_path):
|
||||||
|
# Two ordinary linear instruments: the option path must not touch them.
|
||||||
|
store, _, _ = _store(tmp_path, 40_000.0, 0.05, option_class="crypto_spot")
|
||||||
|
|
||||||
|
result = bt.run(_long_one_option(), _config(), store)
|
||||||
|
|
||||||
|
trades = result.trades.to_pandas()
|
||||||
|
assert (trades.exit_reason != 5).all(), "nothing may settle without contract terms"
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_option_symbol_without_contract_terms_is_refused(tmp_path):
|
||||||
|
"""The Databento case before this branch: an option that never expires.
|
||||||
|
|
||||||
|
A symbol recorded as an option but carrying no strike or expiration would
|
||||||
|
otherwise price, trade and be held forever at its last quoted premium, with
|
||||||
|
nothing in the output looking wrong.
|
||||||
|
"""
|
||||||
|
from manifoldbt.exceptions import DataError
|
||||||
|
|
||||||
|
store, _, _ = _store(tmp_path, 60_000.0, 0.05) # asset_class="option", no terms
|
||||||
|
|
||||||
|
with pytest.raises(DataError) as excinfo:
|
||||||
|
bt.run(_long_one_option(), _config(), store)
|
||||||
|
|
||||||
|
message = str(excinfo.value)
|
||||||
|
assert "no contract terms" in message
|
||||||
|
assert "deribit, databento" in message
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_multiplier_option_costs_and_settles_like_one_contract(tmp_path):
|
||||||
|
"""A listed-style option: 100 units of premium IS one exchange contract."""
|
||||||
|
# Premium 4.70, underlying 490, strike 470 -> one contract pays (490-470)*100.
|
||||||
|
store, _, meta = _store(tmp_path, 490.0, 4.70)
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
conn = sqlite3.connect(meta)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE symbols SET option_underlying = ?, option_type = ?, option_strike = ?,"
|
||||||
|
" option_expiry = ?, option_contract_size = ?, option_settlement = ? WHERE id = ?",
|
||||||
|
(
|
||||||
|
"SPY",
|
||||||
|
"call",
|
||||||
|
470.0,
|
||||||
|
pd.Timestamp(EXPIRY_MS, unit="ms", tz="UTC").isoformat().replace("+00:00", "Z"),
|
||||||
|
100.0,
|
||||||
|
"cash_linear",
|
||||||
|
OPTION_ID,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# 100 units of the option leg, nothing on the underlying.
|
||||||
|
hold_one_contract = _hold(**{str(OPTION_ID): 100.0})
|
||||||
|
config = _config(
|
||||||
|
initial_capital=100_000.0,
|
||||||
|
option_underlyings={OPTION_ID: UNDERLYING_ID},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = bt.run(hold_one_contract, config, store)
|
||||||
|
trades = result.trades.to_pandas()
|
||||||
|
legs = trades[trades.symbol_id == OPTION_ID]
|
||||||
|
|
||||||
|
entry = legs[legs.exit_reason == 0].iloc[0]
|
||||||
|
assert entry.quantity * entry.fill_price == pytest.approx(470.0), "what one contract costs"
|
||||||
|
|
||||||
|
settlement = legs[legs.exit_reason == 5].iloc[0]
|
||||||
|
assert settlement.fill_price == pytest.approx(20.0), "intrinsic per share, not per contract"
|
||||||
|
assert settlement.quantity * settlement.fill_price == pytest.approx(
|
||||||
|
2_000.0
|
||||||
|
), "what one contract pays"
|
||||||
|
|
||||||
|
|
||||||
|
PUT_ID = 3
|
||||||
|
|
||||||
|
|
||||||
|
def _store_two_legs(tmp_path, underlying_price, call_premium, put_premium):
|
||||||
|
"""Underlying + a call + a put, all daily, all the same length."""
|
||||||
|
root = os.path.join(str(tmp_path), "data")
|
||||||
|
meta = os.path.join(str(tmp_path), "m.sqlite")
|
||||||
|
for symbol, symbol_id, price, klass in (
|
||||||
|
("BTC-PERPETUAL", UNDERLYING_ID, underlying_price, "crypto_perp"),
|
||||||
|
("BTC-CALL", OPTION_ID, call_premium, "option"),
|
||||||
|
("BTC-PUT", PUT_ID, put_premium, "option"),
|
||||||
|
):
|
||||||
|
store = bt.import_dataframe(
|
||||||
|
_daily([price] * N_BARS),
|
||||||
|
symbol=symbol,
|
||||||
|
symbol_id=symbol_id,
|
||||||
|
interval="1d",
|
||||||
|
data_root=root,
|
||||||
|
metadata_db=meta,
|
||||||
|
asset_class=klass,
|
||||||
|
)
|
||||||
|
return store, meta
|
||||||
|
|
||||||
|
|
||||||
|
def _write_leg_terms(meta_db, symbol_id, option_type, strike):
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
conn = sqlite3.connect(meta_db)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE symbols SET option_underlying = ?, option_type = ?, option_strike = ?,"
|
||||||
|
" option_expiry = ?, option_contract_size = ?, option_settlement = ? WHERE id = ?",
|
||||||
|
(
|
||||||
|
"BTC_USD index",
|
||||||
|
option_type,
|
||||||
|
strike,
|
||||||
|
pd.Timestamp(EXPIRY_MS, unit="ms", tz="UTC").isoformat().replace("+00:00", "Z"),
|
||||||
|
1.0,
|
||||||
|
"cash_inverse",
|
||||||
|
symbol_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_two_leg_structure_settles_each_leg_on_its_own_terms(tmp_path):
|
||||||
|
"""A risk reversal: long a call, short a put, both expiring together.
|
||||||
|
|
||||||
|
Each leg settles against the same underlying but on its own strike and
|
||||||
|
side, so one finishes in the money and the other worthless.
|
||||||
|
"""
|
||||||
|
# S = 60k at expiry: the 50k call is ITM, the 40k put is worthless.
|
||||||
|
store, meta = _store_two_legs(tmp_path, 60_000.0, 0.05, 0.03)
|
||||||
|
_write_leg_terms(meta, OPTION_ID, "call", 50_000.0)
|
||||||
|
_write_leg_terms(meta, PUT_ID, "put", 40_000.0)
|
||||||
|
|
||||||
|
config = _config(
|
||||||
|
universe=[UNDERLYING_ID, OPTION_ID, PUT_ID],
|
||||||
|
option_underlyings={OPTION_ID: UNDERLYING_ID, PUT_ID: UNDERLYING_ID},
|
||||||
|
option_margin_model="deribit",
|
||||||
|
execution=bt.ExecutionConfig(position_sizing_mode="Units", allow_short=True),
|
||||||
|
)
|
||||||
|
result = bt.run(
|
||||||
|
_hold(**{str(OPTION_ID): 1.0, str(PUT_ID): -1.0}),
|
||||||
|
config,
|
||||||
|
store,
|
||||||
|
)
|
||||||
|
|
||||||
|
trades = result.trades.to_pandas()
|
||||||
|
settlements = trades[trades.exit_reason == 5]
|
||||||
|
assert set(settlements.symbol_id) == {OPTION_ID, PUT_ID}, "both legs must settle"
|
||||||
|
|
||||||
|
call = settlements[settlements.symbol_id == OPTION_ID].iloc[0]
|
||||||
|
put = settlements[settlements.symbol_id == PUT_ID].iloc[0]
|
||||||
|
assert call.fill_price == pytest.approx(10_000.0 / 60_000.0, abs=1e-12)
|
||||||
|
assert put.fill_price == 0.0, "a 40k put is worthless with the underlying at 60k"
|
||||||
|
|
||||||
|
# Long the call, short the put: the short is bought back to close.
|
||||||
|
assert call.side == 2 and put.side == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_per_leg_sizing_leaves_the_other_leg_flat(tmp_path):
|
||||||
|
"""`col("symbol_id")` must target one leg without disturbing the others."""
|
||||||
|
store, meta = _store_two_legs(tmp_path, 60_000.0, 0.05, 0.03)
|
||||||
|
_write_leg_terms(meta, OPTION_ID, "call", 50_000.0)
|
||||||
|
_write_leg_terms(meta, PUT_ID, "put", 40_000.0)
|
||||||
|
|
||||||
|
config = _config(
|
||||||
|
universe=[UNDERLYING_ID, OPTION_ID, PUT_ID],
|
||||||
|
option_underlyings={OPTION_ID: UNDERLYING_ID, PUT_ID: UNDERLYING_ID},
|
||||||
|
)
|
||||||
|
result = bt.run(_hold(**{str(OPTION_ID): 1.0}), config, store)
|
||||||
|
|
||||||
|
trades = result.trades.to_pandas()
|
||||||
|
assert (trades.symbol_id == OPTION_ID).all(), (
|
||||||
|
f"only the call leg may trade, got:\n{trades}"
|
||||||
|
)
|
||||||
@@ -94,3 +94,58 @@ def test_returns_histogram_has_no_unnamed_legend_entry(monkeypatch):
|
|||||||
assert not any(
|
assert not any(
|
||||||
(name or "").startswith("trace ") for name in legend_names
|
(name or "").startswith("trace ") for name in legend_names
|
||||||
), f"auto-generated trace label in the legend: {legend_names}"
|
), f"auto-generated trace label in the legend: {legend_names}"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Adaptive axis / hover formats ────────────────────────────────────────────
|
||||||
|
# These formats used to be hardcoded, and the failures were invisible in any
|
||||||
|
# assertion on figure structure: a two-month backtest labelled every date tick
|
||||||
|
# "May 2025", a -0.9% drawdown labelled every value tick "0%", and a 10-BTC
|
||||||
|
# equity hovered as "$10". Pure functions now decide them, so the contract is
|
||||||
|
# testable without rendering.
|
||||||
|
|
||||||
|
|
||||||
|
def _dates(days):
|
||||||
|
np = pytest.importorskip("numpy")
|
||||||
|
return np.arange("2025-01-01", np.timedelta64(days, "D") + np.datetime64("2025-01-01"),
|
||||||
|
dtype="datetime64[D]").astype("datetime64[ns]")
|
||||||
|
|
||||||
|
|
||||||
|
def test_date_tickformat_follows_the_span():
|
||||||
|
from manifoldbt.plot._convert import date_tickformat
|
||||||
|
|
||||||
|
assert date_tickformat(_dates(2)) == "%d %b %H:%M"
|
||||||
|
assert date_tickformat(_dates(61)) == "%d %b", "a two-month window must show days"
|
||||||
|
assert date_tickformat(_dates(365 * 4)) == "%b %Y", "long spans keep the historical format"
|
||||||
|
|
||||||
|
|
||||||
|
def test_money_hovertemplate_is_currency_and_magnitude_aware():
|
||||||
|
np = pytest.importorskip("numpy")
|
||||||
|
from manifoldbt.plot._convert import money_hovertemplate
|
||||||
|
|
||||||
|
btc = money_hovertemplate(np.array([10.0, 10.04]), "BTC")
|
||||||
|
assert "BTC" in btc and "%{y:,.4f}" in btc, btc
|
||||||
|
assert "$" not in btc, "a BTC equity must not hover in dollars"
|
||||||
|
|
||||||
|
usd = money_hovertemplate(np.array([10_000.0, 21_313.0]), "USD")
|
||||||
|
assert "$%{y:,.0f}" in usd, usd
|
||||||
|
|
||||||
|
eur = money_hovertemplate(np.array([500.0]), "EUR")
|
||||||
|
assert "\u20ac" in eur and "%{y:,.2f}" in eur, eur
|
||||||
|
|
||||||
|
|
||||||
|
def test_percent_tickformat_keeps_small_drawdowns_legible():
|
||||||
|
from manifoldbt.plot._convert import percent_tickformat
|
||||||
|
|
||||||
|
assert percent_tickformat(-0.35) == ".0%"
|
||||||
|
assert percent_tickformat(-0.02) == ".1%"
|
||||||
|
assert percent_tickformat(-0.0037) == ".2%", "a -0.37% max dd must not read as 0%"
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_currency_reads_the_manifest_and_survives_its_absence():
|
||||||
|
from manifoldbt.plot._convert import run_currency
|
||||||
|
|
||||||
|
class WithManifest:
|
||||||
|
manifest = {"config": {"currency": "BTC"}}
|
||||||
|
|
||||||
|
assert run_currency(WithManifest()) == "BTC"
|
||||||
|
assert run_currency(object()) == "USD", "no manifest must fall back, not raise"
|
||||||
|
|||||||
Reference in New Issue
Block a user