Skip to content

fix(stats): use each window's means for rolling_greeks alpha - #554

Closed
WatchTree-19 wants to merge 1 commit into
ranaroussi:mainfrom
WatchTree-19:fix/rolling-greeks-alpha
Closed

WatchTree-19 wants to merge 1 commit into
ranaroussi:mainfrom
WatchTree-19:fix/rolling-greeks-alpha

Conversation

@WatchTree-19

@WatchTree-19 WatchTree-19 commented Sep 26, 2026 •

Copy link
Copy Markdown

Keeping 'em coming, like you asked on #553. rolling_greeks() computes beta over the rolling window, but alpha uses the full-sample means:

alpha = df["returns"].mean() - beta * df["benchmark"].mean()

So every window shares one baseline, and rolling alpha only moves when rolling beta does. A strategy that picks up 10 bp a day halfway through shows the same alpha before and after:

rng = np.random.default_rng(7)
idx = pd.bdate_range("2020-01-01", periods=504)
bench = pd.Series(rng.normal(0.0003, 0.01, 504), index=idx)
strat = 0.8 * bench + rng.normal(0.0, 0.006, 504)
strat.iloc[252:] += 0.001

rg = qs.stats.rolling_greeks(strat, bench, periods=126)
# window ending day 252: regression alpha 0.00035, main 0.00057
# window ending day 504: regression alpha 0.00198, main 0.00056

The fix takes the window's own means, so each row is the intercept of a regression on that window. With it both rows above match the regression (0.00035 and 0.00198). Rolling beta is unchanged, and the rolling beta plots only read beta, so nothing visual moves.

Tests: TestRollingGreeksAlpha in tests/test_regressions.py checks beta and alpha against np.polyfit on three windows. It fails on main and passes with the fix. Full suite: 214 passed (the one test_normalize_tz_aware error is environmental and happens on main too). ruff check and ruff format --check are clean. I added a line under a 0.0.86 heading in the changelog.

rolling_greeks() computed beta over the rolling window but alpha as
returns.mean() - beta * benchmark.mean() over the whole sample, so every
window shared the same baseline and rolling alpha only moved when beta
did. It now uses the window's own means, so each row matches the
intercept of a regression on that window.

Rolling beta is unchanged, and the plots only read beta.
ranaroussi added a commit that referenced this pull request Sep 27, 2026
, #556)

avg_return(), avg_win() and avg_loss() masked the unselected cells to NaN and
then called .dropna(), which drops whole rows on a DataFrame. Each column's
average was computed only over rows where every other column also qualified,
so a strategy's average win was measured only on the days the benchmark also
rose. reports.metrics() with a benchmark therefore reported affected Average
Win/Loss, Payoff Ratio, Win/Loss Ratio, CPC Index and Kelly Criterion for
DataFrame input. .mean() already skips NaN column-wise (#556).

rolling_greeks() computed alpha from the full-sample means instead of each
window's own, so every row shared one baseline and rolling alpha only moved
when rolling beta did. Rolling beta is unchanged (#554).

_generate_cache_key() formatted a time-varying rf into the key with its repr,
which pandas truncates to the first and last few rows: two rate series of the
same length differing only in between shared an entry, and the second caller
got the first one's excess returns back. The answer depended on call order
(#555).

Both rolling_greeks and the cache key were reported with fixes by
@WatchTree-19; the contamination was found from the data @none2003 attached
to #556.

Bumps version to 0.0.86.
@ranaroussi

Copy link
Copy Markdown
Owner

Confirmed and shipped in v0.0.86. Your diagnosis was exact.

Reproduced your snippet before changing anything:

window ending      regression alpha   quantstats alpha   beta ok?
day 252                     0.00035            0.00057       True
day 504                     0.00198            0.00056       True

The strategy demonstrably gains 10bp a day from day 252 and rolling alpha moves by one unit in the fifth decimal. Beta was correct throughout, which is what made this easy to miss: the rolling beta plots read only beta, so nothing looked wrong on screen.

The point I'd underline for anyone reading later is the one in your title — every window shared a single baseline, so rolling alpha was structurally incapable of showing a change in skill. It could only move when beta moved. That is worse than a numerical error, because the series looked plausible and stable.

After the fix both rows match np.polyfit on their window exactly (0.00035 and 0.00198), verified against a clean install of 0.0.86.

I implemented it as your patch describes rather than merging the branch, because this went out alongside two other fixes and I wanted one coherent diff. Your TestRollingGreeksAlpha is in the suite essentially as written, plus a second test asserting alpha actually responds to a regime change, which is the property that was really broken. You're credited in the release notes and the changelog.

Unlike #553 this one was not my regression — rolling_greeks has used the full-sample means since long before the recent work. It needed someone to check the intercept against a regression, and nobody had.

Closing as shipped. Thank you.

@ranaroussi ranaroussi closed this Sep 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

2 participants