Feature decay across horizons

The same signal scored against forward outcomes of increasing horizon, cumulative and day-by-day, with the overlap correction applied. Two examples with opposite shapes, sign versus size.

Updated 21 Sept 20264 code blocks verifiedhorizondecayoverlappingICforward-returns

A signal that works one day ahead may work for a week or die by day two. Scoring it against forward outcomes of increasing horizon answers two questions at once: how long the information lasts, and therefore how often a position based on it would have to be turned over.

Setup

import pandas as pd, numpy as np

df = pd.read_csv("all_stocks_5yr.csv")
df["date"] = pd.to_datetime(df["date"])
df = df.rename(columns={"Name": "ticker"}).sort_values(["ticker", "date"]).reset_index(drop=True)
per = df.groupby("ticker").size()
df = df[df["ticker"].isin(per[per >= 1000].index)].copy()
g = df.groupby("ticker")
df["ret"] = g["close"].pct_change()
df["sig1"] = -df["ret"]
df["vol20"] = g["ret"].transform(lambda x: x.rolling(20).std())
df["relvol"] = df["volume"] / g["volume"].transform(lambda x: x.rolling(20).mean())
test_start = pd.Timestamp("2016-06-30") + pd.Timedelta(days=7)

def daily_ic(d, sig, tgt):
    return d.groupby("date")[[sig, tgt]].apply(lambda x: x[sig].corr(x[tgt], method="spearman")).dropna()

def line(ic, h):
    m, s, n = ic.mean(), ic.std(), len(ic)
    t = m / (s / np.sqrt(n))
    return "h = %2d   mean IC %+.4f   naive t %+.2f   overlap-adjusted t %+.2f   days %d" % (h, m, t, t / np.sqrt(h), n)

Cumulative horizon: signal against the next h days’ return

for h in (1, 2, 3, 5, 10, 20):
    col = "fwd%d" % h
    df[col] = g["ret"].transform(lambda x: x.shift(-h).rolling(h).sum())     # days t+1 .. t+h
    d = df[df["date"] > test_start].dropna(subset=["sig1", col])
    print(line(daily_ic(d, "sig1", col), h))
h =  1   mean IC +0.0156   naive t +1.98   overlap-adjusted t +1.98   days 399
h =  2   mean IC +0.0091   naive t +1.19   overlap-adjusted t +0.84   days 398
h =  3   mean IC +0.0061   naive t +0.80   overlap-adjusted t +0.46   days 397
h =  5   mean IC +0.0031   naive t +0.42   overlap-adjusted t +0.19   days 395
h = 10   mean IC +0.0076   naive t +1.06   overlap-adjusted t +0.34   days 390
h = 20   mean IC +0.0031   naive t +0.43   overlap-adjusted t +0.10   days 380

Mean rank IC of the one-day reversal signal against forward returns of 1 to 20 days

For horizons above one day, consecutive days’ targets overlap, so the naive t-statistic overstates the evidence; dividing by h\sqrt{h} is the conservative correction. See t-statistics from repeated looks.

Day-by-day: signal against the return on day t+k alone

The cumulative view can hide where the information sits. The day-by-day view shows it.

for k in (1, 2, 3, 4, 5):
    df["r_k"] = g["ret"].shift(-k)
    d = df[df["date"] > test_start].dropna(subset=["sig1", "r_k"])
    ic = daily_ic(d, "sig1", "r_k")
    print("day t+%d alone   mean IC %+.4f   t %+.2f   days %d" % (k, ic.mean(), ic.mean() / (ic.std() / np.sqrt(len(ic))), len(ic)))
day t+1 alone   mean IC +0.0156   t +1.98   days 399
day t+2 alone   mean IC -0.0036   t -0.49   days 398
day t+3 alone   mean IC -0.0057   t -0.82   days 397
day t+4 alone   mean IC -0.0045   t -0.66   days 396
day t+5 alone   mean IC +0.0085   t +1.21   days 395

For one-day reversal the information is entirely in the next day and there is nothing on days two to five. A position would have to be turned over daily, which is what the cost analysis finds.

A signal with a slower decay: size instead of sign

Relative volume predicts the size of tomorrow’s move. Sizes are persistent, so the same exercise gives a different shape.

df = df.replace([np.inf, -np.inf], np.nan)
for h in (1, 2, 3, 5, 10, 20):
    col = "size%d" % h
    df[col] = g["ret"].shift(-h).abs() / df["vol20"]                        # the move on day t+h alone, normalised
    d = df[df["date"] > test_start].dropna(subset=["relvol", col])
    ic = daily_ic(d, "relvol", col)
    print("day t+%2d alone   mean IC %+.4f   t %+.2f   days %d" % (h, ic.mean(), ic.mean() / (ic.std() / np.sqrt(len(ic))), len(ic)))
day t+ 1 alone   mean IC +0.1010   t +29.36   days 399
day t+ 2 alone   mean IC +0.0632   t +20.62   days 398
day t+ 3 alone   mean IC +0.0495   t +15.08   days 397
day t+ 5 alone   mean IC +0.0305   t +9.61   days 395
day t+10 alone   mean IC +0.0226   t +7.10   days 390
day t+20 alone   mean IC +0.0204   t +6.50   days 380

Mean rank IC of relative volume against the size of the move h days ahead

The IC halves by day two and is still clearly positive at day twenty. Volatility information is slow; direction information, on this data, does not survive the night. See the relative volume case study.

Reading a decay curve

  • Falls to zero after day one: the effect is short-lived, possibly microstructure (bid-ask bounce). High turnover is unavoidable.
  • Decays smoothly over weeks: a persistent state variable. Positions can be held; costs matter less.
  • Rises with horizon: suspicious. Either the target’s overlap is inflating things or the signal is proxying for a slow-moving characteristic, and the overlap-adjusted t is the number to trust.