Files
manifoldbt/python/tests/test_mtf_indicator_period.py
T
2026-08-23 13:31:37 +00:00

215 lines
9.0 KiB
Python

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