walkforward 3.4.0__py3-none-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- walkforward/__init__.py +86 -0
- walkforward/_core.py +255 -0
- walkforward/linear.py +120 -0
- walkforward/rolling.py +74 -0
- walkforward/sizing.py +162 -0
- walkforward/split.py +255 -0
- walkforward/volatility.py +316 -0
- walkforward/walkforward_native.dll +0 -0
- walkforward/walkforward_native.lib +0 -0
- walkforward-3.4.0.dist-info/METADATA +121 -0
- walkforward-3.4.0.dist-info/RECORD +13 -0
- walkforward-3.4.0.dist-info/WHEEL +5 -0
- walkforward-3.4.0.dist-info/licenses/LICENSE +21 -0
walkforward/__init__.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Volatility forecasts, position sizing and purged walk-forward splits.
|
|
2
|
+
|
|
3
|
+
A thin, checked layer over the mlrisk C library. The organising idea is that
|
|
4
|
+
nothing at index ``t`` may see period ``t``:
|
|
5
|
+
|
|
6
|
+
* :func:`ewma_vol` and the GARCH filters are predictive. The value at ``t`` is
|
|
7
|
+
built from observations before ``t``, so a position sized from it can be
|
|
8
|
+
scored against ``returns[t]``.
|
|
9
|
+
* :func:`rolling_mean`, :func:`rolling_std`, :func:`parkinson_vol` and
|
|
10
|
+
:func:`garman_klass_vol` are contemporaneous by construction, and say so.
|
|
11
|
+
Pass them through :func:`lag` before sizing.
|
|
12
|
+
* :class:`PurgedWalkForward` keeps training labels out of the test window,
|
|
13
|
+
which ordinary time-series splitting does not.
|
|
14
|
+
|
|
15
|
+
A worked loop::
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
import walkforward as wf
|
|
19
|
+
|
|
20
|
+
train = returns[:1000] - returns[:1000].mean() # training mean only
|
|
21
|
+
model = wf.garch_fit(train)
|
|
22
|
+
|
|
23
|
+
sigma = model.filter_from(returns[1000:]) # continues the fit state
|
|
24
|
+
position = wf.vol_target_position(
|
|
25
|
+
sigma, target_vol=0.01, equity=100_000.0,
|
|
26
|
+
price=close[999:-1], max_leverage=2.0, # entry price is the prior close
|
|
27
|
+
)
|
|
28
|
+
pnl = position * close[999:-1] * returns[1000:]
|
|
29
|
+
|
|
30
|
+
This is research tooling, not investment advice, and not a backtester: it
|
|
31
|
+
knows nothing about costs, borrow, calendars or corporate actions.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from ._core import DomainError, c_version
|
|
37
|
+
from .linear import Ridge
|
|
38
|
+
from .rolling import lag, rolling_mean, rolling_std
|
|
39
|
+
from .sizing import drawdown_scale, kelly_fraction, vol_target_position
|
|
40
|
+
from .split import PurgedWalkForward, WalkForwardSplit, walk_forward_splits
|
|
41
|
+
from .volatility import (
|
|
42
|
+
GARCH_MAX_PERSISTENCE,
|
|
43
|
+
GARCH_MIN_SAMPLE,
|
|
44
|
+
GarchModel,
|
|
45
|
+
ewma_vol,
|
|
46
|
+
garch_fit,
|
|
47
|
+
garman_klass_vol,
|
|
48
|
+
parkinson_vol,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"DomainError",
|
|
53
|
+
"GARCH_MAX_PERSISTENCE",
|
|
54
|
+
"GARCH_MIN_SAMPLE",
|
|
55
|
+
"GarchModel",
|
|
56
|
+
"PurgedWalkForward",
|
|
57
|
+
"Ridge",
|
|
58
|
+
"WalkForwardSplit",
|
|
59
|
+
"__version__",
|
|
60
|
+
"c_version",
|
|
61
|
+
"drawdown_scale",
|
|
62
|
+
"ewma_vol",
|
|
63
|
+
"garch_fit",
|
|
64
|
+
"garman_klass_vol",
|
|
65
|
+
"kelly_fraction",
|
|
66
|
+
"lag",
|
|
67
|
+
"parkinson_vol",
|
|
68
|
+
"rolling_mean",
|
|
69
|
+
"rolling_std",
|
|
70
|
+
"vol_target_position",
|
|
71
|
+
"walk_forward_splits",
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _version() -> str:
|
|
76
|
+
try:
|
|
77
|
+
from importlib.metadata import version
|
|
78
|
+
|
|
79
|
+
return version("walkforward")
|
|
80
|
+
except Exception: # pragma: no cover - source checkouts without metadata
|
|
81
|
+
return c_version()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
#: Version of the installed package. :func:`c_version` reports the version the
|
|
85
|
+
#: loaded shared library was built from; the two agree in a correct install.
|
|
86
|
+
__version__: str = _version()
|
walkforward/_core.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""ctypes binding to the mlrisk C library.
|
|
2
|
+
|
|
3
|
+
Everything public in this package goes through here. Two rules keep the C
|
|
4
|
+
contracts unbreakable from Python:
|
|
5
|
+
|
|
6
|
+
* Output buffers are always allocated on this side and never taken from the
|
|
7
|
+
caller, so the ``restrict`` non-aliasing contract on every C output
|
|
8
|
+
parameter cannot be violated by a numpy view.
|
|
9
|
+
* Inputs are converted to C-contiguous float64 before the pointer is taken,
|
|
10
|
+
so a strided view, a Python list or a pandas Series is never read as if it
|
|
11
|
+
were a packed array.
|
|
12
|
+
|
|
13
|
+
Per-call overhead is a few microseconds against work that is linear in the
|
|
14
|
+
input, so ctypes costs nothing measurable here.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import ctypes
|
|
20
|
+
import pathlib
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
|
|
25
|
+
try: # pandas is optional; it is only used to hand a Series back a Series
|
|
26
|
+
import pandas as _pd
|
|
27
|
+
except ImportError: # pragma: no cover - exercised by environments without pandas
|
|
28
|
+
_pd = None
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"DomainError",
|
|
32
|
+
"Garch",
|
|
33
|
+
"LinModel",
|
|
34
|
+
"Split",
|
|
35
|
+
"c_version",
|
|
36
|
+
"check",
|
|
37
|
+
"lib",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
# --------------------------------------------------------------------------
|
|
41
|
+
# Exceptions
|
|
42
|
+
# --------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class DomainError(ValueError):
|
|
46
|
+
"""The arguments were well formed but the computation has no answer.
|
|
47
|
+
|
|
48
|
+
Raised for a singular design matrix, a sample with zero or unrepresentable
|
|
49
|
+
variance, and recursions that overflow. Mirrors ``MLR_EDOMAIN``.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_OK, _EINVAL, _ENOMEM, _EBOUNDS, _EDOMAIN = range(5)
|
|
54
|
+
|
|
55
|
+
_MESSAGES = {
|
|
56
|
+
_EINVAL: "invalid argument",
|
|
57
|
+
_ENOMEM: "allocation failed",
|
|
58
|
+
_EBOUNDS: "output capacity too small",
|
|
59
|
+
_EDOMAIN: "domain error",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def check(status: int, what: str, detail: str = "") -> None:
|
|
64
|
+
"""Turn an ``mlr_status`` into the matching Python exception."""
|
|
65
|
+
if status == _OK:
|
|
66
|
+
return
|
|
67
|
+
message = f"{what}: {_MESSAGES.get(status, f'unknown status {status}')}"
|
|
68
|
+
if detail:
|
|
69
|
+
message = f"{message} ({detail})"
|
|
70
|
+
if status == _ENOMEM:
|
|
71
|
+
raise MemoryError(message)
|
|
72
|
+
if status == _EDOMAIN:
|
|
73
|
+
raise DomainError(message)
|
|
74
|
+
if status == _EINVAL:
|
|
75
|
+
raise ValueError(message)
|
|
76
|
+
raise RuntimeError(message) # EBOUNDS never escapes: we size every buffer
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# --------------------------------------------------------------------------
|
|
80
|
+
# Structures, mirroring include/mlrisk/*.h field for field
|
|
81
|
+
# --------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Garch(ctypes.Structure):
|
|
85
|
+
_fields_ = [
|
|
86
|
+
("omega", ctypes.c_double),
|
|
87
|
+
("alpha", ctypes.c_double),
|
|
88
|
+
("beta", ctypes.c_double),
|
|
89
|
+
("sigma2_next", ctypes.c_double),
|
|
90
|
+
("loglik", ctypes.c_double),
|
|
91
|
+
("converged", ctypes.c_int),
|
|
92
|
+
("backcast", ctypes.c_double),
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Split(ctypes.Structure):
|
|
97
|
+
_fields_ = [
|
|
98
|
+
("train_start", ctypes.c_size_t),
|
|
99
|
+
("train_end", ctypes.c_size_t),
|
|
100
|
+
("test_start", ctypes.c_size_t),
|
|
101
|
+
("test_end", ctypes.c_size_t),
|
|
102
|
+
("train_post_start", ctypes.c_size_t),
|
|
103
|
+
("train_post_end", ctypes.c_size_t),
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class LinModel(ctypes.Structure):
|
|
108
|
+
_fields_ = [
|
|
109
|
+
("d", ctypes.c_size_t),
|
|
110
|
+
("w", ctypes.POINTER(ctypes.c_double)),
|
|
111
|
+
("b", ctypes.c_double),
|
|
112
|
+
("ridge", ctypes.c_double),
|
|
113
|
+
("fitted", ctypes.c_int),
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
_D = ctypes.POINTER(ctypes.c_double)
|
|
118
|
+
_SIZE = ctypes.c_size_t
|
|
119
|
+
|
|
120
|
+
_SIGNATURES: dict[str, tuple[list[Any], Any]] = {
|
|
121
|
+
# rolling.h
|
|
122
|
+
"mlr_rolling_mean": ([_D, _SIZE, _SIZE, _D], ctypes.c_int),
|
|
123
|
+
"mlr_rolling_std": ([_D, _SIZE, _SIZE, _D], ctypes.c_int),
|
|
124
|
+
"mlr_ewma_vol": ([_D, _SIZE, ctypes.c_double, _D], ctypes.c_int),
|
|
125
|
+
# vol.h
|
|
126
|
+
"mlr_garch_fit": ([_D, _SIZE, ctypes.POINTER(Garch)], ctypes.c_int),
|
|
127
|
+
"mlr_garch_filter": ([ctypes.POINTER(Garch), _D, _SIZE, _D], ctypes.c_int),
|
|
128
|
+
"mlr_garch_filter_from": (
|
|
129
|
+
[ctypes.POINTER(Garch), ctypes.c_double, _D, _SIZE, _D],
|
|
130
|
+
ctypes.c_int,
|
|
131
|
+
),
|
|
132
|
+
"mlr_garch_forecast": ([ctypes.POINTER(Garch), _SIZE, _D], ctypes.c_int),
|
|
133
|
+
"mlr_parkinson_vol": ([_D, _D, _SIZE, _D], ctypes.c_int),
|
|
134
|
+
"mlr_garman_klass_vol": ([_D, _D, _D, _D, _SIZE, _D], ctypes.c_int),
|
|
135
|
+
# sizing.h
|
|
136
|
+
"mlr_vol_target_position": (
|
|
137
|
+
[_D, ctypes.c_double, ctypes.c_double, _D, ctypes.c_double, _SIZE, _D],
|
|
138
|
+
ctypes.c_int,
|
|
139
|
+
),
|
|
140
|
+
"mlr_kelly_fraction": ([_D, _SIZE, ctypes.c_double, _D], ctypes.c_int),
|
|
141
|
+
"mlr_drawdown_scale": ([_D, _SIZE, ctypes.c_double, _D], ctypes.c_int),
|
|
142
|
+
# split.h
|
|
143
|
+
"mlr_walk_forward_splits": (
|
|
144
|
+
[_SIZE] * 6 + [ctypes.c_int, ctypes.POINTER(Split), _SIZE, ctypes.POINTER(_SIZE)],
|
|
145
|
+
ctypes.c_int,
|
|
146
|
+
),
|
|
147
|
+
# linreg.h
|
|
148
|
+
"mlr_lin_model_init": ([ctypes.POINTER(LinModel), _SIZE], ctypes.c_int),
|
|
149
|
+
"mlr_lin_model_free": ([ctypes.POINTER(LinModel)], None),
|
|
150
|
+
"mlr_linreg_fit": (
|
|
151
|
+
[_D, _D, _SIZE, _SIZE, ctypes.c_double, ctypes.POINTER(LinModel)],
|
|
152
|
+
ctypes.c_int,
|
|
153
|
+
),
|
|
154
|
+
"mlr_linreg_predict": (
|
|
155
|
+
[_D, _SIZE, _SIZE, ctypes.POINTER(LinModel), _D],
|
|
156
|
+
ctypes.c_int,
|
|
157
|
+
),
|
|
158
|
+
# version.h
|
|
159
|
+
"mlr_version": ([], ctypes.c_char_p),
|
|
160
|
+
"mlr_version_number": ([], ctypes.c_int),
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _load() -> ctypes.CDLL:
|
|
165
|
+
"""Load the shared library that ships inside this package."""
|
|
166
|
+
here = pathlib.Path(__file__).resolve().parent
|
|
167
|
+
matches = sorted(
|
|
168
|
+
path
|
|
169
|
+
for pattern in ("walkforward_native.*", "libwalkforward_native.*")
|
|
170
|
+
for path in here.glob(pattern)
|
|
171
|
+
if path.suffix in {".so", ".dylib", ".dll", ".pyd"}
|
|
172
|
+
)
|
|
173
|
+
if not matches:
|
|
174
|
+
raise ImportError(
|
|
175
|
+
f"the walkforward native library is missing from {here}. "
|
|
176
|
+
"Reinstall the package, or build it in place with "
|
|
177
|
+
"`pip install -e python/`."
|
|
178
|
+
)
|
|
179
|
+
library = ctypes.CDLL(str(matches[0]))
|
|
180
|
+
for name, (argtypes, restype) in _SIGNATURES.items():
|
|
181
|
+
function = getattr(library, name)
|
|
182
|
+
function.argtypes = argtypes
|
|
183
|
+
function.restype = restype
|
|
184
|
+
return library
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
lib = _load()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def c_version() -> str:
|
|
191
|
+
"""Version reported by the loaded shared library itself."""
|
|
192
|
+
return lib.mlr_version().decode()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# --------------------------------------------------------------------------
|
|
196
|
+
# Array plumbing
|
|
197
|
+
# --------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def as_input(values: Any, name: str, *, ndim: int = 1) -> np.ndarray:
|
|
201
|
+
"""Return `values` as a C-contiguous float64 array of the given rank."""
|
|
202
|
+
array = np.ascontiguousarray(values, dtype=np.float64)
|
|
203
|
+
if array.ndim != ndim:
|
|
204
|
+
raise ValueError(f"{name} must be {ndim}-dimensional, got {array.ndim} dimensions")
|
|
205
|
+
if array.size == 0:
|
|
206
|
+
raise ValueError(f"{name} is empty; every estimator needs at least one observation")
|
|
207
|
+
return array
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def ptr(array: np.ndarray) -> Any:
|
|
211
|
+
"""Pointer to the first element. The array must already be contiguous."""
|
|
212
|
+
return array.ctypes.data_as(_D)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def out_like(n: int) -> np.ndarray:
|
|
216
|
+
"""A fresh writable output buffer, never shared with any input."""
|
|
217
|
+
return np.empty(n, dtype=np.float64)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def same_length(name_a: str, a: np.ndarray, name_b: str, b: np.ndarray) -> None:
|
|
221
|
+
if a.shape[0] != b.shape[0]:
|
|
222
|
+
raise ValueError(
|
|
223
|
+
f"{name_a} and {name_b} must be the same length, "
|
|
224
|
+
f"got {a.shape[0]} and {b.shape[0]}"
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def like(values: np.ndarray, template: Any) -> Any:
|
|
229
|
+
"""Give a pandas input its index back.
|
|
230
|
+
|
|
231
|
+
Realigning a bare array by hand is how lookahead creeps into a pandas
|
|
232
|
+
workflow, so a Series in means a Series out, on the same index.
|
|
233
|
+
"""
|
|
234
|
+
if _pd is not None and isinstance(template, _pd.Series):
|
|
235
|
+
return _pd.Series(values, index=template.index, name=template.name)
|
|
236
|
+
return values
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def as_count(value: Any, name: str, *, minimum: int = 0) -> int:
|
|
240
|
+
"""Validate a count before it becomes a ``size_t``.
|
|
241
|
+
|
|
242
|
+
Python integers are unbounded and negative values wrap when converted, so
|
|
243
|
+
the range check has to happen here rather than in C.
|
|
244
|
+
"""
|
|
245
|
+
try:
|
|
246
|
+
count = int(value)
|
|
247
|
+
except (TypeError, ValueError):
|
|
248
|
+
raise TypeError(f"{name} must be an integer, got {value!r}") from None
|
|
249
|
+
if count != value:
|
|
250
|
+
raise TypeError(f"{name} must be a whole number, got {value!r}")
|
|
251
|
+
if count < minimum:
|
|
252
|
+
raise ValueError(f"{name} must be at least {minimum}, got {count}")
|
|
253
|
+
if count > 2**63 - 1:
|
|
254
|
+
raise ValueError(f"{name} is too large: {count}")
|
|
255
|
+
return count
|
walkforward/linear.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Ridge regression.
|
|
2
|
+
|
|
3
|
+
The fit runs in C as a Householder QR on the centered design with
|
|
4
|
+
``sqrt(ridge) I`` appended, so accuracy is about the condition number times
|
|
5
|
+
epsilon rather than its square, and columns are shifted by their first row
|
|
6
|
+
before centering so a feature at a large level (a price near 1e9 with unit
|
|
7
|
+
variation) keeps its slope precision.
|
|
8
|
+
|
|
9
|
+
Prediction is a dot product, so it stays in numpy: the coefficients are
|
|
10
|
+
copied out and the C model is released as soon as the fit returns, which
|
|
11
|
+
means there is no native pointer to outlive its owner.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import ctypes
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
from ._core import LinModel, as_input, check, lib, ptr
|
|
22
|
+
|
|
23
|
+
try: # sklearn is optional
|
|
24
|
+
from sklearn.base import BaseEstimator as _BaseEstimator
|
|
25
|
+
from sklearn.base import RegressorMixin as _RegressorMixin
|
|
26
|
+
except ImportError: # pragma: no cover - exercised where sklearn is absent
|
|
27
|
+
|
|
28
|
+
class _BaseEstimator: # type: ignore[no-redef]
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
class _RegressorMixin: # type: ignore[no-redef]
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
__all__ = ["Ridge"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Ridge(_RegressorMixin, _BaseEstimator):
|
|
39
|
+
"""Ridge regression with an unpenalised intercept.
|
|
40
|
+
|
|
41
|
+
Minimises ``||Xc w - yc||^2 + ridge * ||w||^2`` on centered data, so the
|
|
42
|
+
intercept is recovered afterwards rather than shrunk.
|
|
43
|
+
|
|
44
|
+
Parameters
|
|
45
|
+
----------
|
|
46
|
+
ridge
|
|
47
|
+
Penalty on the squared coefficients. ``0`` is ordinary least squares
|
|
48
|
+
and then the design must be full rank, which needs more rows than
|
|
49
|
+
columns and linearly independent ones.
|
|
50
|
+
|
|
51
|
+
Attributes
|
|
52
|
+
----------
|
|
53
|
+
coef_ : ndarray of shape (n_features,)
|
|
54
|
+
Fitted weights, available after :meth:`fit`.
|
|
55
|
+
intercept_ : float
|
|
56
|
+
Fitted intercept.
|
|
57
|
+
|
|
58
|
+
Raises
|
|
59
|
+
------
|
|
60
|
+
DomainError
|
|
61
|
+
The design is singular, or its arithmetic overflows. A rank-deficient
|
|
62
|
+
design is refused rather than silently pseudo-inverted; add a ridge if
|
|
63
|
+
that is what you want.
|
|
64
|
+
|
|
65
|
+
Notes
|
|
66
|
+
-----
|
|
67
|
+
Intended for tens of features, not thousands: the solve is dense and
|
|
68
|
+
unpivoted. Rank deficiency is detected on the diagonal of ``R``, which
|
|
69
|
+
catches exact and near-exact dependence but is not a guarantee for every
|
|
70
|
+
pathological design.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(self, ridge: float = 0.0) -> None:
|
|
74
|
+
self.ridge = ridge
|
|
75
|
+
|
|
76
|
+
def fit(self, X: Any, y: Any) -> Ridge:
|
|
77
|
+
"""Fit the model. Leaves the estimator untouched if the fit fails."""
|
|
78
|
+
design = as_input(X, "X", ndim=2)
|
|
79
|
+
target = as_input(y, "y")
|
|
80
|
+
n, d = design.shape
|
|
81
|
+
if target.shape[0] != n:
|
|
82
|
+
raise ValueError(
|
|
83
|
+
f"X has {n} rows but y has {target.shape[0]}; they must match"
|
|
84
|
+
)
|
|
85
|
+
if self.ridge == 0.0 and n <= d:
|
|
86
|
+
raise ValueError(
|
|
87
|
+
f"X is {n} by {d}: with ridge=0 the fit needs more rows than "
|
|
88
|
+
"columns, because centering costs one rank. Pass a positive "
|
|
89
|
+
"ridge to fit anyway."
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
model = LinModel()
|
|
93
|
+
check(lib.mlr_lin_model_init(ctypes.byref(model), d), "Ridge.fit")
|
|
94
|
+
try:
|
|
95
|
+
check(
|
|
96
|
+
lib.mlr_linreg_fit(
|
|
97
|
+
ptr(design), ptr(target), n, d, float(self.ridge), ctypes.byref(model)
|
|
98
|
+
),
|
|
99
|
+
"Ridge.fit",
|
|
100
|
+
f"ridge={self.ridge!r} must be finite and non-negative, "
|
|
101
|
+
"and X and y must be finite",
|
|
102
|
+
)
|
|
103
|
+
# Copy out and release: no native pointer survives this call
|
|
104
|
+
self.coef_ = np.array([model.w[j] for j in range(d)], dtype=np.float64)
|
|
105
|
+
self.intercept_ = float(model.b)
|
|
106
|
+
finally:
|
|
107
|
+
lib.mlr_lin_model_free(ctypes.byref(model))
|
|
108
|
+
return self
|
|
109
|
+
|
|
110
|
+
def predict(self, X: Any) -> np.ndarray:
|
|
111
|
+
"""Predict ``X @ coef_ + intercept_``."""
|
|
112
|
+
if not hasattr(self, "coef_"):
|
|
113
|
+
raise ValueError("this Ridge is not fitted yet; call fit first")
|
|
114
|
+
design = as_input(X, "X", ndim=2)
|
|
115
|
+
if design.shape[1] != self.coef_.shape[0]:
|
|
116
|
+
raise ValueError(
|
|
117
|
+
f"X has {design.shape[1]} features but the model was fitted on "
|
|
118
|
+
f"{self.coef_.shape[0]}"
|
|
119
|
+
)
|
|
120
|
+
return design @ self.coef_ + self.intercept_
|
walkforward/rolling.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Rolling window statistics.
|
|
2
|
+
|
|
3
|
+
Trailing windows: ``out[i]`` is computed from ``x[i-window+1 : i+1]``, which
|
|
4
|
+
*includes* ``x[i]``. A rolling statistic at index ``t`` therefore knows period
|
|
5
|
+
``t`` and must be lagged one period before it sizes a position held over
|
|
6
|
+
``t``; :func:`lag` does that. Indices without a full window are ``NaN``, and
|
|
7
|
+
so is any window holding a non-finite value or whose arithmetic overflows.
|
|
8
|
+
|
|
9
|
+
Accuracy does not degrade at index levels. The accumulators are shifted by an
|
|
10
|
+
offset taken from inside the current window and rebuilt periodically, so a
|
|
11
|
+
price series near 1e9, a long trend, and a bad tick that has since left the
|
|
12
|
+
window all keep full precision.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
from ._core import as_count, as_input, check, lib, like, out_like, ptr
|
|
22
|
+
|
|
23
|
+
__all__ = ["lag", "rolling_mean", "rolling_std"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _rolling(function: Any, name: str, x: Any, window: int) -> Any:
|
|
27
|
+
array = as_input(x, "x")
|
|
28
|
+
size = as_count(window, "window", minimum=1)
|
|
29
|
+
out = out_like(array.shape[0])
|
|
30
|
+
check(function(ptr(array), array.shape[0], size, ptr(out)), name)
|
|
31
|
+
return like(out, x)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def rolling_mean(x: Any, window: int) -> Any:
|
|
35
|
+
"""Trailing mean over ``window`` observations, O(n).
|
|
36
|
+
|
|
37
|
+
Contemporaneous: ``out[t]`` includes ``x[t]``. See :func:`lag`.
|
|
38
|
+
"""
|
|
39
|
+
return _rolling(lib.mlr_rolling_mean, "rolling_mean", x, window)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def rolling_std(x: Any, window: int) -> Any:
|
|
43
|
+
"""Trailing standard deviation over ``window`` observations, O(n).
|
|
44
|
+
|
|
45
|
+
Population convention, dividing by ``window``. Note that pandas
|
|
46
|
+
``rolling().std()`` defaults to the sample convention and divides by
|
|
47
|
+
``window - 1``, so the two differ by ``sqrt(window / (window - 1))``.
|
|
48
|
+
|
|
49
|
+
Contemporaneous: ``out[t]`` includes ``x[t]``. See :func:`lag`.
|
|
50
|
+
"""
|
|
51
|
+
return _rolling(lib.mlr_rolling_std, "rolling_std", x, window)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def lag(x: Any, periods: int = 1) -> Any:
|
|
55
|
+
"""Shift a series forward, filling the start with ``NaN``.
|
|
56
|
+
|
|
57
|
+
The blessed way to turn a contemporaneous estimator into something safe to
|
|
58
|
+
size with: ``lag(rolling_std(returns, 20))`` at index ``t`` holds the value
|
|
59
|
+
computed through ``t-1``, so it is known when the position for ``t`` is
|
|
60
|
+
entered.
|
|
61
|
+
|
|
62
|
+
Parameters
|
|
63
|
+
----------
|
|
64
|
+
x
|
|
65
|
+
The series to shift. A pandas Series keeps its index.
|
|
66
|
+
periods
|
|
67
|
+
How many periods to shift forward. Must be non-negative.
|
|
68
|
+
"""
|
|
69
|
+
array = as_input(x, "x")
|
|
70
|
+
shift = as_count(periods, "periods", minimum=0)
|
|
71
|
+
out = np.full(array.shape[0], np.nan, dtype=np.float64)
|
|
72
|
+
if shift < array.shape[0]:
|
|
73
|
+
out[shift:] = array[: array.shape[0] - shift]
|
|
74
|
+
return like(out, x)
|
walkforward/sizing.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Turning a volatility forecast into a position.
|
|
2
|
+
|
|
3
|
+
Timing contract, which the whole package is built around:
|
|
4
|
+
|
|
5
|
+
* ``sigma[t]`` must be a forecast for period ``t`` made from information
|
|
6
|
+
available at the close of ``t-1``. :func:`~walkforward.ewma_vol` and the
|
|
7
|
+
GARCH filters already are; rolling statistics and range estimators are not
|
|
8
|
+
and must be lagged first.
|
|
9
|
+
* ``position[t]`` is then the position, in units, entered at the close of
|
|
10
|
+
``t-1`` at ``price[t-1]`` and held over period ``t``.
|
|
11
|
+
* Its profit is ``position[t] * price[t-1] * returns[t]``. The price factor is
|
|
12
|
+
easy to drop and doing so silently rescales every result.
|
|
13
|
+
|
|
14
|
+
So the price passed for index ``t`` is the *previous* close:
|
|
15
|
+
``vol_target_position(sigma[1:], ..., price=close[:-1], ...)``, or equivalently
|
|
16
|
+
``lag(close)`` aligned to the same index.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import ctypes
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from ._core import as_input, check, lib, like, out_like, ptr, same_length
|
|
25
|
+
|
|
26
|
+
__all__ = ["drawdown_scale", "kelly_fraction", "vol_target_position"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def vol_target_position(
|
|
30
|
+
sigma: Any,
|
|
31
|
+
target_vol: float,
|
|
32
|
+
equity: float,
|
|
33
|
+
price: Any,
|
|
34
|
+
max_leverage: float,
|
|
35
|
+
) -> Any:
|
|
36
|
+
"""Size a position so its volatility matches a target.
|
|
37
|
+
|
|
38
|
+
``position = (target_vol / sigma) * (equity / price)``, capped so that
|
|
39
|
+
``position * price <= max_leverage * equity``.
|
|
40
|
+
|
|
41
|
+
Read ``target_vol`` as a risk cap instead and the same formula caps risk
|
|
42
|
+
per position: both are "the fraction of equity you are willing to see move
|
|
43
|
+
in one period".
|
|
44
|
+
|
|
45
|
+
Parameters
|
|
46
|
+
----------
|
|
47
|
+
sigma
|
|
48
|
+
Per-period volatility forecast for each period. Must be known before
|
|
49
|
+
the period starts; see the module docstring.
|
|
50
|
+
target_vol
|
|
51
|
+
Target per-period volatility as a fraction of equity. Annualised
|
|
52
|
+
targets convert as ``annual / sqrt(periods_per_year)``: 10% a year on
|
|
53
|
+
daily data is ``0.10 / sqrt(252)``, about 0.0063.
|
|
54
|
+
equity
|
|
55
|
+
Account equity. Finite and positive.
|
|
56
|
+
price
|
|
57
|
+
Entry price for each period, that is the previous close.
|
|
58
|
+
max_leverage
|
|
59
|
+
Cap on notional over equity. ``max_leverage * equity`` must be finite.
|
|
60
|
+
|
|
61
|
+
Returns
|
|
62
|
+
-------
|
|
63
|
+
Positions in units. A period whose sigma or price is non-finite,
|
|
64
|
+
non-positive, or extreme enough to make the position unrepresentable gets
|
|
65
|
+
a position of zero rather than a bad number.
|
|
66
|
+
"""
|
|
67
|
+
sigma_array = as_input(sigma, "sigma")
|
|
68
|
+
price_array = as_input(price, "price")
|
|
69
|
+
same_length("sigma", sigma_array, "price", price_array)
|
|
70
|
+
out = out_like(sigma_array.shape[0])
|
|
71
|
+
check(
|
|
72
|
+
lib.mlr_vol_target_position(
|
|
73
|
+
ptr(sigma_array),
|
|
74
|
+
float(target_vol),
|
|
75
|
+
float(equity),
|
|
76
|
+
ptr(price_array),
|
|
77
|
+
float(max_leverage),
|
|
78
|
+
sigma_array.shape[0],
|
|
79
|
+
ptr(out),
|
|
80
|
+
),
|
|
81
|
+
"vol_target_position",
|
|
82
|
+
f"target_vol={target_vol!r}, equity={equity!r} and max_leverage={max_leverage!r} "
|
|
83
|
+
"must be finite and positive, and max_leverage * equity must be finite",
|
|
84
|
+
)
|
|
85
|
+
return like(out, sigma)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def kelly_fraction(returns: Any, fraction: float = 1.0) -> float:
|
|
89
|
+
"""Mean-variance Kelly fraction of equity.
|
|
90
|
+
|
|
91
|
+
``fraction * mean(returns) / var(returns)`` with the sample variance
|
|
92
|
+
(``n - 1`` denominator). Negative when the sample edge is negative, which
|
|
93
|
+
the caller should read as "no position".
|
|
94
|
+
|
|
95
|
+
This is a sizing utility, not an allocation model. It uses the raw
|
|
96
|
+
historical mean rather than the excess over a funding rate, and it knows
|
|
97
|
+
nothing about estimation error, fat tails, drawdown tolerance or the rest
|
|
98
|
+
of the book. Mean over variance from a short sample is a strongly
|
|
99
|
+
upward-biased estimate of the true edge, so treat the result as an upper
|
|
100
|
+
bound and pass a ``fraction`` well below 1. Half Kelly (0.5) gives up a
|
|
101
|
+
quarter of the growth rate for half the volatility, which is why it is the
|
|
102
|
+
usual starting point.
|
|
103
|
+
|
|
104
|
+
Parameters
|
|
105
|
+
----------
|
|
106
|
+
returns
|
|
107
|
+
At least two finite per-period returns.
|
|
108
|
+
fraction
|
|
109
|
+
Fractional Kelly multiplier: 1.0 is full Kelly, 0.5 is half.
|
|
110
|
+
|
|
111
|
+
Raises
|
|
112
|
+
------
|
|
113
|
+
DomainError
|
|
114
|
+
The sample has zero variance, or its variance or the estimate cannot
|
|
115
|
+
be represented.
|
|
116
|
+
"""
|
|
117
|
+
array = as_input(returns, "returns")
|
|
118
|
+
if array.shape[0] < 2:
|
|
119
|
+
raise ValueError(f"kelly_fraction needs at least 2 returns, got {array.shape[0]}")
|
|
120
|
+
out = ctypes.c_double()
|
|
121
|
+
check(
|
|
122
|
+
lib.mlr_kelly_fraction(
|
|
123
|
+
ptr(array), array.shape[0], float(fraction), ctypes.byref(out)
|
|
124
|
+
),
|
|
125
|
+
"kelly_fraction",
|
|
126
|
+
f"fraction={fraction!r} must be finite and positive, and every return finite",
|
|
127
|
+
)
|
|
128
|
+
return out.value
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def drawdown_scale(equity: Any, max_dd: float) -> Any:
|
|
132
|
+
"""Exposure multiplier that tapers to zero as drawdown deepens.
|
|
133
|
+
|
|
134
|
+
With running peak ``P[i] = max(equity[:i+1])`` and drawdown
|
|
135
|
+
``dd[i] = 1 - equity[i] / P[i]``, the result is
|
|
136
|
+
``clip(1 - dd / max_dd, 0, 1)``: full exposure at a new high, nothing at
|
|
137
|
+
``max_dd``.
|
|
138
|
+
|
|
139
|
+
Contemporaneous, and easy to misuse because it looks like a multiplier you
|
|
140
|
+
apply in place. ``scale[t]`` comes from ``equity[t]``, the close at the
|
|
141
|
+
*end* of period ``t``, so it is not known when the position for ``t`` is
|
|
142
|
+
entered. Scale ``position[t]`` by ``scale[t-1]``: use
|
|
143
|
+
:func:`~walkforward.lag`. Applying ``scale[t]`` to ``position[t]``
|
|
144
|
+
de-levers on the bar of a loss using that bar's own close, which flatters
|
|
145
|
+
a backtest.
|
|
146
|
+
|
|
147
|
+
Parameters
|
|
148
|
+
----------
|
|
149
|
+
equity
|
|
150
|
+
Cumulative equity path, every value finite and positive.
|
|
151
|
+
max_dd
|
|
152
|
+
Drawdown at which exposure reaches zero, in ``(0, 1]``.
|
|
153
|
+
"""
|
|
154
|
+
array = as_input(equity, "equity")
|
|
155
|
+
out = out_like(array.shape[0])
|
|
156
|
+
check(
|
|
157
|
+
lib.mlr_drawdown_scale(ptr(array), array.shape[0], float(max_dd), ptr(out)),
|
|
158
|
+
"drawdown_scale",
|
|
159
|
+
f"max_dd={max_dd!r} must be finite and in (0, 1], "
|
|
160
|
+
"and every equity value finite and positive",
|
|
161
|
+
)
|
|
162
|
+
return like(out, equity)
|