Horizon Design

SkillMedia

Choose 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.

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

HorizonTypical ICInterpretation
1d0.01Too noisy, costs dominate
5d0.03Building strength
20d0.05Peak - optimal horizon
40d0.03Decaying
60d0.01Signal 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