Code Along: MA Crossover

Build a moving average crossover strategy from scratch

A moving average crossover is probably the most well-known trading signal in existence. Fast MA crosses above slow MA, go long. Fast crosses below, go short. In this walkthrough we build one from scratch on 5 tech stocks, step by step.

Step 1. The imports

python
import math
import horizon as hz
from horizon import Strategy, Signal
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.features import SMA, RealizedVol
from horizon.portfolio import KellyOptimizer
from horizon.risk import RiskProfile

What each one does:

  • horizon as hz — the top-level module. We use hz.run() and hz.BacktestConfig.
  • Strategy — base class for custom strategies. You subclass it and implement evaluate().
  • Signal — the opinion type. “I think this market should increase/decrease.” Not an order.
  • AssetClass, Equity — tags that tell the engine what kind of instrument we’re trading.
  • SyntheticGBM — generates fake price data with geometric Brownian motion. Deterministic with a seed.
  • StaticUniverse, Market — a fixed list of markets. No discovery, just “here are my 5 tickers.”
  • SMA — simple moving average feature. Computes the arithmetic mean of the last N prices.
  • RealizedVol — rolling realized volatility. We use it to set expected vol on our signals.
  • KellyOptimizer — sizes positions using the Kelly criterion (how much to bet given edge and vol).
  • RiskProfile — preconfigured risk settings. .moderate() gives sensible defaults.

Step 2. The strategy class

python
class MACrossover(Strategy):
    name = "ma_cross"
    asset_classes = [Equity]
    features = {
        "fast": SMA(window=10),
        "slow": SMA(window=30),
        "vol": RealizedVol(window=20),
    }

    def evaluate(self, f, universe):
        signals = []
        for m in universe:
            fast = f.fast[m.id]
            slow = f.slow[m.id]
            vol = f.vol[m.id]

            # Skip until we have enough price history for the slow MA
            if math.isnan(fast) or math.isnan(slow):
                continue

            # Cross ratio: how far is the fast MA above/below the slow MA?
            # Positive = uptrend, negative = downtrend
            cross = (fast - slow) / slow

            if cross > 0.01:
                signals.append(Signal.increase(
                    m,
                    edge_bps=40,
                    expected_vol_bps=max((vol if not math.isnan(vol) else 0.20) * 10_000, 100),
                    horizon="5d",
                    reason=f"cross={cross:+.4f}",
                ))
            elif cross < -0.01:
                signals.append(Signal.decrease(
                    m,
                    edge_bps=40,
                    expected_vol_bps=max((vol if not math.isnan(vol) else 0.20) * 10_000, 100),
                    horizon="5d",
                    reason=f"cross={cross:+.4f}",
                ))

        return signals

Let’s break this down:

features dict. We declare three features that the engine computes every tick:

  • SMA(window=10) — 10-bar simple moving average. This is the “fast” line.
  • SMA(window=30) — 30-bar simple moving average. This is the “slow” line.
  • RealizedVol(window=20) — 20-bar rolling volatility. We use it to tell the sizer how volatile this market is.

The cross ratio. (fast - slow) / slow is the normalized distance between the two MAs. Dividing by slow makes it scale-independent: a cross of 0.02 means “fast MA is 2% above slow MA” whether the stock trades at $5 or $500.

The threshold. We only emit signals when |cross| > 0.01 (1%). Below that, the MAs are basically flat on top of each other — no clear trend. This is a dead zone that prevents whipsaws in sideways markets.

Signal direction. Signal.increase means “I think price will go up.” Signal.decrease means down. The portfolio sizer and executor handle the rest — you never say “buy 50 shares.”

vol_bps. We pass the realized vol (converted to basis points) so the Kelly sizer can compute how much capital to allocate. Higher vol means smaller positions. If vol is NaN (not enough history), we use a default of 20% annualized.

Step 3. Universe and data

python
tickers = ["AAPL", "MSFT", "NVDA", "GOOGL", "AMZN"]

universe = StaticUniverse([
    Market(id=t, asset_class=AssetClass.Equity)
    for t in tickers
])

data = SyntheticGBM(
    market_ids=tickers,
    n_bars=252,
    mu=0.10,
    sigma=0.22,
    seed=42,
)
  • Universe: 5 tech stocks. Each wrapped in a Market with asset_class=Equity.
  • Data: 252 bars of synthetic GBM (one trading year). mu=0.10 is 10% annualized drift, sigma=0.22 is 22% annualized volatility. seed=42 makes it deterministic — run it twice, get the same prices.

Step 4. Run it

python
result = hz.run(
    mode="backtest",
    strategies=[MACrossover],
    asset_classes=[Equity],
    universe=universe,
    portfolio=KellyOptimizer(
        kelly_fraction=0.25,
        max_gross_leverage=1.0,
        transaction_cost_bps=5,
    ),
    risk=RiskProfile.moderate(),
    data_source=data,
    backtest=hz.BacktestConfig(initial_cash_usd=100_000),
)
  • kelly_fraction=0.25: quarter Kelly. Full Kelly is mathematically optimal but too aggressive in practice. Quarter Kelly gives smoother equity curves.
  • max_gross_leverage=1.0: no leverage. Sum of absolute position values cannot exceed equity.
  • transaction_cost_bps=5: assume 5 bps round-trip cost. Kelly subtracts this from edge before sizing, which prevents opening positions whose net edge is negative.
  • RiskProfile.moderate(): preconfigured stops and drawdown guards. 10% per-position stop, 5%/10%/20% daily/weekly/monthly drawdown limits.

Step 5. Read the results

python
print("=" * 60)
print("MA CROSSOVER BACKTEST")
print("=" * 60)
print(f"  Ticks:        {len(result.equity_curve)}")
print(f"  Trades:       {result.n_trades}")
print(f"  Start equity: ${result.equity_curve[0][1]:,.2f}")
print(f"  End equity:   ${result.equity_curve[-1][1]:,.2f}")
print(f"  Total return: {result.total_return:+.2%}")
print(f"  Sharpe:       {result.sharpe:+.3f}")
print(f"  Max drawdown: {result.max_drawdown:.2%}")

Expected output

============================================================
MA CROSSOVER BACKTEST
============================================================
  Ticks:        252
  Trades:       ~100 - 300
  Start equity: $100,000.00
  End equity:   $95,000 - $102,000
  Total return: ~-3% to +2%
  Sharpe:       ~-0.5 to +0.5
  Max drawdown: ~3% - 10%

The numbers will vary slightly, but on pure GBM data a trend-following strategy should roughly break even. GBM has positive drift (mu=0.10) which helps, but no autocorrelation for the MA crossover to exploit. The strategy catches some of the drift but gives back a chunk to transaction costs and whipsaws.

The useful observation: the risk layer caps max drawdown well below the 20% monthly guard threshold. Risk enforcement is working.

Step 6. Run it

bash
PYTHONPATH=. python3 ma_crossover.py

What to try next

Full file

python
# ma_crossover.py
import math

import horizon as hz
from horizon import Signal, Strategy
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.features import RealizedVol, SMA
from horizon.portfolio import KellyOptimizer
from horizon.risk import RiskProfile


class MACrossover(Strategy):
    name = "ma_cross"
    asset_classes = [Equity]
    features = {
        "fast": SMA(window=10),
        "slow": SMA(window=30),
        "vol": RealizedVol(window=20),
    }

    def evaluate(self, f, universe):
        signals = []
        for m in universe:
            fast = f.fast[m.id]
            slow = f.slow[m.id]
            vol = f.vol[m.id]

            if math.isnan(fast) or math.isnan(slow):
                continue

            cross = (fast - slow) / slow

            if cross > 0.01:
                signals.append(Signal.increase(
                    m,
                    edge_bps=40,
                    expected_vol_bps=max((vol if not math.isnan(vol) else 0.20) * 10_000, 100),
                    horizon="5d",
                    reason=f"cross={cross:+.4f}",
                ))
            elif cross < -0.01:
                signals.append(Signal.decrease(
                    m,
                    edge_bps=40,
                    expected_vol_bps=max((vol if not math.isnan(vol) else 0.20) * 10_000, 100),
                    horizon="5d",
                    reason=f"cross={cross:+.4f}",
                ))

        return signals


def main():
    tickers = ["AAPL", "MSFT", "NVDA", "GOOGL", "AMZN"]

    universe = StaticUniverse([
        Market(id=t, asset_class=AssetClass.Equity)
        for t in tickers
    ])

    data = SyntheticGBM(
        market_ids=tickers,
        n_bars=252,
        mu=0.10,
        sigma=0.22,
        seed=42,
    )

    result = hz.run(
        mode="backtest",
        strategies=[MACrossover],
        asset_classes=[Equity],
        universe=universe,
        portfolio=KellyOptimizer(
            kelly_fraction=0.25,
            max_gross_leverage=1.0,
            transaction_cost_bps=5,
        ),
        risk=RiskProfile.moderate(),
        data_source=data,
        backtest=hz.BacktestConfig(initial_cash_usd=100_000),
    )

    print("=" * 60)
    print("MA CROSSOVER BACKTEST")
    print("=" * 60)
    print(f"  Ticks:        {len(result.equity_curve)}")
    print(f"  Trades:       {result.n_trades}")
    print(f"  Start equity: ${result.equity_curve[0][1]:,.2f}")
    print(f"  End equity:   ${result.equity_curve[-1][1]:,.2f}")
    print(f"  Total return: {result.total_return:+.2%}")
    print(f"  Sharpe:       {result.sharpe:+.3f}")
    print(f"  Max drawdown: {result.max_drawdown:.2%}")


if __name__ == "__main__":
    main()

Next