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