mirror of
https://github.com/manifoldbt/manifoldbt.git
synced 2026-08-25 15:08:03 +00:00
release: v0.8.6
This commit is contained in:
@@ -5,9 +5,17 @@ import pyarrow as pa
|
||||
|
||||
|
||||
class DataStore:
|
||||
"""Parquet data store with SQLite metadata."""
|
||||
"""Bar data store (Parquet by default, or Arrow IPC via ``arrow_dir``) with SQLite metadata."""
|
||||
|
||||
def __init__(self, data_root: str, metadata_db: str = "metadata/metadata.sqlite") -> None: ...
|
||||
def __init__(
|
||||
self,
|
||||
data_root: str,
|
||||
metadata_db: str = "metadata/metadata.sqlite",
|
||||
dataset: str = "bars_1m",
|
||||
mega: Optional[str] = None,
|
||||
arrow_dir: Optional[str] = None,
|
||||
) -> None: ...
|
||||
def dataset(self) -> str: ...
|
||||
def data_root(self) -> str: ...
|
||||
def metadata_db(self) -> str: ...
|
||||
def active_version(self, dataset: str) -> str: ...
|
||||
|
||||
@@ -98,8 +98,15 @@ def arrow_to_series(
|
||||
if backend == "polars":
|
||||
import polars as pl
|
||||
|
||||
if hasattr(array, "to_pylist"):
|
||||
return pl.Series(name=name, values=array.to_pylist())
|
||||
try:
|
||||
import pyarrow as pa
|
||||
except ImportError:
|
||||
pa = None
|
||||
# Zero-copy: hand the Arrow buffers straight to polars instead of boxing
|
||||
# every value into a Python object via to_pylist() (copies the whole
|
||||
# column). pl.from_arrow shares the underlying buffers.
|
||||
if pa is not None and isinstance(array, (pa.Array, pa.ChunkedArray)):
|
||||
return pl.from_arrow(array).rename(name)
|
||||
return pl.Series(name=name, values=list(array))
|
||||
|
||||
return array
|
||||
|
||||
@@ -13,6 +13,27 @@ _EMPTY_TS = np.array([], dtype="datetime64[ns]")
|
||||
_SAFETY_PRO_FEATURE = "Safety checks (lookahead, exposure)"
|
||||
|
||||
|
||||
def _prepare_for_diagnostics(config, strategy, store):
|
||||
"""Mirror ``run()``'s config/store preparation for the diagnostics path.
|
||||
|
||||
``run()`` resolves the config and store before serializing
|
||||
(``_cap_output_resolution`` -> ``_resolve_store`` -> ``_prepare_config``).
|
||||
Diagnostics must do the same: in particular a dict ``universe`` has to be
|
||||
resolved to a ``List[SymbolId]`` first, otherwise ``config.to_json()`` emits
|
||||
a JSON map and the Rust loader rejects it ("invalid type: map, expected a
|
||||
sequence"). Returns the prepared ``(config, store)``.
|
||||
"""
|
||||
from manifoldbt import (
|
||||
_cap_output_resolution,
|
||||
_resolve_store,
|
||||
_prepare_config,
|
||||
)
|
||||
config = _cap_output_resolution(config)
|
||||
store = _resolve_store(config, store)
|
||||
config = _prepare_config(config, strategy, store)
|
||||
return config, store
|
||||
|
||||
|
||||
@dataclass
|
||||
class LookaheadReport:
|
||||
"""Result of a single look-ahead bias test."""
|
||||
@@ -289,6 +310,10 @@ def detect_lookahead(
|
||||
run_on_aligned as _run_on_aligned,
|
||||
)
|
||||
|
||||
# Resolve config/store exactly like run() (notably dict universe -> ids),
|
||||
# otherwise config.to_json() emits a map the Rust loader rejects.
|
||||
config, store = _prepare_for_diagnostics(config, strategy, store)
|
||||
|
||||
period = config.time_range_end - config.time_range_start
|
||||
|
||||
# Load data ONCE for the full range.
|
||||
@@ -813,6 +838,10 @@ def check_exposure_stability(
|
||||
run_on_aligned as _run_on_aligned,
|
||||
)
|
||||
|
||||
# Resolve config/store exactly like run() (notably dict universe -> ids),
|
||||
# otherwise config.to_json() emits a map the Rust loader rejects.
|
||||
config, store = _prepare_for_diagnostics(config, strategy, store)
|
||||
|
||||
period = config.time_range_end - config.time_range_start
|
||||
|
||||
# Load data ONCE.
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""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
|
||||
|
||||
import manifoldbt as bt
|
||||
from manifoldbt.diagnostics import _prepare_for_diagnostics
|
||||
|
||||
|
||||
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]
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Doc <-> code signature contract.
|
||||
|
||||
These assertions encode the public signatures and helper outputs that the
|
||||
online documentation and the interactive notebook rely on. They are cheap,
|
||||
IO-free, and Pro-free, and exist to catch *doc drift*: if a documented kwarg,
|
||||
preset, or helper shape changes in the code, a doc snippet silently breaks.
|
||||
|
||||
This guards, among others:
|
||||
* ``plot.monte_carlo`` exposing ``n_simulations`` (NOT ``n_paths``) -- the
|
||||
notebook bug where ``n_paths=`` raised TypeError.
|
||||
* ``Slippage.volume_impact`` emitting ``impact_coeff``/``exponent`` -- the
|
||||
notebook bug where ``{"coefficient": ...}`` failed Rust deserialization.
|
||||
* ``DataStore`` accepting ``mega``/``arrow_dir`` -- the doc signature that
|
||||
omitted them.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
import manifoldbt as bt
|
||||
|
||||
|
||||
def test_monte_carlo_uses_n_simulations_not_n_paths():
|
||||
params = inspect.signature(bt.plot.monte_carlo).parameters
|
||||
assert "n_simulations" in params
|
||||
assert "n_paths" not in params # the notebook snippet bug
|
||||
|
||||
|
||||
def test_slippage_helper_shapes_match_serde():
|
||||
# Keys must match the Rust SlippageConfig serde variants exactly.
|
||||
assert bt.Slippage.volume_impact(0.1) == {
|
||||
"VolumeImpact": {"impact_coeff": 0.1, "exponent": 1.5}
|
||||
}
|
||||
assert bt.Slippage.fixed_bps(2.0) == {"FixedBps": {"bps": 2.0}}
|
||||
|
||||
|
||||
def test_interval_helper_shapes():
|
||||
assert bt.Interval.seconds(1) == {"Seconds": 1}
|
||||
assert bt.Interval.minutes(1) == {"Minutes": 1}
|
||||
assert bt.Interval.hours(12) == {"Hours": 12}
|
||||
assert bt.Interval.days(1) == {"Days": 1}
|
||||
|
||||
|
||||
def test_fee_presets_match_documented_values():
|
||||
# Documented under #configuration > FeeConfig Presets.
|
||||
perps = bt.FeeConfig.binance_perps()
|
||||
assert (perps.maker_fee_bps, perps.taker_fee_bps) == (2.0, 5.0)
|
||||
spot = bt.FeeConfig.binance_spot()
|
||||
assert (spot.maker_fee_bps, spot.taker_fee_bps) == (10.0, 10.0)
|
||||
|
||||
|
||||
def test_datastore_accepts_mega_and_arrow_dir_kwargs(tmp_path):
|
||||
# The real signature is (data_root, metadata_db, dataset, mega, arrow_dir).
|
||||
# We only assert the kwargs are *accepted* (no TypeError for unknown kwarg);
|
||||
# any runtime/IO error from opening an empty dir is fine for this contract.
|
||||
for kw in ("mega", "arrow_dir"):
|
||||
try:
|
||||
bt.DataStore(str(tmp_path), dataset="bars_1m", **{kw: str(tmp_path)})
|
||||
except TypeError as exc: # unexpected keyword argument -> contract broken
|
||||
raise AssertionError(f"DataStore rejected kwarg {kw!r}: {exc}")
|
||||
except Exception:
|
||||
pass # non-TypeError (e.g. cannot open store) -> kwarg was accepted
|
||||
Reference in New Issue
Block a user