2026-06-29 21:23:38 +00:00
|
|
|
"""Regression tests for the diagnostics config-preparation path.
|
|
|
|
|
|
|
|
|
|
Guards the fix for the bug where ``detect_lookahead`` / ``check_exposure_stability``
|
|
|
|
|
crashed with a dict ``universe`` (e.g. ``{"binance": ["BTC-USDT:perp"]}``):
|
|
|
|
|
they serialized the config without resolving the universe, so ``config.to_json()``
|
|
|
|
|
emitted a JSON *map* while the Rust loader expects a *sequence*
|
|
|
|
|
(``ValueError: invalid type: map, expected a sequence``).
|
|
|
|
|
|
|
|
|
|
The fix routes diagnostics through the same preparation as ``run()`` via
|
|
|
|
|
``_prepare_for_diagnostics``. These tests assert that helper resolves a dict
|
|
|
|
|
universe into a list of integer SymbolIds (so serialization is a JSON array),
|
|
|
|
|
without needing a Pro license or real market data.
|
|
|
|
|
"""
|
|
|
|
|
import json
|
|
|
|
|
import sqlite3
|
|
|
|
|
|
2026-07-09 11:01:08 +00:00
|
|
|
import pytest
|
|
|
|
|
|
2026-06-29 21:23:38 +00:00
|
|
|
import manifoldbt as bt
|
|
|
|
|
from manifoldbt.diagnostics import _prepare_for_diagnostics
|
2026-07-09 11:01:08 +00:00
|
|
|
from manifoldbt.exceptions import LicenseError
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_detect_lookahead_gated_on_community(monkeypatch):
|
|
|
|
|
"""Look-ahead detection is Pro: Community gets a clean LicenseError before any
|
|
|
|
|
work (the analysis itself is enforced natively; this is the friendly UX gate).
|
|
|
|
|
"""
|
|
|
|
|
monkeypatch.setattr(bt, "_is_pro", lambda: False)
|
|
|
|
|
with pytest.raises(LicenseError):
|
|
|
|
|
# Raises before touching strategy/config/store, so None args are fine.
|
|
|
|
|
bt.diagnostics.detect_lookahead(None, None, None)
|
2026-06-29 21:23:38 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_metadata_db(path):
|
|
|
|
|
"""Create a minimal metadata sqlite with one resolvable symbol (id=1)."""
|
|
|
|
|
conn = sqlite3.connect(path)
|
|
|
|
|
conn.execute(
|
|
|
|
|
"CREATE TABLE symbols ("
|
|
|
|
|
"id INTEGER PRIMARY KEY, base_currency TEXT, quote_currency TEXT, "
|
|
|
|
|
"asset_class TEXT, exchange TEXT, ticker TEXT)"
|
|
|
|
|
)
|
|
|
|
|
conn.execute(
|
|
|
|
|
"INSERT INTO symbols VALUES (1, 'BTC', 'USDT', 'CryptoPerpetual', "
|
|
|
|
|
"'BINANCE', 'BTC-USDT:perp')"
|
|
|
|
|
)
|
|
|
|
|
conn.commit()
|
|
|
|
|
conn.close()
|
|
|
|
|
return str(path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _StubStore:
|
|
|
|
|
"""Minimal DataStore stand-in.
|
|
|
|
|
|
|
|
|
|
``_resolve_normalized`` only needs ``metadata_db()`` (+ ``resolve_symbol``
|
|
|
|
|
as a fallback). ``dataset()`` raises so ``_resolve_store`` returns the store
|
|
|
|
|
unchanged instead of trying to swap datasets on disk.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def __init__(self, db_path):
|
|
|
|
|
self._db = db_path
|
|
|
|
|
|
|
|
|
|
def metadata_db(self):
|
|
|
|
|
return self._db
|
|
|
|
|
|
|
|
|
|
def dataset(self):
|
|
|
|
|
raise NotImplementedError
|
|
|
|
|
|
|
|
|
|
def resolve_symbol(self, name): # fallback, not expected to be hit here
|
|
|
|
|
return 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _simple_strategy():
|
|
|
|
|
return (
|
|
|
|
|
bt.Strategy.create("regression")
|
|
|
|
|
.signal("s", bt.lit(1.0))
|
|
|
|
|
.size(bt.col("s"))
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_prepare_for_diagnostics_resolves_dict_universe(tmp_path):
|
|
|
|
|
"""A dict universe must become a list of ints before serialization."""
|
|
|
|
|
db = _make_metadata_db(tmp_path / "metadata.sqlite")
|
|
|
|
|
store = _StubStore(db)
|
|
|
|
|
|
|
|
|
|
config = bt.BacktestConfig(
|
|
|
|
|
universe={"binance": ["BTC-USDT:perp"]},
|
|
|
|
|
time_range_start=0,
|
|
|
|
|
time_range_end=4_000_000_000,
|
|
|
|
|
bar_interval={"Hours": 1},
|
|
|
|
|
initial_capital=1000.0,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
prepared, _ = _prepare_for_diagnostics(config, _simple_strategy(), store)
|
|
|
|
|
|
|
|
|
|
# Core invariant: universe is a list of ints, never a dict.
|
|
|
|
|
assert isinstance(prepared.universe, list)
|
|
|
|
|
assert prepared.universe == [1]
|
|
|
|
|
|
|
|
|
|
# And the JSON the Rust loader sees is an array, not a map (the crash cause).
|
|
|
|
|
universe_json = json.loads(prepared.to_json())["universe"]
|
|
|
|
|
assert isinstance(universe_json, list)
|
|
|
|
|
assert universe_json == [1]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_prepare_for_diagnostics_passes_through_list_universe(tmp_path):
|
|
|
|
|
"""An already-resolved list universe is left intact."""
|
|
|
|
|
db = _make_metadata_db(tmp_path / "metadata.sqlite")
|
|
|
|
|
store = _StubStore(db)
|
|
|
|
|
|
|
|
|
|
config = bt.BacktestConfig(
|
|
|
|
|
universe=[1],
|
|
|
|
|
time_range_start=0,
|
|
|
|
|
time_range_end=4_000_000_000,
|
|
|
|
|
bar_interval={"Hours": 1},
|
|
|
|
|
initial_capital=1000.0,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
prepared, _ = _prepare_for_diagnostics(config, _simple_strategy(), store)
|
|
|
|
|
|
|
|
|
|
assert prepared.universe == [1]
|
|
|
|
|
assert json.loads(prepared.to_json())["universe"] == [1]
|