mtgarch 0.1.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.
Potentially problematic release.
This version of mtgarch might be problematic. Click here for more details.
- mtgarch-0.1.0/LICENSE +21 -0
- mtgarch-0.1.0/PKG-INFO +98 -0
- mtgarch-0.1.0/README.md +68 -0
- mtgarch-0.1.0/pyproject.toml +42 -0
- mtgarch-0.1.0/setup.cfg +4 -0
- mtgarch-0.1.0/src/mtgarch/__init__.py +37 -0
- mtgarch-0.1.0/src/mtgarch/core.py +294 -0
- mtgarch-0.1.0/src/mtgarch.egg-info/PKG-INFO +98 -0
- mtgarch-0.1.0/src/mtgarch.egg-info/SOURCES.txt +11 -0
- mtgarch-0.1.0/src/mtgarch.egg-info/dependency_links.txt +1 -0
- mtgarch-0.1.0/src/mtgarch.egg-info/requires.txt +8 -0
- mtgarch-0.1.0/src/mtgarch.egg-info/top_level.txt +1 -0
- mtgarch-0.1.0/tests/test_core.py +99 -0
mtgarch-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Muhammad Uzair
|
|
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.
|
mtgarch-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mtgarch
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Censored GARCH(1,1) estimation under price limits / circuit breakers (Morgan & Trevor, 1999)
|
|
5
|
+
Author-email: Muhammad Uzair <uzairlovesironman@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/uzair1509/psx-censored-volatility
|
|
8
|
+
Project-URL: Repository, https://github.com/uzair1509/psx-censored-volatility
|
|
9
|
+
Project-URL: Preprint (SSRN), https://papers.ssrn.com/sol3/papers.cfm?abstract_id=7527759
|
|
10
|
+
Keywords: garch,volatility,censored likelihood,price limits,econometrics,time series
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering
|
|
19
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: numpy>=1.22
|
|
24
|
+
Requires-Dist: scipy>=1.8
|
|
25
|
+
Provides-Extra: fast
|
|
26
|
+
Requires-Dist: numba>=0.56; extra == "fast"
|
|
27
|
+
Provides-Extra: test
|
|
28
|
+
Requires-Dist: pytest>=7.0; extra == "test"
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# mtgarch
|
|
32
|
+
|
|
33
|
+
Censored GARCH(1,1) estimation for markets with price limits or circuit
|
|
34
|
+
breakers -- an implementation of the expected-shock censored-GARCH approach
|
|
35
|
+
of Morgan & Trevor (1999), with three directly comparable estimators:
|
|
36
|
+
|
|
37
|
+
- **naive** -- ignores censoring; systematically understates shock
|
|
38
|
+
sensitivity (alpha) when censoring is frequent.
|
|
39
|
+
- **censored** -- correct censored likelihood, but feeds the *clipped*
|
|
40
|
+
observed return into the variance recursion; over-corrects alpha.
|
|
41
|
+
- **latent** -- correct censored likelihood *and* feeds the closed-form
|
|
42
|
+
conditional expectation `E[r*^2 | r* censored]` into the recursion; the
|
|
43
|
+
theoretically consistent member of the family.
|
|
44
|
+
|
|
45
|
+
Built for and validated on a full-exchange study of the Pakistan Stock
|
|
46
|
+
Exchange's daily price-limit rule (449 companies, 2016-2026); see the
|
|
47
|
+
accompanying paper and dataset below.
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install mtgarch
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Add the optional `fast` extra for a `numba`-accelerated inner loop
|
|
56
|
+
(recommended for large panels):
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install "mtgarch[fast]"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Usage
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
import numpy as np
|
|
66
|
+
from mtgarch import fit_all
|
|
67
|
+
|
|
68
|
+
r = ... # observed (possibly censored) log returns
|
|
69
|
+
r_up, r_lo = ... # band edges on each day (only used where up/lo are True)
|
|
70
|
+
up, lo = ... # bool masks: was day t censored at the upper/lower band?
|
|
71
|
+
use = ... # bool mask: include day t in the likelihood at all?
|
|
72
|
+
|
|
73
|
+
result = fit_all(r, r_up, r_lo, up, lo, use)
|
|
74
|
+
result["naive"]["alpha"], result["latent"]["alpha"]
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Each of `result["naive"]`, `result["censored"]`, `result["latent"]` is a
|
|
78
|
+
dict with `omega`, `alpha`, `beta`, `loglik`, `converged`.
|
|
79
|
+
|
|
80
|
+
Lower-level building blocks (`naive_nll`, `censored_nll`, `latent_nll`,
|
|
81
|
+
`garch_variance_path`, `garch_variance_path_breach`) are also exported for
|
|
82
|
+
custom optimization setups -- see `mtgarch.core` docstrings.
|
|
83
|
+
|
|
84
|
+
## Citing
|
|
85
|
+
|
|
86
|
+
If you use this package, please cite:
|
|
87
|
+
|
|
88
|
+
Uzair, M. (2026). *Price Limits Hide Volatility: Censored GARCH Evidence
|
|
89
|
+
from the Pakistan Stock Exchange*. SSRN Working Paper, Abstract ID 7527759.
|
|
90
|
+
|
|
91
|
+
## Links
|
|
92
|
+
|
|
93
|
+
- Paper / data / analysis code: https://github.com/uzair1509/psx-censored-volatility
|
|
94
|
+
- Preprint: https://papers.ssrn.com/sol3/papers.cfm?abstract_id=7527759
|
|
95
|
+
|
|
96
|
+
## License
|
|
97
|
+
|
|
98
|
+
MIT
|
mtgarch-0.1.0/README.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# mtgarch
|
|
2
|
+
|
|
3
|
+
Censored GARCH(1,1) estimation for markets with price limits or circuit
|
|
4
|
+
breakers -- an implementation of the expected-shock censored-GARCH approach
|
|
5
|
+
of Morgan & Trevor (1999), with three directly comparable estimators:
|
|
6
|
+
|
|
7
|
+
- **naive** -- ignores censoring; systematically understates shock
|
|
8
|
+
sensitivity (alpha) when censoring is frequent.
|
|
9
|
+
- **censored** -- correct censored likelihood, but feeds the *clipped*
|
|
10
|
+
observed return into the variance recursion; over-corrects alpha.
|
|
11
|
+
- **latent** -- correct censored likelihood *and* feeds the closed-form
|
|
12
|
+
conditional expectation `E[r*^2 | r* censored]` into the recursion; the
|
|
13
|
+
theoretically consistent member of the family.
|
|
14
|
+
|
|
15
|
+
Built for and validated on a full-exchange study of the Pakistan Stock
|
|
16
|
+
Exchange's daily price-limit rule (449 companies, 2016-2026); see the
|
|
17
|
+
accompanying paper and dataset below.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install mtgarch
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Add the optional `fast` extra for a `numba`-accelerated inner loop
|
|
26
|
+
(recommended for large panels):
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install "mtgarch[fast]"
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
import numpy as np
|
|
36
|
+
from mtgarch import fit_all
|
|
37
|
+
|
|
38
|
+
r = ... # observed (possibly censored) log returns
|
|
39
|
+
r_up, r_lo = ... # band edges on each day (only used where up/lo are True)
|
|
40
|
+
up, lo = ... # bool masks: was day t censored at the upper/lower band?
|
|
41
|
+
use = ... # bool mask: include day t in the likelihood at all?
|
|
42
|
+
|
|
43
|
+
result = fit_all(r, r_up, r_lo, up, lo, use)
|
|
44
|
+
result["naive"]["alpha"], result["latent"]["alpha"]
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Each of `result["naive"]`, `result["censored"]`, `result["latent"]` is a
|
|
48
|
+
dict with `omega`, `alpha`, `beta`, `loglik`, `converged`.
|
|
49
|
+
|
|
50
|
+
Lower-level building blocks (`naive_nll`, `censored_nll`, `latent_nll`,
|
|
51
|
+
`garch_variance_path`, `garch_variance_path_breach`) are also exported for
|
|
52
|
+
custom optimization setups -- see `mtgarch.core` docstrings.
|
|
53
|
+
|
|
54
|
+
## Citing
|
|
55
|
+
|
|
56
|
+
If you use this package, please cite:
|
|
57
|
+
|
|
58
|
+
Uzair, M. (2026). *Price Limits Hide Volatility: Censored GARCH Evidence
|
|
59
|
+
from the Pakistan Stock Exchange*. SSRN Working Paper, Abstract ID 7527759.
|
|
60
|
+
|
|
61
|
+
## Links
|
|
62
|
+
|
|
63
|
+
- Paper / data / analysis code: https://github.com/uzair1509/psx-censored-volatility
|
|
64
|
+
- Preprint: https://papers.ssrn.com/sol3/papers.cfm?abstract_id=7527759
|
|
65
|
+
|
|
66
|
+
## License
|
|
67
|
+
|
|
68
|
+
MIT
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mtgarch"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Censored GARCH(1,1) estimation under price limits / circuit breakers (Morgan & Trevor, 1999)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Muhammad Uzair", email = "uzairlovesironman@gmail.com" }
|
|
14
|
+
]
|
|
15
|
+
keywords = ["garch", "volatility", "censored likelihood", "price limits", "econometrics", "time series"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Science/Research",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.9",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Topic :: Scientific/Engineering",
|
|
25
|
+
"Topic :: Office/Business :: Financial",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"numpy>=1.22",
|
|
29
|
+
"scipy>=1.8",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
fast = ["numba>=0.56"]
|
|
34
|
+
test = ["pytest>=7.0"]
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://github.com/uzair1509/psx-censored-volatility"
|
|
38
|
+
Repository = "https://github.com/uzair1509/psx-censored-volatility"
|
|
39
|
+
"Preprint (SSRN)" = "https://papers.ssrn.com/sol3/papers.cfm?abstract_id=7527759"
|
|
40
|
+
|
|
41
|
+
[tool.setuptools.packages.find]
|
|
42
|
+
where = ["src"]
|
mtgarch-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""mtgarch: censored-GARCH(1,1) estimation under price limits.
|
|
2
|
+
|
|
3
|
+
An implementation of the expected-shock censored-GARCH approach of
|
|
4
|
+
Morgan & Trevor (1999), extended with an explicit, numerically stable
|
|
5
|
+
comparison against the naive (censoring-ignoring) and censored
|
|
6
|
+
(clipped-recursion) alternatives.
|
|
7
|
+
|
|
8
|
+
See :mod:`mtgarch.core` for the full estimator API and references.
|
|
9
|
+
"""
|
|
10
|
+
from .core import (
|
|
11
|
+
X0,
|
|
12
|
+
censored_nll,
|
|
13
|
+
fit_all,
|
|
14
|
+
fit_censored,
|
|
15
|
+
fit_latent,
|
|
16
|
+
fit_naive,
|
|
17
|
+
garch_variance_path,
|
|
18
|
+
garch_variance_path_breach,
|
|
19
|
+
latent_nll,
|
|
20
|
+
naive_nll,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__version__ = "0.1.0"
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"__version__",
|
|
27
|
+
"X0",
|
|
28
|
+
"garch_variance_path",
|
|
29
|
+
"garch_variance_path_breach",
|
|
30
|
+
"naive_nll",
|
|
31
|
+
"censored_nll",
|
|
32
|
+
"latent_nll",
|
|
33
|
+
"fit_naive",
|
|
34
|
+
"fit_censored",
|
|
35
|
+
"fit_latent",
|
|
36
|
+
"fit_all",
|
|
37
|
+
]
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Censored-GARCH(1,1) estimation under price limits / circuit breakers.
|
|
2
|
+
|
|
3
|
+
Three estimators, all Gaussian-innovation GARCH(1,1), differing only in how
|
|
4
|
+
they handle observations censored by a symmetric or asymmetric price band
|
|
5
|
+
(e.g. an exchange daily price limit):
|
|
6
|
+
|
|
7
|
+
naive : ignores censoring entirely -- treats the clipped observed
|
|
8
|
+
return as if it were the true return. Systematically
|
|
9
|
+
underestimates alpha (shock sensitivity) when censoring is
|
|
10
|
+
frequent, since large true shocks get truncated before they
|
|
11
|
+
reach the likelihood.
|
|
12
|
+
|
|
13
|
+
censored : uses the correct censored likelihood (upper/lower tail
|
|
14
|
+
probabilities in place of a density on censored days), but
|
|
15
|
+
feeds the *observed* (clipped) return back into next period's
|
|
16
|
+
variance recursion. This is a natural implementation
|
|
17
|
+
shortcut, but it systematically over-corrects: Monte Carlo
|
|
18
|
+
validation shows it inflates alpha, because it treats a
|
|
19
|
+
possibly-far-out-in-the-tail latent shock as if it were
|
|
20
|
+
exactly at the band edge.
|
|
21
|
+
|
|
22
|
+
latent : uses the correct censored likelihood AND feeds the correct
|
|
23
|
+
conditional expectation E[r*^2 | r* censored] into next
|
|
24
|
+
period's variance recursion, exploiting the closed-form
|
|
25
|
+
truncated-normal second moment. This is the theoretically
|
|
26
|
+
consistent (up to a small non-vanishing bias -- see the
|
|
27
|
+
accompanying paper) member of the family and the one that
|
|
28
|
+
should be used for inference.
|
|
29
|
+
|
|
30
|
+
This is an implementation of the expected-shock censored-GARCH approach
|
|
31
|
+
of Morgan & Trevor (1999), with the ``latent`` and ``censored`` maths
|
|
32
|
+
spelled out explicitly here so both are directly comparable, plus a
|
|
33
|
+
numerically robust breach-day convention shared across all three
|
|
34
|
+
estimators (see ``garch_variance_path_breach``).
|
|
35
|
+
|
|
36
|
+
All three share the same GARCH(1,1) variance recursion:
|
|
37
|
+
|
|
38
|
+
sigma2[t] = omega + alpha * e2[t-1] + beta * sigma2[t-1]
|
|
39
|
+
|
|
40
|
+
differing only in what e2[t-1] is on a censored or "breach" day (a day
|
|
41
|
+
excluded from the likelihood entirely -- a bad tick, an ex-dividend date,
|
|
42
|
+
etc.): naive uses the raw observed r^2; censored uses the clipped observed
|
|
43
|
+
r^2; latent uses E[r*^2 | r* censored]; and all three, on a breach day, use
|
|
44
|
+
the model's own current variance forecast sigma2[t] (there being no
|
|
45
|
+
information to do otherwise), rather than zeroing the return -- a
|
|
46
|
+
convention that keeps naive/censored and latent comparable on days neither
|
|
47
|
+
model actually observes.
|
|
48
|
+
|
|
49
|
+
Basic usage
|
|
50
|
+
-----------
|
|
51
|
+
>>> import numpy as np
|
|
52
|
+
>>> from mtgarch import fit_all
|
|
53
|
+
>>> r = np.random.default_rng(0).normal(0, 0.02, 2000)
|
|
54
|
+
>>> # up/lo: boolean masks marking days where the true return was censored
|
|
55
|
+
>>> # at the upper / lower band; r_up/r_lo: the band edges on those days;
|
|
56
|
+
>>> # use: boolean mask of days to include in the likelihood (False on
|
|
57
|
+
>>> # 'breach' days such as bad ticks or corporate-action jumps).
|
|
58
|
+
>>> up = lo = np.zeros(len(r), dtype=bool)
|
|
59
|
+
>>> r_up = r_lo = np.zeros(len(r))
|
|
60
|
+
>>> use = np.ones(len(r), dtype=bool)
|
|
61
|
+
>>> result = fit_all(r, r_up, r_lo, up, lo, use)
|
|
62
|
+
>>> sorted(result.keys())
|
|
63
|
+
['censored', 'latent', 'naive']
|
|
64
|
+
"""
|
|
65
|
+
from __future__ import annotations
|
|
66
|
+
|
|
67
|
+
import math
|
|
68
|
+
|
|
69
|
+
import numpy as np
|
|
70
|
+
from scipy.optimize import minimize
|
|
71
|
+
from scipy.signal import lfilter
|
|
72
|
+
from scipy.stats import norm
|
|
73
|
+
|
|
74
|
+
try: # optional speed-up; pure Python fallback is fine (~1s per series)
|
|
75
|
+
from numba import njit
|
|
76
|
+
except ImportError: # pragma: no cover - exercised only without numba installed
|
|
77
|
+
def njit(*a, **k):
|
|
78
|
+
return (lambda f: f) if not (a and callable(a[0])) else a[0]
|
|
79
|
+
|
|
80
|
+
__all__ = [
|
|
81
|
+
"X0",
|
|
82
|
+
"garch_variance_path",
|
|
83
|
+
"garch_variance_path_breach",
|
|
84
|
+
"naive_nll",
|
|
85
|
+
"censored_nll",
|
|
86
|
+
"latent_nll",
|
|
87
|
+
"fit_naive",
|
|
88
|
+
"fit_censored",
|
|
89
|
+
"fit_latent",
|
|
90
|
+
"fit_all",
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
#: Default GARCH(1,1) starting point (omega, alpha, beta) for the optimizer.
|
|
94
|
+
X0 = np.array([1e-4, 0.10, 0.85])
|
|
95
|
+
|
|
96
|
+
_OPTS = dict(method="Nelder-Mead", options={"maxiter": 4000, "xatol": 1e-7, "fatol": 1e-6})
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def garch_variance_path(r, omega, alpha, beta):
|
|
100
|
+
"""GARCH(1,1) variance path via ``scipy.signal.lfilter``.
|
|
101
|
+
|
|
102
|
+
sigma2[0] = var(r); sigma2[t] = omega + alpha*r[t-1]**2 + beta*sigma2[t-1].
|
|
103
|
+
|
|
104
|
+
Fast path for series with no breach days. Use
|
|
105
|
+
:func:`garch_variance_path_breach` when a breach mask needs to be
|
|
106
|
+
honored -- it falls back to this function automatically when there is
|
|
107
|
+
nothing to handle.
|
|
108
|
+
"""
|
|
109
|
+
s0 = np.var(r)
|
|
110
|
+
x = omega + alpha * np.asarray(r)[:-1] ** 2
|
|
111
|
+
y = lfilter([1.0], [1.0, -beta], x, zi=[beta * s0])[0]
|
|
112
|
+
return np.concatenate(([s0], y))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@njit(cache=True)
|
|
116
|
+
def _variance_path_breach_loop(w, a, b, r, breach):
|
|
117
|
+
n = len(r)
|
|
118
|
+
s2 = np.empty(n)
|
|
119
|
+
s2[0] = np.var(r)
|
|
120
|
+
for t in range(1, n):
|
|
121
|
+
e2 = s2[t - 1] if breach[t - 1] else r[t - 1] * r[t - 1]
|
|
122
|
+
s2[t] = w + a * e2 + b * s2[t - 1]
|
|
123
|
+
return s2
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def garch_variance_path_breach(r, omega, alpha, beta, breach):
|
|
127
|
+
"""Breach-aware GARCH(1,1) variance path.
|
|
128
|
+
|
|
129
|
+
On a breach day (``breach[t]`` True -- a day excluded from the
|
|
130
|
+
likelihood entirely, e.g. a bad tick or an ex-dividend jump), the shock
|
|
131
|
+
fed forward into period t+1 is the model's own current variance
|
|
132
|
+
forecast (``e2 = sigma2[t]``), not the raw (possibly huge, spurious)
|
|
133
|
+
``r[t]**2``, and not zero. Falls back to the faster
|
|
134
|
+
:func:`garch_variance_path` when there are no breach days (the common
|
|
135
|
+
case).
|
|
136
|
+
"""
|
|
137
|
+
r = np.asarray(r, dtype=float)
|
|
138
|
+
breach = np.asarray(breach, dtype=bool)
|
|
139
|
+
if not breach[:-1].any():
|
|
140
|
+
return garch_variance_path(r, omega, alpha, beta)
|
|
141
|
+
return _variance_path_breach_loop(omega, alpha, beta, r, breach)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _valid(p):
|
|
145
|
+
w, a, b = p
|
|
146
|
+
return w > 0 and a >= 0 and b >= 0 and a + b < 1
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def naive_nll(p, r, use, breach=None):
|
|
150
|
+
"""Negative log-likelihood for the naive (censoring-ignoring) model."""
|
|
151
|
+
if not _valid(p):
|
|
152
|
+
return 1e10
|
|
153
|
+
r = np.asarray(r)
|
|
154
|
+
use = np.asarray(use, dtype=bool)
|
|
155
|
+
if breach is None:
|
|
156
|
+
s = np.sqrt(garch_variance_path(r, *p))
|
|
157
|
+
else:
|
|
158
|
+
s = np.sqrt(garch_variance_path_breach(r, *p, breach))
|
|
159
|
+
return -norm.logpdf(r[use], scale=s[use]).sum()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def censored_nll(p, r, r_up, r_lo, up, lo, use, breach=None):
|
|
163
|
+
"""Negative log-likelihood for the censored (clipped-recursion) model."""
|
|
164
|
+
if not _valid(p):
|
|
165
|
+
return 1e10
|
|
166
|
+
r = np.asarray(r)
|
|
167
|
+
up = np.asarray(up, dtype=bool)
|
|
168
|
+
lo = np.asarray(lo, dtype=bool)
|
|
169
|
+
use = np.asarray(use, dtype=bool)
|
|
170
|
+
if breach is None:
|
|
171
|
+
s = np.sqrt(garch_variance_path(r, *p))
|
|
172
|
+
else:
|
|
173
|
+
s = np.sqrt(garch_variance_path_breach(r, *p, breach))
|
|
174
|
+
mid = use & ~up & ~lo
|
|
175
|
+
ll = norm.logpdf(r[mid], scale=s[mid]).sum()
|
|
176
|
+
ll += norm.logsf(np.asarray(r_up)[up & use] / s[up & use]).sum() # P(latent >= upper)
|
|
177
|
+
ll += norm.logcdf(np.asarray(r_lo)[lo & use] / s[lo & use]).sum() # P(latent <= lower)
|
|
178
|
+
return -ll
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@njit(cache=True)
|
|
182
|
+
def _latent_nll(w, a, b, r, ru, rl, up, lo, use):
|
|
183
|
+
n = len(r)
|
|
184
|
+
s2 = np.var(r)
|
|
185
|
+
ll = 0.0
|
|
186
|
+
c = math.sqrt(2 * math.pi)
|
|
187
|
+
for t in range(n):
|
|
188
|
+
s = math.sqrt(s2)
|
|
189
|
+
if use[t]:
|
|
190
|
+
if up[t]:
|
|
191
|
+
z = ru[t] / s
|
|
192
|
+
tail = max(0.5 * math.erfc(z / math.sqrt(2)), 1e-300)
|
|
193
|
+
ll += math.log(tail)
|
|
194
|
+
e2 = s2 * (1 + z * math.exp(-0.5 * z * z) / c / tail) # E[r*^2 | r* >= upper]
|
|
195
|
+
elif lo[t]:
|
|
196
|
+
z = rl[t] / s
|
|
197
|
+
tail = max(0.5 * math.erfc(-z / math.sqrt(2)), 1e-300)
|
|
198
|
+
ll += math.log(tail)
|
|
199
|
+
e2 = s2 * (1 - z * math.exp(-0.5 * z * z) / c / tail) # E[r*^2 | r* <= lower]
|
|
200
|
+
else:
|
|
201
|
+
ll += -0.5 * math.log(2 * math.pi * s2) - 0.5 * r[t] * r[t] / s2
|
|
202
|
+
e2 = r[t] * r[t]
|
|
203
|
+
else:
|
|
204
|
+
e2 = s2 # breach day: no information, carry the forecast forward
|
|
205
|
+
s2 = w + a * e2 + b * s2
|
|
206
|
+
return -ll
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def latent_nll(p, r, r_up, r_lo, up, lo, use):
|
|
210
|
+
"""Negative log-likelihood for the latent (expected-shock) model.
|
|
211
|
+
|
|
212
|
+
This is the theoretically consistent member of the family: it feeds
|
|
213
|
+
E[r*^2 | r* censored] -- the closed-form truncated-normal conditional
|
|
214
|
+
second moment -- into the variance recursion on censored days.
|
|
215
|
+
"""
|
|
216
|
+
if not _valid(p):
|
|
217
|
+
return 1e10
|
|
218
|
+
r = np.asarray(r, dtype=float)
|
|
219
|
+
r_up = np.asarray(r_up, dtype=float)
|
|
220
|
+
r_lo = np.asarray(r_lo, dtype=float)
|
|
221
|
+
up = np.asarray(up, dtype=bool)
|
|
222
|
+
lo = np.asarray(lo, dtype=bool)
|
|
223
|
+
use = np.asarray(use, dtype=bool)
|
|
224
|
+
return _latent_nll(p[0], p[1], p[2], r, r_up, r_lo, up, lo, use)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _fit(nll_fn, args, x0=X0, extra_starts=()):
|
|
228
|
+
starts = [x0, *extra_starts]
|
|
229
|
+
fits = [minimize(nll_fn, s, args=args, **_OPTS) for s in starts]
|
|
230
|
+
return min(fits, key=lambda z: z.fun)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def fit_naive(r, use, breach=None, x0=X0):
|
|
234
|
+
"""Fit the naive GARCH(1,1) model. Returns a ``scipy.optimize`` result."""
|
|
235
|
+
return _fit(naive_nll, (r, use, breach), x0=x0)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def fit_censored(r, r_up, r_lo, up, lo, use, breach=None, x0=X0, extra_starts=()):
|
|
239
|
+
"""Fit the censored (clipped-recursion) model. Returns an optimizer result."""
|
|
240
|
+
return _fit(censored_nll, (r, r_up, r_lo, up, lo, use, breach), x0=x0, extra_starts=extra_starts)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def fit_latent(r, r_up, r_lo, up, lo, use, x0=X0, extra_starts=()):
|
|
244
|
+
"""Fit the latent (expected-shock) model. Returns an optimizer result."""
|
|
245
|
+
return _fit(latent_nll, (r, r_up, r_lo, up, lo, use), x0=x0, extra_starts=extra_starts)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def fit_all(r, r_up, r_lo, up, lo, use, breach=None):
|
|
249
|
+
"""Fit naive, censored, and latent models, warm-starting from the naive fit.
|
|
250
|
+
|
|
251
|
+
Parameters
|
|
252
|
+
----------
|
|
253
|
+
r : array_like
|
|
254
|
+
Observed (possibly censored) log returns.
|
|
255
|
+
r_up, r_lo : array_like
|
|
256
|
+
Upper/lower band edges on each day, in the same units as ``r``.
|
|
257
|
+
Only used where ``up``/``lo`` are True.
|
|
258
|
+
up, lo : array_like of bool
|
|
259
|
+
Whether the true return on day t was censored at the upper/lower
|
|
260
|
+
band (i.e. the true return is only known to be >= r_up[t] or
|
|
261
|
+
<= r_lo[t]).
|
|
262
|
+
use : array_like of bool
|
|
263
|
+
Whether day t should be included in the likelihood at all (False
|
|
264
|
+
for a "breach" day -- a bad tick, a corporate-action jump, etc.).
|
|
265
|
+
breach : array_like of bool, optional
|
|
266
|
+
Same length as ``r``; True marks a breach day for the purpose of
|
|
267
|
+
the variance recursion (see :func:`garch_variance_path_breach`).
|
|
268
|
+
Defaults to ``~use`` if not given.
|
|
269
|
+
|
|
270
|
+
Returns
|
|
271
|
+
-------
|
|
272
|
+
dict with keys "naive", "censored", "latent", each mapping to a dict
|
|
273
|
+
with keys "omega", "alpha", "beta", "loglik", "converged".
|
|
274
|
+
"""
|
|
275
|
+
r = np.asarray(r, dtype=float)
|
|
276
|
+
use = np.asarray(use, dtype=bool)
|
|
277
|
+
if breach is None:
|
|
278
|
+
breach = ~use
|
|
279
|
+
|
|
280
|
+
n = fit_naive(r, use, breach)
|
|
281
|
+
starts = [n.x] if _valid(n.x) else []
|
|
282
|
+
c = fit_censored(r, r_up, r_lo, up, lo, use, breach, extra_starts=starts)
|
|
283
|
+
L = fit_latent(r, r_up, r_lo, up, lo, use, extra_starts=starts)
|
|
284
|
+
|
|
285
|
+
def _pack(res):
|
|
286
|
+
return {
|
|
287
|
+
"omega": float(res.x[0]),
|
|
288
|
+
"alpha": float(res.x[1]),
|
|
289
|
+
"beta": float(res.x[2]),
|
|
290
|
+
"loglik": float(-res.fun),
|
|
291
|
+
"converged": bool(res.success),
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return {"naive": _pack(n), "censored": _pack(c), "latent": _pack(L)}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mtgarch
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Censored GARCH(1,1) estimation under price limits / circuit breakers (Morgan & Trevor, 1999)
|
|
5
|
+
Author-email: Muhammad Uzair <uzairlovesironman@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/uzair1509/psx-censored-volatility
|
|
8
|
+
Project-URL: Repository, https://github.com/uzair1509/psx-censored-volatility
|
|
9
|
+
Project-URL: Preprint (SSRN), https://papers.ssrn.com/sol3/papers.cfm?abstract_id=7527759
|
|
10
|
+
Keywords: garch,volatility,censored likelihood,price limits,econometrics,time series
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering
|
|
19
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: numpy>=1.22
|
|
24
|
+
Requires-Dist: scipy>=1.8
|
|
25
|
+
Provides-Extra: fast
|
|
26
|
+
Requires-Dist: numba>=0.56; extra == "fast"
|
|
27
|
+
Provides-Extra: test
|
|
28
|
+
Requires-Dist: pytest>=7.0; extra == "test"
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# mtgarch
|
|
32
|
+
|
|
33
|
+
Censored GARCH(1,1) estimation for markets with price limits or circuit
|
|
34
|
+
breakers -- an implementation of the expected-shock censored-GARCH approach
|
|
35
|
+
of Morgan & Trevor (1999), with three directly comparable estimators:
|
|
36
|
+
|
|
37
|
+
- **naive** -- ignores censoring; systematically understates shock
|
|
38
|
+
sensitivity (alpha) when censoring is frequent.
|
|
39
|
+
- **censored** -- correct censored likelihood, but feeds the *clipped*
|
|
40
|
+
observed return into the variance recursion; over-corrects alpha.
|
|
41
|
+
- **latent** -- correct censored likelihood *and* feeds the closed-form
|
|
42
|
+
conditional expectation `E[r*^2 | r* censored]` into the recursion; the
|
|
43
|
+
theoretically consistent member of the family.
|
|
44
|
+
|
|
45
|
+
Built for and validated on a full-exchange study of the Pakistan Stock
|
|
46
|
+
Exchange's daily price-limit rule (449 companies, 2016-2026); see the
|
|
47
|
+
accompanying paper and dataset below.
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install mtgarch
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Add the optional `fast` extra for a `numba`-accelerated inner loop
|
|
56
|
+
(recommended for large panels):
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install "mtgarch[fast]"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Usage
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
import numpy as np
|
|
66
|
+
from mtgarch import fit_all
|
|
67
|
+
|
|
68
|
+
r = ... # observed (possibly censored) log returns
|
|
69
|
+
r_up, r_lo = ... # band edges on each day (only used where up/lo are True)
|
|
70
|
+
up, lo = ... # bool masks: was day t censored at the upper/lower band?
|
|
71
|
+
use = ... # bool mask: include day t in the likelihood at all?
|
|
72
|
+
|
|
73
|
+
result = fit_all(r, r_up, r_lo, up, lo, use)
|
|
74
|
+
result["naive"]["alpha"], result["latent"]["alpha"]
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Each of `result["naive"]`, `result["censored"]`, `result["latent"]` is a
|
|
78
|
+
dict with `omega`, `alpha`, `beta`, `loglik`, `converged`.
|
|
79
|
+
|
|
80
|
+
Lower-level building blocks (`naive_nll`, `censored_nll`, `latent_nll`,
|
|
81
|
+
`garch_variance_path`, `garch_variance_path_breach`) are also exported for
|
|
82
|
+
custom optimization setups -- see `mtgarch.core` docstrings.
|
|
83
|
+
|
|
84
|
+
## Citing
|
|
85
|
+
|
|
86
|
+
If you use this package, please cite:
|
|
87
|
+
|
|
88
|
+
Uzair, M. (2026). *Price Limits Hide Volatility: Censored GARCH Evidence
|
|
89
|
+
from the Pakistan Stock Exchange*. SSRN Working Paper, Abstract ID 7527759.
|
|
90
|
+
|
|
91
|
+
## Links
|
|
92
|
+
|
|
93
|
+
- Paper / data / analysis code: https://github.com/uzair1509/psx-censored-volatility
|
|
94
|
+
- Preprint: https://papers.ssrn.com/sol3/papers.cfm?abstract_id=7527759
|
|
95
|
+
|
|
96
|
+
## License
|
|
97
|
+
|
|
98
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/mtgarch/__init__.py
|
|
5
|
+
src/mtgarch/core.py
|
|
6
|
+
src/mtgarch.egg-info/PKG-INFO
|
|
7
|
+
src/mtgarch.egg-info/SOURCES.txt
|
|
8
|
+
src/mtgarch.egg-info/dependency_links.txt
|
|
9
|
+
src/mtgarch.egg-info/requires.txt
|
|
10
|
+
src/mtgarch.egg-info/top_level.txt
|
|
11
|
+
tests/test_core.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mtgarch
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from scipy.optimize import minimize
|
|
3
|
+
|
|
4
|
+
from mtgarch import (
|
|
5
|
+
X0,
|
|
6
|
+
fit_all,
|
|
7
|
+
garch_variance_path,
|
|
8
|
+
garch_variance_path_breach,
|
|
9
|
+
latent_nll,
|
|
10
|
+
naive_nll,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _loop(r, w, a, b):
|
|
15
|
+
s = np.zeros(len(r))
|
|
16
|
+
s[0] = np.var(r)
|
|
17
|
+
for t in range(1, len(r)):
|
|
18
|
+
s[t] = w + a * r[t - 1] ** 2 + b * s[t - 1]
|
|
19
|
+
return s
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_lfilter_matches_original_loop():
|
|
23
|
+
r = np.random.default_rng(0).normal(0, 0.02, 500)
|
|
24
|
+
assert np.allclose(
|
|
25
|
+
garch_variance_path(r, 2e-5, 0.12, 0.83), _loop(r, 2e-5, 0.12, 0.83)
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_latent_model_recovers_alpha_on_simulated_limits():
|
|
30
|
+
rng = np.random.default_rng(1)
|
|
31
|
+
w, a, b, T, L = 2e-5, 0.15, 0.80, 3000, 0.04
|
|
32
|
+
s2 = w / (1 - a - b)
|
|
33
|
+
lat = np.zeros(T)
|
|
34
|
+
for t in range(T):
|
|
35
|
+
lat[t] = np.sqrt(s2) * rng.standard_normal()
|
|
36
|
+
s2 = w + a * lat[t] ** 2 + b * s2
|
|
37
|
+
r = np.clip(lat, -L, L)
|
|
38
|
+
up, lo, use = r >= L, r <= -L, np.ones(T, bool)
|
|
39
|
+
ru, rl = np.full(T, L), np.full(T, -L)
|
|
40
|
+
opts = dict(method="Nelder-Mead", options={"maxiter": 4000})
|
|
41
|
+
n = minimize(naive_nll, X0, args=(r, use), **opts).x
|
|
42
|
+
l = minimize(latent_nll, n, args=(r, ru, rl, up, lo, use), **opts).x
|
|
43
|
+
assert n[1] < a # naive attenuates alpha
|
|
44
|
+
assert abs(l[1] - a) < 0.03, (n, l) # latent model recovers it
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_breach_day_uses_variance_forecast_not_zero():
|
|
48
|
+
# A breach day (ex-date jump, bad tick, etc.) feeds the model's own
|
|
49
|
+
# current variance forecast into next period's recursion (e2 = s2[t]),
|
|
50
|
+
# not a zeroed return and not the raw spurious shock.
|
|
51
|
+
w, a, b = 2e-5, 0.15, 0.80
|
|
52
|
+
r = np.array([0.01, 0.5, -0.02, 0.03, 0.015]) # index 1 is a breach
|
|
53
|
+
breach = np.array([False, True, False, False, False])
|
|
54
|
+
s2 = garch_variance_path_breach(r, w, a, b, breach)
|
|
55
|
+
expected_s2_1 = np.var(r)
|
|
56
|
+
expected_s2_1 = w + a * (r[0] ** 2) + b * expected_s2_1
|
|
57
|
+
expected_s2_2 = w + a * expected_s2_1 + b * expected_s2_1
|
|
58
|
+
assert np.isclose(s2[2], expected_s2_2)
|
|
59
|
+
assert not np.isclose(s2[2], w + a * (r[1] ** 2) + b * expected_s2_1)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_no_breach_matches_fast_lfilter_path():
|
|
63
|
+
r = np.random.default_rng(3).normal(0, 0.02, 300)
|
|
64
|
+
breach = np.zeros(len(r), dtype=bool)
|
|
65
|
+
assert np.allclose(
|
|
66
|
+
garch_variance_path_breach(r, 2e-5, 0.12, 0.83, breach),
|
|
67
|
+
garch_variance_path(r, 2e-5, 0.12, 0.83),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_fit_all_returns_three_models_with_expected_keys():
|
|
72
|
+
rng = np.random.default_rng(2)
|
|
73
|
+
r = rng.normal(0, 0.02, 800)
|
|
74
|
+
up = lo = np.zeros(len(r), dtype=bool)
|
|
75
|
+
r_up = r_lo = np.zeros(len(r))
|
|
76
|
+
use = np.ones(len(r), dtype=bool)
|
|
77
|
+
result = fit_all(r, r_up, r_lo, up, lo, use)
|
|
78
|
+
assert set(result) == {"naive", "censored", "latent"}
|
|
79
|
+
for name, fit in result.items():
|
|
80
|
+
assert set(fit) == {"omega", "alpha", "beta", "loglik", "converged"}
|
|
81
|
+
assert fit["omega"] > 0
|
|
82
|
+
assert 0 <= fit["alpha"]
|
|
83
|
+
assert 0 <= fit["beta"]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_fit_all_naive_understates_alpha_under_censoring():
|
|
87
|
+
rng = np.random.default_rng(4)
|
|
88
|
+
w, a, b, T, L = 2e-5, 0.15, 0.80, 2000, 0.04
|
|
89
|
+
s2 = w / (1 - a - b)
|
|
90
|
+
lat = np.zeros(T)
|
|
91
|
+
for t in range(T):
|
|
92
|
+
lat[t] = np.sqrt(s2) * rng.standard_normal()
|
|
93
|
+
s2 = w + a * lat[t] ** 2 + b * s2
|
|
94
|
+
r = np.clip(lat, -L, L)
|
|
95
|
+
up, lo = r >= L, r <= -L
|
|
96
|
+
use = np.ones(T, dtype=bool)
|
|
97
|
+
r_up, r_lo = np.full(T, L), np.full(T, -L)
|
|
98
|
+
result = fit_all(r, r_up, r_lo, up, lo, use)
|
|
99
|
+
assert result["naive"]["alpha"] < result["latent"]["alpha"]
|