mergeron 2025.739290.6__tar.gz → 2025.739290.7__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 mergeron might be problematic. Click here for more details.

Files changed (23) hide show
  1. {mergeron-2025.739290.6 → mergeron-2025.739290.7}/PKG-INFO +1 -1
  2. {mergeron-2025.739290.6 → mergeron-2025.739290.7}/pyproject.toml +1 -1
  3. {mergeron-2025.739290.6 → mergeron-2025.739290.7}/src/mergeron/__init__.py +1 -1
  4. {mergeron-2025.739290.6 → mergeron-2025.739290.7}/src/mergeron/core/__init__.py +30 -32
  5. {mergeron-2025.739290.6 → mergeron-2025.739290.7}/src/mergeron/core/ftc_merger_investigations_data.py +5 -1
  6. {mergeron-2025.739290.6 → mergeron-2025.739290.7}/src/mergeron/core/guidelines_boundaries.py +11 -11
  7. {mergeron-2025.739290.6 → mergeron-2025.739290.7}/src/mergeron/core/guidelines_boundary_functions.py +113 -113
  8. {mergeron-2025.739290.6 → mergeron-2025.739290.7}/src/mergeron/core/guidelines_boundary_functions_extra.py +207 -0
  9. {mergeron-2025.739290.6 → mergeron-2025.739290.7}/src/mergeron/data/__init__.py +4 -7
  10. mergeron-2025.739290.7/src/mergeron/data/ftc_merger_investigations_data.zip +0 -0
  11. {mergeron-2025.739290.6 → mergeron-2025.739290.7}/src/mergeron/gen/__init__.py +29 -35
  12. {mergeron-2025.739290.6 → mergeron-2025.739290.7}/src/mergeron/gen/data_generation.py +1 -13
  13. {mergeron-2025.739290.6 → mergeron-2025.739290.7}/src/mergeron/gen/enforcement_stats.py +18 -7
  14. {mergeron-2025.739290.6 → mergeron-2025.739290.7}/src/mergeron/gen/upp_tests.py +50 -144
  15. mergeron-2025.739290.6/src/mergeron/data/ftc_merger_investigations_data.zip +0 -0
  16. {mergeron-2025.739290.6 → mergeron-2025.739290.7}/README.rst +0 -0
  17. {mergeron-2025.739290.6 → mergeron-2025.739290.7}/src/mergeron/core/empirical_margin_distribution.py +0 -0
  18. {mergeron-2025.739290.6 → mergeron-2025.739290.7}/src/mergeron/core/pseudorandom_numbers.py +0 -0
  19. {mergeron-2025.739290.6 → mergeron-2025.739290.7}/src/mergeron/data/damodaran_margin_data.xls +0 -0
  20. {mergeron-2025.739290.6 → mergeron-2025.739290.7}/src/mergeron/demo/__init__.py +0 -0
  21. {mergeron-2025.739290.6 → mergeron-2025.739290.7}/src/mergeron/demo/visualize_empirical_margin_distribution.py +0 -0
  22. {mergeron-2025.739290.6 → mergeron-2025.739290.7}/src/mergeron/gen/data_generation_functions.py +0 -0
  23. {mergeron-2025.739290.6 → mergeron-2025.739290.7}/src/mergeron/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: mergeron
3
- Version: 2025.739290.6
3
+ Version: 2025.739290.7
4
4
  Summary: Analyze merger enforcement policy using Python
5
5
  License: MIT
6
6
  Keywords: merger policy analysis,merger guidelines,merger screening,policy presumptions,concentration standards,upward pricing pressure,GUPPI
@@ -13,7 +13,7 @@ keywords = [
13
13
  "upward pricing pressure",
14
14
  "GUPPI",
15
15
  ]
16
- version = "2025.739290.6"
16
+ version = "2025.739290.7"
17
17
 
18
18
  # Classifiers list: https://pypi.org/classifiers/
19
19
  classifiers = [
@@ -12,7 +12,7 @@ from ruamel import yaml
12
12
 
13
13
  _PKG_NAME: str = Path(__file__).parent.stem
14
14
 
15
- VERSION = "2025.739290.6"
15
+ VERSION = "2025.739290.7"
16
16
 
17
17
  __version__ = VERSION
18
18
 
@@ -9,7 +9,14 @@ import mpmath # type: ignore
9
9
  import numpy as np
10
10
  from attrs import cmp_using, field, frozen
11
11
 
12
- from .. import VERSION, ArrayBIGINT, this_yaml, yaml_rt_mapper # noqa: TID252
12
+ from .. import ( # noqa: TID252
13
+ VERSION,
14
+ ArrayBIGINT,
15
+ ArrayDouble,
16
+ this_yaml,
17
+ yamelize_attrs,
18
+ yaml_rt_mapper,
19
+ )
13
20
 
14
21
  __version__ = VERSION
15
22
 
@@ -17,6 +24,17 @@ type MPFloat = mpmath.ctx_mp_python.mpf
17
24
  type MPMatrix = mpmath.matrix # type: ignore
18
25
 
19
26
 
27
+ @frozen
28
+ class GuidelinesBoundary:
29
+ """Output of a Guidelines boundary function."""
30
+
31
+ coordinates: ArrayDouble
32
+ """Market-share pairs as Cartesian coordinates of points on the boundary."""
33
+
34
+ area: float
35
+ """Area under the boundary."""
36
+
37
+
20
38
  @frozen
21
39
  class INVTableData:
22
40
  industry_group: str
@@ -29,6 +47,7 @@ type INVData = MappingProxyType[
29
47
  ]
30
48
  type INVData_in = Mapping[str, Mapping[str, Mapping[str, INVTableData]]]
31
49
 
50
+ yamelize_attrs(INVTableData)
32
51
 
33
52
  (_, _) = (
34
53
  this_yaml.representer.add_representer(
@@ -59,6 +78,16 @@ type INVData_in = Mapping[str, Mapping[str, Mapping[str, INVTableData]]]
59
78
  ),
60
79
  )
61
80
 
81
+ _, _ = (
82
+ this_yaml.representer.add_representer(
83
+ MappingProxyType,
84
+ lambda _r, _d: _r.represent_mapping("!mappingproxy", dict(_d.items())),
85
+ ),
86
+ this_yaml.constructor.add_constructor(
87
+ "!mappingproxy", lambda _c, _n: MappingProxyType(dict(**yaml_rt_mapper(_c, _n)))
88
+ ),
89
+ )
90
+
62
91
 
63
92
  def _dict_from_mapping(_p: Mapping[Any, Any], /) -> dict[Any, Any]:
64
93
  retval: dict[Any, Any] = {}
@@ -76,34 +105,3 @@ def _mappingproxy_from_mapping(_p: Mapping[Any, Any], /) -> MappingProxyType[Any
76
105
  else {_k: _v}
77
106
  )
78
107
  return MappingProxyType(retval)
79
-
80
-
81
- _, _ = (
82
- this_yaml.representer.add_representer(
83
- MappingProxyType,
84
- lambda _r, _d: _r.represent_mapping("!mappingproxy", dict(_d.items())),
85
- ),
86
- this_yaml.constructor.add_constructor(
87
- "!mappingproxy", lambda _c, _n: MappingProxyType(yaml_rt_mapper(_c, _n))
88
- ),
89
- )
90
-
91
-
92
- for _typ in (INVTableData,):
93
- _, _ = (
94
- this_yaml.representer.add_representer(
95
- _typ,
96
- lambda _r, _d: _r.represent_mapping(
97
- f"!{_d.__class__.__name__}",
98
- {
99
- _a.name: getattr(_d, _a.name)
100
- for _a in _d.__attrs_attrs__
101
- if _a.name not in {"coordinates", "area"}
102
- },
103
- ),
104
- ),
105
- this_yaml.constructor.add_constructor(
106
- f"!{_typ.__name__}",
107
- lambda _c, _n: globals()[_n.tag.lstrip("!")](**yaml_rt_mapper(_c, _n)),
108
- ),
109
- )
@@ -12,7 +12,7 @@ from __future__ import annotations
12
12
 
13
13
  import re
14
14
  import shutil
15
- from collections.abc import Sequence
15
+ from collections.abc import Mapping, Sequence
16
16
  from operator import itemgetter
17
17
  from pathlib import Path
18
18
  from types import MappingProxyType
@@ -94,6 +94,10 @@ CNT_FCOUNT_DICT = {
94
94
  }
95
95
 
96
96
 
97
+ def reverse_map(_dict: Mapping[Any, Any]) -> Mapping[Any, Any]:
98
+ return {_v: _k for _k, _v in _dict.items()}
99
+
100
+
97
101
  def construct_data(
98
102
  _archive_path: Path = INVDATA_ARCHIVE_PATH,
99
103
  *,
@@ -146,6 +146,15 @@ class GuidelinesThresholds:
146
146
  class ConcentrationBoundary:
147
147
  """Concentration parameters, boundary coordinates, and area under concentration boundary."""
148
148
 
149
+ threshold: float = field(kw_only=False, default=0.01)
150
+
151
+ @threshold.validator
152
+ def _tv(
153
+ _instance: ConcentrationBoundary, _attribute: Attribute[float], _value: float, /
154
+ ) -> None:
155
+ if not 0 <= _value <= 1:
156
+ raise ValueError("Concentration threshold must lie between 0 and 1.")
157
+
149
158
  measure_name: Literal[
150
159
  "ΔHHI",
151
160
  "Combined share",
@@ -165,17 +174,8 @@ class ConcentrationBoundary:
165
174
  }:
166
175
  raise ValueError(f"Invalid name for a concentration measure, {_value!r}.")
167
176
 
168
- threshold: float = field(kw_only=False, default=0.01)
169
-
170
- @threshold.validator
171
- def _tv(
172
- _instance: ConcentrationBoundary, _attribute: Attribute[float], _value: float, /
173
- ) -> None:
174
- if not 0 <= _value <= 1:
175
- raise ValueError("Concentration threshold must lie between 0 and 1.")
176
-
177
177
  precision: int = field(
178
- kw_only=False, default=5, validator=validators.instance_of(int)
178
+ kw_only=True, default=5, validator=validators.instance_of(int)
179
179
  )
180
180
 
181
181
  area: float = field(init=False, kw_only=True)
@@ -284,7 +284,7 @@ class DiversionRatioBoundary:
284
284
 
285
285
  agg_method: UPPAggrSelector = field(
286
286
  kw_only=True,
287
- default=UPPAggrSelector.MAX,
287
+ default=UPPAggrSelector.MIN,
288
288
  validator=validators.instance_of(UPPAggrSelector),
289
289
  )
290
290
  """
@@ -1,5 +1,6 @@
1
1
  import decimal
2
- from typing import Any, Literal, TypedDict
2
+ from collections.abc import Callable
3
+ from typing import Literal, TypedDict
3
4
 
4
5
  import matplotlib as mpl
5
6
  import matplotlib.axes as mpa
@@ -7,11 +8,10 @@ import matplotlib.patches as mpp
7
8
  import matplotlib.pyplot as plt
8
9
  import matplotlib.ticker as mpt
9
10
  import numpy as np
10
- from attrs import frozen
11
11
  from mpmath import mp, mpf # type: ignore
12
12
 
13
13
  from .. import DEFAULT_REC_RATIO, VERSION, ArrayBIGINT, ArrayDouble # noqa: TID252
14
- from . import MPFloat
14
+ from . import GuidelinesBoundary, MPFloat
15
15
 
16
16
  __version__ = VERSION
17
17
 
@@ -28,17 +28,6 @@ class ShareRatioBoundaryKeywords(TypedDict, total=False):
28
28
  weighting: Literal["own-share", "cross-product-share", None]
29
29
 
30
30
 
31
- @frozen
32
- class GuidelinesBoundary:
33
- """Output of a Guidelines boundary function."""
34
-
35
- coordinates: ArrayDouble
36
- """Market-share pairs as Cartesian coordinates of points on the boundary."""
37
-
38
- area: float
39
- """Area under the boundary."""
40
-
41
-
42
31
  def dh_area(_delta_bound: float | MPFloat = 0.01, /, *, dps: int = 9) -> float:
43
32
  R"""
44
33
  Area under the ΔHHI boundary.
@@ -56,7 +45,7 @@ def dh_area(_delta_bound: float | MPFloat = 0.01, /, *, dps: int = 9) -> float:
56
45
  .. math::
57
46
 
58
47
  2 s1 s_2 &= ΔHHI\\
59
- _s_1 + s_2 &= 1
48
+ s_1 + s_2 &= 1
60
49
 
61
50
  Parameters
62
51
  ----------
@@ -113,7 +102,7 @@ def hhi_delta_boundary(
113
102
  np.column_stack((_s_1, _delta_bound / (2 * _s_1))).astype(float),
114
103
  np.array([(mpf("0.0"), mpf("1.0"))], float),
115
104
  ))
116
- bdry = np.vstack((half_bdry[::-1], half_bdry[1:, ::-1]))
105
+ bdry = np.vstack((half_bdry[::-1], half_bdry[1:, ::-1]), dtype=float)
117
106
 
118
107
  return GuidelinesBoundary(bdry, dh_area(_delta_bound, dps=dps))
119
108
 
@@ -141,12 +130,12 @@ def hhi_pre_contrib_boundary(
141
130
 
142
131
  step_size = mp.power(10, -dps)
143
132
  # Range-limit is 0 less a step, which is -1 * step-size
144
- _s_1 = np.array(mp.arange(_s_mid, -step_size, -step_size))
145
- s_2 = np.sqrt(_hhi_bound - _s_1**2)
146
- half_bdry = np.column_stack((_s_1, s_2)).astype(float)
133
+ s_1 = np.array(mp.arange(_s_mid, -step_size, -step_size))
134
+ s_2 = np.sqrt(_hhi_bound - s_1**2)
135
+ half_bdry = np.column_stack((s_1, s_2)).astype(float)
147
136
 
148
137
  return GuidelinesBoundary(
149
- np.vstack((half_bdry[::-1], half_bdry[1:, ::-1])),
138
+ np.vstack((half_bdry[::-1], half_bdry[1:, ::-1]), dtype=float),
150
139
  round(float(mp.pi * _hhi_bound / 4), dps),
151
140
  )
152
141
 
@@ -178,7 +167,8 @@ def combined_share_boundary(
178
167
 
179
168
  _s1 = np.array([0, _s_mid, _s_intcpt], float)
180
169
  return GuidelinesBoundary(
181
- np.column_stack((_s1, _s1[::-1])), round(float(_s_intcpt * _s_mid), dps)
170
+ np.array(list(zip(_s1, _s1[::-1])), float),
171
+ round(float(_s_intcpt * _s_mid), dps),
182
172
  )
183
173
 
184
174
 
@@ -211,6 +201,7 @@ def hhi_post_contrib_boundary(
211
201
  )
212
202
 
213
203
 
204
+ # hand-rolled root finding
214
205
  def shrratio_boundary_wtd_avg( # noqa: PLR0914
215
206
  _delta_star: float = 0.075,
216
207
  _r_val: float = DEFAULT_REC_RATIO,
@@ -252,58 +243,57 @@ def shrratio_boundary_wtd_avg( # noqa: PLR0914
252
243
  is derived and plotted from y-intercept to the ray of symmetry as follows::
253
244
 
254
245
  from sympy import plot as symplot, solve, symbols
255
- _s_1, s_2 = symbols("_s_1 s_2", positive=True)
246
+ s_1, s_2 = symbols("s_1 s_2", positive=True)
256
247
 
257
248
  g_val, r_val, m_val = 0.06, 0.80, 0.30
258
249
  delta_star = g_val / (r_val * m_val)
259
250
 
260
251
  # recapture_form == "inside-out"
261
252
  oswag = solve(
262
- _s_1 * s_2 / (1 - _s_1)
263
- + s_2 * _s_1 / (1 - (r_val * s_2 + (1 - r_val) * _s_1))
264
- - (_s_1 + s_2) * delta_star,
253
+ s_1 * s_2 / (1 - s_1)
254
+ + s_2 * s_1 / (1 - (r_val * s_2 + (1 - r_val) * s_1))
255
+ - (s_1 + s_2) * delta_star,
265
256
  s_2
266
257
  )[0]
267
258
  symplot(
268
259
  oswag,
269
- (_s_1, 0., d_hat / (1 + d_hat)),
260
+ (s_1, 0., d_hat / (1 + d_hat)),
270
261
  ylabel=s_2
271
262
  )
272
263
 
273
264
  cpswag = solve(
274
- s_2 * s_2 / (1 - _s_1)
275
- + _s_1 * _s_1 / (1 - (r_val * s_2 + (1 - r_val) * _s_1))
276
- - (_s_1 + s_2) * delta_star,
265
+ s_2 * s_2 / (1 - s_1)
266
+ + s_1 * s_1 / (1 - (r_val * s_2 + (1 - r_val) * s_1))
267
+ - (s_1 + s_2) * delta_star,
277
268
  s_2
278
269
  )[1]
279
270
  symplot(
280
271
  cpwag,
281
- (_s_1, 0., d_hat / (1 + d_hat)),
282
- ylabel=s_2
272
+ (s_1, 0.0, d_hat / (1 + d_hat)), ylabel=s_2
283
273
  )
284
274
 
285
275
  # recapture_form == "proportional"
286
276
  oswag = solve(
287
- _s_1 * s_2 / (1 - _s_1)
288
- + s_2 * _s_1 / (1 - s_2)
289
- - (_s_1 + s_2) * delta_star,
277
+ s_1 * s_2 / (1 - s_1)
278
+ + s_2 * s_1 / (1 - s_2)
279
+ - (s_1 + s_2) * delta_star,
290
280
  s_2
291
281
  )[0]
292
282
  symplot(
293
283
  oswag,
294
- (_s_1, 0., d_hat / (1 + d_hat)),
284
+ (s_1, 0., d_hat / (1 + d_hat)),
295
285
  ylabel=s_2
296
286
  )
297
287
 
298
288
  cpswag = solve(
299
- s_2 * s_2 / (1 - _s_1)
300
- + _s_1 * _s_1 / (1 - s_2)
301
- - (_s_1 + s_2) * delta_star,
289
+ s_2 * s_2 / (1 - s_1)
290
+ + s_1 * s_1 / (1 - s_2)
291
+ - (s_1 + s_2) * delta_star,
302
292
  s_2
303
293
  )[1]
304
294
  symplot(
305
295
  cpswag,
306
- (_s_1, 0.0, d_hat / (1 + d_hat)),
296
+ (s_1, 0.0, d_hat / (1 + d_hat)),
307
297
  ylabel=s_2
308
298
  )
309
299
 
@@ -321,26 +311,22 @@ def shrratio_boundary_wtd_avg( # noqa: PLR0914
321
311
  # parameters for iteration
322
312
  _step_size = mp.power(10, -dps)
323
313
  theta_ = _step_size * (10 if weighting == "cross-product-share" else 1)
324
- for _s_1 in mp.arange(_s_mid - _step_size, 0, -_step_size):
314
+ for s_1 in mp.arange(_s_mid - _step_size, 0, -_step_size):
325
315
  # The wtd. avg. GUPPI is not always convex to the origin, so we
326
316
  # increment s_2 after each iteration in which our algorithm
327
317
  # finds (s1, s2) on the boundary
328
318
  s_2 = s_2_pre * (1 + theta_)
329
319
 
330
- if (_s_1 + s_2) > mpf("0.99875"):
331
- # Loss of accuracy at 3-9s and up
332
- break
333
-
334
320
  while True:
335
- de_1 = s_2 / (1 - _s_1)
321
+ de_1 = s_2 / (1 - s_1)
336
322
  de_2 = (
337
- _s_1 / (1 - lerp(_s_1, s_2, _r_val))
323
+ s_1 / (1 - lerp(s_1, s_2, _r_val))
338
324
  if recapture_form == "inside-out"
339
- else _s_1 / (1 - s_2)
325
+ else s_1 / (1 - s_2)
340
326
  )
341
327
 
342
328
  r_ = (
343
- mp.fdiv(_s_1 if weighting == "cross-product-share" else s_2, _s_1 + s_2)
329
+ mp.fdiv(s_1 if weighting == "cross-product-share" else s_2, s_1 + s_2)
344
330
  if weighting
345
331
  else 0.5
346
332
  )
@@ -365,7 +351,7 @@ def shrratio_boundary_wtd_avg( # noqa: PLR0914
365
351
  break
366
352
 
367
353
  # Build-up boundary points
368
- bdry.append((_s_1, s_2))
354
+ bdry.append((s_1, s_2))
369
355
 
370
356
  # Build up area terms
371
357
  s_2_oddsum += s_2 if s_2_oddval else 0
@@ -374,7 +360,11 @@ def shrratio_boundary_wtd_avg( # noqa: PLR0914
374
360
 
375
361
  # Hold share points
376
362
  s_2_pre = s_2
377
- s_1_pre = _s_1
363
+ s_1_pre = s_1
364
+
365
+ if (s_1 + s_2) > mpf("0.99875"):
366
+ # Loss of accuracy at 3-9s and up
367
+ break
378
368
 
379
369
  if s_2_oddval:
380
370
  s_2_evnsum -= s_2_pre
@@ -412,7 +402,7 @@ def shrratio_boundary_wtd_avg( # noqa: PLR0914
412
402
 
413
403
  # Points defining boundary to point-of-symmetry
414
404
  return GuidelinesBoundary(
415
- np.vstack((bdry_array[::-1], bdry_array[1:, ::-1])),
405
+ np.vstack((bdry_array[::-1], bdry_array[1:, ::-1]), dtype=float),
416
406
  round(float(bdry_area_total), dps),
417
407
  )
418
408
 
@@ -436,29 +426,29 @@ def shrratio_boundary_xact_avg( # noqa: PLR0914
436
426
 
437
427
  from sympy import latex, plot as symplot, solve, symbols
438
428
 
439
- _s_1, s_2 = symbols("_s_1 s_2")
429
+ s_1, s_2 = symbols("s_1 s_2")
440
430
 
441
431
  g_val, r_val, m_val = 0.06, 0.80, 0.30
442
432
  d_hat = g_val / (r_val * m_val)
443
433
 
444
434
  # recapture_form = "inside-out"
445
435
  sag = solve(
446
- (s_2 / (1 - _s_1))
447
- + (_s_1 / (1 - (r_val * s_2 + (1 - r_val) * _s_1)))
436
+ (s_2 / (1 - s_1))
437
+ + (s_1 / (1 - (r_val * s_2 + (1 - r_val) * s_1)))
448
438
  - 2 * d_hat,
449
439
  s_2
450
440
  )[0]
451
441
  symplot(
452
442
  sag,
453
- (_s_1, 0., d_hat / (1 + d_hat)),
443
+ (s_1, 0., d_hat / (1 + d_hat)),
454
444
  ylabel=s_2
455
445
  )
456
446
 
457
447
  # recapture_form = "proportional"
458
- sag = solve((s_2/(1 - _s_1)) + (_s_1/(1 - s_2)) - 2 * d_hat, s_2)[0]
448
+ sag = solve((s_2/(1 - s_1)) + (s_1/(1 - s_2)) - 2 * d_hat, s_2)[0]
459
449
  symplot(
460
450
  sag,
461
- (_s_1, 0., d_hat / (1 + d_hat)),
451
+ (s_1, 0., d_hat / (1 + d_hat)),
462
452
  ylabel=s_2
463
453
  )
464
454
 
@@ -614,7 +604,7 @@ def shrratio_boundary_min(
614
604
  _s_mid = _delta_star / (1 + _delta_star)
615
605
 
616
606
  if recapture_form == "inside-out":
617
- # ## Plot envelope of GUPPI boundaries with rk_ = r_bar if sk_ = min(_s_1, s_2)
607
+ # ## Plot envelope of GUPPI boundaries with rk_ = r_bar if sk_ = min(s_1, s_2)
618
608
  # ## See (si_, sj_) in equation~(44), or thereabouts, in paper
619
609
  smin_nr = _delta_star * (1 - _r_val)
620
610
  smax_nr = 1 - _delta_star * _r_val
@@ -626,7 +616,7 @@ def shrratio_boundary_min(
626
616
  s_1, bdry_area = np.array((0, _s_mid, _s_intcpt), float), _s_mid
627
617
 
628
618
  return GuidelinesBoundary(
629
- np.column_stack((s_1, s_1[::-1])), round(float(bdry_area), dps)
619
+ np.array(list(zip(s_1, s_1[::-1])), float), round(float(bdry_area), dps)
630
620
  )
631
621
 
632
622
 
@@ -659,7 +649,7 @@ def shrratio_boundary_max(
659
649
  _s1_pts = (0, _s_mid, _s_intcpt)
660
650
 
661
651
  return GuidelinesBoundary(
662
- np.column_stack((np.array(_s1_pts, float), np.array(_s1_pts[::-1], float))),
652
+ np.array(list(zip(_s1_pts, _s1_pts[::-1])), float),
663
653
  round(float(_s_intcpt * _s_mid), dps), # simplified calculation
664
654
  )
665
655
 
@@ -797,51 +787,57 @@ def round_cust(
797
787
  return float(f_ * (n_ / f_).quantize(e_, rounding=rounding_mode))
798
788
 
799
789
 
800
- def boundary_plot(*, mktshares_plot_flag: bool = True) -> tuple[Any, ...]:
790
+ def boundary_plot(
791
+ *,
792
+ mktshare_plot_flag: bool = True,
793
+ mktshare_axes_flag: bool = True,
794
+ backend: Literal["pgf"] | str | None = "pgf",
795
+ ) -> tuple[mpl.pyplot, mpl.pyplot.Figure, mpl.axes.Axes, Callable[..., mpl.axes.Axes]]:
801
796
  """Setup basic figure and axes for plots of safe harbor boundaries.
802
797
 
803
798
  See, https://matplotlib.org/stable/tutorials/text/pgf.html
804
799
  """
805
800
 
806
- mpl.use("pgf")
807
-
808
- plt.rcParams.update({
809
- "pgf.rcfonts": False,
810
- "pgf.texsystem": "lualatex",
811
- "pgf.preamble": "\n".join([
812
- R"\pdfvariable minorversion=7",
813
- R"\usepackage{fontspec}",
814
- R"\usepackage{luacode}",
815
- R"\begin{luacode}",
816
- R"local function embedfull(tfmdata)",
817
- R' tfmdata.embedding = "full"',
818
- R"end",
819
- R"",
820
- R"luatexbase.add_to_callback("
821
- R' "luaotfload.patch_font", embedfull, "embedfull"'
822
- R")",
823
- R"\end{luacode}",
824
- R"\usepackage{mathtools}",
825
- R"\usepackage{unicode-math}",
826
- R"\setmathfont[math-style=ISO]{STIX Two Math}",
827
- R"\setmainfont{STIX Two Text}",
828
- r"\setsansfont{Fira Sans Light}",
829
- R"\setmonofont[Scale=MatchLowercase,]{Fira Mono}",
830
- R"\defaultfontfeatures[\rmfamily]{",
831
- R" Ligatures={TeX, Common},",
832
- R" Numbers={Proportional, Lining},",
833
- R" }",
834
- R"\defaultfontfeatures[\sffamily]{",
835
- R" Ligatures={TeX, Common},",
836
- R" Numbers={Monospaced, Lining},",
837
- R" LetterSpace=0.50,",
838
- R" }",
839
- R"\usepackage[",
840
- R" activate={true, nocompatibility},",
841
- R" tracking=true,",
842
- R" ]{microtype}",
843
- ]),
844
- })
801
+ if backend == "pgf":
802
+ mpl.use("pgf")
803
+
804
+ plt.rcParams.update({
805
+ "pgf.rcfonts": False,
806
+ "pgf.texsystem": "lualatex",
807
+ "pgf.preamble": "\n".join([
808
+ R"\pdfvariable minorversion=7",
809
+ R"\usepackage{fontspec}",
810
+ R"\usepackage{luacode}",
811
+ R"\begin{luacode}",
812
+ R"local function embedfull(tfmdata)",
813
+ R' tfmdata.embedding = "full"',
814
+ R"end",
815
+ R"",
816
+ R"luatexbase.add_to_callback("
817
+ R' "luaotfload.patch_font", embedfull, "embedfull"'
818
+ R")",
819
+ R"\end{luacode}",
820
+ R"\usepackage{mathtools}",
821
+ R"\usepackage{unicode-math}",
822
+ R"\setmathfont[math-style=ISO]{STIX Two Math}",
823
+ R"\setmainfont{STIX Two Text}",
824
+ r"\setsansfont{Fira Sans Light}",
825
+ R"\setmonofont[Scale=MatchLowercase,]{Fira Mono}",
826
+ R"\defaultfontfeatures[\rmfamily]{",
827
+ R" Ligatures={TeX, Common},",
828
+ R" Numbers={Proportional, Lining},",
829
+ R" }",
830
+ R"\defaultfontfeatures[\sffamily]{",
831
+ R" Ligatures={TeX, Common},",
832
+ R" Numbers={Monospaced, Lining},",
833
+ R" LetterSpace=0.50,",
834
+ R" }",
835
+ R"\usepackage[",
836
+ R" activate={true, nocompatibility},",
837
+ R" tracking=true,",
838
+ R" ]{microtype}",
839
+ ]),
840
+ })
845
841
 
846
842
  # Initialize a canvas with a single figure (set of axes)
847
843
  fig_ = plt.figure(figsize=(5, 5), dpi=600)
@@ -851,8 +847,8 @@ def boundary_plot(*, mktshares_plot_flag: bool = True) -> tuple[Any, ...]:
851
847
  ax1_: mpa.Axes,
852
848
  /,
853
849
  *,
854
- mktshares_plot_flag: bool = False,
855
- mktshares_axlbls_flag: bool = False,
850
+ mktshare_plot_flag: bool = False,
851
+ mktshare_axes_flag: bool = False,
856
852
  ) -> mpa.Axes:
857
853
  # Set the width of axis grid lines, and tick marks:
858
854
  # both axes, both major and minor ticks
@@ -883,16 +879,7 @@ def boundary_plot(*, mktshares_plot_flag: bool = True) -> tuple[Any, ...]:
883
879
  ax1_.yaxis.get_majorticklabels(), horizontalalignment="right", fontsize=6
884
880
  )
885
881
 
886
- if mktshares_plot_flag:
887
- # Axis labels
888
- if mktshares_axlbls_flag:
889
- # x-axis
890
- ax1_.set_xlabel("Firm 1 Market Share, $_s_1$", fontsize=10)
891
- ax1_.xaxis.set_label_coords(0.75, -0.1)
892
- # y-axis
893
- ax1_.set_ylabel("Firm 2 Market Share, $s_2$", fontsize=10)
894
- ax1_.yaxis.set_label_coords(-0.1, 0.75)
895
-
882
+ if mktshare_plot_flag:
896
883
  # Plot the ray of symmetry
897
884
  ax1_.plot(
898
885
  [0, 1], [0, 1], linewidth=0.5, linestyle=":", color="grey", zorder=1
@@ -944,8 +931,21 @@ def boundary_plot(*, mktshares_plot_flag: bool = True) -> tuple[Any, ...]:
944
931
  for axl_ in ax1_.get_xticklabels(), ax1_.get_yticklabels():
945
932
  plt.setp(axl_[::2], visible=False)
946
933
 
934
+ # Axis labels
935
+ if mktshare_axes_flag:
936
+ # x-axis
937
+ ax1_.set_xlabel("Firm 1 Market Share, $s_1$", fontsize=10)
938
+ ax1_.xaxis.set_label_coords(0.75, -0.1)
939
+ # y-axis
940
+ ax1_.set_ylabel("Firm 2 Market Share, $s_2$", fontsize=10)
941
+ ax1_.yaxis.set_label_coords(-0.1, 0.75)
942
+
947
943
  return ax1_
948
944
 
949
- ax_out = _set_axis_def(ax_out, mktshares_plot_flag=mktshares_plot_flag)
945
+ ax_out = _set_axis_def(
946
+ ax_out,
947
+ mktshare_plot_flag=mktshare_plot_flag,
948
+ mktshare_axes_flag=mktshare_axes_flag,
949
+ )
950
950
 
951
951
  return plt, fig_, ax_out, _set_axis_def
@@ -17,6 +17,7 @@ from scipy.spatial.distance import minkowski as distance_function # type: ignor
17
17
  from sympy import lambdify, simplify, solve, symbols # type: ignore
18
18
 
19
19
  from .. import DEFAULT_REC_RATIO, VERSION, ArrayDouble # noqa: TID252
20
+ from . import GuidelinesBoundary, MPFloat
20
21
  from . import guidelines_boundary_functions as gbf
21
22
 
22
23
  __version__ = VERSION
@@ -529,3 +530,209 @@ def shrratio_boundary_xact_avg_mp( # noqa: PLR0914
529
530
  ) - mp.power(_s_mid, 2)
530
531
 
531
532
  return gbf.GuidelinesBoundary(bdry, float(mp.nstr(bdry_area_simpson, dps)))
533
+
534
+
535
+ # shrratio_boundary_wtd_avg_autoroot
536
+ # this function is about half as fast as the manual one! ... and a touch less precise
537
+ def _shrratio_boundary_wtd_avg_autoroot( # noqa: PLR0914
538
+ _delta_star: float = 0.075,
539
+ _r_val: float = DEFAULT_REC_RATIO,
540
+ /,
541
+ *,
542
+ agg_method: Literal[
543
+ "arithmetic mean", "geometric mean", "distance"
544
+ ] = "arithmetic mean",
545
+ weighting: Literal["own-share", "cross-product-share", None] = "own-share",
546
+ recapture_form: Literal["inside-out", "proportional"] = "inside-out",
547
+ dps: int = 5,
548
+ ) -> GuidelinesBoundary:
549
+ """
550
+ Share combinations on the share-weighted average diversion ratio boundary.
551
+
552
+ Parameters
553
+ ----------
554
+ _delta_star
555
+ Share ratio (:math:`\\overline{d} / \\overline{r}`)
556
+ _r_val
557
+ recapture ratio
558
+ agg_method
559
+ Whether "arithmetic mean", "geometric mean", or "distance".
560
+ weighting
561
+ Whether "own-share" or "cross-product-share" (or None for simple, unweighted average).
562
+ recapture_form
563
+ Whether recapture-ratio is MNL-consistent ("inside-out") or has fixed
564
+ value for both merging firms ("proportional").
565
+ dps
566
+ Number of decimal places for rounding returned shares and area.
567
+
568
+ Returns
569
+ -------
570
+ Array of share-pairs, area under boundary.
571
+
572
+ Notes
573
+ -----
574
+ An analytical expression for the share-weighted arithmetic mean boundary
575
+ is derived and plotted from y-intercept to the ray of symmetry as follows::
576
+
577
+ from sympy import plot as symplot, solve, symbols
578
+ s_1, s_2 = symbols("s_1 s_2", positive=True)
579
+
580
+ g_val, r_val, m_val = 0.06, 0.80, 0.30
581
+ delta_star = g_val / (r_val * m_val)
582
+
583
+ # recapture_form == "inside-out"
584
+ oswag = solve(
585
+ s_1 * s_2 / (1 - s_1)
586
+ + s_2 * s_1 / (1 - (r_val * s_2 + (1 - r_val) * s_1))
587
+ - (s_1 + s_2) * delta_star,
588
+ s_2
589
+ )[0]
590
+ symplot(
591
+ oswag,
592
+ (s_1, 0., d_hat / (1 + d_hat)),
593
+ ylabel=s_2
594
+ )
595
+
596
+ cpswag = solve(
597
+ s_2 * s_2 / (1 - s_1)
598
+ + s_1 * s_1 / (1 - (r_val * s_2 + (1 - r_val) * s_1))
599
+ - (s_1 + s_2) * delta_star,
600
+ s_2
601
+ )[1]
602
+ symplot(
603
+ cpwag,
604
+ (s_1, 0.0, d_hat / (1 + d_hat)), ylabel=s_2
605
+ )
606
+
607
+ # recapture_form == "proportional"
608
+ oswag = solve(
609
+ s_1 * s_2 / (1 - s_1)
610
+ + s_2 * s_1 / (1 - s_2)
611
+ - (s_1 + s_2) * delta_star,
612
+ s_2
613
+ )[0]
614
+ symplot(
615
+ oswag,
616
+ (s_1, 0., d_hat / (1 + d_hat)),
617
+ ylabel=s_2
618
+ )
619
+
620
+ cpswag = solve(
621
+ s_2 * s_2 / (1 - s_1)
622
+ + s_1 * s_1 / (1 - s_2)
623
+ - (s_1 + s_2) * delta_star,
624
+ s_2
625
+ )[1]
626
+ symplot(
627
+ cpswag,
628
+ (s_1, 0.0, d_hat / (1 + d_hat)),
629
+ ylabel=s_2
630
+ )
631
+
632
+
633
+ """
634
+
635
+ _delta_star, _r_val = (mpf(f"{_v}") for _v in (_delta_star, _r_val))
636
+ _s_mid = mp.fdiv(_delta_star, 1 + _delta_star)
637
+
638
+ # initial conditions
639
+ bdry = [(_s_mid, _s_mid)]
640
+ s_1_pre, s_2_pre = _s_mid, _s_mid
641
+ s_2_oddval, s_2_oddsum, s_2_evnsum = True, 0.0, 0.0
642
+
643
+ # parameters for iteration
644
+ _step_size = mp.power(10, -dps)
645
+ theta_ = _step_size * (10 if weighting == "cross-product-share" else 1)
646
+ for s_1 in mp.arange(_s_mid - _step_size, 0, -_step_size):
647
+
648
+ def delta_test(x: MPFloat) -> MPFloat:
649
+ _de_1 = x / (1 - s_1)
650
+ _de_2 = (
651
+ s_1 / (1 - gbf.lerp(s_1, x, _r_val))
652
+ if recapture_form == "inside-out"
653
+ else s_1 / (1 - x)
654
+ )
655
+ _w = (
656
+ mp.fdiv(s_1 if weighting == "cross-product-share" else x, s_1 + x)
657
+ if weighting
658
+ else 0.5
659
+ )
660
+
661
+ match agg_method:
662
+ case "geometric mean":
663
+ delta_test = mp.expm1(
664
+ gbf.lerp(mp.log1p(_de_1), mp.log1p(_de_2), _w)
665
+ )
666
+ case "distance":
667
+ delta_test = mp.sqrt(gbf.lerp(_de_1**2, _de_2**2, _w))
668
+ case _:
669
+ delta_test = gbf.lerp(_de_1, _de_2, _w)
670
+
671
+ return _delta_star - delta_test
672
+
673
+ try:
674
+ s_2 = mp.findroot(
675
+ delta_test,
676
+ x0=(s_2_pre * (1 - theta_), s_2_pre * (1 + theta_)),
677
+ tol=mp.sqrt(_step_size),
678
+ solver="ridder",
679
+ )
680
+ except (mp.ComplexResult, ValueError, ZeroDivisionError) as _e:
681
+ print(s_1, s_2_pre)
682
+ raise _e
683
+
684
+ # Build-up boundary points
685
+ bdry.append((s_1, s_2))
686
+
687
+ # Build up area terms
688
+ s_2_oddsum += s_2 if s_2_oddval else 0
689
+ s_2_evnsum += s_2 if not s_2_oddval else 0
690
+ s_2_oddval = not s_2_oddval
691
+
692
+ # Hold share points
693
+ s_2_pre = s_2
694
+ s_1_pre = s_1
695
+
696
+ if (s_1_pre + s_2_pre) > mpf("0.99875"):
697
+ # Loss of accuracy at 3-9s and up
698
+ break
699
+
700
+ if s_2_oddval:
701
+ s_2_evnsum -= s_2_pre
702
+ else:
703
+ s_2_oddsum -= s_1_pre
704
+
705
+ _s_intcpt = gbf._shrratio_boundary_intcpt(
706
+ s_2_pre,
707
+ _delta_star,
708
+ _r_val,
709
+ recapture_form=recapture_form,
710
+ agg_method=agg_method,
711
+ weighting=weighting,
712
+ )
713
+
714
+ if weighting == "own-share":
715
+ gbd_prtlarea = (
716
+ _step_size * (4 * s_2_oddsum + 2 * s_2_evnsum + _s_mid + s_2_pre) / 3
717
+ )
718
+ # Area under boundary
719
+ bdry_area_total = float(
720
+ 2 * (s_1_pre + gbd_prtlarea)
721
+ - (mp.power(_s_mid, "2") + mp.power(s_1_pre, "2"))
722
+ )
723
+
724
+ else:
725
+ gbd_prtlarea = (
726
+ _step_size * (4 * s_2_oddsum + 2 * s_2_evnsum + _s_mid + _s_intcpt) / 3
727
+ )
728
+ # Area under boundary
729
+ bdry_area_total = float(2 * gbd_prtlarea - mp.power(_s_mid, "2"))
730
+
731
+ bdry.append((mpf("0.0"), _s_intcpt))
732
+ bdry_array = np.array(bdry, float)
733
+
734
+ # Points defining boundary to point-of-symmetry
735
+ return GuidelinesBoundary(
736
+ np.vstack((bdry_array[::-1], bdry_array[1:, ::-1]), dtype=float),
737
+ round(float(bdry_area_total), dps),
738
+ )
@@ -12,10 +12,9 @@ from .. import _PKG_NAME, VERSION # noqa: TID252
12
12
 
13
13
  __version__ = VERSION
14
14
 
15
+ data_resources = resources.files(f"{_PKG_NAME}.data")
15
16
 
16
- DAMODARAN_MARGIN_WORKBOOK = resources.files(f"{_PKG_NAME}.data").joinpath(
17
- "damodaran_margin_data.xls"
18
- )
17
+ DAMODARAN_MARGIN_WORKBOOK = data_resources / "damodaran_margin_data.xls"
19
18
  """
20
19
  Python object pointing to included copy of Prof. Damodaran's margin data
21
20
 
@@ -36,9 +35,7 @@ Use as, for example:
36
35
  shutil.copy2(DAMODARAN_MARGIN_WORKBOOK, Path.home() / f"{DAMODARAN_MARGIN_WORKBOOK.name}")
37
36
  """
38
37
 
39
- FTC_MERGER_INVESTIGATIONS_DATA = resources.files(f"{_PKG_NAME}.data").joinpath(
40
- "ftc_merger_investigations_data.zip"
41
- )
38
+ FTC_MERGER_INVESTIGATIONS_DATA = data_resources / "ftc_merger_investigations_data.zip"
42
39
  """
43
40
  FTC merger investigtions data published in 2004, 2007, 2008, and 2013
44
41
 
@@ -46,7 +43,7 @@ NOTES
46
43
  -----
47
44
  Raw data tables published by the FTC are loaded into a nested distionary, organized by
48
45
  data period, table type, and table number. Each table is stored as a numerical array
49
- (:module:`numpy` arrray), with additonal attrubutes for the industry group and additonal
46
+ (:mod:`numpy` arrray), with additonal attrubutes for the industry group and additonal
50
47
  evidence noted in the source data.
51
48
 
52
49
  Data for additonal data periods (time spans) not reported in the source data,
@@ -13,7 +13,7 @@ from operator import attrgetter
13
13
 
14
14
  import h5py # type: ignore
15
15
  import numpy as np
16
- from attrs import Attribute, Converter, cmp_using, field, frozen, validators
16
+ from attrs import Attribute, Converter, cmp_using, field, frozen
17
17
  from numpy.random import SeedSequence
18
18
 
19
19
  from .. import ( # noqa: TID252
@@ -588,45 +588,39 @@ class INVResolution(str, Enameled):
588
588
  class UPPTestRegime:
589
589
  """Configuration for UPP tests."""
590
590
 
591
- resolution: INVResolution = field(
592
- kw_only=False,
593
- default=INVResolution.ENFT,
594
- validator=validators.in_([INVResolution.CLRN, INVResolution.ENFT]),
595
- )
596
- """Whether to test clearance, enforcement, or both."""
597
-
598
- guppi_aggregator: UPPAggrSelector = field(
599
- kw_only=False, default=UPPAggrSelector.MIN
600
- )
601
- """Aggregator for GUPPI test."""
602
-
603
- divr_aggregator: UPPAggrSelector = field(kw_only=False, default=UPPAggrSelector.MIN)
604
- """Aggregator for diversion ratio test."""
605
-
591
+ resolution: INVResolution = field(kw_only=False, default=INVResolution.ENFT)
592
+ """Whether to test clearance, enforcement."""
606
593
 
607
- @frozen
608
- class UPPTestsRaw:
609
- """Container for arrays marking test failures and successes
610
-
611
- A test success is a draw ("market") that meeets the
612
- specified test criterion, and a test failure is
613
- one that does not; test criteria are evaluated in
614
- :func:`enforcement_stats.gen_upp_arrays`.
615
- """
594
+ @resolution.validator
595
+ def _resvdtr(
596
+ _i: UPPTestRegime, _a: Attribute[INVResolution], _v: INVResolution
597
+ ) -> None:
598
+ if _v == INVResolution.BOTH:
599
+ raise ValueError(
600
+ "GUPPI test cannot be performed with both resolutions; only useful for reporting"
601
+ )
602
+ elif _v not in {INVResolution.CLRN, INVResolution.ENFT}:
603
+ raise ValueError(
604
+ f"Must be one of, {INVResolution.CLRN!r} or {INVResolution.ENFT!r}"
605
+ )
616
606
 
617
- guppi_test_simple: ArrayBoolean
618
- """True if GUPPI estimate meets criterion"""
607
+ guppi_aggregator: UPPAggrSelector = field(kw_only=False)
608
+ """Aggregator for GUPPI test."""
619
609
 
620
- guppi_test_compound: ArrayBoolean
621
- """True if both GUPPI estimate and diversion ratio estimate
622
- meet criterion
623
- """
610
+ @guppi_aggregator.default
611
+ def __gad(_i: UPPTestRegime) -> UPPAggrSelector:
612
+ return (
613
+ UPPAggrSelector.MIN
614
+ if _i.resolution == INVResolution.ENFT
615
+ else UPPAggrSelector.MAX
616
+ )
624
617
 
625
- cmcr_test: ArrayBoolean
626
- """True if CMCR estimate meets criterion"""
618
+ divr_aggregator: UPPAggrSelector = field(kw_only=False)
619
+ """Aggregator for diversion ratio test."""
627
620
 
628
- ipr_test: ArrayBoolean
629
- """True if IPR (partial price-simulation) estimate meets criterion"""
621
+ @divr_aggregator.default
622
+ def __dad(_i: UPPTestRegime) -> UPPAggrSelector:
623
+ return _i.guppi_aggregator
630
624
 
631
625
 
632
626
  @frozen
@@ -453,15 +453,6 @@ class MarketSample:
453
453
  )
454
454
 
455
455
  if not _ndt:
456
- # byte_stream = io.BytesIO()
457
- # with h5py.File(byte_stream, "w") as h5f:
458
- # for _a in self.dataset.__attrs_attrs__:
459
- # if all((
460
- # (_arr := getattr(self.dataset, _a.name)).any(),
461
- # not np.isnan(_arr).all(),
462
- # )):
463
- # h5f.create_dataset(_a.name, data=_arr, fletcher32=True)
464
-
465
456
  with (zpath / f"{name_root}_dataset.h5").open("wb") as _hfh:
466
457
  _hfh.write(self.dataset.to_h5bin())
467
458
 
@@ -490,10 +481,7 @@ class MarketSample:
490
481
  if _dt:
491
482
  with _dp.open("rb") as _hfh:
492
483
  object.__setattr__( # noqa: PLC2801
493
- market_sample_,
494
- "dataset",
495
- # MarketSampleData(**{_a: h5f[_a][:] for _a in h5f}),
496
- MarketSampleData.from_h5f(_hfh),
484
+ market_sample_, "dataset", MarketSampleData.from_h5f(_hfh)
497
485
  )
498
486
  if _et:
499
487
  object.__setattr__( # noqa: PLC2801
@@ -7,7 +7,7 @@ import enum
7
7
  from collections.abc import Mapping
8
8
 
9
9
  import numpy as np
10
- from scipy.interpolate import interp1d # type: ignore
10
+ from scipy.interpolate import make_interp_spline # type: ignore
11
11
 
12
12
  from .. import VERSION, ArrayBIGINT, Enameled, this_yaml # noqa: TID252
13
13
  from ..core import ftc_merger_investigations_data as fid # noqa: TID252
@@ -77,7 +77,7 @@ HHI_DELTA_KNOTS = np.array(
77
77
  )
78
78
  HHI_POST_ZONE_KNOTS = np.array([0, 1800, 2400, 10001], dtype=np.int64)
79
79
  hhi_delta_ranger, hhi_zone_post_ranger = (
80
- interp1d(_f / 1e4, _f, kind="previous", assume_sorted=True)
80
+ make_interp_spline(_f / 1e4, _f, k=0)
81
81
  for _f in (HHI_DELTA_KNOTS, HHI_POST_ZONE_KNOTS)
82
82
  )
83
83
 
@@ -256,11 +256,16 @@ def table_no_lku(
256
256
 
257
257
 
258
258
  def enf_cnts_byfirmcount(_cnts_array: ArrayBIGINT, /) -> ArrayBIGINT:
259
+ if not _cnts_array[:, 0].any():
260
+ return np.array([], int)
261
+
259
262
  ndim_in = 1
260
263
  return np.vstack([
261
264
  np.concatenate([
262
265
  (_i,),
263
- np.einsum("ij->j", _cnts_array[_cnts_array[:, 0] == _i][:, ndim_in:]),
266
+ np.einsum(
267
+ "ij->j", _cnts_array[_cnts_array[:, 0] == _i][:, ndim_in:], dtype=int
268
+ ),
264
269
  ])
265
270
  for _i in np.unique(_cnts_array[:, 0])
266
271
  ])
@@ -271,14 +276,16 @@ def enf_cnts_bydelta(_cnts_array: ArrayBIGINT, /) -> ArrayBIGINT:
271
276
  return np.vstack([
272
277
  np.concatenate([
273
278
  (_k,),
274
- np.einsum("ij->j", _cnts_array[_cnts_array[:, 1] == _k][:, ndim_in:]),
279
+ np.einsum(
280
+ "ij->j", _cnts_array[_cnts_array[:, 1] == _k][:, ndim_in:], dtype=int
281
+ ),
275
282
  ])
276
283
  for _k in HHI_DELTA_KNOTS[:-1]
277
284
  ])
278
285
 
279
286
 
280
287
  def enf_cnts_byconczone(_cnts_array: ArrayBIGINT, /) -> ArrayBIGINT:
281
- if not _cnts_array.any():
288
+ if not _cnts_array[:, 0].any() or np.isnan(_cnts_array[:, 0]).all():
282
289
  return np.array([], int)
283
290
  # Step 1: Tag and agg. from HHI-post and Delta to zone triple
284
291
  # NOTE: Although you could just map and not (partially) aggregate in this step,
@@ -315,7 +322,9 @@ def enf_cnts_byconczone(_cnts_array: ArrayBIGINT, /) -> ArrayBIGINT:
315
322
  np.array(
316
323
  (
317
324
  *zone_val,
318
- *np.einsum("ij->j", _cnts_array[:, _ndim_in:][conc_test]),
325
+ *np.einsum(
326
+ "ij->j", _cnts_array[:, _ndim_in:][conc_test], dtype=int
327
+ ),
319
328
  ),
320
329
  dtype=int,
321
330
  ),
@@ -338,7 +347,9 @@ def enf_cnts_byconczone(_cnts_array: ArrayBIGINT, /) -> ArrayBIGINT:
338
347
  (
339
348
  zone_val,
340
349
  np.einsum(
341
- "ij->j", cnts_byhhipostanddelta[hhi_zone_test][:, _nkeys:]
350
+ "ij->j",
351
+ cnts_byhhipostanddelta[hhi_zone_test][:, _nkeys:],
352
+ dtype=int,
342
353
  ),
343
354
  ),
344
355
  dtype=int,
@@ -21,13 +21,7 @@ from .. import ( # noqa
21
21
  UPPAggrSelector,
22
22
  )
23
23
  from ..core import guidelines_boundaries as gbl # noqa: TID252
24
- from . import (
25
- INVResolution,
26
- MarketSampleData,
27
- UPPTestRegime,
28
- UPPTestsCounts,
29
- UPPTestsRaw,
30
- )
24
+ from . import INVResolution, MarketSampleData, UPPTestRegime, UPPTestsCounts
31
25
  from . import enforcement_stats as esl
32
26
 
33
27
  __version__ = VERSION
@@ -41,7 +35,7 @@ class INVRESCntsArgs(TypedDict, total=False):
41
35
  nthreads: int
42
36
 
43
37
 
44
- def compute_upp_test_counts(
38
+ def compute_upp_test_counts( # noqa: PLR0914
45
39
  _market_data_sample: MarketSampleData,
46
40
  _upp_test_parms: gbl.HMGThresholds,
47
41
  _upp_test_regime: UPPTestRegime,
@@ -71,134 +65,6 @@ def compute_upp_test_counts(
71
65
 
72
66
  """
73
67
 
74
- upp_test_arrays = compute_upp_test_arrays(
75
- _market_data_sample, _upp_test_parms, _upp_test_regime
76
- )
77
-
78
- fcounts, hhi_delta, hhi_post = (
79
- getattr(_market_data_sample, _g) for _g in ("fcounts", "hhi_delta", "hhi_post")
80
- )
81
-
82
- stats_rowlen = 6
83
- # Clearance/enforcement counts --- by firm count
84
- enf_cnts_sim_byfirmcount_array: ArrayBIGINT = np.zeros(stats_rowlen, int)
85
- firmcounts_list = np.unique(fcounts)
86
- if firmcounts_list.any():
87
- for _fc in firmcounts_list:
88
- fc_test = fcounts == _fc
89
-
90
- enf_cnts_sim_byfirmcount_array = np.vstack((
91
- enf_cnts_sim_byfirmcount_array,
92
- np.array([
93
- _fc,
94
- np.einsum("ij->", 1 * fc_test),
95
- *[
96
- np.einsum(
97
- "ij->", 1 * (fc_test & getattr(upp_test_arrays, _a.name))
98
- )
99
- for _a in upp_test_arrays.__attrs_attrs__
100
- ],
101
- ]),
102
- ))
103
-
104
- enf_cnts_sim_byfirmcount_array = enf_cnts_sim_byfirmcount_array[1:]
105
- else:
106
- enf_cnts_sim_byfirmcount_array = np.array([], int)
107
-
108
- # Clearance/enforcement counts --- by delta
109
- enf_cnts_sim_bydelta_array: ArrayBIGINT = np.zeros(stats_rowlen, int)
110
- hhi_deltaranged = esl.hhi_delta_ranger(hhi_delta)
111
- for hhi_deltalim in esl.HHI_DELTA_KNOTS[:-1]:
112
- hhi_deltatest = hhi_deltaranged == hhi_deltalim
113
-
114
- enf_cnts_sim_bydelta_array = np.vstack((
115
- enf_cnts_sim_bydelta_array,
116
- np.array([
117
- hhi_deltalim,
118
- np.einsum("ij->", 1 * hhi_deltatest),
119
- *[
120
- np.einsum(
121
- "ij->", 1 * (hhi_deltatest & getattr(upp_test_arrays, _a.name))
122
- )
123
- for _a in upp_test_arrays.__attrs_attrs__
124
- ],
125
- ]),
126
- ))
127
-
128
- enf_cnts_sim_bydelta_array = enf_cnts_sim_bydelta_array[1:]
129
-
130
- # Clearance/enforcement counts --- by zone
131
- if np.isnan(hhi_post).all():
132
- stats_byconczone_sim = np.array([], int)
133
- else:
134
- try:
135
- hhi_zone_post_ranged = esl.hhi_zone_post_ranger(hhi_post)
136
- except ValueError as _err:
137
- print(hhi_post)
138
- raise _err
139
-
140
- stats_byconczone_sim = np.zeros(stats_rowlen + 1, int)
141
- for hhi_zone_post_knot in esl.HHI_POST_ZONE_KNOTS[:-1]:
142
- level_test = hhi_zone_post_ranged == hhi_zone_post_knot
143
-
144
- for hhi_zone_delta_knot in [0, 100, 200]:
145
- delta_test = (
146
- hhi_deltaranged > 100
147
- if hhi_zone_delta_knot == 200
148
- else hhi_deltaranged == hhi_zone_delta_knot
149
- )
150
-
151
- conc_test = level_test & delta_test
152
-
153
- stats_byconczone_sim = np.vstack((
154
- stats_byconczone_sim,
155
- np.array([
156
- hhi_zone_post_knot,
157
- hhi_zone_delta_knot,
158
- np.einsum("ij->", 1 * conc_test),
159
- *[
160
- np.einsum(
161
- "ij->",
162
- 1 * (conc_test & getattr(upp_test_arrays, _a.name)),
163
- )
164
- for _a in upp_test_arrays.__attrs_attrs__
165
- ],
166
- ]),
167
- ))
168
-
169
- enf_cnts_sim_byconczone_array = esl.enf_cnts_byconczone(stats_byconczone_sim[1:])
170
-
171
- del stats_byconczone_sim
172
- del hhi_delta, hhi_post, fcounts
173
-
174
- return UPPTestsCounts(
175
- enf_cnts_sim_byfirmcount_array,
176
- enf_cnts_sim_bydelta_array,
177
- enf_cnts_sim_byconczone_array,
178
- )
179
-
180
-
181
- def compute_upp_test_arrays(
182
- _market_data_sample: MarketSampleData,
183
- _upp_test_parms: gbl.HMGThresholds,
184
- _sim_test_regime: UPPTestRegime,
185
- /,
186
- ) -> UPPTestsRaw:
187
- """
188
- Generate UPP tests arrays for given configuration and market sample
189
-
190
- Given a standards vector, market
191
-
192
- Parameters
193
- ----------
194
- _market_data_sample
195
- market data sample
196
- _upp_test_parms
197
- guidelines thresholds for testing UPP and related statistics
198
- _sim_test_regime
199
- configuration to use for generating UPP tests
200
-
201
- """
202
68
  g_bar_, divr_bar_, cmcr_bar_, ipr_bar_ = (
203
69
  getattr(_upp_test_parms, _f) for _f in ("guppi", "divr", "cmcr", "ipr")
204
70
  )
@@ -228,32 +94,72 @@ def compute_upp_test_arrays(
228
94
  (divr_test_vector,) = _compute_test_array_seq(
229
95
  (_market_data_sample.divr_array,),
230
96
  _market_data_sample.frmshr_array,
231
- _sim_test_regime.divr_aggregator,
97
+ _upp_test_regime.divr_aggregator,
232
98
  )
233
99
 
234
100
  (guppi_test_vector, cmcr_test_vector, ipr_test_vector) = _compute_test_array_seq(
235
101
  (guppi_array, cmcr_array, ipr_array),
236
102
  _market_data_sample.frmshr_array,
237
- _sim_test_regime.guppi_aggregator,
103
+ _upp_test_regime.guppi_aggregator,
238
104
  )
239
105
  del cmcr_array, ipr_array, guppi_array
240
106
 
241
- if _sim_test_regime.resolution == INVResolution.ENFT:
242
- upp_test_arrays = UPPTestsRaw(
107
+ if _upp_test_regime.resolution == INVResolution.ENFT:
108
+ upp_test_arrays = np.hstack((
243
109
  guppi_test_vector >= g_bar_,
244
110
  (guppi_test_vector >= g_bar_) | (divr_test_vector >= divr_bar_),
245
111
  cmcr_test_vector >= cmcr_bar_,
246
112
  ipr_test_vector >= ipr_bar_,
247
- )
113
+ ))
248
114
  else:
249
- upp_test_arrays = UPPTestsRaw(
115
+ upp_test_arrays = np.hstack((
250
116
  guppi_test_vector < g_bar_,
251
117
  (guppi_test_vector < g_bar_) & (divr_test_vector < divr_bar_),
252
118
  cmcr_test_vector < cmcr_bar_,
253
119
  ipr_test_vector < ipr_bar_,
254
- )
120
+ ))
121
+
122
+ fcounts, hhi_delta, hhi_post = (
123
+ getattr(_market_data_sample, _g) for _g in ("fcounts", "hhi_delta", "hhi_post")
124
+ )
255
125
 
256
- return upp_test_arrays
126
+ # Clearance counts by firm count
127
+ enf_cnts_sim_byfirmcount_array = esl.enf_cnts_byfirmcount(
128
+ np.hstack((fcounts, np.ones_like(fcounts), upp_test_arrays))
129
+ )
130
+
131
+ # Clearance counts by Delta and Concentration Zone
132
+ hhi_zone_ranged = (
133
+ esl.hhi_zone_post_ranger(hhi_post).astype(int)
134
+ if hhi_post.any() and not np.isnan(hhi_post).all()
135
+ else np.zeros_like(hhi_post, int)
136
+ )
137
+ hhi_delta_ranged = esl.hhi_delta_ranger(hhi_delta).astype(int)
138
+
139
+ enf_cnts_sim_byhhianddelta_array = np.hstack(
140
+ (
141
+ hhi_zone_ranged,
142
+ hhi_delta_ranged,
143
+ np.ones_like(hhi_delta_ranged),
144
+ upp_test_arrays,
145
+ # *[
146
+ # 1 * getattr(upp_test_arrays, _a.name)
147
+ # for _a in upp_test_arrays.__attrs_attrs__
148
+ # ],
149
+ ),
150
+ dtype=int,
151
+ )
152
+
153
+ enf_cnts_sim_bydelta_array = esl.enf_cnts_bydelta(enf_cnts_sim_byhhianddelta_array)
154
+ enf_cnts_sim_byconczone_array = esl.enf_cnts_byconczone(
155
+ enf_cnts_sim_byhhianddelta_array
156
+ )
157
+
158
+ return UPPTestsCounts(
159
+ enf_cnts_sim_byfirmcount_array,
160
+ enf_cnts_sim_bydelta_array,
161
+ enf_cnts_sim_byconczone_array,
162
+ )
257
163
 
258
164
 
259
165
  def _compute_test_array_seq(