Guyon-Lekeufack path-dependent volatility ported to native MQL5. sigma = b0 + b1*R1 + b2*sqrt(R2), where R1 and R2 are two-exponential kernel sums over past signed and squared returns, implemented as four running accumulators updated in O(1) per bar with no stored history. CPdvModel calibrates nine parameters by splitting them: a Nelder-Mead simplex over the six kernel parameters with the closed-form regression for the three betas nested inside its objective. Features are causal, the target is strictly forward, and the train/test cut is chronological. PDV_Evidence scores the model out of sample against a constant, an EWMA volatility and GARCH(1,1), and runs the b1-pinned-at-zero ablation that isolates what the sign of the path is worth.
139 lines
7.9 KiB
Markdown
139 lines
7.9 KiB
Markdown
# 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.
|