walkforward 3.4.0__tar.gz

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.
Files changed (44) hide show
  1. walkforward-3.4.0/.gitattributes +3 -0
  2. walkforward-3.4.0/.gitignore +28 -0
  3. walkforward-3.4.0/CHANGELOG.md +372 -0
  4. walkforward-3.4.0/CMakeLists.txt +112 -0
  5. walkforward-3.4.0/LICENSE +21 -0
  6. walkforward-3.4.0/PKG-INFO +121 -0
  7. walkforward-3.4.0/README.md +215 -0
  8. walkforward-3.4.0/RELEASING.md +82 -0
  9. walkforward-3.4.0/cmake/mlrisk.pc.in +9 -0
  10. walkforward-3.4.0/cmake/mlriskConfig.cmake.in +5 -0
  11. walkforward-3.4.0/include/mlrisk/linreg.h +122 -0
  12. walkforward-3.4.0/include/mlrisk/mlrisk.h +17 -0
  13. walkforward-3.4.0/include/mlrisk/rolling.h +94 -0
  14. walkforward-3.4.0/include/mlrisk/sizing.h +117 -0
  15. walkforward-3.4.0/include/mlrisk/split.h +98 -0
  16. walkforward-3.4.0/include/mlrisk/types.h +70 -0
  17. walkforward-3.4.0/include/mlrisk/version.h.in +24 -0
  18. walkforward-3.4.0/include/mlrisk/vol.h +205 -0
  19. walkforward-3.4.0/pyproject.toml +100 -0
  20. walkforward-3.4.0/python/.gitignore +5 -0
  21. walkforward-3.4.0/python/CMakeLists.txt +39 -0
  22. walkforward-3.4.0/python/README.md +94 -0
  23. walkforward-3.4.0/python/src/walkforward/__init__.py +86 -0
  24. walkforward-3.4.0/python/src/walkforward/_core.py +255 -0
  25. walkforward-3.4.0/python/src/walkforward/linear.py +120 -0
  26. walkforward-3.4.0/python/src/walkforward/rolling.py +74 -0
  27. walkforward-3.4.0/python/src/walkforward/sizing.py +162 -0
  28. walkforward-3.4.0/python/src/walkforward/split.py +255 -0
  29. walkforward-3.4.0/python/src/walkforward/volatility.py +316 -0
  30. walkforward-3.4.0/python/tests/test_walkforward.py +385 -0
  31. walkforward-3.4.0/src/linreg.c +226 -0
  32. walkforward-3.4.0/src/rolling.c +226 -0
  33. walkforward-3.4.0/src/sizing.c +118 -0
  34. walkforward-3.4.0/src/split.c +64 -0
  35. walkforward-3.4.0/src/version.c +9 -0
  36. walkforward-3.4.0/src/vol.c +399 -0
  37. walkforward-3.4.0/tests/test_fuzz.c +181 -0
  38. walkforward-3.4.0/tests/test_linreg.c +284 -0
  39. walkforward-3.4.0/tests/test_main.c +53 -0
  40. walkforward-3.4.0/tests/test_rolling.c +358 -0
  41. walkforward-3.4.0/tests/test_sizing.c +165 -0
  42. walkforward-3.4.0/tests/test_split.c +170 -0
  43. walkforward-3.4.0/tests/test_util.h +35 -0
  44. walkforward-3.4.0/tests/test_vol.c +417 -0
@@ -0,0 +1,3 @@
1
+ # Verification tooling, not part of the library: keep GitHub's language
2
+ # statistics honest about what mlrisk is
3
+ tests/reference/** linguist-detectable=false
@@ -0,0 +1,28 @@
1
+ # Build
2
+ build/
3
+ build-*/
4
+ out/
5
+ cmake-build-*/
6
+ CMakeCache.txt
7
+ CMakeFiles/
8
+ Testing/
9
+ compile_commands.json
10
+
11
+ # Compiled
12
+ *.o
13
+ *.obj
14
+ *.a
15
+ *.lib
16
+ *.so
17
+ *.dylib
18
+ *.dll
19
+ *.exe
20
+ *.pdb
21
+ *.dSYM/
22
+
23
+ # IDE / OS
24
+ .vscode/
25
+ .idea/
26
+ .vs/
27
+ *.swp
28
+ .DS_Store
@@ -0,0 +1,372 @@
1
+ # Changelog
2
+
3
+ ## 3.4.0 (2026-09-04)
4
+
5
+ First release published to PyPI as `walkforward`. The C library is unchanged
6
+ from 3.3.1; everything here is the Python package and the machinery to ship
7
+ it. Publishing uses a PyPI API token rather than trusted publishing, because
8
+ GitHub issues this repository an OIDC subject claim carrying numeric owner and
9
+ repository identifiers that PyPI does not match; `RELEASING.md` has the
10
+ detail.
11
+
12
+ ### Added
13
+
14
+ - A Python package, `walkforward`, in `python/`. It binds this library with
15
+ ctypes and numpy: `ewma_vol`, `garch_fit` returning a `GarchModel` with
16
+ `persistence`, `half_life` and `unconditional_vol`, the rolling statistics
17
+ with a `lag` helper, the sizing functions, `Ridge`, and
18
+ `PurgedWalkForward`, a scikit-learn cross-validator.
19
+ - Two API decisions worth naming. The splitter asks for `label_horizon`
20
+ rather than a purge count, because `purge = h - 1` is the part users get
21
+ wrong, and it has no `embargo` argument, because a walk-forward never
22
+ trains on data after the window it is testing. `walk_forward_splits`
23
+ exposes the post-training variant for people who want it.
24
+ - The binding allocates every output buffer itself, so the `restrict`
25
+ non-aliasing contract on the C outputs cannot be violated by a numpy view,
26
+ and a pandas Series keeps its index through a call.
27
+ - Wheels are `py3-none`: the binding never touches the Python C API, so one
28
+ wheel per platform serves every Python 3.
29
+ - A `python-package` CI job builds and tests the wheel on Linux, macOS and
30
+ Windows, asserts the package and the library it bundles report the same
31
+ version, and installs from a source distribution in a clean directory so
32
+ the sdist keeps carrying the C it has to compile.
33
+ - The Python packaging sits at the repository root rather than in `python/`,
34
+ because a source distribution has to contain `src/` and `include/` and
35
+ those cannot be reached from a subdirectory.
36
+ - A `Release` workflow that builds the sdist and five wheels (Linux x86-64
37
+ and aarch64, macOS Intel and Apple silicon, Windows x64), tests each wheel
38
+ on the platform it was built for, installs the sdist from scratch, and
39
+ publishes to PyPI through trusted publishing, with no API token in the
40
+ repository. A tag that disagrees with the version in `CMakeLists.txt` is
41
+ refused before anything is built. See `RELEASING.md`.
42
+ Validated by running the workflow before any tag: it produces one
43
+ `py3-none` wheel per platform, each carrying its own shared library, and
44
+ the Linux wheels need only glibc 2.17 so they install anywhere numpy does.
45
+
46
+ ### Changed
47
+
48
+ - The README is rewritten around the Python package, which is how most people
49
+ will use this, with the C library as its own section rather than the
50
+ headline. Every measured number is carried over, and both Python snippets
51
+ in it are executed as part of the checks.
52
+ - The repository is now `haeganm/walkforward`, matching the Python package.
53
+ GitHub redirects the old address and the earlier releases are unaffected.
54
+ The C keeps its `mlr_` prefix and its `include/mlrisk/` headers, the way
55
+ Pillow ships `PIL`.
56
+
57
+
58
+ ## 3.3.1 (2026-09-04)
59
+
60
+ - `mlr_drawdown_scale` is contemporaneous (`scale[t]` uses the close at the
61
+ end of period `t`) and its header said "multiply position sizes by
62
+ scale_out" with no lag, in the same file whose timing contract says index
63
+ `t` must not know period `t`. Applying `scale[t]` to the position held
64
+ over period `t` de-levers on the bar of a loss using that bar's own close.
65
+ The header, the sizing contract and the README now say to use
66
+ `scale[t-1]`; a test pins the alignment. No code change.
67
+ - The rolling mean and standard deviation shifted every value by the first
68
+ finite value of the whole series and never revisited that choice. A bad
69
+ first tick therefore corrupted every later window: with prices near 100
70
+ and a first value of 1e9, windows that did not even contain it had a
71
+ standard deviation 600% off and a mean off by 1e-6; a first value of 1e12
72
+ made the std meaningless. A long trend away from the starting level eroded
73
+ precision the same way. The offset now comes from inside the current
74
+ window, the accumulators are rebuilt every `window` samples (amortized
75
+ O(1)) and whenever a leaving sample carried almost all of the variance or
76
+ the sum, so an outlier that has left leaves no rounding behind. Measured
77
+ against exact rational arithmetic: 6e-16 relative for the std and an exact
78
+ mean after first ticks of 1e9, 1e12 and 1e15 and along a trend from 100
79
+ to 1e6. Cost: about 6 ns per element for the mean and 20 for the std,
80
+ up from 4 and 15.
81
+ - `mlr_linreg_fit` centered each feature with a plain running sum, so a
82
+ feature at a large level carried rounding of order n * eps * level into
83
+ every centered value: with a price column near 1e9 and unit variation
84
+ the slope was off by 4e-12, and at 1e12 by 7e-7, while an SVD solve
85
+ reached 1e-15. Columns are now shifted by the first row before centering
86
+ (differences of nearby doubles are exact), which puts the slope at 4e-15
87
+ from 1e6 to 1e15, ahead of scikit-learn's 1.7e-5 at 1e15.
88
+ - The rolling mean returned `Inf` and the rolling std returned `0` with
89
+ `MLR_OK` for a window of finite values whose differences overflow (values
90
+ of opposite sign near 1e308): the shifted accumulators overflowed and the
91
+ `fmax` that guards an epsilon-negative variance turned the resulting NaN
92
+ into a zero standard deviation. Such windows are now NaN, the state is
93
+ rebuilt on the next step, and no output is ever Inf.
94
+ - `mlr_garch_fit` documented `MLR_EDOMAIN` for a denormally small variance
95
+ but only rejected zero; at a backcast of 4e-321 it returned `MLR_OK` with
96
+ alpha drifted by 0.003 and `converged = 0`. Variances below `DBL_MIN` are
97
+ now refused as documented; anything above fits to the same parameters as
98
+ the unscaled series.
99
+ - `mlr_kelly_fraction` returned `f = 0` with `MLR_OK` when the squared
100
+ deviations overflowed (variance `Inf`), a silently rounded estimate where
101
+ the same function already refuses the NaN form of the overflow. It now
102
+ returns `MLR_EDOMAIN`.
103
+
104
+
105
+ ## 3.3.0 (2026-09-04)
106
+
107
+ The last pass before the C API is frozen for language bindings. Three
108
+ reviewers who had not seen the code read it; nine of their findings
109
+ reproduced and are fixed here.
110
+
111
+ ### Fixed
112
+
113
+ - The GARCH recursion was written three ways: the likelihood and the fit's
114
+ `sigma2_next` loop as `omega + alpha*r*r + beta*s2`, the filter as
115
+ `omega + alpha*(r*r) + beta*s2`. The two differ by an ulp on about a third
116
+ of steps, so for 12% of fit samples `sigma2_next` was not the value the
117
+ filter reached and the documented bit-exact `mlr_garch_filter_from`
118
+ continuation failed by an ulp. One `garch_step` now serves all three;
119
+ the continuation is tested across 60 fits.
120
+ - `mlr_garch_forecast` accepted `sigma2_next == 0` (the value in a
121
+ hand-built model that never set it) and forecast zero volatility with
122
+ `MLR_OK`. Now `MLR_EINVAL`, matching `mlr_garch_filter_from`.
123
+ - `mlr_linreg_fit` wrote weights into the model during back-substitution
124
+ and the intercept before checking it, so a failing fit left a half-new
125
+ model behind. It now solves into scratch and touches the model only on
126
+ success; the header says "on any failure the model is left exactly as it
127
+ was".
128
+ - `mlr_linreg_predict` on a model that was initialized but never fitted
129
+ returned all zeros with `MLR_OK`. `mlr_lin_model` gained a `fitted` field
130
+ (set by a successful fit, cleared by init and free) and predict requires
131
+ it. Recompile consumers: the struct grew.
132
+ - The installed `mlrisk.pc` located the prefix as `${pcfiledir}/../..`,
133
+ which is wrong when `CMAKE_INSTALL_LIBDIR` is two levels deep
134
+ (`lib/x86_64-linux-gnu`, the Debian and Ubuntu default under `/usr`).
135
+ The relative path is now computed at configure time.
136
+ - `mlr_walk_forward_splits` did not write `*count_out` on its `MLR_EINVAL`
137
+ paths; it now writes 0.
138
+ - The fuzz sweep never fitted a GARCH model: every element was non-finite
139
+ with probability 0.11 and the fit needs 100 finite returns, so the
140
+ success path had probability 9e-6 per round. Every fourth round is now
141
+ clean, split parameters can hit every status code, and the sweep asserts
142
+ that the success paths were reached.
143
+ - The C11 language requirement was exported to consumers through
144
+ `target_compile_features(PUBLIC)`; the public headers need only C99, so
145
+ it is now private. MSVC builds add `/fp:contract-`, the real counterpart
146
+ of `-ffp-contract=off`. The 32-bit CI leg uses SSE math instead of x87.
147
+ - Documentation that no longer matched the code: `linreg.h` described the
148
+ pre-3.2.0 normal-equation solver; `sizing.h`'s lag-before-sizing warning
149
+ omitted the rolling statistics, which are contemporaneous; "with
150
+ ridge > 0 the system is always solvable" was overstated; `mlr_linreg_fit`
151
+ called its in/out model `model_out`.
152
+
153
+ ### Added
154
+
155
+ - `mlr_version()` and `mlr_version_number()`, so a binding that loads the
156
+ compiled library can check what it loaded (the reference suite now does).
157
+ - `MLR_GARCH_MIN_N` and `MLR_GARCH_MAX_PERSISTENCE` as public constants.
158
+ - Header contracts a binding author asked for: partially written output on
159
+ `MLR_EDOMAIN`, count-query mode keyed on the pointer, the `backcast == 0`
160
+ sentinel, `converged` exactly 0 or 1, the model owning `w` and not being
161
+ copyable, `mlr_ewma_vol` all-NaN when no usable return precedes the last
162
+ index, the lag warning on each range estimator. README Conventions now
163
+ tabulates the five non-finite-element policies and states the `n == 0`
164
+ and trusted-`size_t` rules.
165
+ - `MLRISK_BUILD_BENCH` option (default off); the benchmark is no longer
166
+ built with the examples.
167
+ - A reference check that the purge rule is exactly the leakage boundary:
168
+ with labels spanning h periods, purge = h-1 leaves no training label
169
+ inside the test window and purge = h-2 does, for h = 2, 5 and 21.
170
+ - The README says to demean with the training-window mean before fitting
171
+ (the full-sample mean is a lookahead) and quantifies the drift bias.
172
+
173
+ ### Changed
174
+
175
+ - Size-overflow rejections in `mlr_lin_model_init` and `mlr_linreg_fit`
176
+ return `MLR_EINVAL` (a dimension that cannot be sized is bad input);
177
+ `MLR_ENOMEM` is reserved for a real allocation failure.
178
+ - `tests/reference/requirements.txt` pins `statsmodels`; the reference
179
+ build uses `-O3` to match the CMake Release build.
180
+ - Work arrays in `mlr_linreg_fit` are zero-initialized; GCC's analyzer
181
+ flagged reads it could not prove written (they were), and removing the
182
+ question costs nothing next to the solve.
183
+
184
+
185
+ ## 3.2.0 (2026-09-04)
186
+
187
+ A second review (by a reader who had not seen the code before) named six
188
+ weak spots. Four were real bugs or traps and are fixed; the other two were
189
+ design choices and are now either better or better documented.
190
+
191
+ ### Fixed
192
+
193
+ - `mlr_linreg_fit` sized its work arrays with a constant chosen for 64-bit
194
+ `size_t`; on 32-bit targets the `d * (d + 1)` product wrapped for d above
195
+ about 23,000 and the fit could write past its allocation. Sizes are now
196
+ checked arithmetically before anything is allocated or read.
197
+ - `mlr_vol_target_position` accepted an `equity` and `max_leverage` whose
198
+ product overflowed, which made the cap infinite and therefore never
199
+ applied. Such a pair is now `MLR_EINVAL`.
200
+ - Passing the same array as input and output corrupted rolling and EWMA
201
+ results. Output parameters are now `restrict`-qualified (`MLR_RESTRICT`,
202
+ empty under C++) and the contract is documented in `types.h`.
203
+ - Continuing a fitted GARCH model onto new data by filtering the new data
204
+ alone restarted the recursion from the pre-sample backcast (about 30% off
205
+ at the first period on the test series). `mlr_garch_filter_from` starts
206
+ from a given variance, and with `model->sigma2_next` reproduces the tail
207
+ of the full filter bit for bit; the example uses it and the plain filter's
208
+ header explains the trap.
209
+
210
+ ### Changed
211
+
212
+ - The regression solver is Householder QR on the centered design with
213
+ `sqrt(ridge) I` appended, instead of Gaussian elimination on the normal
214
+ equations. Accuracy is now about condition number times epsilon (2.8e-8
215
+ at condition number 1e8, where the old solver refused the problem and was
216
+ already at 1.5e-5 by 1e6). Same API, same results on well-conditioned data
217
+ to 1e-14.
218
+ - `mlr_kelly_fraction` is documented as a sizing utility and an upper bound,
219
+ not an allocation model.
220
+
221
+ ### Added
222
+
223
+ - Reference checks for the QR solver on designs with condition numbers
224
+ 1e4 to 1e10 against SVD least squares, and for `mlr_garch_filter_from`
225
+ continuation; C tests for the ill-conditioned fit, the cap overflow, the
226
+ continuation, and the 32-bit size guard.
227
+
228
+
229
+ ## 3.1.0 (2026-09-03)
230
+
231
+ Verification release. Every public function is now compared against an
232
+ independent implementation on every push, and the pass turned up a handful
233
+ of contract gaps.
234
+
235
+ ### Fixed
236
+
237
+ - The GARCH optimizer's convergence test measured the simplex relative to
238
+ each parameter's own magnitude, so a maximum on the boundary `alpha = 0`
239
+ (returns with no ARCH effect) could never satisfy it: the fit ran to the
240
+ 2000-iteration cap, took 16x longer than it should, and reported
241
+ `converged = 0` on a finished fit. The simplex is now measured against a
242
+ fixed scale per parameter (the backcast for omega, 1 for alpha and beta).
243
+ - `mlr_garch_fit` ran Nelder-Mead once, from the best grid point, and could
244
+ stop in the wrong basin: against a 108-start brute-force search on the
245
+ identical likelihood it lost on three of eleven adversarial series (tiny
246
+ alpha with a second maximum at persistence 0.99, a 50-sigma outlier, a
247
+ fourfold variance regime switch), by up to 1.7 log-likelihood units. It now
248
+ runs from its three best grid points, restarts each from its own result
249
+ until that stops improving, and keeps the best; it matches the brute-force
250
+ optimum on ten of the eleven and beats it on the outlier series. Fits cost 5 to 7x what they did (8 ms at n = 1000).
251
+ - `mlr_vol_target_position` let a denormal price overflow `equity / price`
252
+ past the leverage cap (an infinite position with `MLR_OK`), and let a
253
+ denormal position slip the cap by rounding; both are now zero.
254
+ - `mlr_garch_filter` and `mlr_garch_forecast` emitted `Inf` when extreme but
255
+ valid parameters overflowed the recursion; they now return `MLR_EDOMAIN`.
256
+ - `mlr_lin_model_init` and `mlr_linreg_fit` refuse dimensions whose
257
+ allocation size would overflow (`MLR_ENOMEM`) instead of relying on
258
+ `calloc` to notice.
259
+ - `mlr_garch_filter` treated a finite return whose square overflows as data,
260
+ pinning every later sigma at `Inf` with `MLR_OK`; it is now treated as
261
+ missing, the same rule EWMA already used.
262
+ - `mlr_garman_klass_vol` accepted bars with the open or close outside
263
+ `[low, high]`. They now give `NAN`. On a consistent bar the estimator is
264
+ bounded below by `0.114 * ln(high/low)^2`, so the "negative variance"
265
+ branch was unreachable and has been removed from the code and the docs.
266
+ - The installed `mlrisk.pc` hard-coded the configure-time prefix, so an
267
+ install with `--prefix` produced a pkg-config file pointing at the wrong
268
+ tree. It is now relocatable (`${pcfiledir}/../..`).
269
+ - `$<INSTALL_INTERFACE>` hard-coded `include` instead of
270
+ `CMAKE_INSTALL_INCLUDEDIR`; install and export rules are now behind
271
+ `MLRISK_INSTALL` so a parent project does not inherit them.
272
+ - A GCC `-Wmaybe-uninitialized` in the GARCH grid search would have failed
273
+ the `-Werror` build on the Linux/gcc CI leg.
274
+ - `mlr_drawdown_scale` returned `MLR_EDOMAIN` for a non-finite equity value
275
+ while every other function returns `MLR_EINVAL` for non-finite input; it
276
+ now returns `MLR_EINVAL` (non-positive equity is still `MLR_EDOMAIN`).
277
+ - Header and README claims corrected: the PnL of a position is
278
+ `position[t] * price[t-1] * returns[t]` (the price factor was missing);
279
+ the GARCH constraint is `alpha + beta < 0.9999`; only alpha and beta are
280
+ scale invariant; `n <= d` with `ridge == 0` returns `MLR_EDOMAIN` rather
281
+ than fitting exactly; rolling std is O(n) on clean data with an O(window)
282
+ rebuild after each gap.
283
+
284
+ ### Added
285
+
286
+ - `tests/reference/reference_check.py`: 19 checks of the compiled C against
287
+ pandas, numpy, scikit-learn, `arch`, exact rational arithmetic, an
288
+ independent split generator, a bitwise no-lookahead sweep, a 400-fit
289
+ GARCH Monte Carlo, and a reconciliation of the example's printed PnL.
290
+ A `reference` CI job runs it on every push.
291
+ - Prefix-stability tests for EWMA and the rolling statistics (previously
292
+ only the GARCH filter had one), tests for `window == n`, all-NaN rolling
293
+ input, NULL outputs on the range estimators, and the GARCH filter
294
+ overflow rule.
295
+ - `tests/test_fuzz.c`: 4000 randomized calls across the whole API (NaN, Inf,
296
+ denormals, `SIZE_MAX` arguments) asserting the documented contracts; runs
297
+ under the sanitizers in CI and found the two sizing/filter holes above.
298
+ - `tests/reference/real_data_check.py` for daily OHLC files, and
299
+ `bench/bench.c` (`mlrisk_bench`) with per-element timings and scaling ratios.
300
+ - A 32-bit (`-m32`) CI leg, so the `size_t` guards are exercised where
301
+ `size_t` is 32 bits; the workflow now runs on every branch push and can be
302
+ dispatched by hand.
303
+ - The sanitizer CI job also runs the example; the Windows example step
304
+ declares its shell; the workflow passes `actionlint`.
305
+ - The C two-pass reference in the rolling tests is computed on shifted
306
+ values so that it is itself exact at large levels.
307
+
308
+
309
+ ## 3.0.0 (2026-09-03)
310
+
311
+ Breaking release. Every volatility forecast now shares one timing convention,
312
+ and a technical audit closed a set of silent-failure paths.
313
+
314
+ ### Breaking
315
+
316
+ - `mlr_ewma_vol` is predictive: `out[t]` is the forecast for period t from
317
+ `returns[0..t-1]`, matching `mlr_garch_filter`. Previously `out[t]` included
318
+ `returns[t]`, so sizing period t from it was a one-bar lookahead. `out[0]` is
319
+ now `NAN`. `lambda` must be in `[0, 1)`.
320
+ - `mlr_lin_model_init(model, d)` drops the ridge argument; the `ridge` field is
321
+ written by `mlr_linreg_fit` with the value actually used (it was previously
322
+ never updated and could disagree with the fit).
323
+ - `mlr_garch_fit` requires `n >= 100` (was 20).
324
+ - `mlr_garch` gained a `backcast` field (appended). The recursion starts from
325
+ `sigma2[0] = omega + (alpha + beta) * backcast`, the `arch` presample rule.
326
+ Fitted parameters move by about 1e-5 relative to 2.0.0.
327
+ - CMake 3.21 is required; the library compiles as strict ISO C11.
328
+
329
+ ### Fixed
330
+
331
+ - `mlr_garch_filter` seeded its recursion from the series being filtered, so
332
+ every output depended on future returns. The seed now comes from the model.
333
+ - `mlr_garch_fit` had an absolute feasibility floor on `omega`; returns with
334
+ rms below about 2e-8 returned an unfitted model with `MLR_OK`. The bound is
335
+ now positivity only and estimates are scale invariant.
336
+ - Nelder-Mead stopped on function-value spread alone, so parameter accuracy
337
+ varied about 1000x with the units of the returns. Convergence now also
338
+ requires a small simplex; accuracy is about 1e-7 at any scale.
339
+ - `mlr_garch_filter` and `mlr_garch_forecast` accepted `omega = Inf` from
340
+ hand-built models and produced `Inf`/`NaN` output with `MLR_OK`.
341
+ - `mlr_walk_forward_splits` could wrap `size_t` arithmetic: a huge `embargo`
342
+ placed the post-test training segment inside the test window, and a huge
343
+ `train_len` produced out-of-range splits, both with `MLR_OK`.
344
+ - `mlr_linreg_fit` accepted NaN or Inf inputs (NaN pivots passed the
345
+ singularity test) and negative ridge, producing garbage models with
346
+ `MLR_OK`. Its singularity threshold had an absolute floor that rejected
347
+ well-conditioned systems with features below about 1e-8.
348
+ - `mlr_linreg_predict` dereferenced a freed model.
349
+ - `mlr_vol_target_position` did not check `equity` or `max_leverage` for
350
+ finiteness: NaN equity gave NaN positions, Inf leverage disabled the cap.
351
+ - `mlr_kelly_fraction` could return NaN with `MLR_OK` on overflowing sums.
352
+ - Missing data no longer poisons `mlr_ewma_vol` or `mlr_garch_filter`; the
353
+ forecast already made is kept and the recursion carries on.
354
+ - Range estimators return `NAN` rather than `Inf` when the price ratio overflows.
355
+
356
+ ### Added
357
+
358
+ - `mlr_garch_fit` is checked against the Python `arch` package on identical
359
+ samples with an identical likelihood (`tests/reference/garch_arch_reference.py`).
360
+ - Tests for prefix stability of the GARCH filter, scale invariance of the fit,
361
+ the timing alignment of every forecast, ridge shrinkage, and all of the
362
+ input-validation paths above. Tests run per module under ctest.
363
+ - `mlrisk/version.h` (`MLRISK_VERSION`), a pkg-config file, and CMake options
364
+ `MLRISK_WERROR`, `MLRISK_BUILD_TESTS`, `MLRISK_BUILD_EXAMPLES` for consumers.
365
+ - The example runs a walk-forward GARCH fit, filter, and sizing loop and
366
+ reports realized strategy volatility against the target.
367
+
368
+ ## 2.0.0
369
+
370
+ Public API rework: `mlr_` prefix on every function, purged and embargoed
371
+ walk-forward splits with a capacity-safe API, GARCH(1,1), range estimators,
372
+ Kelly and drawdown sizing. No compatibility layer with 1.x.
@@ -0,0 +1,112 @@
1
+ cmake_minimum_required(VERSION 3.21)
2
+ project(mlrisk VERSION 3.4.0 LANGUAGES C)
3
+
4
+ set(CMAKE_C_STANDARD 11)
5
+ set(CMAKE_C_STANDARD_REQUIRED ON)
6
+ set(CMAKE_C_EXTENSIONS OFF)
7
+
8
+ option(MLRISK_WERROR "Treat warnings as errors" ${PROJECT_IS_TOP_LEVEL})
9
+ option(MLRISK_BUILD_TESTS "Build the test suite" ${PROJECT_IS_TOP_LEVEL})
10
+ option(MLRISK_BUILD_EXAMPLES "Build the examples" ${PROJECT_IS_TOP_LEVEL})
11
+ option(MLRISK_BUILD_BENCH "Build the benchmark" OFF)
12
+ option(MLRISK_INSTALL "Generate install and export rules" ${PROJECT_IS_TOP_LEVEL})
13
+
14
+ include(GNUInstallDirs)
15
+
16
+ if(MSVC)
17
+ # /fp:contract- forbids fused multiply-add the way -ffp-contract=off does
18
+ set(MLRISK_COMPILE_OPTIONS /W4 /fp:precise /fp:contract- $<$<BOOL:${MLRISK_WERROR}>:/WX>)
19
+ else()
20
+ # -ffp-contract=off: no fused multiply-add, so results match across
21
+ # compilers and architectures to the last bit
22
+ set(MLRISK_COMPILE_OPTIONS -Wall -Wextra -Wpedantic -ffp-contract=off
23
+ $<$<BOOL:${MLRISK_WERROR}>:-Werror>)
24
+ endif()
25
+
26
+ configure_file(include/mlrisk/version.h.in include/mlrisk/version.h @ONLY)
27
+
28
+ add_library(mlrisk STATIC
29
+ src/rolling.c
30
+ src/sizing.c
31
+ src/vol.c
32
+ src/split.c
33
+ src/linreg.c
34
+ src/version.c
35
+ )
36
+ add_library(mlrisk::mlrisk ALIAS mlrisk)
37
+
38
+ # Built as C11; the public headers need only C99, so no standard is exported
39
+ target_compile_features(mlrisk PRIVATE c_std_11)
40
+ target_compile_options(mlrisk PRIVATE ${MLRISK_COMPILE_OPTIONS})
41
+ target_include_directories(mlrisk PUBLIC
42
+ $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
43
+ $<BUILD_INTERFACE:${CMAKE_CURRENT_BINARY_DIR}/include>
44
+ $<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}>
45
+ )
46
+ if(UNIX)
47
+ target_link_libraries(mlrisk PUBLIC m)
48
+ endif()
49
+
50
+ if(MLRISK_BUILD_TESTS)
51
+ enable_testing()
52
+ add_executable(mlrisk_tests
53
+ tests/test_main.c
54
+ tests/test_rolling.c
55
+ tests/test_sizing.c
56
+ tests/test_vol.c
57
+ tests/test_split.c
58
+ tests/test_linreg.c
59
+ tests/test_fuzz.c
60
+ )
61
+ target_compile_options(mlrisk_tests PRIVATE ${MLRISK_COMPILE_OPTIONS})
62
+ target_link_libraries(mlrisk_tests PRIVATE mlrisk::mlrisk)
63
+ foreach(module rolling vol sizing split linreg fuzz)
64
+ add_test(NAME ${module} COMMAND mlrisk_tests ${module})
65
+ endforeach()
66
+ endif()
67
+
68
+ if(MLRISK_BUILD_EXAMPLES)
69
+ add_executable(vol_target_demo examples/vol_target_demo.c)
70
+ target_compile_options(vol_target_demo PRIVATE ${MLRISK_COMPILE_OPTIONS})
71
+ target_link_libraries(vol_target_demo PRIVATE mlrisk::mlrisk)
72
+
73
+ endif()
74
+
75
+ if(MLRISK_BUILD_BENCH)
76
+ add_executable(mlrisk_bench bench/bench.c)
77
+ target_compile_options(mlrisk_bench PRIVATE ${MLRISK_COMPILE_OPTIONS})
78
+ target_link_libraries(mlrisk_bench PRIVATE mlrisk::mlrisk)
79
+ endif()
80
+
81
+ if(MLRISK_INSTALL)
82
+ include(CMakePackageConfigHelpers)
83
+
84
+ install(TARGETS mlrisk EXPORT mlriskTargets
85
+ ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR})
86
+ install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}
87
+ FILES_MATCHING PATTERN "*.h")
88
+ install(FILES ${CMAKE_CURRENT_BINARY_DIR}/include/mlrisk/version.h
89
+ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/mlrisk)
90
+ install(EXPORT mlriskTargets NAMESPACE mlrisk::
91
+ DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/mlrisk)
92
+
93
+ configure_package_config_file(cmake/mlriskConfig.cmake.in
94
+ ${CMAKE_CURRENT_BINARY_DIR}/mlriskConfig.cmake
95
+ INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/mlrisk)
96
+ write_basic_package_version_file(
97
+ ${CMAKE_CURRENT_BINARY_DIR}/mlriskConfigVersion.cmake
98
+ COMPATIBILITY SameMajorVersion)
99
+ install(FILES
100
+ ${CMAKE_CURRENT_BINARY_DIR}/mlriskConfig.cmake
101
+ ${CMAKE_CURRENT_BINARY_DIR}/mlriskConfigVersion.cmake
102
+ DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/mlrisk)
103
+
104
+ # The .pc file locates the prefix relative to its own directory, whatever
105
+ # the depth of CMAKE_INSTALL_LIBDIR (lib, lib64, lib/x86_64-linux-gnu, ...)
106
+ file(RELATIVE_PATH MLRISK_PC_TO_PREFIX
107
+ "${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_LIBDIR}/pkgconfig" "${CMAKE_INSTALL_PREFIX}")
108
+ string(REGEX REPLACE "/$" "" MLRISK_PC_TO_PREFIX "${MLRISK_PC_TO_PREFIX}")
109
+ configure_file(cmake/mlrisk.pc.in ${CMAKE_CURRENT_BINARY_DIR}/mlrisk.pc @ONLY)
110
+ install(FILES ${CMAKE_CURRENT_BINARY_DIR}/mlrisk.pc
111
+ DESTINATION ${CMAKE_INSTALL_LIBDIR}/pkgconfig)
112
+ endif()
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 mlrisk contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,121 @@
1
+ Metadata-Version: 2.1
2
+ Name: walkforward
3
+ Version: 3.4.0
4
+ Summary: Volatility forecasts, position sizing and purged walk-forward splits that never see the future
5
+ Keywords: walk-forward,purged-cross-validation,backtesting,lookahead-bias,volatility,garch,position-sizing,quantitative-finance
6
+ Author: Haegan McGarry
7
+ License: MIT
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Financial and Insurance Industry
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: C
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Office/Business :: Financial :: Investment
15
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
16
+ Project-URL: Homepage, https://github.com/haeganm/walkforward
17
+ Project-URL: Changelog, https://github.com/haeganm/walkforward/blob/main/CHANGELOG.md
18
+ Requires-Python: >=3.9
19
+ Requires-Dist: numpy>=1.23
20
+ Provides-Extra: sklearn
21
+ Requires-Dist: scikit-learn>=1.1; extra == "sklearn"
22
+ Provides-Extra: test
23
+ Requires-Dist: pytest>=7; extra == "test"
24
+ Requires-Dist: scikit-learn>=1.1; extra == "test"
25
+ Requires-Dist: pandas>=1.5; extra == "test"
26
+ Description-Content-Type: text/markdown
27
+
28
+ # walkforward
29
+
30
+ > Volatility forecasts, position sizing and purged walk-forward splits that never see the future. A C11 library with a numpy front door.
31
+
32
+ ```bash
33
+ pip install walkforward
34
+ ```
35
+
36
+ Most backtests that look good are quietly peeking one bar ahead. The estimator that sizes a position knows the return it is about to earn, or a training label was computed from prices inside the test window. This library is built so those two things cannot happen by accident, and it is checked on every push against pandas, numpy, scikit-learn, the `arch` package and exact rational arithmetic.
37
+
38
+ - **One timing convention.** Every forecast at index `t` is built from data before `t`. `ewma_vol` and the GARCH filters are predictive; the rolling statistics and range estimators are contemporaneous and say so in their own docstrings, with `lag` to fix them.
39
+ - **Purging that asks the right question.** `PurgedWalkForward` takes a label horizon, not a purge count, because `purge = h - 1` is the part people get wrong. Drops straight into `cross_val_score`.
40
+ - **GARCH(1,1) by maximum likelihood**, checked against `arch` on identical samples and identical likelihoods, and invariant to the units of the returns.
41
+ - **Sizing that fails closed.** A bad price gives a zero position, not a NaN one. A leverage cap that could overflow is refused rather than silently ignored.
42
+ - **Ridge by Householder QR**, so a feature at a price level near 1e9 keeps its slope precision.
43
+
44
+ Research tooling, not investment advice. It is a set of building blocks, not a backtester: it knows nothing about costs, borrow, calendars or corporate actions.
45
+
46
+ ## The loop
47
+
48
+ ```python
49
+ import numpy as np
50
+ import walkforward as wf
51
+
52
+ # The fit assumes mean-zero returns. Demean with the TRAINING mean; the
53
+ # full-sample mean would put the future into the fit.
54
+ train = returns[:1000] - returns[:1000].mean()
55
+ model = wf.garch_fit(train)
56
+
57
+ # sigma[t] forecasts period t from returns before t. filter_from continues
58
+ # from the variance state the fit ended on, which is what makes the
59
+ # out-of-sample path the same as filtering everything together.
60
+ sigma = model.filter_from(returns[1000:])
61
+
62
+ # A position held over period t is entered at the close of t-1, so it is
63
+ # sized against the previous close, and earns position * price * return.
64
+ entry = close[999:-1]
65
+ position = wf.vol_target_position(
66
+ sigma, target_vol=0.01, equity=100_000.0, price=entry, max_leverage=2.0
67
+ )
68
+ pnl = position * entry * returns[1000:]
69
+ ```
70
+
71
+ `model.persistence`, `model.half_life` and `model.unconditional_vol` are there so you do not have to recompute them.
72
+
73
+ ## Cross-validation
74
+
75
+ ```python
76
+ from sklearn.ensemble import GradientBoostingRegressor
77
+ from sklearn.model_selection import cross_val_score
78
+ import walkforward as wf
79
+
80
+ # Target is a 21-period forward return, so labels span 21 periods and the
81
+ # last 20 training rows before each test window are dropped.
82
+ cv = wf.PurgedWalkForward(train_size=756, test_size=252, label_horizon=21)
83
+
84
+ scores = cross_val_score(GradientBoostingRegressor(), X, y, cv=cv)
85
+ ```
86
+
87
+ Without the purge, the twenty training rows before each test window carry labels that were partly computed from test-window prices. The library counts that leak in its own test suite: at a horizon of 21, a purge of 20 leaves zero leaking training rows and a purge of 19 leaves ten.
88
+
89
+ There is no `embargo` argument, on purpose. An embargo protects training data that sits after a test window, and a walk-forward never trains on anything after the window it is testing. `walk_forward_splits(..., include_post_train=True)` exposes that variant with the warning it deserves.
90
+
91
+ ## Conventions
92
+
93
+ Volatility is per period. Annualised converts as `annual / sqrt(periods_per_year)`, so a 10% annual target on daily data is `0.10 / sqrt(252)`, about 0.0063.
94
+
95
+ `rolling_std` uses the population convention and divides by `window`. Pandas `rolling().std()` defaults to the sample convention, so the two differ by `sqrt(window / (window - 1))`.
96
+
97
+ A pandas Series in gives a pandas Series out, on the same index. Realigning a bare array by hand is one of the ways lookahead gets in.
98
+
99
+ Bad arguments raise. Bad elements are handled per function and documented on each: a non-finite return is skipped by the recursions, makes a rolling window NaN, and gives a zero position in sizing. `DomainError` (a `ValueError`) means the arguments were fine but the computation has no answer: a singular design, a sample with no variance, an overflowing recursion.
100
+
101
+ ## What it is checked against
102
+
103
+ The C library underneath is compared on every push to independent implementations, and the numbers are reproducible from `tests/reference/` in the repository.
104
+
105
+ | Check | Reference | Result |
106
+ |---|---|---|
107
+ | Rolling mean and std, with gaps | pandas | 3.1e-11 |
108
+ | Rolling std at a price level of 1e9 | exact rational arithmetic | 1.7e-15, where a two-pass computation is off by 4.9e-12 |
109
+ | EWMA, predictive alignment | pandas `ewm` shifted one period | 3.5e-18 |
110
+ | GARCH filter and forecast | `arch` | 4.4e-16 relative |
111
+ | GARCH fit over 20 samples | `arch`, same likelihood | 5.0e-7 max parameter difference |
112
+ | Ridge, condition number 1e4 to 1e10 | SVD least squares | within 2x condition times epsilon |
113
+ | Ridge with a feature at level 1e6 to 1e15 | exact rational OLS | under 1e-14 |
114
+ | Walk-forward splits, 1620 parameter sets | independent generator | 0 mismatches |
115
+ | No lookahead, 40 trials | bitwise prefix comparison | 0 violations |
116
+
117
+ The C also runs under AddressSanitizer and UndefinedBehaviorSanitizer, on 32-bit and 64-bit, and gives bit-identical answers under GCC and Clang.
118
+
119
+ ## License
120
+
121
+ MIT. Source and the C library: https://github.com/haeganm/walkforward