quant-web/strategies/builtin/ma_crossover.py
epistemophiliac 627b2326df Add Python strategy engine, parameter optimization, and faster Docker builds.
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.
2026-06-19 01:29:28 -04:00

93 lines
2.9 KiB
Python

"""Moving-average crossover — predefined Python strategy."""
from __future__ import annotations
import numpy as np
import pandas as pd
import vectorbt as vbt
STRATEGY_KEY = "ma_crossover"
DISPLAY_NAME = "MA Crossover"
DESCRIPTION = "Enter when fast MA crosses above slow MA; exit on cross below."
PARAM_GRID = {
"window_pool": list(range(5, 101, 5)),
}
DEFAULT_PARAMS = {
"fast_window": 20,
"slow_window": 50,
}
def generate_signals(
close: pd.Series,
high: pd.Series,
low: pd.Series,
volume: pd.Series,
fast_window: int = 20,
slow_window: int = 50,
**_kwargs,
) -> tuple[pd.Series, pd.Series]:
if fast_window >= slow_window:
raise ValueError("fast_window must be smaller than slow_window")
fast_ma = vbt.MA.run(close, fast_window).ma
slow_ma = vbt.MA.run(close, slow_window).ma
entries = fast_ma.vbt.crossed_above(slow_ma).fillna(False)
exits = fast_ma.vbt.crossed_below(slow_ma).fillna(False)
return entries, exits
def optimize_vectorized(
close: pd.Series,
window_pool: list[int] | None = None,
init_cash: float = 10_000.0,
fees: float = 0.001,
metric: str = "sharpe_ratio",
) -> pd.DataFrame:
"""VectorBT combinatorial scan across all fast/slow pairs (fast < slow)."""
pool = np.array(window_pool or PARAM_GRID["window_pool"], dtype=int)
fast_ma, slow_ma = vbt.MA.run_combs(close, pool, r=2, short_names=["fast", "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",
)
metric_fn = {
"sharpe_ratio": portfolio.sharpe_ratio,
"sortino_ratio": portfolio.sortino_ratio,
"total_return": portfolio.total_return,
"max_drawdown": lambda **_: portfolio.max_drawdown(group_by=False),
}.get(metric, portfolio.sharpe_ratio)
scores = metric_fn(group_by=False)
max_dd = portfolio.max_drawdown(group_by=False)
total_ret = portfolio.total_return(group_by=False)
sortino = portfolio.sortino_ratio(group_by=False)
trades = portfolio.trades.count(group_by=False)
rows = []
for idx, score in scores.items():
fast_w, slow_w = int(idx[0]), int(idx[1])
rows.append(
{
"fast_window": fast_w,
"slow_window": slow_w,
"score": float(score) if score == score else float("nan"),
"sharpe_ratio": float(scores[idx]) if scores[idx] == scores[idx] else float("nan"),
"sortino_ratio": float(sortino[idx]) if sortino[idx] == sortino[idx] else float("nan"),
"total_return": float(total_ret[idx]),
"max_drawdown": float(max_dd[idx]),
"total_trades": int(trades[idx]),
}
)
return pd.DataFrame(rows).sort_values("score", ascending=False, na_position="last")