mergeron 2024.738963.0__py3-none-any.whl → 2024.738973.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.

@@ -4,19 +4,24 @@ with a canvas on which to draw boundaries for Guidelines standards.
4
4
 
5
5
  """
6
6
 
7
- import decimal
8
- from collections.abc import Callable
9
7
  from dataclasses import dataclass
10
8
  from importlib.metadata import version
11
- from typing import Any, Literal, TypeAlias
9
+ from typing import Literal, TypeAlias
12
10
 
13
11
  import numpy as np
14
- from attrs import define, field
12
+ from attrs import field, frozen
15
13
  from mpmath import mp, mpf # type: ignore
16
- from numpy.typing import NDArray
17
14
 
18
15
  from .. import _PKG_NAME, UPPAggrSelector # noqa: TID252
19
- from . import UPPBoundarySpec
16
+ from . import GuidelinesBoundary, UPPBoundarySpec
17
+ from .guidelines_boundary_functions import (
18
+ dh_area,
19
+ round_cust,
20
+ shrratio_boundary_max,
21
+ shrratio_boundary_min,
22
+ shrratio_boundary_wtd_avg,
23
+ shrratio_boundary_xact_avg,
24
+ )
20
25
 
21
26
  __version__ = version(_PKG_NAME)
22
27
 
@@ -24,12 +29,13 @@ __version__ = version(_PKG_NAME)
24
29
  mp.prec = 80
25
30
  mp.trap_complex = True
26
31
 
27
- HMGPubYear: TypeAlias = Literal[1992, 2010, 2023]
32
+ HMGPubYear: TypeAlias = Literal[1992, 2004, 2010, 2023]
28
33
 
29
34
 
30
- @dataclass(slots=True, frozen=True)
35
+ @dataclass(frozen=True)
31
36
  class HMGThresholds:
32
37
  delta: float
38
+ fc: float
33
39
  rec: float
34
40
  guppi: float
35
41
  divr: float
@@ -37,31 +43,28 @@ class HMGThresholds:
37
43
  ipr: float
38
44
 
39
45
 
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)
46
+ @frozen
54
47
  class GuidelinesThresholds:
55
48
  """
56
49
  Guidelines threholds by Guidelines publication year
57
50
 
58
51
  ΔHHI, Recapture Rate, GUPPI, Diversion ratio, CMCR, and IPR thresholds
59
- constructed from concentration standards.
52
+ constructed from concentration standards in Guidelines published in
53
+ 1992, 2004, 2010, and 2023.
54
+
55
+ The 2004 Guidelines refernced here are the EU Commission
56
+ guidelines on assessment of horizontal mergers. These
57
+ guidelines also define a presumption for mergers with
58
+ post-merger HHI in [1000, 2000) and ΔHHI >= 250 points,
59
+ whi is not modeled here.
60
+
61
+ All other Guidelines modeled here are U.S. merger guidelines.
62
+
60
63
  """
61
64
 
62
65
  pub_year: HMGPubYear
63
66
  """
64
- Year of publication of the U.S. Horizontal Merger Guidelines (HMG)
67
+ Year of publication of the Guidelines
65
68
  """
66
69
 
67
70
  safeharbor: HMGThresholds = field(kw_only=True, default=None)
@@ -99,6 +102,7 @@ class GuidelinesThresholds:
99
102
  _hhi_p, _dh_s, _dh_p = {
100
103
  1992: (0.18, 0.005, 0.01),
101
104
  2010: (0.25, 0.01, 0.02),
105
+ 2004: (0.20, 0.015, 0.015),
102
106
  2023: (0.18, 0.01, 0.01),
103
107
  }[self.pub_year]
104
108
 
@@ -107,8 +111,9 @@ class GuidelinesThresholds:
107
111
  "safeharbor",
108
112
  HMGThresholds(
109
113
  _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),
114
+ _fc := int(np.ceil(1 / _hhi_p)),
115
+ _r := round_cust(_fc / (_fc + 1)),
116
+ _g_s := guppi_from_delta(_dh_s, m_star=1.0, r_bar=_r),
112
117
  _dr := round_cust(1 / (_fc + 1)),
113
118
  _cmcr := 0.03, # Not strictly a Guidelines standard
114
119
  _ipr := _g_s, # Not strictly a Guidelines standard
@@ -122,17 +127,19 @@ class GuidelinesThresholds:
122
127
  (
123
128
  HMGThresholds(
124
129
  _dh_i := 2 * (0.5 / _fc) ** 2,
130
+ _fc,
125
131
  _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)),
132
+ _g_i := guppi_from_delta(_dh_i, m_star=1.0, r_bar=_r_i),
133
+ round_cust((1 / 2) / (_fc + 1 / 2)),
128
134
  _cmcr,
129
135
  _g_i,
130
136
  )
131
137
  if self.pub_year == 2010
132
138
  else HMGThresholds(
133
139
  _dh_i := 2 * (1 / (_fc + 1)) ** 2,
140
+ _fc,
134
141
  _r,
135
- _g_i := gbd_from_dsf(_dh_i, m_star=1.0, r_bar=_r),
142
+ _g_i := guppi_from_delta(_dh_i, m_star=1.0, r_bar=_r),
136
143
  _dr,
137
144
  _cmcr,
138
145
  _g_i,
@@ -145,8 +152,9 @@ class GuidelinesThresholds:
145
152
  "presumption",
146
153
  HMGThresholds(
147
154
  _dh_p,
155
+ _fc,
148
156
  _r,
149
- _g_p := gbd_from_dsf(_dh_p, m_star=1.0, r_bar=_r),
157
+ _g_p := guppi_from_delta(_dh_p, m_star=1.0, r_bar=_r),
150
158
  _dr,
151
159
  _cmcr,
152
160
  _ipr := _g_p,
@@ -154,112 +162,8 @@ class GuidelinesThresholds:
154
162
  )
155
163
 
156
164
 
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.
166
-
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
- ----------
244
-
245
- .. [2] C++ Reference, https://en.cppreference.com/w/cpp/numeric/lerp
246
-
247
- """
248
-
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
165
+ def guppi_from_delta(
166
+ _delta_bound: float = 0.01, /, *, m_star: float = 1.00, r_bar: float = 0.855
263
167
  ) -> float:
264
168
  """
265
169
  Translate ∆HHI bound to GUPPI bound.
@@ -279,18 +183,18 @@ def gbd_from_dsf(
279
183
 
280
184
  """
281
185
  return round_cust(
282
- m_star * r_bar * (_s_m := np.sqrt(_deltasf / 2)) / (1 - _s_m),
186
+ m_star * r_bar * (_s_m := np.sqrt(_delta_bound / 2)) / (1 - _s_m),
283
187
  frac=0.005,
284
188
  rounding_mode="ROUND_HALF_DOWN",
285
189
  )
286
190
 
287
191
 
288
- def critical_shrratio(
289
- _gbd: float = 0.06,
192
+ def critical_share_ratio(
193
+ _guppi_bound: float = 0.075,
290
194
  /,
291
195
  *,
292
196
  m_star: float = 1.00,
293
- r_bar: float = 0.80,
197
+ r_bar: float = 1.00,
294
198
  frac: float = 1e-16,
295
199
  ) -> mpf:
296
200
  """
@@ -298,7 +202,7 @@ def critical_shrratio(
298
202
 
299
203
  Parameters
300
204
  ----------
301
- _gbd
205
+ _guppi_bound
302
206
  Specified GUPPI bound.
303
207
  m_star
304
208
  Parametric price-cost margin.
@@ -311,18 +215,20 @@ def critical_shrratio(
311
215
  for given margin and recapture rate.
312
216
 
313
217
  """
314
- return round_cust(mpf(f"{_gbd}") / mp.fmul(f"{m_star}", f"{r_bar}"), frac=frac)
218
+ return round_cust(
219
+ mpf(f"{_guppi_bound}") / mp.fmul(f"{m_star}", f"{r_bar}"), frac=frac
220
+ )
315
221
 
316
222
 
317
- def shr_from_gbd(
318
- _gbd: float = 0.06, /, *, m_star: float = 1.00, r_bar: float = 0.80
223
+ def share_from_guppi(
224
+ _guppi_bound: float = 0.065, /, *, m_star: float = 1.00, r_bar: float = 0.855
319
225
  ) -> float:
320
226
  """
321
227
  Symmetric-firm share for given GUPPI, margin, and recapture rate.
322
228
 
323
229
  Parameters
324
230
  ----------
325
- _gbd
231
+ _guppi_bound
326
232
  GUPPI bound.
327
233
  m_star
328
234
  Parametric price-cost margin.
@@ -338,249 +244,12 @@ def shr_from_gbd(
338
244
  """
339
245
 
340
246
  return round_cust(
341
- (_d0 := critical_shrratio(_gbd, m_star=m_star, r_bar=r_bar)) / (1 + _d0)
247
+ (_d0 := critical_share_ratio(_guppi_bound, m_star=m_star, r_bar=r_bar))
248
+ / (1 + _d0)
342
249
  )
343
250
 
344
251
 
345
- def boundary_plot(*, mktshares_plot_flag: bool = True) -> tuple[Any, ...]:
346
- """Setup basic figure and axes for plots of safe harbor boundaries.
347
-
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,
408
- /,
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
479
- )
480
-
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, /, *, prec: 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
- prec
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
- prec,
548
- )
549
-
550
-
551
- def dh_area_quad(_dh_val: float = 0.01, /, *, prec: int = 9) -> float:
552
- """
553
- Area under the ΔHHI boundary.
554
-
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.
558
-
559
- Parameters
560
- ----------
561
- _dh_val
562
- Merging-firms' ΔHHI bound.
563
- prec
564
- Specified precision in decimal places.
565
-
566
- Returns
567
- -------
568
- Area under ΔHHI boundary.
569
-
570
- """
571
-
572
- _dh_val = mpf(f"{_dh_val}")
573
- _s_naught = (1 - mp.sqrt(1 - 2 * _dh_val)) / 2
574
-
575
- return round(
576
- float(
577
- _s_naught + mp.quad(lambda x: _dh_val / (2 * x), [_s_naught, 1 - _s_naught])
578
- ),
579
- prec,
580
- )
581
-
582
-
583
- def delta_hhi_boundary(
252
+ def hhi_delta_boundary(
584
253
  _dh_val: float = 0.01, /, *, prec: int = 5
585
254
  ) -> GuidelinesBoundary:
586
255
  """
@@ -691,29 +360,56 @@ def hhi_pre_contrib_boundary(
691
360
  )
692
361
 
693
362
 
694
- def shrratio_boundary(_bdry_spec: UPPBoundarySpec) -> GuidelinesBoundary:
363
+ def hhi_post_contrib_boundary(
364
+ _hhi_contrib: float = 0.800, /, *, bdry_dps: int = 10
365
+ ) -> GuidelinesBoundary:
366
+ """
367
+ Share combinations on the postmerger HHI contribution boundary.
368
+
369
+ The post-merger HHI contribution boundary is identical to the
370
+ combined-share boundary.
371
+
372
+ Parameters
373
+ ----------
374
+ _hhi_contrib:
375
+ Merging-firms' pre-merger HHI contribution bound.
376
+ bdry_dps
377
+ Number of decimal places for rounding reported shares.
378
+
379
+ Returns
380
+ -------
381
+ Array of share-pairs, area under boundary.
382
+
383
+ """
384
+ return combined_share_boundary(np.sqrt(_hhi_contrib), bdry_dps=bdry_dps)
385
+
386
+
387
+ def diversion_ratio_boundary(_bdry_spec: UPPBoundarySpec) -> GuidelinesBoundary:
388
+ _share_ratio = critical_share_ratio(
389
+ _bdry_spec.diversion_ratio, r_bar=_bdry_spec.rec
390
+ )
695
391
  match _bdry_spec.agg_method:
696
392
  case UPPAggrSelector.AVG:
697
393
  return shrratio_boundary_xact_avg(
698
- _bdry_spec.share_ratio,
394
+ _share_ratio,
699
395
  _bdry_spec.rec,
700
396
  recapture_form=_bdry_spec.recapture_form.value, # type: ignore
701
397
  prec=_bdry_spec.precision,
702
398
  )
703
399
  case UPPAggrSelector.MAX:
704
400
  return shrratio_boundary_max(
705
- _bdry_spec.share_ratio, _bdry_spec.rec, prec=_bdry_spec.precision
401
+ _share_ratio, _bdry_spec.rec, prec=_bdry_spec.precision
706
402
  )
707
403
  case UPPAggrSelector.MIN:
708
404
  return shrratio_boundary_min(
709
- _bdry_spec.share_ratio,
405
+ _share_ratio,
710
406
  _bdry_spec.rec,
711
407
  recapture_form=_bdry_spec.recapture_form.value, # type: ignore
712
408
  prec=_bdry_spec.precision,
713
409
  )
714
410
  case UPPAggrSelector.DIS:
715
411
  return shrratio_boundary_wtd_avg(
716
- _bdry_spec.share_ratio,
412
+ _share_ratio,
717
413
  _bdry_spec.rec,
718
414
  agg_method="distance",
719
415
  weighting=None,
@@ -734,516 +430,10 @@ def shrratio_boundary(_bdry_spec: UPPBoundarySpec) -> GuidelinesBoundary:
734
430
  )
735
431
 
736
432
  return shrratio_boundary_wtd_avg(
737
- _bdry_spec.share_ratio,
433
+ _share_ratio,
738
434
  _bdry_spec.rec,
739
435
  agg_method=_agg_method, # type: ignore
740
436
  weighting=_weighting, # type: ignore
741
437
  recapture_form=_bdry_spec.recapture_form.value, # type: ignore
742
438
  prec=_bdry_spec.precision,
743
439
  )
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,
789
- /,
790
- *,
791
- recapture_form: str = "inside-out",
792
- prec: int = 10,
793
- ) -> GuidelinesBoundary:
794
- """
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.
804
-
805
- Parameters
806
- ----------
807
- _delta_star
808
- Margin-adjusted benchmark share ratio.
809
- _r_val
810
- Recapture ratio.
811
- recapture_form
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.
816
-
817
- Returns
818
- -------
819
- Array of share-pairs, area under boundary.
820
-
821
- """
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_form == "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)
850
- )
851
-
852
-
853
- def shrratio_boundary_wtd_avg(
854
- _delta_star: float = 0.075,
855
- _r_val: float = 0.80,
856
- /,
857
- *,
858
- agg_method: Literal["arithmetic", "geometric", "distance"] = "arithmetic",
859
- weighting: Literal["own-share", "cross-product-share"] | None = "own-share",
860
- recapture_form: Literal["inside-out", "proportional"] = "inside-out",
861
- prec: int = 5,
862
- ) -> GuidelinesBoundary:
863
- """
864
- Share combinations for the share-weighted average GUPPI boundary with symmetric
865
- merging-firm margins.
866
-
867
- Parameters
868
- ----------
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_form
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.
882
-
883
- Returns
884
- -------
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_form == "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_form == "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
-
948
-
949
- """
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_form == "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_form=recapture_form,
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),
1058
- )
1059
-
1060
-
1061
- def shrratio_boundary_xact_avg(
1062
- _delta_star: float = 0.075,
1063
- _r_val: float = 0.80,
1064
- /,
1065
- *,
1066
- recapture_form: Literal["inside-out", "proportional"] = "inside-out",
1067
- prec: int = 5,
1068
- ) -> GuidelinesBoundary:
1069
- """
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_form = "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_form = "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
- )
1105
-
1106
- Parameters
1107
- ----------
1108
- _delta_star
1109
- Margin-adjusted benchmark share ratio.
1110
- _r_val
1111
- Recapture ratio
1112
- recapture_form
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.
1117
-
1118
- Returns
1119
- -------
1120
- Array of share-pairs, area under boundary, area under boundary.
1121
-
1122
- """
1123
-
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_form == "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)
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(
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_form}"'} 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),
1217
- )
1218
-
1219
-
1220
- def _shrratio_boundary_intcpt(
1221
- _s_2_pre: float,
1222
- _delta_star: mpf,
1223
- _r_val: mpf,
1224
- /,
1225
- *,
1226
- recapture_form: 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_form == "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