mergeron 2024.738953.1__py3-none-any.whl → 2025.739265.0__py3-none-any.whl

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 (39) hide show
  1. mergeron/__init__.py +26 -6
  2. mergeron/core/__init__.py +5 -65
  3. mergeron/core/{damodaran_margin_data.py → empirical_margin_distribution.py} +74 -58
  4. mergeron/core/ftc_merger_investigations_data.py +147 -101
  5. mergeron/core/guidelines_boundaries.py +290 -1078
  6. mergeron/core/guidelines_boundary_functions.py +1128 -0
  7. mergeron/core/{guidelines_boundaries_specialized_functions.py → guidelines_boundary_functions_extra.py} +87 -55
  8. mergeron/core/pseudorandom_numbers.py +16 -22
  9. mergeron/data/__init__.py +3 -0
  10. mergeron/data/damodaran_margin_data.xls +0 -0
  11. mergeron/data/damodaran_margin_data_dict.msgpack +0 -0
  12. mergeron/demo/__init__.py +3 -0
  13. mergeron/demo/visualize_empirical_margin_distribution.py +86 -0
  14. mergeron/gen/__init__.py +258 -246
  15. mergeron/gen/data_generation.py +473 -224
  16. mergeron/gen/data_generation_functions.py +876 -0
  17. mergeron/gen/enforcement_stats.py +355 -0
  18. mergeron/gen/upp_tests.py +171 -259
  19. mergeron-2025.739265.0.dist-info/METADATA +115 -0
  20. mergeron-2025.739265.0.dist-info/RECORD +23 -0
  21. {mergeron-2024.738953.1.dist-info → mergeron-2025.739265.0.dist-info}/WHEEL +1 -1
  22. mergeron/License.txt +0 -16
  23. mergeron/core/InCommon RSA Server CA cert chain.pem +0 -68
  24. mergeron/core/excel_helper.py +0 -257
  25. mergeron/core/proportions_tests.py +0 -520
  26. mergeron/ext/__init__.py +0 -5
  27. mergeron/ext/tol_colors.py +0 -851
  28. mergeron/gen/_data_generation_functions_nonpublic.py +0 -623
  29. mergeron/gen/investigations_stats.py +0 -709
  30. mergeron/jinja_LaTex_templates/clrrate_cis_summary_table_template.tex.jinja2 +0 -121
  31. mergeron/jinja_LaTex_templates/ftcinvdata_byhhianddelta_table_template.tex.jinja2 +0 -82
  32. mergeron/jinja_LaTex_templates/ftcinvdata_summary_table_template.tex.jinja2 +0 -57
  33. mergeron/jinja_LaTex_templates/ftcinvdata_summarypaired_table_template.tex.jinja2 +0 -104
  34. mergeron/jinja_LaTex_templates/mergeron.cls +0 -161
  35. mergeron/jinja_LaTex_templates/mergeron_table_collection_template.tex.jinja2 +0 -90
  36. mergeron/jinja_LaTex_templates/setup_tikz_tables.tex.jinja2 +0 -84
  37. mergeron-2024.738953.1.dist-info/METADATA +0 -93
  38. mergeron-2024.738953.1.dist-info/RECORD +0 -30
  39. /mergeron/{core → data}/ftc_invdata.msgpack +0 -0
@@ -1,35 +1,40 @@
1
1
  """
2
- Routines for defining and analyzing boundaries for Guidelines standards,
2
+ Methods for defining and analyzing boundaries for Guidelines standards,
3
3
  with a canvas on which to draw boundaries for Guidelines standards.
4
4
 
5
5
  """
6
6
 
7
+ from __future__ import annotations
8
+
7
9
  import decimal
8
- from collections.abc import Callable
9
10
  from dataclasses import dataclass
10
- from importlib.metadata import version
11
- from typing import Any, Literal, TypeAlias
11
+ from typing import Literal
12
12
 
13
13
  import numpy as np
14
- from attrs import define, field
14
+ from attrs import Attribute, field, frozen, validators
15
15
  from mpmath import mp, mpf # type: ignore
16
- from numpy.typing import NDArray
17
16
 
18
- from .. import _PKG_NAME, UPPAggrSelector # noqa: TID252
19
- from . import UPPBoundarySpec
17
+ from .. import ( # noqa: TID252
18
+ DEFAULT_REC_RATIO,
19
+ VERSION,
20
+ ArrayDouble,
21
+ HMGPubYear,
22
+ RECForm,
23
+ UPPAggrSelector,
24
+ )
25
+ from . import guidelines_boundary_functions as gbfn
20
26
 
21
- __version__ = version(_PKG_NAME)
27
+ __version__ = VERSION
22
28
 
23
29
 
24
- mp.prec = 80
30
+ mp.dps = 32
25
31
  mp.trap_complex = True
26
32
 
27
- HMGPubYear: TypeAlias = Literal[1992, 2010, 2023]
28
-
29
33
 
30
- @dataclass(slots=True, frozen=True)
34
+ @dataclass(frozen=True)
31
35
  class HMGThresholds:
32
36
  delta: float
37
+ fc: float
33
38
  rec: float
34
39
  guppi: float
35
40
  divr: float
@@ -37,67 +42,62 @@ class HMGThresholds:
37
42
  ipr: float
38
43
 
39
44
 
40
- @dataclass(slots=True, frozen=True)
41
- class GuidelinesBoundary:
42
- coordinates: NDArray[np.float64]
43
- area: float
44
-
45
-
46
- @dataclass(slots=True, frozen=True)
47
- class GuidelinesBoundaryCallable:
48
- boundary_function: Callable[[NDArray[np.float64]], NDArray[np.float64]]
49
- area: float
50
- s_naught: float = 0
51
-
52
-
53
- @define(slots=True, frozen=True)
45
+ @frozen
54
46
  class GuidelinesThresholds:
55
47
  """
56
48
  Guidelines threholds by Guidelines publication year
57
49
 
58
- ΔHHI, Recapture Rate, GUPPI, Diversion ratio, CMCR, and IPR thresholds
59
- constructed from concentration standards.
50
+ ΔHHI, Recapture Ratio, GUPPI, Diversion ratio, CMCR, and IPR thresholds
51
+ constructed from concentration standards in Guidelines published in
52
+ 1982, 1984, 1992, 2010, and 2023.
53
+
60
54
  """
61
55
 
62
- pub_year: HMGPubYear
56
+ pub_year: HMGPubYear = field(
57
+ kw_only=False,
58
+ default=2023,
59
+ validator=validators.in_([1982, 1984, 1992, 2010, 2023]),
60
+ )
63
61
  """
64
- Year of publication of the U.S. Horizontal Merger Guidelines (HMG)
62
+ Year of publication of the Guidelines
65
63
  """
66
64
 
67
65
  safeharbor: HMGThresholds = field(kw_only=True, default=None)
68
66
  """
69
67
  Negative presumption quantified on various measures
70
68
 
71
- ΔHHI safeharbor bound, default recapture rate, GUPPI bound,
69
+ ΔHHI safeharbor bound, default recapture ratio, GUPPI bound,
72
70
  diversion ratio limit, CMCR, and IPR
73
71
  """
74
72
 
75
- imputed_presumption: HMGThresholds = field(kw_only=True, default=None)
73
+ presumption: HMGThresholds = field(kw_only=True, default=None)
76
74
  """
77
- Presumption of harm imputed from guidelines
75
+ Presumption of harm defined in HMG
78
76
 
79
- ΔHHI bound inferred from strict numbers-equivalent
80
- of (post-merger) HHI presumption, and corresponding default recapture rate,
81
- GUPPI bound, diversion ratio limit, CMCR, and IPR
77
+ ΔHHI bound and corresponding default recapture ratio, GUPPI bound,
78
+ diversion ratio limit, CMCR, and IPR
82
79
  """
83
80
 
84
- presumption: HMGThresholds = field(kw_only=True, default=None)
81
+ imputed_presumption: HMGThresholds = field(kw_only=True, default=None)
85
82
  """
86
- Presumption of harm defined in HMG
83
+ Presumption of harm imputed from guidelines
87
84
 
88
- ΔHHI bound and corresponding default recapture rate, GUPPI bound,
89
- diversion ratio limit, CMCR, and IPR
85
+ ΔHHI bound inferred from strict numbers-equivalent
86
+ of (post-merger) HHI presumption, and corresponding default recapture ratio,
87
+ GUPPI bound, diversion ratio limit, CMCR, and IPR
90
88
  """
91
89
 
92
90
  def __attrs_post_init__(self, /) -> None:
93
- # In the 2023 Guidlines, the agencies do not define a
91
+ # In the 2023 Guidelines, the agencies do not define a
94
92
  # negative presumption, or safeharbor. Practically speaking,
95
93
  # given resource constraints and loss aversion, it is likely
96
94
  # that staff only investigates mergers that meet the presumption;
97
95
  # thus, here, the tentative delta safeharbor under
98
96
  # the 2023 Guidelines is 100 points
99
97
  _hhi_p, _dh_s, _dh_p = {
100
- 1992: (0.18, 0.005, 0.01),
98
+ 1982: (_s1982 := (0.18, 0.005, 0.01)),
99
+ 1984: _s1982,
100
+ 1992: _s1982,
101
101
  2010: (0.25, 0.01, 0.02),
102
102
  2023: (0.18, 0.01, 0.01),
103
103
  }[self.pub_year]
@@ -107,1143 +107,355 @@ class GuidelinesThresholds:
107
107
  "safeharbor",
108
108
  HMGThresholds(
109
109
  _dh_s,
110
- _r := round_cust((_fc := int(np.ceil(1 / _hhi_p))) / (_fc + 1)),
111
- _g_s := gbd_from_dsf(_dh_s, m_star=1.0, r_bar=_r),
112
- _dr := round_cust(1 / (_fc + 1)),
110
+ _fc := int(np.ceil(1 / _hhi_p)),
111
+ _r := float(_r_s := gbfn.round_cust(_fc / (_fc + 1), frac=0.05)),
112
+ _g := float(guppi_from_delta(_dh_s, m_star=1.0, r_bar=_r)),
113
+ _dr := float(1 - _r_s),
113
114
  _cmcr := 0.03, # Not strictly a Guidelines standard
114
- _ipr := _g_s, # Not strictly a Guidelines standard
115
+ _ipr := _g, # Not strictly a Guidelines standard
115
116
  ),
116
117
  )
117
118
 
118
- # imputed_presumption is relevant for 2010 Guidelines
119
+ object.__setattr__(
120
+ self, "presumption", HMGThresholds(_dh_p, _fc, _r, _g, _dr, _cmcr, _ipr)
121
+ )
122
+
123
+ # imputed_presumption is relevant for presumptions implicating
124
+ # mergers *to* symmetry in numbers-equivalent of post-merger HHI
125
+ # as in 2010 U.S.Guidelines
119
126
  object.__setattr__(
120
127
  self,
121
128
  "imputed_presumption",
122
129
  (
123
130
  HMGThresholds(
124
- _dh_i := 2 * (0.5 / _fc) ** 2,
125
- _r_i := round_cust((_fc - 1 / 2) / (_fc + 1 / 2)),
126
- _g_i := gbd_from_dsf(_dh_i, m_star=1.0, r_bar=_r_i),
127
- round_cust((1 / 2) / (_fc - 1 / 2)),
131
+ 2 * (0.5 / _fc) ** 2,
132
+ _fc,
133
+ float(
134
+ _r_i := gbfn.round_cust(
135
+ (_fc - 1 / 2) / (_fc + 1 / 2), frac=0.05
136
+ )
137
+ ),
138
+ _g,
139
+ float((1 - _r_i) / 2),
128
140
  _cmcr,
129
- _g_i,
141
+ _ipr := _g,
130
142
  )
131
143
  if self.pub_year == 2010
132
144
  else HMGThresholds(
133
- _dh_i := 2 * (1 / (_fc + 1)) ** 2,
134
- _r,
135
- _g_i := gbd_from_dsf(_dh_i, m_star=1.0, r_bar=_r),
136
- _dr,
137
- _cmcr,
138
- _g_i,
145
+ 2 * (1 / (_fc + 1)) ** 2, _fc, _r, _g, _dr, _cmcr, _ipr
139
146
  )
140
147
  ),
141
148
  )
142
149
 
143
- object.__setattr__(
144
- self,
145
- "presumption",
146
- HMGThresholds(
147
- _dh_p,
148
- _r,
149
- _g_p := gbd_from_dsf(_dh_p, m_star=1.0, r_bar=_r),
150
- _dr,
151
- _cmcr,
152
- _ipr := _g_p,
153
- ),
154
- )
155
150
 
151
+ @frozen
152
+ class ConcentrationBoundary:
153
+ """Concentration parameters, boundary coordinates, and area under concentration boundary."""
156
154
 
157
- def round_cust(
158
- _num: float = 0.060215,
159
- /,
160
- *,
161
- frac: float = 0.005,
162
- rounding_mode: str = "ROUND_HALF_UP",
163
- ) -> float:
164
- """
165
- Custom rounding, to the nearest 0.5% by default.
155
+ measure_name: Literal[
156
+ "ΔHHI", "Combined share", "Pre-merger HHI", "Post-merger HHI"
157
+ ] = field(kw_only=False, default="ΔHHI")
166
158
 
167
- Parameters
168
- ----------
169
- _num
170
- Number to be rounded.
171
- frac
172
- Fraction to be rounded to.
173
- rounding_mode
174
- Rounding mode, as defined in the :code:`decimal` package.
175
-
176
- Returns
177
- -------
178
- The given number, rounded as specified.
179
-
180
- Raises
181
- ------
182
- ValueError
183
- If rounding mode is not defined in the :code:`decimal` package.
184
-
185
- Notes
186
- -----
187
- Integer-round the quotient, :code:`(_num / frac)` using the specified
188
- rounding mode. Return the product of the rounded quotient times
189
- the specified precision, :code:`frac`.
190
-
191
- """
192
-
193
- if rounding_mode not in (
194
- decimal.ROUND_05UP,
195
- decimal.ROUND_CEILING,
196
- decimal.ROUND_DOWN,
197
- decimal.ROUND_FLOOR,
198
- decimal.ROUND_HALF_DOWN,
199
- decimal.ROUND_HALF_EVEN,
200
- decimal.ROUND_HALF_UP,
201
- decimal.ROUND_UP,
202
- ):
203
- raise ValueError(
204
- f"Value, {f'"{rounding_mode}"'} is invalid for rounding_mode."
205
- "Documentation for the, \"decimal\" built-in lists valid rounding modes."
206
- )
207
-
208
- _n, _f, _e = (decimal.Decimal(f"{_g}") for _g in [_num, frac, 1])
209
-
210
- return float(_f * (_n / _f).quantize(_e, rounding=rounding_mode))
211
-
212
-
213
- def lerp(
214
- _x1: int | float | mpf | NDArray[np.float64 | np.int64] = 3,
215
- _x2: int | float | mpf | NDArray[np.float64 | np.int64] = 1,
216
- _r: float | mpf = 0.25,
217
- /,
218
- ) -> float | mpf | NDArray[np.float64]:
219
- """
220
- From the function of the same name in the C++ standard [2]_
221
-
222
- Constructs the weighted average, :math:`w_1 x_1 + w_2 x_2`, where
223
- :math:`w_1 = 1 - r` and :math:`w_2 = r`.
224
-
225
- Parameters
226
- ----------
227
- _x1, _x2
228
- bounds :math:`x_1, x_2` to interpolate between.
229
- _r
230
- interpolation weight :math:`r` assigned to :math:`x_2`
231
-
232
- Returns
233
- -------
234
- The linear interpolation, or weighted average,
235
- :math:`x_1 + r \\cdot (x_1 - x_2) \\equiv (1 - r) \\cdot x_1 + r \\cdot x_2`.
236
-
237
- Raises
238
- ------
239
- ValueError
240
- If the interpolation weight is not in the interval, :math:`[0, 1]`.
241
-
242
- References
243
- ----------
159
+ @measure_name.validator # pyright: ignore
160
+ def __mnv(
161
+ _instance: ConcentrationBoundary, _attribute: Attribute[str], _value: str, /
162
+ ) -> None:
163
+ if _value not in (
164
+ "ΔHHI",
165
+ "Combined share",
166
+ "Pre-merger HHI",
167
+ "Post-merger HHI",
168
+ ):
169
+ raise ValueError(f"Invalid name for a concentration measure, {_value!r}.")
244
170
 
245
- .. [2] C++ Reference, https://en.cppreference.com/w/cpp/numeric/lerp
171
+ threshold: float = field(kw_only=False, default=0.01)
246
172
 
247
- """
173
+ @threshold.validator # pyright: ignore
174
+ def __tv(
175
+ _instance: ConcentrationBoundary, _attribute: Attribute[float], _value: float, /
176
+ ) -> None:
177
+ if not 0 <= _value <= 1:
178
+ raise ValueError("Concentration threshold must lie between 0 and 1.")
248
179
 
249
- if not 0 <= _r <= 1:
250
- raise ValueError("Specified interpolation weight must lie in [0, 1].")
251
- elif _r == 0:
252
- return _x1
253
- elif _r == 1:
254
- return _x2
255
- elif _r == 0.5:
256
- return 1 / 2 * (_x1 + _x2)
257
- else:
258
- return _r * _x2 + (1 - _r) * _x1
259
-
260
-
261
- def gbd_from_dsf(
262
- _deltasf: float = 0.01, /, *, m_star: float = 1.00, r_bar: float = 0.80
263
- ) -> float:
264
- """
265
- Translate ∆HHI bound to GUPPI bound.
266
-
267
- Parameters
268
- ----------
269
- _deltasf
270
- Specified ∆HHI bound.
271
- m_star
272
- Parametric price-cost margin.
273
- r_bar
274
- Default recapture rate.
275
-
276
- Returns
277
- -------
278
- GUPPI bound corresponding to ∆HHI bound, at given margin and recapture rate.
279
-
280
- """
281
- return round_cust(
282
- m_star * r_bar * (_s_m := np.sqrt(_deltasf / 2)) / (1 - _s_m),
283
- frac=0.005,
284
- rounding_mode="ROUND_HALF_DOWN",
180
+ precision: int = field(
181
+ kw_only=False, default=5, validator=validators.instance_of(int)
285
182
  )
286
183
 
184
+ coordinates: ArrayDouble = field(init=False, kw_only=True)
185
+ """Market-share pairs as Cartesian coordinates of points on the concentration boundary."""
287
186
 
288
- def critical_shrratio(
289
- _gbd: float = 0.06,
290
- /,
291
- *,
292
- m_star: float = 1.00,
293
- r_bar: float = 0.80,
294
- frac: float = 1e-16,
295
- ) -> mpf:
296
- """
297
- Corollary to GUPPI bound.
187
+ area: float = field(init=False, kw_only=True)
188
+ """Area under the concentration boundary."""
298
189
 
299
- Parameters
300
- ----------
301
- _gbd
302
- Specified GUPPI bound.
303
- m_star
304
- Parametric price-cost margin.
305
- r_bar
306
- Default recapture rate.
307
-
308
- Returns
309
- -------
310
- Critical share ratio (share ratio bound) corresponding to the GUPPI bound
311
- for given margin and recapture rate.
190
+ def __attrs_post_init__(self, /) -> None:
191
+ match self.measure_name:
192
+ case "ΔHHI":
193
+ _conc_fn = gbfn.hhi_delta_boundary
194
+ case "Combined share":
195
+ _conc_fn = gbfn.combined_share_boundary
196
+ case "Pre-merger HHI":
197
+ _conc_fn = gbfn.hhi_pre_contrib_boundary
198
+ case "Post-merger HHI":
199
+ _conc_fn = gbfn.hhi_post_contrib_boundary
312
200
 
313
- """
314
- return round_cust(mpf(f"{_gbd}") / mp.fmul(f"{m_star}", f"{r_bar}"), frac=frac)
201
+ _boundary = _conc_fn(self.threshold, dps=self.precision)
202
+ object.__setattr__(self, "coordinates", _boundary.coordinates)
203
+ object.__setattr__(self, "area", _boundary.area)
315
204
 
316
205
 
317
- def shr_from_gbd(
318
- _gbd: float = 0.06, /, *, m_star: float = 1.00, r_bar: float = 0.80
319
- ) -> float:
206
+ @frozen
207
+ class DiversionRatioBoundary:
320
208
  """
321
- Symmetric-firm share for given GUPPI, margin, and recapture rate.
209
+ Diversion ratio specification, boundary coordinates, and area under boundary.
322
210
 
323
- Parameters
324
- ----------
325
- _gbd
326
- GUPPI bound.
327
- m_star
328
- Parametric price-cost margin.
329
- r_bar
330
- Default recapture rate.
331
-
332
- Returns
333
- -------
334
- float
335
- Symmetric firm market share on GUPPI boundary, for given margin and
336
- recapture rate.
211
+ Along with the default diversion ratio and recapture ratio,
212
+ a diversion ratio boundary specification includes the recapture form --
213
+ whether fixed for both merging firms' products ("proportional") or
214
+ consistent with share-proportionality, i.e., "inside-out";
215
+ the method of aggregating diversion ratios for the two products, and
216
+ the precision for the estimate of area under the divertion ratio boundary
217
+ (also defines the number of points on the boundary).
337
218
 
338
219
  """
339
220
 
340
- return round_cust(
341
- (_d0 := critical_shrratio(_gbd, m_star=m_star, r_bar=r_bar)) / (1 + _d0)
342
- )
343
-
344
-
345
- def boundary_plot(*, mktshares_plot_flag: bool = True) -> tuple[Any, ...]:
346
- """Setup basic figure and axes for plots of safe harbor boundaries.
221
+ diversion_ratio: float = field(kw_only=False, default=0.065)
347
222
 
348
- See, https://matplotlib.org/stable/tutorials/text/pgf.html
349
- """
350
-
351
- import matplotlib as mpl
352
- import matplotlib.axes as mpa
353
- import matplotlib.patches as mpp
354
- import matplotlib.ticker as mpt
355
-
356
- mpl.use("pgf")
357
- import matplotlib.pyplot as plt
358
- # from matplotlib.backends.backend_pgf import FigureCanvasPgf
359
-
360
- # from matplotlib.backends.backend_pgf import FigureCanvasPgf
361
- # mpl.backend_bases.register_backend("pdf", FigureCanvasPgf)
362
- # import matplotlib.pyplot as plt
363
-
364
- plt.rcParams.update({
365
- "pgf.rcfonts": False,
366
- "pgf.texsystem": "lualatex",
367
- "pgf.preamble": "\n".join([
368
- R"\pdfvariable minorversion=7",
369
- R"\usepackage{fontspec}",
370
- R"\usepackage{luacode}",
371
- R"\begin{luacode}",
372
- R"local function embedfull(tfmdata)",
373
- R' tfmdata.embedding = "full"',
374
- R"end",
375
- R"",
376
- R"luatexbase.add_to_callback("
377
- R' "luaotfload.patch_font", embedfull, "embedfull"'
378
- R")",
379
- R"\end{luacode}",
380
- R"\usepackage{mathtools}",
381
- R"\usepackage{unicode-math}",
382
- R"\setmathfont[math-style=ISO]{STIX Two Math}",
383
- R"\setmainfont{STIX Two Text}",
384
- r"\setsansfont{Fira Sans Light}",
385
- R"\setmonofont[Scale=MatchLowercase,]{Fira Mono}",
386
- R"\defaultfontfeatures[\rmfamily]{",
387
- R" Ligatures={TeX, Common},",
388
- R" Numbers={Proportional, Lining},",
389
- R" }",
390
- R"\defaultfontfeatures[\sffamily]{",
391
- R" Ligatures={TeX, Common},",
392
- R" Numbers={Monospaced, Lining},",
393
- R" LetterSpace=0.50,",
394
- R" }",
395
- R"\usepackage[",
396
- R" activate={true, nocompatibility},",
397
- R" tracking=true,",
398
- R" ]{microtype}",
399
- ]),
400
- })
401
-
402
- # Initialize a canvas with a single figure (set of axes)
403
- _fig = plt.figure(figsize=(5, 5), dpi=600)
404
- _ax_out = _fig.add_subplot()
405
-
406
- def _set_axis_def(
407
- _ax1: mpa.Axes,
223
+ @diversion_ratio.validator
224
+ def __dvv(
225
+ _instance: DiversionRatioBoundary,
226
+ _attribute: Attribute[float],
227
+ _value: float,
408
228
  /,
409
- *,
410
- mktshares_plot_flag: bool = False,
411
- mktshares_axlbls_flag: bool = False,
412
- ) -> mpa.Axes:
413
- # Set the width of axis gridlines, and tick marks:
414
- # both axes, both major and minor ticks
415
- # Frame, grid, and facecolor
416
- for _spos0 in "left", "bottom":
417
- _ax1.spines[_spos0].set_linewidth(0.5)
418
- _ax1.spines[_spos0].set_zorder(5)
419
- for _spos1 in "top", "right":
420
- _ax1.spines[_spos1].set_linewidth(0.0)
421
- _ax1.spines[_spos1].set_zorder(0)
422
- _ax1.spines[_spos1].set_visible(False)
423
- _ax1.set_facecolor("#E6E6E6")
424
-
425
- _ax1.grid(linewidth=0.5, linestyle=":", color="grey", zorder=1)
426
- _ax1.tick_params(axis="x", which="both", width=0.5)
427
- _ax1.tick_params(axis="y", which="both", width=0.5)
428
-
429
- # Tick marks skip, size, and rotation
430
- # x-axis
431
- plt.setp(
432
- _ax1.xaxis.get_majorticklabels(),
433
- horizontalalignment="right",
434
- fontsize=6,
435
- rotation=45,
436
- )
437
- # y-axis
438
- plt.setp(
439
- _ax1.yaxis.get_majorticklabels(), horizontalalignment="right", fontsize=6
440
- )
441
-
442
- if mktshares_plot_flag:
443
- # Axis labels
444
- if mktshares_axlbls_flag:
445
- # x-axis
446
- _ax1.set_xlabel("Firm 1 Market Share, $s_1$", fontsize=10)
447
- _ax1.xaxis.set_label_coords(0.75, -0.1)
448
- # y-axis
449
- _ax1.set_ylabel("Firm 2 Market Share, $s_2$", fontsize=10)
450
- _ax1.yaxis.set_label_coords(-0.1, 0.75)
451
-
452
- # Plot the ray of symmetry
453
- _ax1.plot(
454
- [0, 1], [0, 1], linewidth=0.5, linestyle=":", color="grey", zorder=1
455
- )
456
-
457
- # Axis scale
458
- _ax1.set_xlim(0, 1)
459
- _ax1.set_ylim(0, 1)
460
- _ax1.set_aspect(1.0)
461
-
462
- # Truncate the axis frame to a triangle:
463
- _ax1.add_patch(
464
- mpp.Rectangle(
465
- xy=(1.0025, 0.00),
466
- width=1.1 * mp.sqrt(2),
467
- height=1.1 * mp.sqrt(2),
468
- angle=45,
469
- color="white",
470
- edgecolor=None,
471
- fill=True,
472
- clip_on=True,
473
- zorder=5,
474
- )
475
- )
476
- # Feasible space is bounded by the other diagonal:
477
- _ax1.plot(
478
- [0, 1], [1, 0], linestyle="-", linewidth=0.5, color="black", zorder=1
229
+ ) -> None:
230
+ if not (isinstance(_value, float) and 0 <= _value <= 1):
231
+ raise ValueError(
232
+ "Margin-adjusted benchmark share ratio must lie between 0 and 1."
479
233
  )
480
234
 
481
- # Axis Tick-mark locations
482
- # One can supply an argument to mpt.AutoMinorLocator to
483
- # specify a fixed number of minor intervals per major interval, e.g.:
484
- # minorLocator = mpt.AutoMinorLocator(2)
485
- # would lead to a single minor tick between major ticks.
486
- _minorLocator = mpt.AutoMinorLocator(5)
487
- _majorLocator = mpt.MultipleLocator(0.05)
488
- for _axs in _ax1.xaxis, _ax1.yaxis:
489
- if _axs == _ax1.xaxis:
490
- _majorticklabels_rot = 45
491
- elif _axs == _ax1.yaxis:
492
- _majorticklabels_rot = 0
493
- # x-axis
494
- _axs.set_major_locator(_majorLocator)
495
- _axs.set_minor_locator(_minorLocator)
496
- # It"s always x when specifying the format
497
- _axs.set_major_formatter(mpt.StrMethodFormatter("{x:>3.0%}"))
498
-
499
- # Hide every other tick-label
500
- for _axl in _ax1.get_xticklabels(), _ax1.get_yticklabels():
501
- plt.setp(_axl[::2], visible=False)
502
-
503
- return _ax1
504
-
505
- _ax_out = _set_axis_def(_ax_out, mktshares_plot_flag=mktshares_plot_flag)
506
-
507
- return plt, _fig, _ax_out, _set_axis_def
508
-
509
-
510
- def dh_area(_dh_val: float = 0.01, /, *, dh_dps: int = 9) -> float:
511
- R"""
512
- Area under the ΔHHI boundary.
513
-
514
- When the given ΔHHI bound matches a Guidelines standard,
515
- the area under the boundary is half the intrinsic clearance rate
516
- for the ΔHHI safeharbor.
517
-
518
- Notes
519
- -----
520
- To derive the knots, :math:`(s^0_1, s^1_1), (s^1_1, s^0_1)`
521
- of the ΔHHI boundary, i.e., the points where it intersects
522
- the merger-to-monopoly boundary, solve
523
-
524
- .. math::
525
-
526
- 2 s1 s_2 &= ΔHHI\\
527
- s_1 + s_2 &= 1
528
-
529
- Parameters
530
- ----------
531
- _dh_val
532
- Change in concentration.
533
- dh_dps
534
- Specified precision in decimal places.
535
-
536
- Returns
537
- -------
538
- Area under ΔHHI boundary.
539
-
540
- """
541
-
542
- _dh_val = mpf(f"{_dh_val}")
543
- _s_naught = (1 - mp.sqrt(1 - 2 * _dh_val)) / 2
544
-
545
- return round(
546
- float(_s_naught + (_dh_val / 2) * (mp.ln(1 - _s_naught) - mp.ln(_s_naught))),
547
- dh_dps,
235
+ recapture_ratio: float = field(
236
+ kw_only=False,
237
+ default=DEFAULT_REC_RATIO,
238
+ validator=validators.instance_of(float),
548
239
  )
549
240
 
550
-
551
- def dh_area_quad(_dh_val: float = 0.01, /, *, dh_dps: int = 9) -> float:
241
+ recapture_form: RECForm | None = field(kw_only=True, default=RECForm.INOUT)
552
242
  """
553
- Area under the ΔHHI boundary.
243
+ The form of the recapture ratio.
554
244
 
555
- When the given ΔHHI bound matches a Guidelines safeharbor,
556
- the area under the boundary is half the intrinsic clearance rate
557
- for the ΔHHI safeharbor.
245
+ When :attr:`mergeron.RECForm.INOUT`, the recapture ratio for
246
+ he product having the smaller market-share is assumed to equal the default,
247
+ and the recapture ratio for the product with the larger market-share is
248
+ computed assuming MNL demand. Fixed recapture ratios are specified as
249
+ :attr:`mergeron.RECForm.FIXED`. (To specify that recapture ratios be
250
+ constructed from the generated purchase-probabilities for products in
251
+ the market and for the outside good, specify :attr:`mergeron.RECForm.OUTIN`.)
558
252
 
559
- Parameters
560
- ----------
561
- _dh_val
562
- Merging-firms' ΔHHI bound.
563
- dh_dps
564
- Specified precision in decimal places.
565
-
566
- Returns
567
- -------
568
- Area under ΔHHI boundary.
253
+ The GUPPI boundary is a continuum of diversion ratio boundaries conditional on
254
+ price-cost margins, :math:`d_{ij} = g_i * p_i / (m_j * p_j)`,
255
+ with :math:`d_{ij}` the diverion ratio from product :math:`i` to product :math:`j`;
256
+ :math:`g_i` the GUPPI for product :math:`i`;
257
+ :math:`m_j` the margin for product :math:`j`; and
258
+ :math:`p_i, p_j` the prices of goods :math:`i, j`, respectively.
569
259
 
570
260
  """
571
261
 
572
- _dh_val = mpf(f"{_dh_val}")
573
- _s_naught = (1 - mp.sqrt(1 - 2 * _dh_val)) / 2
262
+ @recapture_form.validator
263
+ def __rsv(
264
+ _instance: DiversionRatioBoundary,
265
+ _attribute: Attribute[RECForm],
266
+ _value: RECForm,
267
+ /,
268
+ ) -> None:
269
+ if _value and not (isinstance(_value, RECForm)):
270
+ raise ValueError(f"Invalid recapture specification, {_value!r}.")
271
+ if _value == RECForm.OUTIN and _instance.recapture_ratio:
272
+ raise ValueError(
273
+ f"Invalid recapture specification, {_value!r}. "
274
+ "You may consider specifying `mergeron.RECForm.INOUT` here, and "
275
+ 'assigning the default recapture ratio as attribute, "recapture_ratio" of '
276
+ "this `DiversionRatioBoundarySpec` object."
277
+ )
278
+ if _value is None and _instance.agg_method != UPPAggrSelector.MAX:
279
+ raise ValueError(
280
+ f"Specified aggregation method, {_instance.agg_method} requires a recapture specification."
281
+ )
574
282
 
575
- return round(
576
- float(
577
- _s_naught + mp.quad(lambda x: _dh_val / (2 * x), [_s_naught, 1 - _s_naught])
578
- ),
579
- dh_dps,
283
+ agg_method: UPPAggrSelector = field(
284
+ kw_only=True,
285
+ default=UPPAggrSelector.MAX,
286
+ validator=validators.instance_of(UPPAggrSelector),
580
287
  )
581
-
582
-
583
- def delta_hhi_boundary(
584
- _dh_val: float = 0.01, /, *, prec: int = 5
585
- ) -> GuidelinesBoundary:
586
288
  """
587
- Generate the list of share combination on the ΔHHI boundary.
289
+ Method for aggregating the distinct diversion ratio measures for the two products.
588
290
 
589
- Parameters
590
- ----------
591
- _dh_val:
592
- Merging-firms' ΔHHI bound.
593
- dh_dps
594
- Number of decimal places for rounding reported shares.
595
-
596
- Returns
597
- -------
598
- Array of share-pairs, area under boundary.
291
+ Distinct diversion ratio or GUPPI measures for the two merging-firms' products are
292
+ aggregated using the method specified by the `agg_method` attribute, which is specified
293
+ using the enum :class:`mergeron.UPPAggrSelector`.
599
294
 
600
295
  """
601
296
 
602
- _dh_val = mpf(f"{_dh_val}")
603
- _s_naught = 1 / 2 * (1 - mp.sqrt(1 - 2 * _dh_val))
604
- _s_mid = mp.sqrt(_dh_val / 2)
605
-
606
- _dh_step_sz = mp.power(10, -6)
607
- _s_1 = np.array(mp.arange(_s_mid, _s_naught - mp.eps, -_dh_step_sz))
608
- _s_2 = _dh_val / (2 * _s_1)
609
-
610
- # Boundary points
611
- _dh_half = np.row_stack((
612
- np.column_stack((_s_1, _s_2)),
613
- np.array([(mpf("0.0"), mpf("1.0"))]),
614
- ))
615
- _dh_bdry_pts = np.row_stack((np.flip(_dh_half, 0), np.flip(_dh_half[1:], 1)))
616
-
617
- _s_1_pts, _s_2_pts = np.split(_dh_bdry_pts, 2, axis=1)
618
- return GuidelinesBoundary(
619
- np.column_stack((
620
- np.array(_s_1_pts, np.float64),
621
- np.array(_s_2_pts, np.float64),
622
- )),
623
- dh_area(_dh_val, dh_dps=prec),
297
+ precision: int = field(
298
+ kw_only=False, default=5, validator=validators.instance_of(int)
624
299
  )
625
-
626
-
627
- def combined_share_boundary(
628
- _s_intcpt: float = 0.0625, /, *, bdry_dps: int = 10
629
- ) -> GuidelinesBoundary:
630
- """
631
- Share combinations on the merging-firms' combined share boundary.
632
-
633
- Assumes symmetric merging-firm margins. The combined-share is
634
- congruent to the post-merger HHI contribution boundary, as the
635
- post-merger HHI bound is the square of the combined-share bound.
636
-
637
- Parameters
638
- ----------
639
- _s_intcpt:
640
- Merging-firms' combined share.
641
- bdry_dps
642
- Number of decimal places for rounding reported shares.
643
-
644
- Returns
645
- -------
646
- Array of share-pairs, area under boundary.
647
-
648
300
  """
649
- _s_intcpt = mpf(f"{_s_intcpt}")
650
- _s_mid = _s_intcpt / 2
651
-
652
- _s1_pts = (0, _s_mid, _s_intcpt)
653
- return GuidelinesBoundary(
654
- np.column_stack((
655
- np.array(_s1_pts, np.float64),
656
- np.array(_s1_pts[::-1], np.float64),
657
- )),
658
- round(float(_s_intcpt * _s_mid), bdry_dps),
659
- )
301
+ The number of decimal places of precision for the estimated area under the UPP boundary.
660
302
 
303
+ Leaving this attribute unspecified will result in the default precision,
304
+ which varies based on the `agg_method` attribute, reflecting
305
+ the limit of precision available from the underlying functions. The number of
306
+ boundary points generated is also defined based on this attribute.
661
307
 
662
- def hhi_pre_contrib_boundary(
663
- _hhi_contrib: float = 0.03125, /, *, bdry_dps: int = 5
664
- ) -> GuidelinesBoundary:
665
308
  """
666
- Share combinations on the premerger HHI contribution boundary.
667
309
 
668
- Parameters
669
- ----------
670
- _hhi_contrib:
671
- Merging-firms' pre-merger HHI contribution bound.
672
- bdry_dps
673
- Number of decimal places for rounding reported shares.
310
+ coordinates: ArrayDouble = field(init=False, kw_only=True)
311
+ """Market-share pairs as Cartesian coordinates of points on the diversion ratio boundary."""
674
312
 
675
- Returns
676
- -------
677
- Array of share-pairs, area under boundary.
313
+ area: float = field(init=False, kw_only=True)
314
+ """Area under the diversion ratio boundary."""
678
315
 
679
- """
680
- _hhi_contrib = mpf(f"{_hhi_contrib}")
681
- _s_mid = mp.sqrt(_hhi_contrib / 2)
682
-
683
- _bdry_step_sz = mp.power(10, -bdry_dps)
684
- # Range-limit is 0 less a step, which is -1 * step-size
685
- _s_1 = np.array(mp.arange(_s_mid, -_bdry_step_sz, -_bdry_step_sz), np.float64)
686
- _s_2 = np.sqrt(_hhi_contrib - _s_1**2).astype(np.float64)
687
- _bdry_pts_mid = np.column_stack((_s_1, _s_2))
688
- return GuidelinesBoundary(
689
- np.row_stack((np.flip(_bdry_pts_mid, 0), np.flip(_bdry_pts_mid[1:], 1))),
690
- round(float(mp.pi * _hhi_contrib / 4), bdry_dps),
691
- )
692
-
693
-
694
- def shrratio_boundary(_bdry_spec: UPPBoundarySpec) -> GuidelinesBoundary:
695
- match _bdry_spec.agg_method:
696
- case UPPAggrSelector.AVG:
697
- return shrratio_boundary_xact_avg(
698
- _bdry_spec.share_ratio,
699
- _bdry_spec.rec,
700
- recapture_spec=_bdry_spec.recapture_spec.value, # type: ignore
701
- prec=_bdry_spec.precision,
702
- )
703
- case UPPAggrSelector.MAX:
704
- return shrratio_boundary_max(
705
- _bdry_spec.share_ratio, _bdry_spec.rec, prec=_bdry_spec.precision
706
- )
707
- case UPPAggrSelector.MIN:
708
- return shrratio_boundary_min(
709
- _bdry_spec.share_ratio,
710
- _bdry_spec.rec,
711
- recapture_spec=_bdry_spec.recapture_spec.value, # type: ignore
712
- prec=_bdry_spec.precision,
713
- )
714
- case UPPAggrSelector.DIS:
715
- return shrratio_boundary_wtd_avg(
716
- _bdry_spec.share_ratio,
717
- _bdry_spec.rec,
718
- agg_method="distance",
719
- weighting=None,
720
- recapture_spec=_bdry_spec.recapture_spec.value, # type: ignore
721
- prec=_bdry_spec.precision,
722
- )
723
- case _:
724
- _weighting = (
725
- "cross-product-share"
726
- if _bdry_spec.agg_method.value.startswith("cross-product-share")
727
- else "own-share"
728
- )
729
-
730
- _agg_method = (
731
- "arithmetic"
732
- if _bdry_spec.agg_method.value.endswith("average")
733
- else "distance"
734
- )
735
-
736
- return shrratio_boundary_wtd_avg(
737
- _bdry_spec.share_ratio,
738
- _bdry_spec.rec,
739
- agg_method=_agg_method, # type: ignore
740
- weighting=_weighting, # type: ignore
741
- recapture_spec=_bdry_spec.recapture_spec.value, # type: ignore
742
- prec=_bdry_spec.precision,
743
- )
744
-
745
-
746
- def shrratio_boundary_max(
747
- _delta_star: float = 0.075, _r_val: float = 0.80, /, *, prec: int = 10
748
- ) -> GuidelinesBoundary:
749
- """
750
- Share combinations on the minimum GUPPI boundary with symmetric
751
- merging-firm margins.
752
-
753
- Parameters
754
- ----------
755
- _delta_star
756
- Margin-adjusted benchmark share ratio.
757
- _r_val
758
- Recapture ratio.
759
- prec
760
- Number of decimal places for rounding returned shares.
761
-
762
- Returns
763
- -------
764
- Array of share-pairs, area under boundary.
765
-
766
- """
767
-
768
- # _r_val is not needed for max boundary, but is specified for consistency
769
- # of function call with other shrratio_mgnsym_boundary functions
770
- del _r_val
771
- _delta_star = mpf(f"{_delta_star}")
772
- _s_intcpt = _delta_star
773
- _s_mid = _delta_star / (1 + _delta_star)
774
-
775
- _s1_pts = (0, _s_mid, _s_intcpt)
776
-
777
- return GuidelinesBoundary(
778
- np.column_stack((
779
- np.array(_s1_pts, np.float64),
780
- np.array(_s1_pts[::-1], np.float64),
781
- )),
782
- round(float(_s_intcpt * _s_mid), prec), # simplified calculation
783
- )
784
-
785
-
786
- def shrratio_boundary_min(
787
- _delta_star: float = 0.075,
788
- _r_val: float = 0.80,
316
+ def __attrs_post_init__(self, /) -> None:
317
+ _share_ratio = critical_share_ratio(
318
+ self.diversion_ratio, r_bar=self.recapture_ratio
319
+ )
320
+ _upp_agg_kwargs: gbfn.ShareRatioBoundaryKeywords = {
321
+ "recapture_form": getattr(self.recapture_form, "value", "inside-out"),
322
+ "dps": self.precision,
323
+ }
324
+
325
+ match self.agg_method:
326
+ case UPPAggrSelector.DIS:
327
+ _upp_agg_fn = gbfn.shrratio_boundary_wtd_avg
328
+ _upp_agg_kwargs |= {"agg_method": "distance", "weighting": None}
329
+ case UPPAggrSelector.AVG:
330
+ _upp_agg_fn = gbfn.shrratio_boundary_xact_avg # type: ignore
331
+ case UPPAggrSelector.MAX:
332
+ _upp_agg_fn = gbfn.shrratio_boundary_max # type: ignore
333
+ _upp_agg_kwargs = {"dps": 10} # replace here
334
+ case UPPAggrSelector.MIN:
335
+ _upp_agg_fn = gbfn.shrratio_boundary_min # type: ignore
336
+ _upp_agg_kwargs |= {"dps": 10} # update here
337
+ case _:
338
+ _upp_agg_fn = gbfn.shrratio_boundary_wtd_avg
339
+
340
+ _aggregator: Literal["arithmetic mean", "geometric mean", "distance"]
341
+ if self.agg_method.value.endswith("average"):
342
+ _aggregator = "arithmetic mean"
343
+ elif self.agg_method.value.endswith("geometric mean"):
344
+ _aggregator = "geometric mean"
345
+ else:
346
+ _aggregator = "distance"
347
+
348
+ _wgt_type: Literal["cross-product-share", "own-share", None]
349
+ if self.agg_method.value.startswith("cross-product-share"):
350
+ _wgt_type = "cross-product-share"
351
+ elif self.agg_method.value.startswith("own-share"):
352
+ _wgt_type = "own-share"
353
+ else:
354
+ _wgt_type = None
355
+
356
+ _upp_agg_kwargs |= {"agg_method": _aggregator, "weighting": _wgt_type}
357
+
358
+ _boundary = _upp_agg_fn(_share_ratio, self.recapture_ratio, **_upp_agg_kwargs)
359
+ object.__setattr__(self, "coordinates", _boundary.coordinates)
360
+ object.__setattr__(self, "area", _boundary.area)
361
+
362
+
363
+ def guppi_from_delta(
364
+ _delta_bound: float = 0.01,
789
365
  /,
790
366
  *,
791
- recapture_spec: str = "inside-out",
792
- prec: int = 10,
793
- ) -> GuidelinesBoundary:
367
+ m_star: float = 1.00,
368
+ r_bar: float = DEFAULT_REC_RATIO,
369
+ ) -> decimal.Decimal:
794
370
  """
795
- Share combinations on the minimum GUPPI boundary, with symmetric
796
- merging-firm margins.
797
-
798
- Notes
799
- -----
800
- With symmetric merging-firm margins, the maximum GUPPI boundary is
801
- defined by the diversion ratio from the smaller merging-firm to the
802
- larger one, and is hence unaffected by the method of estimating the
803
- diversion ratio for the larger firm.
371
+ Translate ∆HHI bound to GUPPI bound.
804
372
 
805
373
  Parameters
806
374
  ----------
807
- _delta_star
808
- Margin-adjusted benchmark share ratio.
809
- _r_val
810
- Recapture ratio.
811
- recapture_spec
812
- Whether recapture-ratio is MNL-consistent ("inside-out") or has fixed
813
- value for both merging firms ("proportional").
814
- prec
815
- Number of decimal places for rounding returned shares.
375
+ _delta_bound
376
+ Specified ∆HHI bound.
377
+ m_star
378
+ Parametric price-cost margin.
379
+ r_bar
380
+ Default recapture ratio.
816
381
 
817
382
  Returns
818
383
  -------
819
- Array of share-pairs, area under boundary.
384
+ GUPPI bound corresponding to ∆HHI bound, at given margin and recapture ratio.
820
385
 
821
386
  """
822
-
823
- _delta_star = mpf(f"{_delta_star}")
824
- _s_intcpt = mpf("1.00")
825
- _s_mid = _delta_star / (1 + _delta_star)
826
-
827
- if recapture_spec == "inside-out":
828
- # ## Plot envelope of GUPPI boundaries with r_k = r_bar if s_k = min(_s_1, _s_2)
829
- # ## See (s_i, s_j) in equation~(44), or thereabouts, in paper
830
- _smin_nr = _delta_star * (1 - _r_val)
831
- _smax_nr = 1 - _delta_star * _r_val
832
- _guppi_bdry_env_dr = _smin_nr + _smax_nr
833
- _s1_pts = np.array(
834
- (
835
- 0,
836
- _smin_nr / _guppi_bdry_env_dr,
837
- _s_mid,
838
- _smax_nr / _guppi_bdry_env_dr,
839
- _s_intcpt,
840
- ),
841
- np.float64,
842
- )
843
-
844
- _gbd_area = _s_mid + _s1_pts[1] * (1 - 2 * _s_mid)
845
- else:
846
- _s1_pts, _gbd_area = np.array((0, _s_mid, _s_intcpt), np.float64), _s_mid
847
-
848
- return GuidelinesBoundary(
849
- np.column_stack((_s1_pts, _s1_pts[::-1])), round(float(_gbd_area), prec)
387
+ return gbfn.round_cust(
388
+ m_star * r_bar * (_s_m := np.sqrt(_delta_bound / 2)) / (1 - _s_m),
389
+ frac=0.005,
390
+ rounding_mode="ROUND_HALF_DOWN",
850
391
  )
851
392
 
852
393
 
853
- def shrratio_boundary_wtd_avg(
854
- _delta_star: float = 0.075,
855
- _r_val: float = 0.80,
394
+ def critical_share_ratio(
395
+ _guppi_bound: float | decimal.Decimal = 0.075,
856
396
  /,
857
397
  *,
858
- agg_method: Literal["arithmetic", "geometric", "distance"] = "arithmetic",
859
- weighting: Literal["own-share", "cross-product-share"] | None = "own-share",
860
- recapture_spec: Literal["inside-out", "proportional"] = "inside-out",
861
- prec: int = 5,
862
- ) -> GuidelinesBoundary:
398
+ m_star: float = 1.00,
399
+ r_bar: float = 1.00,
400
+ frac: float = 1e-16,
401
+ ) -> decimal.Decimal:
863
402
  """
864
- Share combinations for the share-weighted average GUPPI boundary with symmetric
865
- merging-firm margins.
403
+ Corollary to GUPPI bound.
866
404
 
867
405
  Parameters
868
406
  ----------
869
- _delta_star
870
- corollary to GUPPI bound (:math:`\\overline{g} / (m^* \\cdot \\overline{r})`)
871
- _r_val
872
- recapture ratio
873
- agg_method
874
- Whether "arithmetic", "geometric", or "distance".
875
- weighting
876
- Whether "own-share" or "cross-product-share" (or None for simple, unweighted average).
877
- recapture_spec
878
- Whether recapture-ratio is MNL-consistent ("inside-out") or has fixed
879
- value for both merging firms ("proportional").
880
- prec
881
- Number of decimal places for rounding returned shares and area.
407
+ _guppi_bound
408
+ Specified GUPPI bound.
409
+ m_star
410
+ Parametric price-cost margin.
411
+ r_bar
412
+ Default recapture ratio.
882
413
 
883
414
  Returns
884
415
  -------
885
- Array of share-pairs, area under boundary.
886
-
887
- Notes
888
- -----
889
- An analytical expression for the share-weighted arithmetic mean boundary
890
- is derived and plotted from y-intercept to the ray of symmetry as follows::
891
-
892
- from sympy import plot as symplot, solve, symbols
893
- s_1, s_2 = symbols("s_1 s_2", positive=True)
894
-
895
- g_val, r_val, m_val = 0.06, 0.80, 0.30
896
- delta_star = g_val / (r_val * m_val)
897
-
898
- # recapture_spec == "inside-out"
899
- oswag = solve(
900
- s_1 * s_2 / (1 - s_1)
901
- + s_2 * s_1 / (1 - (r_val * s_2 + (1 - r_val) * s_1))
902
- - (s_1 + s_2) * delta_star,
903
- s_2
904
- )[0]
905
- symplot(
906
- oswag,
907
- (s_1, 0., d_hat / (1 + d_hat)),
908
- ylabel=s_2
909
- )
910
-
911
- cpswag = solve(
912
- s_2 * s_2 / (1 - s_1)
913
- + s_1 * s_1 / (1 - (r_val * s_2 + (1 - r_val) * s_1))
914
- - (s_1 + s_2) * delta_star,
915
- s_2
916
- )[1]
917
- symplot(
918
- cpwag,
919
- (s_1, 0., d_hat / (1 + d_hat)),
920
- ylabel=s_2
921
- )
922
-
923
- # recapture_spec == "proportional"
924
- oswag = solve(
925
- s_1 * s_2 / (1 - s_1)
926
- + s_2 * s_1 / (1 - s_2)
927
- - (s_1 + s_2) * delta_star,
928
- s_2
929
- )[0]
930
- symplot(
931
- oswag,
932
- (s_1, 0., d_hat / (1 + d_hat)),
933
- ylabel=s_2
934
- )
935
-
936
- cpswag = solve(
937
- s_2 * s_2 / (1 - s_1)
938
- + s_1 * s_1 / (1 - s_2)
939
- - (s_1 + s_2) * delta_star,
940
- s_2
941
- )[1]
942
- symplot(
943
- cpswag,
944
- (s_1, 0.0, d_hat / (1 + d_hat)),
945
- ylabel=s_2
946
- )
947
-
416
+ Critical share ratio (share ratio bound) corresponding to the GUPPI bound
417
+ for given margin and recapture ratio.
948
418
 
949
419
  """
950
-
951
- _delta_star = mpf(f"{_delta_star}")
952
- _s_mid = _delta_star / (1 + _delta_star)
953
-
954
- # initial conditions
955
- _gbdry_points = [(_s_mid, _s_mid)]
956
- _s_1_pre, _s_2_pre = _s_mid, _s_mid
957
- _s_2_oddval, _s_2_oddsum, _s_2_evnsum = True, 0, 0
958
-
959
- # parameters for iteration
960
- _gbd_step_sz = mp.power(10, -prec)
961
- _theta = _gbd_step_sz * (10 if weighting == "cross-product-share" else 1)
962
- for _s_1 in mp.arange(_s_mid - _gbd_step_sz, 0, -_gbd_step_sz):
963
- # The wtd. avg. GUPPI is not always convex to the origin, so we
964
- # increment _s_2 after each iteration in which our algorithm
965
- # finds (s1, s2) on the boundary
966
- _s_2 = _s_2_pre * (1 + _theta)
967
-
968
- if (_s_1 + _s_2) > mpf("0.99875"):
969
- # Loss of accuracy at 3-9s and up
970
- break
971
-
972
- while True:
973
- _de_1 = _s_2 / (1 - _s_1)
974
- _de_2 = (
975
- _s_1 / (1 - lerp(_s_1, _s_2, _r_val))
976
- if recapture_spec == "inside-out"
977
- else _s_1 / (1 - _s_2)
978
- )
979
-
980
- _r = (
981
- mp.fdiv(
982
- _s_1 if weighting == "cross-product-share" else _s_2, _s_1 + _s_2
983
- )
984
- if weighting
985
- else 0.5
986
- )
987
-
988
- match agg_method:
989
- case "geometric":
990
- _delta_test = mp.expm1(lerp(mp.log1p(_de_1), mp.log1p(_de_2), _r))
991
- case "distance":
992
- _delta_test = mp.sqrt(lerp(_de_1**2, _de_2**2, _r))
993
- case _:
994
- _delta_test = lerp(_de_1, _de_2, _r)
995
-
996
- _test_flag, _incr_decr = (
997
- (_delta_test > _delta_star, -1)
998
- if weighting == "cross-product-share"
999
- else (_delta_test < _delta_star, 1)
1000
- )
1001
-
1002
- if _test_flag:
1003
- _s_2 += _incr_decr * _gbd_step_sz
1004
- else:
1005
- break
1006
-
1007
- # Build-up boundary points
1008
- _gbdry_points.append((_s_1, _s_2))
1009
-
1010
- # Build up area terms
1011
- _s_2_oddsum += _s_2 if _s_2_oddval else 0
1012
- _s_2_evnsum += _s_2 if not _s_2_oddval else 0
1013
- _s_2_oddval = not _s_2_oddval
1014
-
1015
- # Hold share points
1016
- _s_2_pre = _s_2
1017
- _s_1_pre = _s_1
1018
-
1019
- if _s_2_oddval:
1020
- _s_2_evnsum -= _s_2_pre
1021
- else:
1022
- _s_2_oddsum -= _s_1_pre
1023
-
1024
- _s_intcpt = _shrratio_boundary_intcpt(
1025
- _s_1_pre,
1026
- _delta_star,
1027
- _r_val,
1028
- recapture_spec=recapture_spec,
1029
- agg_method=agg_method,
1030
- weighting=weighting,
1031
- )
1032
-
1033
- if weighting == "own-share":
1034
- _gbd_prtlarea = (
1035
- _gbd_step_sz * (4 * _s_2_oddsum + 2 * _s_2_evnsum + _s_mid + _s_2_pre) / 3
1036
- )
1037
- # Area under boundary
1038
- _gbdry_area_total = float(
1039
- 2 * (_s_1_pre + _gbd_prtlarea)
1040
- - (mp.power(_s_mid, "2") + mp.power(_s_1_pre, "2"))
1041
- )
1042
-
1043
- else:
1044
- _gbd_prtlarea = (
1045
- _gbd_step_sz * (4 * _s_2_oddsum + 2 * _s_2_evnsum + _s_mid + _s_intcpt) / 3
1046
- )
1047
- # Area under boundary
1048
- _gbdry_area_total = float(2 * _gbd_prtlarea - mp.power(_s_mid, "2"))
1049
-
1050
- _gbdry_points = np.row_stack((_gbdry_points, (mpf("0.0"), _s_intcpt))).astype(
1051
- np.float64
1052
- )
1053
-
1054
- # Points defining boundary to point-of-symmetry
1055
- return GuidelinesBoundary(
1056
- np.row_stack((np.flip(_gbdry_points, 0), np.flip(_gbdry_points[1:], 1))),
1057
- round(float(_gbdry_area_total), prec),
420
+ return gbfn.round_cust(
421
+ mpf(f"{_guppi_bound}") / mp.fmul(f"{m_star}", f"{r_bar}"), frac=frac
1058
422
  )
1059
423
 
1060
424
 
1061
- def shrratio_boundary_xact_avg(
1062
- _delta_star: float = 0.075,
1063
- _r_val: float = 0.80,
425
+ def share_from_guppi(
426
+ _guppi_bound: float | decimal.Decimal = 0.065,
1064
427
  /,
1065
428
  *,
1066
- recapture_spec: Literal["inside-out", "proportional"] = "inside-out",
1067
- prec: int = 5,
1068
- ) -> GuidelinesBoundary:
429
+ m_star: float = 1.00,
430
+ r_bar: float = DEFAULT_REC_RATIO,
431
+ ) -> decimal.Decimal:
1069
432
  """
1070
- Share combinations for the simple average GUPPI boundary with symmetric
1071
- merging-firm margins.
1072
-
1073
- Notes
1074
- -----
1075
- An analytical expression for the exact average boundary is derived
1076
- and plotted from the y-intercept to the ray of symmetry as follows::
1077
-
1078
- from sympy import latex, plot as symplot, solve, symbols
1079
-
1080
- s_1, s_2 = symbols("s_1 s_2")
1081
-
1082
- g_val, r_val, m_val = 0.06, 0.80, 0.30
1083
- d_hat = g_val / (r_val * m_val)
1084
-
1085
- # recapture_spec = "inside-out"
1086
- sag = solve(
1087
- (s_2 / (1 - s_1))
1088
- + (s_1 / (1 - (r_val * s_2 + (1 - r_val) * s_1)))
1089
- - 2 * d_hat,
1090
- s_2
1091
- )[0]
1092
- symplot(
1093
- sag,
1094
- (s_1, 0., d_hat / (1 + d_hat)),
1095
- ylabel=s_2
1096
- )
1097
-
1098
- # recapture_spec = "proportional"
1099
- sag = solve((s_2/(1 - s_1)) + (s_1/(1 - s_2)) - 2 * d_hat, s_2)[0]
1100
- symplot(
1101
- sag,
1102
- (s_1, 0., d_hat / (1 + d_hat)),
1103
- ylabel=s_2
1104
- )
433
+ Symmetric-firm share for given GUPPI, margin, and recapture ratio.
1105
434
 
1106
435
  Parameters
1107
436
  ----------
1108
- _delta_star
1109
- Margin-adjusted benchmark share ratio.
1110
- _r_val
1111
- Recapture ratio
1112
- recapture_spec
1113
- Whether recapture-ratio is MNL-consistent ("inside-out") or has fixed
1114
- value for both merging firms ("proportional").
1115
- prec
1116
- Number of decimal places for rounding returned shares.
437
+ _guppi_bound
438
+ GUPPI bound.
439
+ m_star
440
+ Parametric price-cost margin.
441
+ r_bar
442
+ Default recapture ratio.
1117
443
 
1118
444
  Returns
1119
445
  -------
1120
- Array of share-pairs, area under boundary, area under boundary.
446
+ float
447
+ Symmetric firm market share on GUPPI boundary, for given margin and
448
+ recapture ratio.
1121
449
 
1122
450
  """
1123
451
 
1124
- _delta_star = mpf(f"{_delta_star}")
1125
- _s_mid = _delta_star / (1 + _delta_star)
1126
- _gbd_step_sz = mp.power(10, -prec)
1127
-
1128
- _gbdry_points_start = np.array([(_s_mid, _s_mid)])
1129
- _s_1 = np.array(mp.arange(_s_mid - _gbd_step_sz, 0, -_gbd_step_sz), np.float64)
1130
- if recapture_spec == "inside-out":
1131
- _s_intcpt = mp.fdiv(
1132
- mp.fsub(
1133
- 2 * _delta_star * _r_val + 1, mp.fabs(2 * _delta_star * _r_val - 1)
1134
- ),
1135
- 2 * mpf(f"{_r_val}"),
1136
- )
1137
- _nr_t1 = 1 + 2 * _delta_star * _r_val * (1 - _s_1) - _s_1 * (1 - _r_val) # type: ignore
1138
-
1139
- _nr_sqrt_mdr = 4 * _delta_star * _r_val
1140
- _nr_sqrt_mdr2 = _nr_sqrt_mdr * _r_val
1141
- _nr_sqrt_md2r2 = _nr_sqrt_mdr2 * _delta_star
1142
-
1143
- _nr_sqrt_t1 = _nr_sqrt_md2r2 * (_s_1**2 - 2 * _s_1 + 1)
1144
- _nr_sqrt_t2 = _nr_sqrt_mdr2 * _s_1 * (_s_1 - 1)
1145
- _nr_sqrt_t3 = _nr_sqrt_mdr * (2 * _s_1 - _s_1**2 - 1)
1146
- _nr_sqrt_t4 = (_s_1**2) * (_r_val**2 - 6 * _r_val + 1)
1147
- _nr_sqrt_t5 = _s_1 * (6 * _r_val - 2) + 1
1148
-
1149
- _nr_t2_mdr = _nr_sqrt_t1 + _nr_sqrt_t2 + _nr_sqrt_t3 + _nr_sqrt_t4 + _nr_sqrt_t5
1150
-
1151
- # Alternative grouping of terms in np.sqrt
1152
- _nr_sqrt_s1sq = (_s_1**2) * (
1153
- _nr_sqrt_md2r2 + _nr_sqrt_mdr2 - _nr_sqrt_mdr + _r_val**2 - 6 * _r_val + 1
1154
- )
1155
- _nr_sqrt_s1 = _s_1 * (
1156
- -2 * _nr_sqrt_md2r2 - _nr_sqrt_mdr2 + 2 * _nr_sqrt_mdr + 6 * _r_val - 2
1157
- )
1158
- _nr_sqrt_nos1 = _nr_sqrt_md2r2 - _nr_sqrt_mdr + 1
1159
-
1160
- _nr_t2_s1 = _nr_sqrt_s1sq + _nr_sqrt_s1 + _nr_sqrt_nos1
1161
-
1162
- if not np.isclose( # type: ignore
1163
- np.einsum("i->", _nr_t2_mdr.astype(np.float64)),
1164
- np.einsum("i->", _nr_t2_s1.astype(np.float64)),
1165
- rtol=0,
1166
- atol=0.5 * prec,
1167
- ):
1168
- raise RuntimeError(
1169
- "Calculation of sq. root term in exact average GUPPI"
1170
- f"with recapture spec, {f'"{recapture_spec}"'} is incorrect."
1171
- )
1172
-
1173
- _s_2 = (_nr_t1 - np.sqrt(_nr_t2_s1)) / (2 * _r_val)
1174
-
1175
- else:
1176
- _s_intcpt = mp.fsub(_delta_star + 1 / 2, mp.fabs(_delta_star - 1 / 2))
1177
- _s_2 = (
1178
- (1 / 2)
1179
- + _delta_star
1180
- - _delta_star * _s_1
1181
- - np.sqrt(
1182
- ((_delta_star**2) - 1) * (_s_1**2)
1183
- + (-2 * (_delta_star**2) + _delta_star + 1) * _s_1
1184
- + (_delta_star**2)
1185
- - _delta_star
1186
- + (1 / 4)
1187
- )
1188
- )
1189
-
1190
- _gbdry_points_inner = np.column_stack((_s_1, _s_2))
1191
- _gbdry_points_end = np.array([(mpf("0.0"), _s_intcpt)], np.float64)
1192
-
1193
- _gbdry_points = np.row_stack((
1194
- _gbdry_points_end,
1195
- np.flip(_gbdry_points_inner, 0),
1196
- _gbdry_points_start,
1197
- np.flip(_gbdry_points_inner, 1),
1198
- np.flip(_gbdry_points_end, 1),
1199
- )).astype(np.float64)
1200
- _s_2 = np.concatenate((np.array([_s_mid], np.float64), _s_2))
1201
-
1202
- _gbdry_ends = [0, -1]
1203
- _gbdry_odds = np.array(range(1, len(_s_2), 2), np.int64)
1204
- _gbdry_evns = np.array(range(2, len(_s_2), 2), np.int64)
1205
-
1206
- # Double the are under the curve, and subtract the double counted bit.
1207
- _gbdry_area_simpson = 2 * _gbd_step_sz * (
1208
- (4 / 3) * np.sum(_s_2.take(_gbdry_odds))
1209
- + (2 / 3) * np.sum(_s_2.take(_gbdry_evns))
1210
- + (1 / 3) * np.sum(_s_2.take(_gbdry_ends))
1211
- ) - np.power(_s_mid, 2)
1212
-
1213
- _s_1_pts, _s_2_pts = np.split(_gbdry_points, 2, axis=1)
1214
- return GuidelinesBoundary(
1215
- np.column_stack((np.array(_s_1_pts), np.array(_s_2_pts))),
1216
- round(float(_gbdry_area_simpson), prec),
452
+ return gbfn.round_cust(
453
+ (_d0 := critical_share_ratio(_guppi_bound, m_star=m_star, r_bar=r_bar))
454
+ / (1 + _d0)
1217
455
  )
1218
456
 
1219
457
 
1220
- def _shrratio_boundary_intcpt(
1221
- _s_2_pre: float,
1222
- _delta_star: mpf,
1223
- _r_val: mpf,
1224
- /,
1225
- *,
1226
- recapture_spec: Literal["inside-out", "proportional"],
1227
- agg_method: Literal["arithmetic", "geometric", "distance"],
1228
- weighting: Literal["cross-product-share", "own-share"] | None,
1229
- ) -> float:
1230
- match weighting:
1231
- case "cross-product-share":
1232
- _s_intcpt: float = _delta_star
1233
- case "own-share":
1234
- _s_intcpt = mpf("1.0")
1235
- case None if agg_method == "distance":
1236
- _s_intcpt = _delta_star * mp.sqrt("2")
1237
- case None if agg_method == "arithmetic" and recapture_spec == "inside-out":
1238
- _s_intcpt = mp.fdiv(
1239
- mp.fsub(
1240
- 2 * _delta_star * _r_val + 1, mp.fabs(2 * _delta_star * _r_val - 1)
1241
- ),
1242
- 2 * mpf(f"{_r_val}"),
1243
- )
1244
- case None if agg_method == "arithmetic":
1245
- _s_intcpt = mp.fsub(_delta_star + 1 / 2, mp.fabs(_delta_star - 1 / 2))
1246
- case _:
1247
- _s_intcpt = _s_2_pre
1248
-
1249
- return _s_intcpt
458
+ if __name__ == "__main__":
459
+ print(
460
+ "This module defines classes with methods for generating boundaries for concentration and diversion-ratio screens."
461
+ )