mirror of
https://github.com/manifoldbt/manifoldbt.git
synced 2026-08-24 14:38:04 +00:00
release: v0.4.0
This commit is contained in:
@@ -250,7 +250,7 @@ def _resolve_store(config: BacktestConfig, store: DataStore) -> DataStore:
|
||||
- **Normal** (default): dataset matches ``bar_interval`` exactly.
|
||||
If no exact match, picks the closest smaller dataset and sets
|
||||
``resample_to`` so the engine resamples to bar_interval (no hybrid overhead).
|
||||
- **Accuracy** (``accuracy=True`` on config): always loads ``bars_1m``.
|
||||
- **Precise** (``precise=True`` on config): always loads ``bars_1m``.
|
||||
Signals on ``bar_interval``, simulation on 1-min bars.
|
||||
Required for precise SL/TP fills.
|
||||
|
||||
@@ -261,12 +261,17 @@ def _resolve_store(config: BacktestConfig, store: DataStore) -> DataStore:
|
||||
except Exception:
|
||||
return store
|
||||
|
||||
# ArrowIpcDataStore handles multi-resolution internally via bar_interval —
|
||||
# skip Python-side dataset swapping. Detected by dataset() returning "arrow_ipc".
|
||||
if current == "arrow_ipc":
|
||||
return store
|
||||
|
||||
# If user explicitly chose a non-default dataset, respect it
|
||||
if current != "bars_1m":
|
||||
return store
|
||||
|
||||
# Accuracy mode: keep bars_1m (hybrid: signals on bar_interval, sim on 1m)
|
||||
if getattr(config, "accuracy", False):
|
||||
if getattr(config, "precise", False):
|
||||
return store
|
||||
|
||||
# Normal mode: pick dataset <= bar_interval.
|
||||
@@ -312,55 +317,118 @@ def _cap_output_resolution(config: BacktestConfig) -> BacktestConfig:
|
||||
|
||||
def ingest(
|
||||
provider: str,
|
||||
symbol: str,
|
||||
symbol_id: int,
|
||||
start: str,
|
||||
end: str,
|
||||
symbol: Optional[str] = None,
|
||||
symbol_id: Optional[int] = None,
|
||||
start: str = "",
|
||||
end: str = "",
|
||||
*,
|
||||
symbols: Optional[list] = None,
|
||||
interval: str = "1m",
|
||||
dataset: Optional[str] = None,
|
||||
data_root: str = "data",
|
||||
metadata_db: str = "metadata/metadata.sqlite",
|
||||
exchange: Optional[str] = None,
|
||||
asset_class: str = "crypto_spot",
|
||||
progress: bool = True,
|
||||
) -> DataStore:
|
||||
"""Ingest bars from a data provider into the local Parquet store.
|
||||
"""Ingest bars from a data provider into the Arrow IPC store.
|
||||
|
||||
Providers: ``"binance"``, ``"hyperliquid"`` (free), ``"databento"``, ``"massive"`` (Pro).
|
||||
|
||||
Returns a :class:`DataStore` ready for :func:`run`.
|
||||
|
||||
Example::
|
||||
Example (single symbol)::
|
||||
|
||||
store = bt.ingest(
|
||||
provider="databento",
|
||||
symbol="ESH5",
|
||||
provider="binance",
|
||||
symbol="BTCUSDT",
|
||||
symbol_id=1,
|
||||
start="2025-01-01T00:00:00Z",
|
||||
end="2025-01-31T00:00:00Z",
|
||||
dataset="GLBX.MDP3",
|
||||
exchange="CME",
|
||||
asset_class="future",
|
||||
start="2020-01-01T00:00:00Z",
|
||||
end="2025-01-01T00:00:00Z",
|
||||
)
|
||||
|
||||
Example (multiple symbols)::
|
||||
|
||||
store = bt.ingest(
|
||||
provider="binance",
|
||||
symbols=[("XMRUSDT", 26), ("VETUSDT", 27), ("ZECUSDT", 28)],
|
||||
start="2020-06-01T00:00:00Z",
|
||||
end="2026-03-01T00:00:00Z",
|
||||
)
|
||||
result = bt.run(strategy, config, store)
|
||||
"""
|
||||
_PRO_PROVIDERS = {"databento", "massive"}
|
||||
if provider in _PRO_PROVIDERS:
|
||||
_require_pro(f"Data connector: {provider}")
|
||||
|
||||
return _ingest_native(
|
||||
provider=provider,
|
||||
symbol=symbol,
|
||||
symbol_id=symbol_id,
|
||||
start=start,
|
||||
end=end,
|
||||
interval=interval,
|
||||
dataset=dataset,
|
||||
data_root=data_root,
|
||||
metadata_db=metadata_db,
|
||||
exchange=exchange,
|
||||
asset_class=asset_class,
|
||||
)
|
||||
# Build list of (symbol, symbol_id) pairs.
|
||||
if symbols is not None:
|
||||
pairs = [(s, sid) for s, sid in symbols]
|
||||
elif symbol is not None and symbol_id is not None:
|
||||
pairs = [(symbol, symbol_id)]
|
||||
else:
|
||||
raise ValueError("provide either symbol+symbol_id or symbols=[(ticker, id), ...]")
|
||||
|
||||
if len(pairs) == 1:
|
||||
return _ingest_single(
|
||||
provider=provider, symbol=pairs[0][0], symbol_id=pairs[0][1],
|
||||
start=start, end=end, interval=interval, dataset=dataset,
|
||||
data_root=data_root, metadata_db=metadata_db,
|
||||
exchange=exchange, asset_class=asset_class, progress=progress,
|
||||
)
|
||||
|
||||
# Multi-symbol: show all symbols with pending ones in grey.
|
||||
display = None
|
||||
callbacks = {}
|
||||
if progress:
|
||||
from manifoldbt._progress import make_multi_progress
|
||||
display, callbacks = make_multi_progress(pairs, provider)
|
||||
|
||||
store = None
|
||||
try:
|
||||
for sym, sid in pairs:
|
||||
cb = callbacks.get(sym) if callbacks else None
|
||||
store = _ingest_native(
|
||||
provider=provider, symbol=sym, symbol_id=sid,
|
||||
start=start, end=end, interval=interval, dataset=dataset,
|
||||
data_root=data_root, metadata_db=metadata_db,
|
||||
exchange=exchange, asset_class=asset_class,
|
||||
progress_cb=cb,
|
||||
)
|
||||
finally:
|
||||
if display is not None:
|
||||
display.stop()
|
||||
|
||||
return store
|
||||
|
||||
|
||||
def _ingest_single(
|
||||
*, provider, symbol, symbol_id, start, end, interval, dataset,
|
||||
data_root, metadata_db, exchange, asset_class, progress,
|
||||
) -> DataStore:
|
||||
cb = None
|
||||
display = None
|
||||
if progress:
|
||||
from manifoldbt._progress import make_progress_display
|
||||
display, cb = make_progress_display(symbol, provider)
|
||||
|
||||
try:
|
||||
return _ingest_native(
|
||||
provider=provider,
|
||||
symbol=symbol,
|
||||
symbol_id=symbol_id,
|
||||
start=start,
|
||||
end=end,
|
||||
interval=interval,
|
||||
dataset=dataset,
|
||||
data_root=data_root,
|
||||
metadata_db=metadata_db,
|
||||
exchange=exchange,
|
||||
asset_class=asset_class,
|
||||
progress_cb=cb,
|
||||
)
|
||||
finally:
|
||||
if display is not None:
|
||||
display.stop()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Rich progress display for data ingestion."""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
|
||||
def make_progress_display(symbol: str, provider: str):
|
||||
"""Create a rich progress display for a single symbol.
|
||||
|
||||
Returns (display, callback).
|
||||
"""
|
||||
try:
|
||||
from rich.progress import (
|
||||
Progress,
|
||||
SpinnerColumn,
|
||||
BarColumn,
|
||||
TextColumn,
|
||||
TimeRemainingColumn,
|
||||
)
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
progress = Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("{task.description}"),
|
||||
BarColumn(bar_width=40),
|
||||
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
||||
TextColumn("[dim]{task.fields[status]}"),
|
||||
TimeRemainingColumn(),
|
||||
console=console,
|
||||
transient=False,
|
||||
)
|
||||
task_id = progress.add_task(
|
||||
f"[bold blue]{symbol} ({provider})", total=10000, status="connecting..."
|
||||
)
|
||||
progress.start()
|
||||
|
||||
def callback(phase: str, fetched: int, pct: float, msg: str):
|
||||
if phase == "done":
|
||||
progress.update(task_id, completed=10000, status=f"done - {fetched:,} bars")
|
||||
progress.stop()
|
||||
elif phase == "store":
|
||||
progress.update(task_id, completed=9900, status="writing...")
|
||||
else:
|
||||
progress.update(
|
||||
task_id,
|
||||
completed=max(1, int(pct * 9800)),
|
||||
status=f"{fetched:,} bars",
|
||||
)
|
||||
|
||||
return progress, callback
|
||||
|
||||
except ImportError:
|
||||
return _make_fallback(symbol, provider)
|
||||
|
||||
|
||||
def make_multi_progress(symbols: list[tuple[str, int]], provider: str):
|
||||
"""Create a rich progress display for multiple symbols.
|
||||
|
||||
All symbols are shown upfront: current in blue, pending in dim grey.
|
||||
Returns (display, dict of {symbol: callback}).
|
||||
"""
|
||||
try:
|
||||
from rich.progress import (
|
||||
Progress,
|
||||
SpinnerColumn,
|
||||
BarColumn,
|
||||
TextColumn,
|
||||
TimeRemainingColumn,
|
||||
)
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
progress = Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("{task.description}"),
|
||||
BarColumn(bar_width=40),
|
||||
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
||||
TextColumn("[dim]{task.fields[status]}"),
|
||||
TimeRemainingColumn(),
|
||||
console=console,
|
||||
transient=False,
|
||||
)
|
||||
|
||||
task_ids = {}
|
||||
for i, (sym, _sid) in enumerate(symbols):
|
||||
if i == 0:
|
||||
desc = f"[bold blue]{sym} ({provider})"
|
||||
status = "connecting..."
|
||||
else:
|
||||
desc = f"[dim]{sym} ({provider})[/dim]"
|
||||
status = "waiting"
|
||||
task_ids[sym] = progress.add_task(desc, total=10000, status=status)
|
||||
|
||||
progress.start()
|
||||
|
||||
def make_callback(sym: str, idx: int):
|
||||
tid = task_ids[sym]
|
||||
|
||||
def callback(phase: str, fetched: int, pct: float, msg: str):
|
||||
if phase == "done":
|
||||
progress.update(
|
||||
tid,
|
||||
description=f"[green]{sym} ({provider})[/green]",
|
||||
completed=10000,
|
||||
status=f"{fetched:,} bars",
|
||||
)
|
||||
# Activate next symbol if any.
|
||||
next_idx = idx + 1
|
||||
if next_idx < len(symbols):
|
||||
next_sym = symbols[next_idx][0]
|
||||
next_tid = task_ids[next_sym]
|
||||
progress.update(
|
||||
next_tid,
|
||||
description=f"[bold blue]{next_sym} ({provider})",
|
||||
status="connecting...",
|
||||
)
|
||||
elif phase == "store":
|
||||
progress.update(tid, completed=9900, status="writing...")
|
||||
else:
|
||||
progress.update(
|
||||
tid,
|
||||
completed=max(1, int(pct * 9800)),
|
||||
status=f"{fetched:,} bars",
|
||||
)
|
||||
|
||||
return callback
|
||||
|
||||
callbacks = {sym: make_callback(sym, i) for i, (sym, _sid) in enumerate(symbols)}
|
||||
return progress, callbacks
|
||||
|
||||
except ImportError:
|
||||
return _make_multi_fallback(symbols, provider)
|
||||
|
||||
|
||||
def _make_fallback(symbol: str, provider: str):
|
||||
"""Plain print fallback when rich is not installed."""
|
||||
t0 = time.perf_counter()
|
||||
last_pct = [-1]
|
||||
|
||||
def callback(phase: str, fetched: int, pct: float, msg: str):
|
||||
elapsed = time.perf_counter() - t0
|
||||
current = int(pct * 100)
|
||||
if phase == "done":
|
||||
print(f"\r {symbol} ({provider}): done - {fetched:,} bars [{elapsed:.1f}s] ")
|
||||
elif current > last_pct[0] + 4 or phase == "store":
|
||||
last_pct[0] = current
|
||||
label = "writing..." if phase == "store" else f"{fetched:,} bars"
|
||||
print(f"\r {symbol} ({provider}): {current}% {label} [{elapsed:.1f}s]", end="", flush=True)
|
||||
|
||||
return None, callback
|
||||
|
||||
|
||||
def _make_multi_fallback(symbols: list[tuple[str, int]], provider: str):
|
||||
"""Plain print fallback for multiple symbols."""
|
||||
callbacks = {}
|
||||
for sym, _sid in symbols:
|
||||
_, cb = _make_fallback(sym, provider)
|
||||
callbacks[sym] = cb
|
||||
return None, callbacks
|
||||
@@ -187,8 +187,8 @@ class BacktestConfig:
|
||||
Allows indicators (EMA, SMA, etc.) to stabilise. During warmup,
|
||||
equity tracking runs but no trades are generated.
|
||||
Set to at least the longest indicator window (e.g. 25 for EMA(25))."""
|
||||
accuracy: bool = False
|
||||
"""When True, simulation runs on 1-minute bars regardless of bar_interval.
|
||||
precise: bool = False
|
||||
"""When True, always load finest resolution (1m) regardless of bar_interval.
|
||||
Signals are still evaluated at bar_interval resolution (hybrid mode).
|
||||
Use for precise SL/TP fills and intraday drawdown tracking. Slower."""
|
||||
extra_timeframes: Dict[str, Any] = field(default_factory=dict)
|
||||
@@ -224,6 +224,8 @@ class BacktestConfig:
|
||||
d["symbol_names"] = self.symbol_names
|
||||
if self.warmup_bars > 0:
|
||||
d["warmup_bars"] = self.warmup_bars
|
||||
if self.precise:
|
||||
d["precise"] = True
|
||||
if self.extra_timeframes:
|
||||
d["extra_timeframes"] = self.extra_timeframes
|
||||
return d
|
||||
|
||||
@@ -143,6 +143,24 @@ class Result:
|
||||
if val is not None:
|
||||
lines.append(f" {label:<18s} {fmt(val):>12s}")
|
||||
|
||||
# Signal quality metrics (MAE/MFE)
|
||||
sq = ts.get("signal_quality")
|
||||
if isinstance(sq, dict):
|
||||
lines.append("")
|
||||
lines.append(" Signal Quality")
|
||||
lines.append(" " + "-" * 38)
|
||||
_sq_fmt = [
|
||||
("Avg MAE", "avg_mae", _pct),
|
||||
("Avg MFE", "avg_mfe", _pct),
|
||||
("Edge Ratio", "edge_ratio", _f2),
|
||||
("Entry Efficiency", "avg_entry_efficiency", _pct),
|
||||
("Exit Efficiency", "avg_exit_efficiency", _pct),
|
||||
]
|
||||
for label, key, fmt in _sq_fmt:
|
||||
val = sq.get(key)
|
||||
if val is not None:
|
||||
lines.append(f" {label:<18s} {fmt(val):>12s}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def profile_summary(self) -> str:
|
||||
|
||||
Reference in New Issue
Block a user