Repository navigation
ENH: Add max_undetectable_understatement to backtest - #1015
sarthakguptaquant wants to merge 2 commits into
Conversation
Adds the sixth function from bashtage#904 that bashtage#965 left out pending a design decision. It finds the largest fraction by which a VaR series could have been scaled down, with the same returns, and still have passed kupiec_pof, christoffersen_independence, christoffersen_conditional_coverage and traffic_light. The search is over proportional scalings of var and is exact: the exception set only changes where a loss crosses the scaled VaR, so one pass over the breakpoints finds the limit with no optimizer. The result, MaxUndetectableUnderstatementResult, reports the limit, the test that binds just beyond it, the exceptions the limit would add, and whether the reported VaR passes to begin with. kupiec_pof and christoffersen_conditional_coverage are two-sided, so by default (ignore_too_few=True) a rejection caused by too few exceptions is not counted, since a conservative VaR is not evidence of understatement. This is a lower bound: a series that understates VaR on some days and overstates it on others can produce the same, or fewer, exceptions than a proportional scaling, and so can be harder to detect. The docstring states this caveat. Reuses _aligned, _exceptions, _lr_pof and _lr_independence for the search and the four public test functions for the baseline check, so the baseline validates p, length and the traffic_light window with the same errors as calling them directly. Tests add a hand-computed case, a brute-force grid check that the limit is the smallest passing proportional scaling, the no-exceptions and all-exceptions edges, both settings of ignore_too_few, and agreement with the public functions at the computed boundary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #1015 +/- ##
==========================================
+ Coverage 99.44% 99.51% +0.07%
==========================================
Files 153 152 -1
Lines 34229 34461 +232
Branches 2591 2603 +12
==========================================
+ Hits 34039 34295 +256
+ Misses 114 94 -20
+ Partials 76 72 -4
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Thanks for this. The code is clean and the breakpoint search is exact. There are two problems with what it reports.
1. The limit can lie beyond a scaling that is rejected. The search returns the smallest passing threshold, scanning up from 0. The independence test, and so the conditional coverage test, is not monotone in the threshold. So a smaller understatement can be rejected while the reported one passes:
import numpy as np
from arch.univariate import christoffersen_independence, max_undetectable_understatement
rng = np.random.default_rng(1234)
for _ in range(32):
returns = rng.standard_t(5, 250) * 0.01
var = np.full(250, 1.645 * 0.01 * rng.uniform(1.0, 1.4))
res = max_undetectable_understatement(returns, var, p=0.05)
res.understatement # 0.226, binding ('traffic_light',)
christoffersen_independence(returns, var * (1 - 0.187)).pval # 0.0357, rejected
christoffersen_independence(returns, var * (1 - 0.226)).pval # 0.1153Understating by 18.7% would have been detected, but the result says 22.6% is undetectable. This happened in 1 of the 50 passing series of this kind that I tried. The natural definition is the first rejection met when scaling down from the reported VaR. Scanning the breakpoints downward from 1 and stopping at the first failure gives that, and it usually needs only a few evaluations. Please also add a test with a non-monotone case.
2. ignore_too_few also ignores rejections that have nothing to do with too few exceptions. For christoffersen_conditional_coverage the code ignores any rejection when the exception rate is below p, even when the rejection comes from clustering:
from arch.univariate import christoffersen_conditional_coverage
returns = np.full(500, 0.001)
returns[200:210] = -0.05 # 10 consecutive exceptions, rate 0.02
var = np.full(500, 0.02)
christoffersen_conditional_coverage(returns, var, 0.05).pval # 4.1e-20
res = max_undetectable_understatement(
returns, var, p=0.05, tests=("christoffersen_conditional_coverage",)
)
res.passes, res.understatement # (True, 1.0)When the rate is below p, only the coverage part of the statistic should be set aside. For example, use LR_pof = 0 in the joint statistic and keep the chi2(2) critical value.
Smaller points:
- The boundary: the baseline counts a rejection at
pval <= size, but the search counts one only atLR > crit, so the two disagree at equality. Please use one rule. - Coverage: codecov reports 1 missed line and 3 partial lines in
backtest.py. - Reference: please add one for the diagnostic itself, if there is one. The current references are for the tests.
Address review on bashtage#1015. - Scan the breakpoints downward from the reported VaR and stop at the first rejected scaling. The independence test, and so the conditional coverage test, is not monotone in the scaling, so the old upward scan could report a limit beyond a rejected scaling. The result is now the largest s for which every scaling from 0 to s passes. - With ignore_too_few, set aside only the coverage part of a test. christoffersen_conditional_coverage now uses LR_pof = 0 with the chi2(2) critical value, so a rejection caused by clustering still counts. - Use one rejection rule, pval <= size, for the reported VaR and for every scaling, through the same gate. - Tests for the non-monotone case, clustered exceptions with too few exceptions, the shared rejection rule and the remaining branches. - Notes relate the result to Table 1 of the 1996 Basel framework. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks, both were real. Fixed in c99ac1e.
Smaller points:
|
xref #904. Follow-up to #965: adds the sixth function from the proposal,
max_undetectable_understatement, which was left out pending a decision. Kevin said hewas happy to have a follow-up PR.
A passing backtest says the reported VaR was not rejected. It does not say how close it
was. This adds a function that answers a concrete question: by what fraction could the
whole VaR series have been scaled down, with the same returns, and still have passed
the selected tests.
Adds to
arch/univariate/backtest.py, exported fromarch.univariate:max_undetectable_understatement(returns, var, p=0.05, size=0.05, tests=..., ignore_too_few=True, window=250):the largest fraction by which
varcould have been understated and still passkupiec_pof,christoffersen_independence,christoffersen_conditional_coverageandtraffic_light(all four by default). Returns a frozenMaxUndetectableUnderstatementResultwith the limit (
understatement), the test that binds just beyond it (binding), theexceptions the limit would add (
extra_exceptions), whether the reported VaR passes tobegin with (
passes,failed),nobsandp.The search is exact. The exception set of a proportional scaling
(1-s)*varonlychanges at the breakpoints
s = 1 - (-returns/var), so one pass over the breakpointsfinds the limit with no optimizer. It evaluates the tests once at each distinct
breakpoint, which is exact for this family of series.
It is a lower bound. A series that understates VaR on some days and overstates it on
others can produce the same, or fewer, exceptions than a proportional scaling, and so can
be harder to detect than this result suggests. The docstring states this.
ignore_too_few.kupiec_pofandchristoffersen_conditional_coveragearetwo-sided, so a VaR with too few exceptions is otherwise rejected for being too
conservative. That is not evidence of an understated VaR, so by default
(
ignore_too_few=True) it is not counted as a failure.Implementation. Reuses
_aligned,_exceptions,_lr_pofand_lr_independenceforthe breakpoint search, and the four public test functions for the baseline (
s=0) check,so the baseline validates
p, the length of the series and, fortraffic_light, thewindow, with the same errors as calling them directly.
Usage:
Tests, added to
arch/tests/univariate/test_backtest.py:computed limit is the largest understatement that still passes, and that any larger one fails.
and both settings of
ignore_too_fewon the same series.traffic_light-only case with a closed-form limit (the 5th largest ratio).var, an unknown or emptytests, a bare string passedwhere a sequence is expected, out-of-range
p/size/window, length mismatches, andtoo few observations for the selected tests.
Docs: a new paragraph and
autosummary/autoclassentries indoc/source/univariate/utility.rst, and a changelog entry indoc/source/changes/9.0.rst.Checked:
pytest arch/tests/univariate/test_backtest.py(231 passed), the fullarch/tests/univariatesuite (13678 passed, 336 skipped),numpydoc lint,ruff,blackandisort, all clean.