PDV/README.md

139 lines
7.9 KiB
Markdown

2026-09-07 09:11:20 +00:00
# PDV
Path-dependent volatility in native MQL5: the Guyon-Lekeufack model written as
four exponential accumulators, so the whole thing costs four multiplies and
four adds per bar and stores no history at all.
Companion code for the MQL5 article: https://www.mql5.com/en/articles/24607
## What it does
Square a return and you throw away its sign. Every GARCH-family model does this
in its first step, which is why a market that has fallen five percent and a
market that has risen five percent look identical to it. The econometric
patches, GJR and TARCH and EGARCH, add back one bit of that discarded
information: the sign of yesterday's return, times a single fixed multiplier.
Guyon and Lekeufack keep the whole path instead. Volatility becomes
```
sigma = b0 + b1 * R1 + b2 * sqrt(R2)
```
where `R1` is a kernel-weighted sum of past signed returns and `R2` the same
construction on squared returns. `R1` carries where the path has been going,
`R2` how hard it has been moving, and the claim is that those two numbers are
most of what there is to know about today's volatility.
The reason this belongs in an indicator rather than a research report is the
kernel. Both kernels are convex mixtures of two exponentials, and an
exponential kernel has a recursive update, so a sum over unbounded history
collapses to one running scalar. Two exponentials per feature and two features
make four scalars. `CPdvState` is that arithmetic and nothing else: no lookback
array, no matrix, nothing that grows with the length of the history, and no
knowledge of bars, so the same object serves a calibration sweep over ten years
and an indicator updating on every tick.
Calibration splits nine parameters by how they are found rather than solving
all nine the same way. Hold the kernels fixed and the model is a plain linear
regression of realised volatility on two features, so the three betas come out
of a closed form with no search. Only the six kernel parameters need an
optimiser, and they get a Nelder-Mead simplex with the regression nested inside
its objective. Six dimensions searched over three solved exactly is what makes
this calibrate in seconds rather than minutes.
Two choices in `CPdvModel` are about honesty rather than accuracy. The features
at bar `i` use returns up to and including bar `i` while the target is measured
from bar `i+1` onward, and the train/test cut is chronological, because a
shuffled split on data this autocorrelated will manufacture an R-squared out of
nothing. The evaluation window is also fixed before the search starts rather
than derived per candidate: a long-memoried kernel needs more warm-up than a
short one, so letting the window follow the candidate would score different
models on different samples and quietly reward whichever got the easiest bars.
`PDV_Evidence.mq5` is where the method has to justify itself. It scores the
model out of sample against a constant, an EWMA volatility and GARCH(1,1),
every baseline given the same intercept and slope the model gets, and then runs
the ablation that matters: the identical machinery with `b1` pinned at zero.
Same data, same target, same split, same two-exponential kernel on squared
returns, and the only difference is whether the sign of the path is allowed to
count. Whatever R-squared the trend term adds over that is the entire
measurable value of path dependence.
On H1 equity indices it is worth a lot. The trend term adds +0.171 out-of-sample
R-squared on SPX500 and +0.15 and +0.11 on US30 and NDX100. On FX it adds
roughly nothing, and that null is the useful part of the result rather than a
failure: the leverage effect is an equity phenomenon, a currency pair has no
issuer whose equity can be geared, and a `b1` near zero there is the model
reporting the truth. Gold's `b1` comes out positive, which is a different
market telling a different story about its own path.
Two things are worth knowing before reading any of those numbers.
A realised-volatility target caps the attainable R-squared by its own sampling
noise, because the target is an estimate and not the thing itself. On synthetic
data with a known generating process the fit scored 0.1156 against a
close-to-close target while a forecaster that *knew* the true volatility scored
0.1114. The fit was not weak; the target was noisy. Against the true generating
sigma the same fit correlated 0.976. Compute that ceiling before calling an
R-squared disappointing.
The model is linear in `R1` and `b1` is negative wherever leverage exists, so a
long enough rally can drive fitted volatility through zero. That is a property
of the specification, which the paper is explicit about, not a bug in the port.
Both values are exposed: `Sigma()` is floored at a share of the activity term
and is what a stop distance should be built on, while `SigmaRaw()` is what tells
you the model has been pushed outside the range it was fitted in.
`PDV_Calibrate.mq5` reports the count of negative raw bars for exactly this
reason. A handful is the model working as specified; thousands means the fit is
not usable.
The decomposition, not the R-squared, is the deliverable. Splitting volatility
into a direction component and an activity component answers a question a
single conditional-variance number cannot express, and it costs nothing extra
to compute: a market falling steadily and a market thrashing sideways at the
same volatility become distinguishable, before the fact, from the state alone.
## Layout
```
Include/PDV/PdvTypes.mqh structs, buffer map, half-life and decay conversions
Include/PDV/PdvState.mqh CPdvState: the four accumulators, O(1) per bar
Include/PDV/PdvModel.mqh CPdvModel: forward target, closed-form betas, Nelder-Mead, ablation
Include/PDV/PdvForecast.mqh CPdvForecast: decomposition, floor, trend share, multi-step projection
Indicators/PDV/PDV_Decomposition.mq5 total volatility against its trend and activity components
Scripts/PDV/PDV_Calibrate.mq5 fits one symbol, reports parameters, split scores, decomposition summary
Scripts/PDV/PDV_Evidence.mq5 out-of-sample comparison against constant, EWMA and GARCH(1,1)
```
Run `PDV_Calibrate.mq5` first on the symbol you care about. Its last line
prints the nine fitted parameters in input order, which paste straight into
`PDV_Decomposition.mq5`; the defaults shipped in the indicator are the SPX500
H1 fit. Then run `PDV_Evidence.mq5` for the comparison table and the ablation.
Pick the instrument deliberately, because it is load-bearing: an FX-only run
will show a gain near zero and no story at all. Broker symbol names vary, and
the indices are not always called what you expect.
Do not reconcile a figure from one script against another. Each rebuilds its
own scored window, so a different warm-up starts the scored range at a
different bar and the numbers legitimately differ: SPX500 comes out at 0.4480
under `PDV_Calibrate` at a 750-bar warm-up and 0.4464 inside the `PDV_Evidence`
loop, and both are correct.
Substituting your own target is a single edit. `BuildTarget()` in
`PdvModel.mqh` is the only place that decides what the model is being asked to
predict, and nothing downstream knows where the target came from. The two
supplied forms are close-to-close and Parkinson, which are on the same scale
and interchangeable; Parkinson reads the whole bar range and is several times
more efficient, at the cost of ignoring gaps.
## Disclaimer
Educational code. This is a volatility measurement and forecasting library, not
a trading system: there is no Expert Advisor here and nothing in this repository
demonstrates a trading edge. A better volatility forecast is an input to
position sizing and stop placement, which is a different thing from money.
The out-of-sample gains reported above are R-squared against a realised
volatility target on H1 bars from one broker's history, and they are strongly
instrument-dependent, close to zero on FX by construction. Test on your own
data and broker conditions before drawing conclusions.