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

python
from horizon.quant import MovingAverageCrossStrategy

Signature

python
MovingAverageCrossStrategy(
    fast: int = 10,
    slow: int = 30,
    vol_window: int = 60,
    edge_bps: float = 30.0,
    horizon_days: int = 5,
)
fastint
Window for the fast moving average. Must be strictly less than `slow`.
slowint
Window for the slow moving average.
vol_windowint
Window for `RealizedVol`.
edge_bpsfloat
Expected edge in bps. Lower than momentum strategies because crossover signals are slow.
horizon_daysint
Expected horizon of the trend signal.

Features it uses

python
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

python
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

python
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

Long-term trends Works best on markets that have sustained directional moves. equities with persistent uptrends, trending commodities, crypto bull runs.
Low-turnover portfolios MA cross only fires on direction changes, so turnover is much lower than z-score methods. Friendly for cost-sensitive strategies.
Risk budgets with lots of markets Cheap enough per-signal that you can run it on hundreds of markets without drowning in trades.

When NOT to use

Parameter tuning

Risk companions

A trailing stop is the natural partner:

python
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:

python
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.

Next