An earlier round of this study found that no classical model forecast the next day’s Nifty 50 return better than a flat guess of zero. So I stopped trying to forecast the number and started forecasting how far it might move.
The interval that stayed still
The first interval I had came with the zero forecast. Take the standard deviation of every daily return since 1996, multiply by the Normal quantile, and draw an 80% band around zero. For the Nifty that band was about plus or minus 1.9%.
Then it met March 2020. Across 2019 to 2021 the band’s half-width went 0.0192, 0.0194, 0.0192. It moved by about 1% while the market moved by several times that. Inside February to June 2020 it covered 0.579 of next-day returns, 8 misses out of 19 test origins, against a nominal 0.80.
It was wrong the other way too. In the calm half of 2019 the band was far wider than it needed to be. An interval built from all of history is right on average and wrong almost every day.
Remember that yesterday was violent
A conditional variance model fixes this with one idea. If yesterday was a violent day, today probably will be too, so today’s band should be wider. Calm and turbulent periods come in runs, and the model keeps a running estimate of which one you are in.
The simplest version is EWMA, from the 1996 RiskMetrics document. It has no fitted parameters at all. Tomorrow’s variance is 94% of today’s variance plus 6% of today’s squared return, and that is the whole model.
- 1Yesterday's estimate carries over, shrunk to 94% of itself.
- 2Today's return enters squared, at 6% weight. A 4% day moves the estimate 64 times as much as a 0.5% day.
- 3The result is tomorrow's variance, and tomorrow it is the starting point. Nothing else is remembered.
GARCH generalises the loop by fitting the two weights and adding a constant, which gives the variance a long-run level to drift back to. The asymmetric version, GJR-GARCH, lets a falling day raise tomorrow’s variance more than a rising day of the same size, which is the best documented asymmetry in equity indices.
Here is what the loop did to the same stretch of 2019 to 2021.
- 1Jun to Dec 2019, calm. EWMA half-width 0.0116, flat 0.0192.
- 2Mar to May 2020, the crash. EWMA 0.0418, flat 0.0194.
- 3Jun to Dec 2021, calm again. EWMA 0.0093, flat 0.0192.
- EWMA 80% interval for the next day, set at each origin.
- Flat 80% interval from all of history.
- The next day's return. Filled when it fell outside the EWMA interval.
The EWMA half-width went 0.0116 in the calm, 0.0418 in the crash, and 0.0093 after, widening about 3.6 times and coming back. Inside the crash window it covered 0.895.
Over the full Phase 2 test, 400 origins from July 2018 to August 2026, the registered gate named EWMA before scoring anything and it passed all 14 checks. The 80% interval covered 0.787 at one day ahead and 0.815 at five, where five means the single day five sessions out. The 95% interval covered 0.940 and 0.938. On CRPS, which scores the whole predictive distribution, it came in at 0.874 and 0.866 of the flat interval. The model with no fitted parameters beat all three fitted GARCH variants on coverage.
Where the tails come from
The variance model gives you a standard deviation. It does not give you a shape, and most write-ups quietly reach for the Normal at this point.
I did not. Divide each historical return by the volatility the model assigned to that day and you get a standardised residual, z = r / σ. If the model is doing its job, those z values have no clustering left in them but keep whatever tail shape the market actually has. The interval’s quantiles come from the empirical quantiles of z, scaled by today’s σ. This is filtered historical simulation: history supplies the shape, the model supplies the scale.
The ablation says how much each half is worth. Empirical tails on a flat variance still covered 0.883 against a nominal 0.80 and failed Kupiec at p below 0.0001, so conditional variance is most of the effect. The tails matter on top of it. At five days ahead, GARCH with empirical tails covered 0.838 and the same model with Normal tails covered 0.873 and failed.
A month is not twenty independent days
A risk number is usually wanted over a holding period, not a single day. The move that matters is log(pt+20 / pt), the whole month from the origin.
The textbook shortcut is to scale: take the one-day spread and multiply by the square root of 20. That is exact if each day is an independent draw from the same distribution. The entire point of the variance model is that they are not. A bad first week raises the variance of the second, so a month that starts badly tends to keep going.
So I simulated the path instead.
- 1Draw one standardised residual from the fit's own history, with replacement.
- 2Scale it by the volatility this path has right now.
- 3Add the result to the path's running total.
- 4Feed the scaled shock into the variance recursion. A big draw makes the next day's volatility bigger on this path only.
- 5Repeat to twenty days. The total is one possible month; ten thousand of them are the distribution.
Step 4 is the one the shortcut throws away. In the EWMA simulator it is the last line of this loop, running across 10,000 paths at once:
var = np.full(n_paths, float(self._next))
for s in range(h):
out[:, s] = np.sqrt(var) * z[:, s]
var = lam * var + (1 - lam) * out[:, s] ** 2
The GJR version needs the sign of each simulated return to decide which weight applies, which is why it cannot be read off a variance forecast at all. The models only step one day forward; the engine sums the steps into a 20-day return.
What the simulator was checked against
A simulator nobody validated is a random number generator with ambition. The first check is a known-answer test: the constant-variance and EWMA paths are rebuilt from the formula in the test file, not by calling the model, and must match element for element.
The second check is that the simulator reduces to the shortcut when it should. With constant variance there is nothing to feed back, so the 20-day spread must equal σ√20. Over the 200 Phase 3 test origins it came out at 1.000 of that.
The third is that the conditional models must come out different, and in a specific direction. I compared each against twenty independent draws from its own one-day distribution: the same residuals and the same starting volatility, with the feedback removed.
| Over 200 test origins, median | EWMA | GJR-GARCH |
|---|---|---|
| 20-day standard deviation, simulated over independent | 1.03 | 1.12 |
| Excess kurtosis, simulated | 1.45 | 3.16 |
| Excess kurtosis, independent | 0.15 | 0.15 |
| 1% quantile, simulated over independent | 1.10 | 1.34 |
| Mean loss beyond the 5% quantile, simulated over independent | 1.08 | 1.30 |
For EWMA the 5% quantile barely moved, a ratio of 1.00. The difference lives further out, which is where a risk number is supposed to look. Most of GJR’s wider standard deviation is something else the shortcut ignores: its variance forecast drifting back up towards a long-run level through a calm test period. The fatter tail is the feedback.
- Square root of time: twenty independent draws from today's one-day distribution, summed.
- The path simulation: each draw feeds back into the next day's variance.
- Tail mass the square-root rule leaves out.
- 11% quantile under the square-root rule: -5.98%.
- 21% quantile from the simulation: -8.76%.
At the last test origin, 24 August 2026, the shortcut put the 1% quantile of the 20-day return at −5.98%. The simulation put it at −8.76%.
Two numbers out of the paths
With the target set to the cumulative move, value at risk is just a quantile of the simulated distribution: the 95% VaR is the lower edge of the 90% interval, converted to a loss as 1 − exp(r). It is already in the forecast store.
Expected shortfall is not. It is the average loss given that the VaR was breached, and ten stored quantiles cannot tell you the mean beyond the lowest one. So the engine computes it from the paths at fold time, the only moment they exist, and stores it beside the quantiles.
Phase 3 ran 200 origins from July 2010 to August 2026, spaced 20 trading days apart so no two months overlap. The registered primary model was GJR-GARCH.
| Horizon | 95% VaR | Expected shortfall | Breaches | Expected |
|---|---|---|---|---|
| 1 day | 1.67% | 2.32% | 9 | 10 |
| 5 days | 3.73% | 5.27% | 9 | 10 |
| 20 days | 7.50% | 11.03% | 9 | 10 |
Expected shortfall ran 1.4 to 1.5 times VaR at every horizon. That ratio is the part a VaR alone never tells you: how bad the bad month is once you are in it.
The flat comparator, constant variance with empirical tails, on the same 20-day object: its 80% interval covered 0.950, Kupiec p below 0.0001. It quoted a 20-day VaR of 10.0% where the conditional model quoted 7.5%, and it was breached 4 times where 10 were expected. Conditioning on current volatility is not a refinement. The unconditional interval is simply wrong.
The gate still said no. Of 21 registered checks, 20 passed; the 80% interval at 20 days covered 0.865 against a band of 0.75 to 0.85, too wide by about a point and a half. Kupiec on that same cell returned 0.016, which clears the corrected threshold of 0.0043, so a gate built on p-values alone would have passed it. Every check at the 95% level, the one the VaR sits on, passed. Fixing the width was a later round and a different post.
What was not tested
The ES backtest asks whether the realised losses on breach days averaged what the model predicted. It needs breaches to average, and I set the floor at ten, because below that the mean is one or two observations wearing a p-value.
GJR-GARCH was breached 9 times at every horizon. Every one of its expected shortfall rows says “not tested”. Across the whole table, 5 of 45 rows cleared the floor, all at the 95% level, and in all 5 the interval covered zero.
An earlier version of the test returned a pass when it declined, which turned “there were three breaches” into a clean bill of health. It now returns nothing, and the report prints “not tested”.
So the VaR above was tested and held: 9 breaches against 10 expected, at every horizon. The 11.03% expected shortfall was computed, not tested. At 200 origins the data could not check it, and the table says so.