MovingAverageCrossStrategy
Classic fast-slow moving average crossover. trend following
MovingAverageCrossStrategy is the classic trend-following technique: long when the fast moving average is above the slow MA, short when below. Low turnover, robust, and every quant textbook starts with it for a reason.
Import
from horizon.quant import MovingAverageCrossStrategy
Signature
MovingAverageCrossStrategy(
fast: int = 10,
slow: int = 30,
vol_window: int = 60,
edge_bps: float = 30.0,
horizon_days: int = 5,
)
fastintslowintvol_windowintedge_bpsfloathorizon_daysintFeatures it uses
features = {
"cross": MovingAverageCross(fast=fast, slow=slow),
"vol": RealizedVol(window=vol_window),
}
MovingAverageCross returns (fast_ma - slow_ma) / slow_ma: a normalized distance between the two moving averages. Positive = fast above slow = uptrend. See MovingAverageCross for the feature math.
Signal logic
def evaluate(self, f, universe):
signals = []
horizon = timedelta(days=self.horizon_days)
for m in universe:
cross = f.cross[m.id]
vol = f.vol[m.id]
if isnan(cross):
continue
vol_bps = max((vol if not isnan(vol) else 0.20) * 10_000, 50)
if cross > 0:
signals.append(Signal(
market_id=m.id,
direction=Direction.Increase,
confidence=min(1.0, abs(cross) * 20),
expected_edge_bps=self.edge_bps,
expected_expected_vol_bps=vol_bps,
horizon=horizon,
reason=f"MA cross +{cross:.3f}",
))
elif cross < 0:
signals.append(Signal(
market_id=m.id,
direction=Direction.Decrease,
confidence=min(1.0, abs(cross) * 20),
expected_edge_bps=self.edge_bps,
expected_expected_vol_bps=vol_bps,
horizon=horizon,
reason=f"MA cross {cross:.3f}",
))
return signals
- Fast MA above slow MA → long signal
- Fast MA below slow MA → short signal
- Confidence scales with the normalized gap: a 5% gap between MAs is more convinced than a 0.1% gap
- No dead zone: any sign flip produces a signal immediately
Use it
import horizon as hz
from horizon.asset_classes import AssetClass, Equity
from horizon.data import SyntheticGBM
from horizon.discovery import StaticUniverse
from horizon.discovery.base import Market
from horizon.portfolio import KellyOptimizer
from horizon.quant import MovingAverageCrossStrategy
from horizon.risk import RiskProfile
result = hz.run(
mode="backtest",
strategies=[MovingAverageCrossStrategy(fast=10, slow=30, edge_bps=40)],
asset_classes=[Equity],
universe=StaticUniverse([
Market(id=t, asset_class=AssetClass.Equity)
for t in ["AAPL", "MSFT", "NVDA", "GOOGL", "AMZN"]
]),
portfolio=KellyOptimizer(kelly_fraction=0.25, max_gross_leverage=1.0),
risk=RiskProfile.moderate(),
data_source=SyntheticGBM(
["AAPL", "MSFT", "NVDA", "GOOGL", "AMZN"],
n_bars=252,
seed=42,
),
backtest=hz.BacktestConfig(initial_cash_usd=100_000),
)
When to use
When NOT to use
Parameter tuning
Risk companions
A trailing stop is the natural partner:
from horizon.risk import RiskConfig, StopLoss
risk = RiskConfig(
stop_loss=StopLoss(trailing_pct=0.05), # 5% trailing
max_gross_leverage=1.0,
)
When the trend dies, the trailing stop closes the position automatically, letting you capture most of the trend without predicting the top.
Ensemble composition
MA cross is an excellent diversifier inside an Ensemble:
from horizon.quant import Ensemble, MovingAverageCrossStrategy, TSMomentum, BollingerMeanRev
strategy = Ensemble(
strategies=[
MovingAverageCrossStrategy(fast=10, slow=30), # slow trend
TSMomentum(lookback=20), # fast trend
BollingerMeanRev(window=20, entry_z=2.0), # mean reversion
],
weights=[0.4, 0.3, 0.3],
name="trend_and_mr",
)
The slow MA cross catches long trends; the faster TS momentum catches shorter moves; the MR component hedges during chop.
Pitfalls
Source
horizon/quant/timeseries.py. ~40 lines.