"""VectorBT backtest engine reading local Parquet OHLCV data.""" from __future__ import annotations import os from dataclasses import dataclass from pathlib import Path import pandas as pd import vectorbt as vbt @dataclass(frozen=True) class BacktestResult: ticker: str fast_window: int slow_window: int sharpe_ratio: float max_drawdown: float total_return: float equity_curve: pd.Series price: pd.Series def parquet_dir() -> Path: return Path(os.environ.get("PARQUET_DIR", "/data/parquet")) def load_ohlcv(ticker: str) -> pd.DataFrame: path = parquet_dir() / f"{ticker.upper()}.parquet" if not path.exists(): raise FileNotFoundError(f"No Parquet file for {ticker.upper()} at {path}") df = pd.read_parquet(path) if "Date" in df.columns: df = df.set_index("Date") df.index = pd.to_datetime(df.index) df = df.sort_index() return df def run_ma_crossover( ticker: str, fast_window: int, slow_window: int, init_cash: float = 10_000.0, fees: float = 0.001, ) -> BacktestResult: if fast_window >= slow_window: raise ValueError("Fast MA window must be smaller than slow MA window") ohlcv = load_ohlcv(ticker) close = ohlcv["Close"].astype(float) fast_ma = vbt.MA.run(close, fast_window, short_name="fast") slow_ma = vbt.MA.run(close, slow_window, short_name="slow") entries = fast_ma.ma_crossed_above(slow_ma) exits = fast_ma.ma_crossed_below(slow_ma) portfolio = vbt.Portfolio.from_signals( close, entries=entries, exits=exits, init_cash=init_cash, fees=fees, freq="1D", ) stats = portfolio.stats() sharpe = float(stats.get("Sharpe Ratio", 0.0) or 0.0) max_dd = float(stats.get("Max Drawdown [%]", 0.0) or 0.0) / 100.0 total_return = float(stats.get("Total Return [%]", 0.0) or 0.0) / 100.0 equity = portfolio.value() if isinstance(equity, pd.DataFrame): equity = equity.iloc[:, 0] return BacktestResult( ticker=ticker.upper(), fast_window=fast_window, slow_window=slow_window, sharpe_ratio=sharpe, max_drawdown=max_dd, total_return=total_return, equity_curve=equity, price=close, )