Source code for skfolio.descriptor._momentum._rolling_momentum
"""Fixed-window (rolling) momentum descriptor."""
# Copyright (c) 2023-2026
# Author: Hugo Delatte <hugo.delatte@skfoliolabs.com>
# SPDX-License-Identifier: BSD-3-Clause
from __future__ import annotations
from skfolio.descriptor._base import _BaseRollingLogReturn
from skfolio.typing import FloatArray
[docs]
class RollingMomentum(_BaseRollingLogReturn):
r"""Fixed-window momentum descriptor.
Computes the sum of log returns over a trailing window with an optional skip period
to exclude the most recent observations:
The skip period separates medium-term momentum from short-term reversal. The classic
"12-1" momentum signal uses a skip of approximately one month [1]_.
.. math::
:nowrap:
\[
\begin{aligned}
x(k)
&= \log(1 + r(k)) \\[0.75em]
S(t)
&= \sum_{k=t-\text{skip}-\text{window}+1}^{t-\text{skip}}
x(k) \\[0.75em]
\text{momentum}(t)
&=
\begin{cases}
\exp(S(t)) - 1 & \text{if } \texttt{exponentiate=True} \\
S(t) & \text{otherwise}
\end{cases}
\end{aligned}
\]
The window uses the :math:`\text{window}` observations ending at
:math:`t - \text{skip}`. Output is NaN until the asset has a full active lookback
window.
By default, the descriptor is returned in log-return space. Log cumulative returns
are more symmetric than simple cumulative returns, which makes them better suited to
cross-sectional standardization. Because the logarithm is monotonic, log-space and
simple cumulative returns produce the same cross-sectional rankings when returns
are finite and greater than `-1`.
Parameters
----------
window : int, default=252
Number of observations in the lookback window.
skip : int, default=21
Number of most recent observations excluded from the window. The last
observation included is at :math:`t - \text{skip}`. Classic 12-1 momentum uses a
skip of about one month (21 daily obs). Set to 0 for no skip.
exponentiate : bool, default=False
If True, output is :math:`\exp(S(t)) - 1` (return units). If False, output is
:math:`S(t)` (log space). Cross-sectional ranking is unchanged and only the
scale differs.
Attributes
----------
n_assets_ : int
Number of assets seen during fitting.
asset_names_ : ndarray of shape (n_assets,)
Asset names seen during fitting.
momentum_ : ndarray of shape (n_assets,)
Last rolling momentum value for each asset.
Notes
-----
Two code paths are used depending on context:
- Batch (first call with sufficient data): vectorized cumsum over
the full panel. Time :math:`O(T \cdot n)`, space :math:`O(T \cdot n)`.
- Online (subsequent calls or streaming): ring buffer of size
:math:`L = \text{skip} + \text{window}` with a running sum. Per observation: one
subtract (value leaving the window), one add (value entering), one write.
Time :math:`O(n)` per step, space :math:`O(L \cdot n)`, zero allocation.
After a batch computation, the ring buffer state is populated for subsequent online
calls.
NaNs are allowed as missing observations. Non-missing `returns` values must be
finite and greater than `-1`, so :math:`\log(1 + r)` is finite. Active assets with
NaN returns (e.g. holidays) contribute 0 to the sum. Inactive asset outputs are
set to NaN.
References
----------
.. [1] "Returns to buying winners and selling losers: Implications for stock market
efficiency" The Journal of Finance. Jegadeesh, N., & Titman, S. (1993).
See Also
--------
EWMomentum : Exponentially weighted momentum.
Examples
--------
>>> from skfolio.datasets import make_synthetic_characteristics
>>> from skfolio.descriptor import RollingMomentum
>>>
>>> X = make_synthetic_characteristics()
>>>
>>> # 12-1 momentum
>>> descriptor = RollingMomentum(window=252, skip=21)
>>> momentum = descriptor.fit_transform(X)
>>>
>>> # Log-space output
>>> descriptor = RollingMomentum(window=252, skip=21, exponentiate=False)
>>> momentum_log = descriptor.fit_transform(X)
"""
_FITTED_ATTR = "momentum_"
_TRANSFORM_SIGN = 1.0
momentum_: FloatArray
def __init__(self, window: int = 252, skip: int = 21, exponentiate: bool = False):
super().__init__(window=window, skip=skip, exponentiate=exponentiate)