Files
manifoldbt/python/tests/test_trade_clock.py
T

274 lines
10 KiB
Python
Raw Normal View History

2026-10-05 21:11:28 +00:00
"""The trade clock (``bar_interval=Interval.trades()``) seen from Python.
Two halves, and the split is the same one `test_fill_resolution.py` makes.
The half that runs everywhere needs no tape: the JSON shape, the DSL
guard, and every refusal the engine takes BEFORE it loads anything. Those are
the ones a user hits first, and they are the ones a wrong config must not slip
past.
The end-to-end half needs a store holding a tape, and a tape enters a store
only through ``bt.ingest_trades``, which fetches a venue's archive over the
network. Writing Arrow files into the store's private layout instead would test
the layout rather than the feature and would keep passing after the layout
moved. So it skips by name unless ``MANIFOLDBT_TEST_TAPE_STORE`` points at a
store that already holds one, as ``<root>|<meta.sqlite>|<symbol_id>|<day>``.
The per-print semantics themselves are pinned by the engine's own end-to-end
tests.
"""
import os
import pytest
import manifoldbt as bt
from manifoldbt.expr import col
from manifoldbt.helpers import Interval, Slippage
IS_PRO = bt.license_info()[0] == "Pro"
def _locked():
"""Probe the tick gate, not the licence banner.
The gate answers before any I/O, so a path that is not a CSV separates the
two cleanly: PermissionError = locked, anything else = we are through.
"""
try:
bt.ticks.tape_info(__file__)
except PermissionError:
return True
except Exception:
return False
return False
# ---------------------------------------------------------------------------
# The name and its JSON
# ---------------------------------------------------------------------------
def test_interval_trades_is_a_name_not_a_duration():
# A unit variant on the Rust side, so the JSON of the sized intervals is
# byte-for-byte what it always was.
assert Interval.trades() == "Trades"
assert Interval.seconds(1) == {"Seconds": 1}
assert Interval.minutes(1) == {"Minutes": 1}
assert Interval.days(1) == {"Days": 1}
def test_the_clock_survives_the_config_round_trip():
cfg = bt.BacktestConfig(
universe=[1],
time_range_start=0,
time_range_end=86_400_000_000_000,
bar_interval=Interval.trades(),
output_resolution=Interval.trades(),
)
payload = cfg.to_json_dict()
assert payload["bar_interval"] == "Trades"
assert payload["output_resolution"] == "Trades"
def test_a_clock_is_not_a_window():
"""``rolling_sum(Interval.trades())`` is a category error, and says so.
Under this clock an integer window counts EVENTS and a duration window
counts TIME; "trades" is neither, it is how often the engine steps.
"""
with pytest.raises(TypeError, match="not a window"):
col("buy_volume").rolling_sum(Interval.trades())
# And both real forms still work.
assert col("buy_volume").rolling_sum(34) is not None
assert col("buy_volume").rolling_sum(Interval.seconds(34)) is not None
# ---------------------------------------------------------------------------
# Refusals taken before anything is loaded
# ---------------------------------------------------------------------------
def _bar_store(tmp_path):
"""A store with ordinary bars: enough for refusals that fire before load."""
pd = pytest.importorskip("pandas")
np = pytest.importorskip("numpy")
n = 200
ts = pd.date_range("2026-01-01", periods=n, freq="1min", tz="UTC")
px = 100.0 + np.arange(n) * 0.01
df = pd.DataFrame(
{
"timestamp": ts,
"open": px,
"high": px + 0.05,
"low": px - 0.05,
"close": px,
"volume": np.full(n, 10.0),
}
)
return bt.import_dataframe(
df,
symbol="BTCUSDT",
symbol_id=1,
data_root=str(tmp_path / "data"),
metadata_db=str(tmp_path / "meta.sqlite"),
)
def _strategy():
return bt.Strategy.create("plat").signal("pos", col("close") * 0.0 + 1.0).size(col("pos"))
def test_per_event_output_needs_the_clock(tmp_path):
"""``output_resolution="Trades"`` under a bar clock is a category error."""
store = _bar_store(tmp_path)
cfg = bt.BacktestConfig(
universe=[1],
time_range_start=int(bt.date_to_ns("2026-01-01")),
time_range_end=int(bt.date_to_ns("2026-01-02")),
bar_interval=Interval.minutes(1),
output_resolution=Interval.trades(),
slippage=Slippage.none(),
)
with pytest.raises(Exception) as err:
bt.run(_strategy(), cfg, store)
assert "output_resolution=trades" in str(err.value)
# ---------------------------------------------------------------------------
# End to end, on a store that really holds a tape
# ---------------------------------------------------------------------------
_TAPE = os.environ.get("MANIFOLDBT_TEST_TAPE_STORE", "")
_needs_tape = pytest.mark.skipif(
not _TAPE or _locked(),
reason="set MANIFOLDBT_TEST_TAPE_STORE=<root>|<meta.sqlite>|<symbol_id>|<YYYY-MM-DD> "
"to run the trade clock end to end (a tape enters a store only through "
"bt.ingest_trades, which needs the network), and hold the tick-layer key",
)
def _tape_store():
root, meta, sid, day = _TAPE.split("|")
store = bt.DataStore(data_root=root, metadata_db=meta, arrow_dir=os.path.join(root, "mega"))
start = int(bt.date_to_ns(day))
return store, int(sid), start, start + 86_400_000_000_000
def _config(sid, start, end, **kw):
cfg = bt.BacktestConfig(
universe=[sid],
time_range_start=start,
time_range_end=end,
bar_interval=Interval.trades(),
initial_capital=10_000.0,
slippage=Slippage.none(),
**kw,
)
cfg.execution.signal_delay = 1
return cfg
def _flux(k_seconds=34, thr=0.9):
"""The flow strategy of the one-second study, with a window in TIME."""
from manifoldbt.expr import lit, when
b = col("buy_volume").rolling_sum(Interval.seconds(k_seconds))
v = col("sell_volume").rolling_sum(Interval.seconds(k_seconds))
imb = (b - v) / (b + v + lit(1e-12))
armed = ((imb >= lit(thr)) | (imb <= lit(-thr))).value_when(
when(imb >= lit(thr), lit(1.0), lit(-1.0))
)
return bt.Strategy.create("flux").signal("pos", armed).size(col("pos"))
@_needs_tape
def test_the_result_has_the_shape_of_a_run_over_events():
store, sid, start, end = _tape_store()
# Forced daily output from the first day, the way the CI without a licence
# will see it: a test that asserts on intraday rows goes red there.
cfg = _config(sid, start, end, output_resolution=Interval.days(1))
res = bt.run(_flux(), cfg, store)
pos = res.positions_df()
assert {"timestamp", "position", "close", "equity"} <= set(pos.columns)
assert len(pos) <= 2, "a single UTC day resamples to one row, plus the first"
# The trade counters count FILLS, as they do on every clock.
stats = res.metrics["trade_stats"]
assert stats["total_trades"] >= 0
assert res.metrics["sharpe"] == res.metrics["sharpe"] # not NaN
@_needs_tape
@pytest.mark.skipif(not IS_PRO, reason="per-event output is not a Community shape")
def test_per_event_output_hands_back_one_row_per_trade():
store, sid, start, end = _tape_store()
cfg = _config(sid, start, end, output_resolution=Interval.trades())
res = bt.run(_flux(), cfg, store)
pos = res.positions_df()
# Millions of prints in a liquid day: the only thing worth asserting is
# that nothing was bucketed away, which strictly increasing sub-second
# timestamps show.
assert len(pos) > 100_000
ts = pos["timestamp"].astype("int64").to_numpy()
assert (ts[1:] >= ts[:-1]).all()
assert (ts[1:] - ts[:-1]).min() < 1_000_000_000, "sub-second steps survived"
@_needs_tape
def test_the_flow_columns_are_readable_from_the_dsl():
store, sid, start, end = _tape_store()
cfg = _config(sid, start, end, output_resolution=Interval.days(1))
cfg.execution.signal_delay = 0
# Units, so the position IS the column rather than a fraction of equity,
# and no notional cap, which would otherwise scale one whole coin down to
# what the capital can carry.
cfg.execution.position_sizing_mode = "Units"
cfg.execution.max_position_pct = 1e9
strat = bt.Strategy.create("side").signal("pos", col("side")).size(col("pos"))
res = bt.run(strat, cfg, store)
# +1 aggressive buy, -1 aggressive sell: never anything else, never zero.
assert abs(res.positions_df()["position"].iloc[-1]) == 1.0
@_needs_tape
def test_the_lite_paths_refuse_the_clock_by_name():
store, sid, start, end = _tape_store()
cfg = _config(sid, start, end)
with pytest.raises(Exception) as err:
bt.run_batch_lite([_flux()], cfg, store)
assert "trade clock" in str(err.value)
@_needs_tape
def test_two_runs_on_the_same_tape_agree_to_the_bit():
store, sid, start, end = _tape_store()
cfg = _config(sid, start, end, output_resolution=Interval.days(1))
a = bt.run(_flux(), cfg, store)
b = bt.run(_flux(), cfg, store)
assert a.metrics["sharpe"] == b.metrics["sharpe"]
assert a.metrics["total_return"] == b.metrics["total_return"]
assert a.trades_df().equals(b.trades_df())
def test_per_event_output_is_never_silently_downgraded(monkeypatch):
"""The Community cap must not turn a refusal into a silent success.
``output_resolution`` is capped to daily for tiers that stop there, and the
cap rewrites the field. Per-event output is not a resolution to round down
but a shape the engine answers by name, so the cap has to leave it alone --
on every tier, which is why the tier is forced here rather than read.
"""
monkeypatch.setattr(bt, "_is_pro", lambda: False)
cfg = bt.BacktestConfig(
universe=[1],
time_range_start=0,
time_range_end=86_400_000_000_000,
bar_interval=Interval.trades(),
output_resolution=Interval.trades(),
)
assert bt._cap_output_resolution(cfg).output_resolution == "Trades"
# And a real sub-daily duration is still capped, as it always was.
cfg.output_resolution = Interval.minutes(1)
assert bt._cap_output_resolution(cfg).output_resolution is None