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,
)
lookbackintNumber of bars over which to compute the log return. Shorter = more reactive, higher turnover. Longer = smoother, fewer whipsaws.
vol_windowintNumber of bars for the `RealizedVol` feature. Used to populate `expected_vol_bps` on each emitted signal, which drives Kelly sizing.
thresholdfloatMinimum absolute log return to trigger a signal. `0.01` = 1% move over the lookback window. Increase to be more selective.
edge_bpsfloatExpected edge in basis points per $1 of notional over the signal's horizon. Gets multiplied by `confidence` in Kelly sizing.
horizon_daysintHow 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 threshold →
Direction.Increase(go long) - Negative return below threshold →
Direction.Decrease(go short) - Confidence scales with magnitude of return (capped at 1.0)
- Edge is fixed from the constructor parameter
- Vol taken from the
RealizedVolfeature (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.