TSMomentum

Time-series momentum. trade in the direction of recent return

TSMomentum is a classical single-asset momentum strategy. On each tick, it computes the log return over the past lookback bars and emits a signal in the direction of that return if its magnitude exceeds a threshold.

Import

python
from horizon.quant import TSMomentum

Signature

python
TSMomentum(
    lookback: int = 20,
    vol_window: int = 60,
    threshold: float = 0.01,
    edge_bps: float = 50.0,
    horizon_days: int = 5,
)
lookbackint
Number of bars over which to compute the log return. Shorter = more reactive, higher turnover. Longer = smoother, fewer whipsaws.
vol_windowint
Number of bars for the `RealizedVol` feature. Used to populate `expected_vol_bps` on each emitted signal, which drives Kelly sizing.
thresholdfloat
Minimum absolute log return to trigger a signal. `0.01` = 1% move over the lookback window. Increase to be more selective.
edge_bpsfloat
Expected edge in basis points per $1 of notional over the signal's horizon. Gets multiplied by `confidence` in Kelly sizing.
horizon_daysint
How long the signal's edge is expected to persist. Also the default TTL in the signal store.

Features it uses

python
features = {
    "r": Return(window=lookback),
    "vol": RealizedVol(window=vol_window),
}

See Return and RealizedVol for the feature math.

Signal logic

python
def evaluate(self, f, universe):
    signals = []
    horizon = timedelta(days=self.horizon_days)
    for m in universe:
        r = f.r[m.id]           # log return over lookback
        vol = f.vol[m.id]       # annualized realized vol
        if isnan(r):
            continue

        vol_bps = max((vol if not isnan(vol) else 0.20) * 10_000, 50)

        if r > self.threshold:
            signals.append(Signal(
                market_id=m.id,
                direction=Direction.Increase,
                confidence=min(1.0, abs(r) * 20),
                expected_edge_bps=self.edge_bps,
                expected_expected_vol_bps=vol_bps,
                horizon=horizon,
                reason=f"momentum r={r:.3f}",
            ))
        elif r < -self.threshold:
            signals.append(Signal(
                market_id=m.id,
                direction=Direction.Decrease,
                ...
            ))
    return signals
  • Positive return above thresholdDirection.Increase (go long)
  • Negative return below thresholdDirection.Decrease (go short)
  • Confidence scales with magnitude of return (capped at 1.0)
  • Edge is fixed from the constructor parameter
  • Vol taken from the RealizedVol feature (floor at 50 bps to avoid division issues in Kelly)

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 TSMomentum
from horizon.risk import RiskProfile

result = hz.run(
    mode="backtest",
    strategies=[TSMomentum(lookback=20, edge_bps=60, horizon_days=5)],
    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,
        mu=0.12,
        sigma=0.22,
        seed=42,
    ),
    backtest=hz.BacktestConfig(initial_cash_usd=100_000),
)
print(f"Sharpe: {result.sharpe:+.3f}, Max DD: {result.max_drawdown:.2%}")

When to use

Persistent trends Works when returns autocorrelate positively at the `lookback` horizon. Cryptocurrency, trending equities, commodity moves.
Cross-sectional trending universes Running it on a basket lets the portfolio optimizer rotate into the strongest movers.

When NOT to use

Parameter tuning

Pitfalls

Combining with risk

python
from horizon.risk import (
    DrawdownGuard,
    RiskConfig,
    StopLoss,
)
from horizon.risk.drawdown import DrawdownAction

risk = RiskConfig(
    max_gross_leverage=1.0,
    stop_loss=StopLoss(per_position_pct=0.08, trailing_pct=0.05),
    drawdown=[
        DrawdownGuard(daily_pct=0.05, action=DrawdownAction.HaltNew),
        DrawdownGuard(weekly_pct=0.10, action=DrawdownAction.ReduceHalf),
    ],
)

A trailing stop is especially useful for momentum strategies: it lets winners run while cutting reversal losses.

Source

Lives in horizon/quant/timeseries.py. The full implementation is ~40 lines.

Next