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
+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"