Trend Following

TSMomentum + MovingAverageCross ensemble

A classical trend-following setup using two trend indicators that catch moves at different timescales. TSMomentum fires on short-term returns; MovingAverageCross fires on longer trends.

The file

python
# trend_following.py
import horizon as hz
from horizon.asset_classes import AssetClass, Equity
from horizon.data import SyntheticRegimes
from horizon.discovery import StaticUniverse
from horizon.discovery.base import Market
from horizon.portfolio import KellyOptimizer
from horizon.quant import MovingAverageCrossStrategy, TSMomentum
from horizon.risk import RiskProfile, StopLoss


def main():
    tickers = ["AAPL", "MSFT", "NVDA", "GOOGL", "AMZN", "META"]
    universe = StaticUniverse([
        Market(id=t, asset_class=AssetClass.Equity) for t in tickers
    ])

    # Regime-switching data. includes an uptrend for the trend strategies to exploit
    data = SyntheticRegimes(
        market_ids=tickers,
        n_bars=400,
        regimes=[
            (0.40, 0.30, 0.18),    # strong uptrend
            (0.30, 0.00, 0.20),    # chop
            (0.30, -0.20, 0.30),   # declining
        ],
        seed=42,
    )

    result = hz.run(
        mode="backtest",
        strategies=[
            TSMomentum(lookback=20, edge_bps=60, horizon_days=5),
            MovingAverageCrossStrategy(fast=10, slow=30, edge_bps=40, horizon_days=10),
        ],
        asset_classes=[Equity],
        universe=universe,
        portfolio=KellyOptimizer(
            kelly_fraction=0.20,      # diluted. 2 strategies
            max_gross_leverage=1.5,
            transaction_cost_bps=5,
        ),
        risk=RiskProfile.moderate().override(
            stop_loss=StopLoss(per_position_pct=0.10, trailing_pct=0.05),
        ),
        data_source=data,
        backtest=hz.BacktestConfig(initial_cash_usd=100_000),
    )

    print("=" * 60)
    print("TREND FOLLOWING. TSMomentum + MovingAverageCross")
    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()

Why these two together

TSMomentum(lookback=20) **Fast.** 20-bar returns. Catches shorter trends. Higher turnover.
MovingAverageCross(10, 30) **Slow.** Fast MA vs slow MA. Catches longer trends. Low turnover.

When both agree, the portfolio optimizer sees stronger combined signal and sizes bigger. When they disagree (one sees trend, one doesn’t), the net is smaller. Automatic regime detection via signal agreement.

Add a trailing stop

The StopLoss(trailing_pct=0.05) override is important for trend following. Trend strategies ride winners: they need to give the position room to run, but cut when the trend dies. A trailing stop:

  • Gives room when P&L is growing (stop moves up with the position)
  • Locks in gains when P&L peaks
  • Closes the position on a 5% reversal from peak

Expected behavior

On SyntheticRegimes with a 40% uptrend regime, trend strategies should capture most of the move. Sharpe will be positive: unlike pure GBM, the regime data has exploitable structure.

Run it

bash
PYTHONPATH=. python3 trend_following.py

Next