release: v0.19.0

This commit is contained in:
github-actions[bot]
2026-08-23 13:31:37 +00:00
parent a5f51e2fde
commit 44f8ed1a91
43 changed files with 2666 additions and 205 deletions
+117 -2
View File
@@ -54,7 +54,7 @@ from manifoldbt.exceptions import (
LicenseError,
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 (
ExecutionPrice,
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
# strategies carrying different brackets over a single data load. A bracket
# set directly on config.execution.orders still applies as the fallback.
_attach_option_contracts(cfg, store)
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:
"""Return True if an Interval dict represents sub-daily resolution."""
if not isinstance(res, dict):
@@ -612,7 +681,8 @@ def ingest(
"""Ingest bars from a data provider into the Arrow IPC store.
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`.
@@ -634,6 +704,50 @@ def ingest(
start="2020-06-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"}
if provider in _PRO_PROVIDERS:
@@ -1642,6 +1756,7 @@ __all__ = [
"s",
"scan",
"symbol_ref",
"choice",
"tf",
"when",
# Strategy & config
+8 -1
View File
@@ -18,7 +18,14 @@ def main() -> None:
# ── ingest ────────────────────────────────────────────────────────────
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-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)")
+32
View File
@@ -375,6 +375,32 @@ class BacktestConfig:
"""Explicit mapping from signal symbol to execution symbol.
Required when signal and execution have different tickers.
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
provider: Optional[str] = None
exo_sources: Dict = field(default_factory=dict)
@@ -417,6 +443,12 @@ class BacktestConfig:
d["signal_source"] = self.signal_source
if 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)
if self.provider:
d["provider"] = self.provider
+15 -2
View File
@@ -111,11 +111,24 @@ def detect_lookahead(
Data is loaded once and sliced for each sub-test (no redundant I/O).
Two sub-tests:
* **extension** split at 2/3 of the period. Catches *global*
look-ahead (e.g. ``np.mean(all_prices)`` instead of rolling).
* **extension** split at 2/3 of the period. Catches look-ahead that
depends on how much data the run was given.
* **truncation** split at 1/3 of the period. Catches *rolling*
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:
strategy: Strategy definition.
config: BacktestConfig.
+127 -2
View File
@@ -182,6 +182,17 @@ class Expr:
if v == "IfElse":
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":
return {"Column": args[0]}
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))
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:
"""Reference an exogenous data column.
@@ -626,6 +693,28 @@ class TimeframeRef:
"""Reference any column from this timeframe."""
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:
return f"TimeframeRef({self._tf!r})"
@@ -633,12 +722,48 @@ class TimeframeRef:
def tf(timeframe: str) -> TimeframeRef:
"""Reference a higher timeframe for multi-TF strategies.
Usage::
Two different things, and the distinction matters::
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.
.. 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)
+74
View File
@@ -101,3 +101,77 @@ def trades_arrays(result) -> dict:
else:
out[name] = arrow_to_numpy(col)
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%"
+10 -5
View File
@@ -25,6 +25,10 @@ from manifoldbt.plot._convert import (
positions_arrays,
trades_arrays,
_ts_to_int64,
date_tickformat,
percent_tickformat,
money_hovertemplate,
run_currency,
)
from manifoldbt.plot._decimate import maybe_decimate
from manifoldbt.plot._utils import finalize, format_pct, new_figure
@@ -277,10 +281,10 @@ def equity(
dates, values = maybe_decimate(dates, values)
fig.add_traces(_area_traces(
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_xaxes(tickformat="%b %Y")
fig.update_xaxes(tickformat=date_tickformat(dates))
return finalize(fig, show=show, save=save)
@@ -324,7 +328,7 @@ def benchmark_equity(
line=dict(color=benchmark_color, width=1.0),
))
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))
return finalize(fig, show=show, save=save)
@@ -357,9 +361,10 @@ def drawdown(
hovertemplate="%{x|%d %b %Y} %{y:.1%}<extra></extra>",
))
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])
fig.update_xaxes(tickformat="%b %Y")
fig.update_xaxes(tickformat=date_tickformat(dates))
return finalize(fig, show=show, save=save)
+140
View File
@@ -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"
)
+161
View File
@@ -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"
)
+285
View File
@@ -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}"
+214
View File
@@ -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"
)
+205
View File
@@ -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)
+365
View File
@@ -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}"
)
+55
View File
@@ -94,3 +94,58 @@ def test_returns_histogram_has_no_unnamed_legend_entry(monkeypatch):
assert not any(
(name or "").startswith("trace ") for name in 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"