Horizon Design
SkillMediaChoose prediction horizon by analyzing IC decay, turnover cost, and feature-horizon alignment. Use when determining the optimal lookahead window for labels.
Available today. Use it from your connected AI after setup.
No other account needed.
Add ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.
Then ask your AI: use the Horizon Design skill
What this skill tells your AI
The instructions your AI receives, as published by ml4t/skills in features/horizon-design/SKILL.md and read by ahel’s review.
An arbitrary 1-day horizon forces daily rebalancing, which costs 2-5% annually in transaction costs. If the signal's IC peaks at 20 days, you are paying for turnover that destroys the edge.
The Problem
The prediction horizon determines everything downstream: label construction, feature relevance, turnover, and whether transaction costs leave any alpha. Choosing it arbitrarily - or defaulting to "1 day because that is what everyone uses" - misaligns the model with the actual signal dynamics.
The Pattern
WRONG
import numpy as np
# Arbitrary 1-day horizon - no evidence this matches the signal
labels = np.roll(returns, -1) # forward 1-day return as label
# Result: high turnover, transaction costs eat the edge
CORRECT
from scipy.stats import spearmanr
import numpy as np
def find_optimal_horizon(
signal: np.ndarray, returns: np.ndarray, horizons: list[int] = None,
) -> dict:
"""Analyze IC decay to find the horizon where the signal is strongest."""
if horizons is None:
horizons = [1, 2, 5, 10, 20, 40, 60]
results = {}
for h in horizons:
fwd_ret = np.full_like(returns, np.nan)
fwd_ret[:-h] = np.sum(
[np.roll(returns, -i) for i in range(1, h + 1)], axis=0
)[:-h]
valid = ~np.isnan(signal) & ~np.isnan(fwd_ret)
ic, _ = spearmanr(signal[valid], fwd_ret[valid])
results[h] = ic
optimal = max(results, key=lambda k: abs(results[k]))
return {"ic_by_horizon": results, "optimal_horizon": optimal}
IC Decay Profile
| Horizon | Typical IC | Interpretation |
|---|---|---|
| 1d | 0.01 | Too noisy, costs dominate |
| 5d | 0.03 | Building strength |
| 20d | 0.05 | Peak - optimal horizon |
| 40d | 0.03 | Decaying |
| 60d | 0.01 | Signal exhausted |
Transaction Cost Constraint
def min_viable_horizon(cost_per_trade: float, annual_alpha: float) -> int:
"""Shortest horizon where alpha covers costs."""
for h in [1, 2, 5, 10, 20, 40, 60]:
trades_per_year = 252 / h
alpha_per_trade = annual_alpha / trades_per_year
if alpha_per_trade > cost_per_trade * 2.5: # 2.5x safety margin
return h
return 60 # Default to low-frequency if costs are high
Feature-Horizon Alignment
# Misaligned: 5-day feature predicting 60-day returns
feature = returns_5d
label = fwd_returns_60d
# Aligned: 60-day feature predicting 60-day returns
feature = returns_60d
label = fwd_returns_60d
Guardrails
- Never default to 1-day without IC decay analysis - most alpha signals peak at 5-20 days
- Transaction costs are the binding constraint - a 5-day signal with 10 bps costs beats a 1-day signal with the same IC
- Feature lookback should match horizon within a factor of 2-3x
Production Implementation
from ml4t.diagnostic.metrics import compute_ic_by_horizon
ic_by_horizon = compute_ic_by_horizon(
predictions=prediction_frame,
prices=price_frame,
horizons=[1, 5, 10, 20, 60],
pred_col="prediction",
price_col="close",
date_col="date",
group_col="symbol",
)
print(ic_by_horizon)
Checklist
- IC decay analysis run across at least 5 horizons (1d, 5d, 10d, 20d, 60d)
- Optimal horizon identified as the peak of |IC| vs horizon
- Transaction costs modeled - alpha per trade exceeds cost by at least 2.5x
- Feature lookback windows aligned with chosen horizon
- Rebalancing frequency matches horizon (not more frequent)
Signals
- GitHub stars
- 20
- Forks
- 11
- Last commit
- Sep 2026
Advanced
- Item type
- skill
- Key
ml4t-horizon-design- Source
- github.com/ml4t/skills