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.
@@ -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)