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 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
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,8 @@
1
+ numpy>=1.22
2
+ scipy>=1.8
3
+
4
+ [fast]
5
+ numba>=0.56
6
+
7
+ [test]
8
+ pytest>=7.0
@@ -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"]