Skip to content

Commit e8a593b

Browse files
author
Tyler Patterson
committed
rolling metrics
1 parent 5512f9c commit e8a593b

3 files changed

Lines changed: 491 additions & 0 deletions

File tree

quantalytics/analytics/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333
rar,
3434
recovery_factor,
3535
risk_return_ratio,
36+
rolling_sharpe,
37+
rolling_sortino,
3638
romad,
3739
risk_of_ruin,
3840
ror,
@@ -120,6 +122,8 @@
120122
"rar",
121123
"recovery_factor",
122124
"risk_return_ratio",
125+
"rolling_sharpe",
126+
"rolling_sortino",
123127
"romad",
124128
"risk_of_ruin",
125129
"ror",

quantalytics/analytics/metrics.py

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,233 @@ def sharpe(
7777
return res * sqrt(1 if periods is None else periods) if annualize else res
7878

7979

80+
@overload
81+
def rolling_sharpe(
82+
returns: Series,
83+
rf: float = 0.0,
84+
rolling_period: int | None = 126,
85+
annualize: bool = True,
86+
periods_per_year: int = 252,
87+
prepare_returns: bool = True,
88+
) -> Series: ...
89+
@overload
90+
def rolling_sharpe(
91+
returns: DataFrame,
92+
rf: float = 0.0,
93+
rolling_period: int | None = 126,
94+
annualize: bool = True,
95+
periods_per_year: int = 252,
96+
prepare_returns: bool = True,
97+
) -> DataFrame: ...
98+
def rolling_sharpe(
99+
returns: Series | DataFrame,
100+
rf: float = 0.0,
101+
rolling_period: int | None = 126,
102+
annualize: bool = True,
103+
periods_per_year: int = 252,
104+
prepare_returns: bool = True,
105+
) -> Series | DataFrame:
106+
"""Calculate rolling Sharpe ratio over a specified window.
107+
108+
The rolling Sharpe ratio computes the Sharpe ratio using a rolling window,
109+
providing a time-varying measure of risk-adjusted performance. This allows
110+
you to track how the strategy's risk-adjusted returns evolve over time and
111+
adapt to changing market conditions.
112+
113+
Args:
114+
returns (Series | DataFrame): Portfolio returns data. If DataFrame, returns a DataFrame
115+
with rolling Sharpe values for each column.
116+
rf (float, optional): Risk-free rate (annualized). Defaults to 0.0.
117+
rolling_period (int, optional): Rolling window size in periods.
118+
Defaults to 126 (~6 months of daily data).
119+
annualize (bool, optional): Whether to annualize the ratio. Defaults to True.
120+
periods_per_year (int, optional): Number of periods per year for annualization.
121+
Defaults to 252 (daily data).
122+
prepare_returns (bool, optional): Whether to normalize returns before calculation.
123+
Defaults to True.
124+
125+
Returns:
126+
Series | DataFrame: Rolling Sharpe ratio time series. Returns Series for Series input,
127+
DataFrame for DataFrame input (one column per input column).
128+
Earlier values will be NaN until the rolling window is filled.
129+
130+
Raises:
131+
ValueError: If rf != 0 and rolling_period is None.
132+
133+
Examples:
134+
>>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02, 0.01])
135+
>>> rolling_sharpe(returns, rolling_period=3, annualize=False)
136+
0 NaN
137+
1 NaN
138+
2 0.577350
139+
3 -0.707107
140+
4 0.816497
141+
5 0.707107
142+
dtype: float64
143+
144+
>>> df = pd.DataFrame({"strategy_a": [0.01, -0.01, 0.02, 0.01],
145+
... "strategy_b": [0.02, -0.02, 0.03, 0.01]})
146+
>>> rolling_sharpe(df, rolling_period=3, periods_per_year=252)
147+
# Returns DataFrame with rolling Sharpe for both strategies
148+
149+
Notes:
150+
- First (rolling_period - 1) values will be NaN
151+
- Useful for monitoring strategy performance over time
152+
- Captures regime changes and varying market conditions
153+
- Annualization uses sqrt(periods_per_year) scaling
154+
- When prepare_returns=True, adjusts for risk-free rate
155+
- Standard rolling window is 126 days (~6 months) or 63 days (~3 months)
156+
157+
See Also:
158+
sharpe: Static Sharpe ratio for entire period
159+
rolling_sortino: Rolling Sortino ratio (downside risk focus)
160+
smart_sharpe: Sharpe with autocorrelation penalty
161+
"""
162+
# Validate parameters for risk-free rate handling
163+
if rf != 0 and rolling_period is None:
164+
raise ValueError("Must provide periods if rf != 0")
165+
166+
normalized = (
167+
_utils.normalize_returns(data=returns, rf=rf, nperiods=rolling_period)
168+
if prepare_returns
169+
else returns
170+
)
171+
172+
# Calculate rolling mean and standard deviation
173+
res = (
174+
normalized.rolling(rolling_period).mean()
175+
/ normalized.rolling(rolling_period).std()
176+
)
177+
178+
# Annualize if requested
179+
if annualize:
180+
res = res * _np.sqrt(1 if periods_per_year is None else periods_per_year)
181+
182+
return res
183+
184+
185+
@overload
186+
def rolling_sortino(
187+
returns: Series,
188+
rf: float = 0.0,
189+
rolling_period: int | None = 126,
190+
annualize: bool = True,
191+
periods_per_year: int = 252,
192+
prepare_returns: bool = True,
193+
) -> Series: ...
194+
@overload
195+
def rolling_sortino(
196+
returns: DataFrame,
197+
rf: float = 0.0,
198+
rolling_period: int | None = 126,
199+
annualize: bool = True,
200+
periods_per_year: int = 252,
201+
prepare_returns: bool = True,
202+
) -> DataFrame: ...
203+
def rolling_sortino(
204+
returns: Series | DataFrame,
205+
rf: float = 0.0,
206+
rolling_period: int | None = 126,
207+
annualize: bool = True,
208+
periods_per_year: int = 252,
209+
prepare_returns: bool = True,
210+
) -> Series | DataFrame:
211+
"""Calculate rolling Sortino ratio over a specified window.
212+
213+
The rolling Sortino ratio computes the Sortino ratio using a rolling window,
214+
providing a time-varying measure of downside risk-adjusted performance. Unlike
215+
the rolling Sharpe ratio, it only penalizes downside volatility, making it
216+
more suitable for strategies with asymmetric return distributions.
217+
218+
Args:
219+
returns (Series | DataFrame): Portfolio returns data. If DataFrame, returns a DataFrame
220+
with rolling Sortino values for each column.
221+
rf (float, optional): Risk-free rate (annualized). Defaults to 0.0.
222+
rolling_period (int, optional): Rolling window size in periods.
223+
Defaults to 126 (~6 months of daily data).
224+
annualize (bool, optional): Whether to annualize the ratio. Defaults to True.
225+
periods_per_year (int, optional): Number of periods per year for annualization.
226+
Defaults to 252 (daily data).
227+
prepare_returns (bool, optional): Whether to normalize returns before calculation.
228+
Defaults to True.
229+
230+
Returns:
231+
Series | DataFrame: Rolling Sortino ratio time series. Returns Series for Series input,
232+
DataFrame for DataFrame input (one column per input column).
233+
Earlier values will be NaN until the rolling window is filled.
234+
May contain inf values when downside deviation is zero.
235+
236+
Raises:
237+
ValueError: If rf != 0 and rolling_period is None.
238+
239+
Examples:
240+
>>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02, 0.01])
241+
>>> rolling_sortino(returns, rolling_period=3, annualize=False)
242+
0 NaN
243+
1 NaN
244+
2 0.816497
245+
3 -1.000000
246+
4 1.154701
247+
5 1.000000
248+
dtype: float64
249+
250+
>>> df = pd.DataFrame({"strategy_a": [0.01, -0.01, 0.02, 0.01],
251+
... "strategy_b": [0.02, -0.02, 0.03, 0.01]})
252+
>>> rolling_sortino(df, rolling_period=3, periods_per_year=252)
253+
# Returns DataFrame with rolling Sortino for both strategies
254+
255+
Notes:
256+
- First (rolling_period - 1) values will be NaN
257+
- Only penalizes downside volatility (returns below zero)
258+
- More forgiving than rolling Sharpe for strategies with positive skew
259+
- May return inf when window has no negative returns (zero downside)
260+
- Annualization uses sqrt(periods_per_year) scaling
261+
- Downside deviation calculated from squared negative returns only
262+
- Useful for monitoring asymmetric risk profiles over time
263+
264+
See Also:
265+
sortino: Static Sortino ratio for entire period
266+
rolling_sharpe: Rolling Sharpe ratio (total risk focus)
267+
adjusted_sortino: Sortino adjusted for Sharpe comparability
268+
smart_sortino: Sortino with autocorrelation penalty
269+
"""
270+
# Validate parameters for risk-free rate handling
271+
if rf != 0 and rolling_period is None:
272+
raise ValueError("Must provide periods if rf != 0")
273+
274+
normalized = (
275+
_utils.normalize_returns(data=returns, rf=rf, nperiods=rolling_period)
276+
if prepare_returns
277+
else returns
278+
)
279+
280+
# Optimized downside calculation using vectorized operations
281+
def calc_downside(x):
282+
"""
283+
Calculate downside variance more efficiently.
284+
285+
This function computes the sum of squared negative returns,
286+
which is used to calculate downside deviation.
287+
"""
288+
negative_returns = x[x < 0]
289+
return (negative_returns**2).sum() if len(negative_returns) > 0 else 0
290+
291+
# Calculate rolling downside deviation
292+
downside = (
293+
normalized.rolling(rolling_period).apply(calc_downside, raw=True)
294+
/ rolling_period
295+
)
296+
297+
# Calculate rolling Sortino ratio
298+
res = normalized.rolling(rolling_period).mean() / _np.sqrt(downside)
299+
300+
# Annualize if requested
301+
if annualize:
302+
res = res * _np.sqrt(1 if periods_per_year is None else periods_per_year)
303+
304+
return res
305+
306+
80307
@overload
81308
def sortino(
82309
returns: Series,

0 commit comments

Comments
 (0)