Support builtin and custom generate_signals strategies with SQLite persistence, exhaustive grid scans (VectorBT comb optimization for MA crossover), professional backtest/optimize UI, and split harvester/app requirements with BuildKit pip cache.
185 lines
5.2 KiB
Python
185 lines
5.2 KiB
Python
"""Backtest and optimization engine."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pandas as pd
|
|
import vectorbt as vbt
|
|
|
|
from strategies.executor import (
|
|
CUSTOM_TEMPLATE,
|
|
StrategyError,
|
|
load_custom_strategy,
|
|
optimize_custom,
|
|
run_builtin_signals,
|
|
run_custom_signals,
|
|
)
|
|
from strategies.registry import BuiltinStrategy, get_builtin
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BacktestResult:
|
|
ticker: str
|
|
strategy_key: str
|
|
params: dict[str, Any]
|
|
sharpe_ratio: float
|
|
sortino_ratio: float
|
|
max_drawdown: float
|
|
total_return: float
|
|
win_rate: float
|
|
total_trades: int
|
|
equity_curve: pd.Series
|
|
price: pd.Series
|
|
entries: pd.Series
|
|
exits: pd.Series
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class OptimizationResult:
|
|
ticker: str
|
|
strategy_key: str
|
|
metric: str
|
|
best_params: dict[str, Any]
|
|
best_score: float
|
|
results: pd.DataFrame
|
|
combinations_tested: int
|
|
|
|
|
|
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)
|
|
return df.sort_index()
|
|
|
|
|
|
from metrics import run_from_signals, safe_float as _safe_float
|
|
def _portfolio_from_signals(
|
|
close: pd.Series,
|
|
entries: pd.Series,
|
|
exits: pd.Series,
|
|
init_cash: float,
|
|
fees: float,
|
|
) -> vbt.Portfolio:
|
|
return vbt.Portfolio.from_signals(
|
|
close,
|
|
entries=entries,
|
|
exits=exits,
|
|
init_cash=init_cash,
|
|
fees=fees,
|
|
freq="1D",
|
|
)
|
|
|
|
|
|
def run_backtest(
|
|
ticker: str,
|
|
strategy_key: str,
|
|
params: dict[str, Any],
|
|
source_code: str | None = None,
|
|
init_cash: float = 10_000.0,
|
|
fees: float = 0.001,
|
|
) -> BacktestResult:
|
|
ohlcv = load_ohlcv(ticker)
|
|
close = ohlcv["Close"].astype(float)
|
|
|
|
runtime_params = {k: v for k, v in params.items() if k not in ("init_cash", "fees")}
|
|
init_cash = float(params.get("init_cash", init_cash))
|
|
fees = float(params.get("fees", fees))
|
|
|
|
if strategy_key == "custom":
|
|
if not source_code:
|
|
raise StrategyError("Custom strategy requires source_code")
|
|
entries, exits, _, merged = run_custom_signals(source_code, ohlcv, runtime_params)
|
|
params = merged
|
|
else:
|
|
builtin = get_builtin(strategy_key)
|
|
entries, exits = run_builtin_signals(builtin, ohlcv, runtime_params)
|
|
params = {**builtin.default_params, **runtime_params}
|
|
|
|
portfolio = _portfolio_from_signals(close, entries, exits, init_cash, fees)
|
|
stats = portfolio.stats()
|
|
equity = portfolio.value()
|
|
if isinstance(equity, pd.DataFrame):
|
|
equity = equity.iloc[:, 0]
|
|
|
|
return BacktestResult(
|
|
ticker=ticker.upper(),
|
|
strategy_key=strategy_key,
|
|
params=params,
|
|
sharpe_ratio=_safe_float(stats.get("Sharpe Ratio")),
|
|
sortino_ratio=_safe_float(stats.get("Sortino Ratio")),
|
|
max_drawdown=_safe_float(stats.get("Max Drawdown [%]")) / 100.0,
|
|
total_return=_safe_float(stats.get("Total Return [%]")) / 100.0,
|
|
win_rate=_safe_float(stats.get("Win Rate [%]")) / 100.0,
|
|
total_trades=int(stats.get("Total Trades", 0) or 0),
|
|
equity_curve=equity,
|
|
price=close,
|
|
entries=entries,
|
|
exits=exits,
|
|
)
|
|
|
|
|
|
def run_optimization(
|
|
ticker: str,
|
|
strategy_key: str,
|
|
metric: str = "sharpe_ratio",
|
|
init_cash: float = 10_000.0,
|
|
fees: float = 0.001,
|
|
source_code: str | None = None,
|
|
param_grid: dict | None = None,
|
|
) -> OptimizationResult:
|
|
ohlcv = load_ohlcv(ticker)
|
|
close = ohlcv["Close"].astype(float)
|
|
|
|
if strategy_key == "custom":
|
|
if not source_code:
|
|
raise StrategyError("Custom strategy requires source_code")
|
|
results = optimize_custom(
|
|
source_code,
|
|
close,
|
|
ohlcv,
|
|
init_cash=init_cash,
|
|
fees=fees,
|
|
metric=metric,
|
|
param_grid=param_grid,
|
|
)
|
|
else:
|
|
builtin = get_builtin(strategy_key)
|
|
results = builtin.optimize(close, init_cash, fees, metric, grid_override=param_grid)
|
|
|
|
if results.empty:
|
|
raise StrategyError("Optimization produced no valid parameter combinations.")
|
|
|
|
best = results.iloc[0]
|
|
param_cols = [c for c in results.columns if c not in {
|
|
"score", "sharpe_ratio", "sortino_ratio", "max_drawdown", "total_return", "win_rate", "total_trades",
|
|
}]
|
|
best_params = {col: best[col] for col in param_cols}
|
|
|
|
return OptimizationResult(
|
|
ticker=ticker.upper(),
|
|
strategy_key=strategy_key,
|
|
metric=metric,
|
|
best_params=best_params,
|
|
best_score=float(best["score"]),
|
|
results=results,
|
|
combinations_tested=len(results),
|
|
)
|
|
|
|
|
|
def get_strategy_source(strategy_key: str, source_code: str | None = None) -> str:
|
|
if strategy_key == "custom":
|
|
return source_code or CUSTOM_TEMPLATE
|
|
return get_builtin(strategy_key).source_code
|