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
@@ -0,0 +1,981 @@
1
+ import decimal
2
+ from dataclasses import dataclass
3
+ from typing import Any, Literal, TypedDict
4
+
5
+ import numpy as np
6
+ from mpmath import mp, mpf # type: ignore
7
+ from numpy.typing import NDArray
8
+
9
+ from .. import VERSION # noqa: TID252
10
+
11
+ __version__ = VERSION
12
+
13
+ mp.prec = 80
14
+ mp.trap_complex = True
15
+
16
+
17
+ class ShareRatioBoundaryKeywords(TypedDict, total=False):
18
+ """Keyword arguments for functions generating share ratio boundaries."""
19
+
20
+ recapture_form: Literal["inside-out", "proportional"]
21
+ prec: int
22
+ agg_method: Literal["arithmetic mean", "geometric mean", "distance"]
23
+ weighting: Literal["own-share", "cross-product-share", None]
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class GuidelinesBoundary:
28
+ """Output of a Guidelines boundary function."""
29
+
30
+ coordinates: NDArray[np.float64]
31
+ """Market-share pairs as Cartesian coordinates of points on the boundary."""
32
+
33
+ area: float
34
+ """Area under the boundary."""
35
+
36
+
37
+ def dh_area(_dh_val: float = 0.01, /, *, prec: int = 9) -> float:
38
+ R"""
39
+ Area under the ΔHHI boundary.
40
+
41
+ When the given ΔHHI bound matches a Guidelines standard,
42
+ the area under the boundary is half the intrinsic clearance rate
43
+ for the ΔHHI safeharbor.
44
+
45
+ Notes
46
+ -----
47
+ To derive the knots, :math:`(s^0_1, s^1_1), (s^1_1, s^0_1)`
48
+ of the ΔHHI boundary, i.e., the points where it intersects
49
+ the merger-to-monopoly boundary, solve
50
+
51
+ .. math::
52
+
53
+ 2 s1 s_2 &= ΔHHI\\
54
+ s_1 + s_2 &= 1
55
+
56
+ Parameters
57
+ ----------
58
+ _dh_val
59
+ Change in concentration.
60
+ prec
61
+ Specified precision in decimal places.
62
+
63
+ Returns
64
+ -------
65
+ Area under ΔHHI boundary.
66
+
67
+ """
68
+
69
+ _dh_val = mpf(f"{_dh_val}")
70
+ _s_naught = (1 - mp.sqrt(1 - 2 * _dh_val)) / 2
71
+
72
+ return round(
73
+ float(_s_naught + (_dh_val / 2) * (mp.ln(1 - _s_naught) - mp.ln(_s_naught))),
74
+ prec,
75
+ )
76
+
77
+
78
+ def hhi_delta_boundary(
79
+ _dh_val: float = 0.01, /, *, prec: int = 5
80
+ ) -> GuidelinesBoundary:
81
+ """
82
+ Generate the list of share combination on the ΔHHI boundary.
83
+
84
+ Parameters
85
+ ----------
86
+ _dh_val:
87
+ Merging-firms' ΔHHI bound.
88
+ prec
89
+ Number of decimal places for rounding reported shares.
90
+
91
+ Returns
92
+ -------
93
+ Array of share-pairs, area under boundary.
94
+
95
+ """
96
+
97
+ _dh_val = mpf(f"{_dh_val}")
98
+ _s_naught = 1 / 2 * (1 - mp.sqrt(1 - 2 * _dh_val))
99
+ _s_mid = mp.sqrt(_dh_val / 2)
100
+
101
+ _dh_step_sz = mp.power(10, -6)
102
+ _s_1 = np.array(mp.arange(_s_mid, _s_naught - mp.eps, -_dh_step_sz))
103
+ _s_2 = _dh_val / (2 * _s_1)
104
+
105
+ # Boundary points
106
+ _dh_half = np.vstack((
107
+ np.column_stack((_s_1, _s_2)),
108
+ np.array([(mpf("0.0"), mpf("1.0"))]),
109
+ ))
110
+ _dh_bdry_pts = np.vstack((np.flip(_dh_half, 0), np.flip(_dh_half[1:], 1)))
111
+
112
+ _s_1_pts, _s_2_pts = np.split(_dh_bdry_pts, 2, axis=1)
113
+ return GuidelinesBoundary(
114
+ np.column_stack((
115
+ np.array(_s_1_pts, np.float64),
116
+ np.array(_s_2_pts, np.float64),
117
+ )),
118
+ dh_area(_dh_val, prec=prec),
119
+ )
120
+
121
+
122
+ def hhi_pre_contrib_boundary(
123
+ _hhi_contrib: float = 0.03125, /, *, prec: int = 5
124
+ ) -> GuidelinesBoundary:
125
+ """
126
+ Share combinations on the premerger HHI contribution boundary.
127
+
128
+ Parameters
129
+ ----------
130
+ _hhi_contrib:
131
+ Merging-firms' pre-merger HHI contribution bound.
132
+ prec
133
+ Number of decimal places for rounding reported shares.
134
+
135
+ Returns
136
+ -------
137
+ Array of share-pairs, area under boundary.
138
+
139
+ """
140
+ _hhi_contrib = mpf(f"{_hhi_contrib}")
141
+ _s_mid = mp.sqrt(_hhi_contrib / 2)
142
+
143
+ _bdry_step_sz = mp.power(10, -prec)
144
+ # Range-limit is 0 less a step, which is -1 * step-size
145
+ _s_1 = np.array(mp.arange(_s_mid, -_bdry_step_sz, -_bdry_step_sz), np.float64)
146
+ _s_2 = np.sqrt(_hhi_contrib - _s_1**2).astype(np.float64)
147
+ _bdry_pts_mid = np.column_stack((_s_1, _s_2))
148
+ return GuidelinesBoundary(
149
+ np.vstack((np.flip(_bdry_pts_mid, 0), np.flip(_bdry_pts_mid[1:], 1))),
150
+ round(float(mp.pi * _hhi_contrib / 4), prec),
151
+ )
152
+
153
+
154
+ def combined_share_boundary(
155
+ _s_intcpt: float = 0.0625, /, *, prec: int = 10
156
+ ) -> GuidelinesBoundary:
157
+ """
158
+ Share combinations on the merging-firms' combined share boundary.
159
+
160
+ Assumes symmetric merging-firm margins. The combined-share is
161
+ congruent to the post-merger HHI contribution boundary, as the
162
+ post-merger HHI bound is the square of the combined-share bound.
163
+
164
+ Parameters
165
+ ----------
166
+ _s_intcpt:
167
+ Merging-firms' combined share.
168
+ prec
169
+ Number of decimal places for rounding reported shares.
170
+
171
+ Returns
172
+ -------
173
+ Array of share-pairs, area under boundary.
174
+
175
+ """
176
+ _s_intcpt = mpf(f"{_s_intcpt}")
177
+ _s_mid = _s_intcpt / 2
178
+
179
+ _s1_pts = (0, _s_mid, _s_intcpt)
180
+ return GuidelinesBoundary(
181
+ np.column_stack((
182
+ np.array(_s1_pts, np.float64),
183
+ np.array(_s1_pts[::-1], np.float64),
184
+ )),
185
+ round(float(_s_intcpt * _s_mid), prec),
186
+ )
187
+
188
+
189
+ def hhi_post_contrib_boundary(
190
+ _hhi_contrib: float = 0.800, /, *, prec: int = 10
191
+ ) -> GuidelinesBoundary:
192
+ """
193
+ Share combinations on the postmerger HHI contribution boundary.
194
+
195
+ The post-merger HHI contribution boundary is identical to the
196
+ combined-share boundary.
197
+
198
+ Parameters
199
+ ----------
200
+ _hhi_contrib:
201
+ Merging-firms' pre-merger HHI contribution bound.
202
+ prec
203
+ Number of decimal places for rounding reported shares.
204
+
205
+ Returns
206
+ -------
207
+ Array of share-pairs, area under boundary.
208
+
209
+ """
210
+ return combined_share_boundary(np.sqrt(_hhi_contrib), prec=prec)
211
+
212
+
213
+ def shrratio_boundary_wtd_avg(
214
+ _delta_star: float = 0.075,
215
+ _r_val: float = 0.85,
216
+ /,
217
+ *,
218
+ agg_method: Literal[
219
+ "arithmetic mean", "geometric mean", "distance"
220
+ ] = "arithmetic mean",
221
+ weighting: Literal["own-share", "cross-product-share", None] = "own-share",
222
+ recapture_form: Literal["inside-out", "proportional"] = "inside-out",
223
+ prec: int = 5,
224
+ ) -> GuidelinesBoundary:
225
+ """
226
+ Share combinations on the share-weighted average diversion ratio boundary.
227
+
228
+ Parameters
229
+ ----------
230
+ _delta_star
231
+ Share ratio (:math:`\\overline{d} / \\overline{r}`)
232
+ _r_val
233
+ recapture ratio
234
+ agg_method
235
+ Whether "arithmetic mean", "geometric mean", or "distance".
236
+ weighting
237
+ Whether "own-share" or "cross-product-share" (or None for simple, unweighted average).
238
+ recapture_form
239
+ Whether recapture-ratio is MNL-consistent ("inside-out") or has fixed
240
+ value for both merging firms ("proportional").
241
+ prec
242
+ Number of decimal places for rounding returned shares and area.
243
+
244
+ Returns
245
+ -------
246
+ Array of share-pairs, area under boundary.
247
+
248
+ Notes
249
+ -----
250
+ An analytical expression for the share-weighted arithmetic mean boundary
251
+ is derived and plotted from y-intercept to the ray of symmetry as follows::
252
+
253
+ from sympy import plot as symplot, solve, symbols
254
+ s_1, s_2 = symbols("s_1 s_2", positive=True)
255
+
256
+ g_val, r_val, m_val = 0.06, 0.80, 0.30
257
+ delta_star = g_val / (r_val * m_val)
258
+
259
+ # recapture_form == "inside-out"
260
+ oswag = solve(
261
+ s_1 * s_2 / (1 - s_1)
262
+ + s_2 * s_1 / (1 - (r_val * s_2 + (1 - r_val) * s_1))
263
+ - (s_1 + s_2) * delta_star,
264
+ s_2
265
+ )[0]
266
+ symplot(
267
+ oswag,
268
+ (s_1, 0., d_hat / (1 + d_hat)),
269
+ ylabel=s_2
270
+ )
271
+
272
+ cpswag = solve(
273
+ s_2 * s_2 / (1 - s_1)
274
+ + s_1 * s_1 / (1 - (r_val * s_2 + (1 - r_val) * s_1))
275
+ - (s_1 + s_2) * delta_star,
276
+ s_2
277
+ )[1]
278
+ symplot(
279
+ cpwag,
280
+ (s_1, 0., d_hat / (1 + d_hat)),
281
+ ylabel=s_2
282
+ )
283
+
284
+ # recapture_form == "proportional"
285
+ oswag = solve(
286
+ s_1 * s_2 / (1 - s_1)
287
+ + s_2 * s_1 / (1 - s_2)
288
+ - (s_1 + s_2) * delta_star,
289
+ s_2
290
+ )[0]
291
+ symplot(
292
+ oswag,
293
+ (s_1, 0., d_hat / (1 + d_hat)),
294
+ ylabel=s_2
295
+ )
296
+
297
+ cpswag = solve(
298
+ s_2 * s_2 / (1 - s_1)
299
+ + s_1 * s_1 / (1 - s_2)
300
+ - (s_1 + s_2) * delta_star,
301
+ s_2
302
+ )[1]
303
+ symplot(
304
+ cpswag,
305
+ (s_1, 0.0, d_hat / (1 + d_hat)),
306
+ ylabel=s_2
307
+ )
308
+
309
+
310
+ """
311
+
312
+ _delta_star = mpf(f"{_delta_star}")
313
+ _s_mid = _delta_star / (1 + _delta_star)
314
+
315
+ # initial conditions
316
+ _gbdry_points = [(_s_mid, _s_mid)]
317
+ _s_1_pre, _s_2_pre = _s_mid, _s_mid
318
+ _s_2_oddval, _s_2_oddsum, _s_2_evnsum = True, 0, 0
319
+
320
+ # parameters for iteration
321
+ _gbd_step_sz = mp.power(10, -prec)
322
+ _theta = _gbd_step_sz * (10 if weighting == "cross-product-share" else 1)
323
+ for _s_1 in mp.arange(_s_mid - _gbd_step_sz, 0, -_gbd_step_sz):
324
+ # The wtd. avg. GUPPI is not always convex to the origin, so we
325
+ # increment _s_2 after each iteration in which our algorithm
326
+ # finds (s1, s2) on the boundary
327
+ _s_2 = _s_2_pre * (1 + _theta)
328
+
329
+ if (_s_1 + _s_2) > mpf("0.99875"):
330
+ # Loss of accuracy at 3-9s and up
331
+ break
332
+
333
+ while True:
334
+ _de_1 = _s_2 / (1 - _s_1)
335
+ _de_2 = (
336
+ _s_1 / (1 - lerp(_s_1, _s_2, _r_val))
337
+ if recapture_form == "inside-out"
338
+ else _s_1 / (1 - _s_2)
339
+ )
340
+
341
+ _r = (
342
+ mp.fdiv(
343
+ _s_1 if weighting == "cross-product-share" else _s_2, _s_1 + _s_2
344
+ )
345
+ if weighting
346
+ else 0.5
347
+ )
348
+
349
+ match agg_method:
350
+ case "geometric":
351
+ _delta_test = mp.expm1(lerp(mp.log1p(_de_1), mp.log1p(_de_2), _r))
352
+ case "distance":
353
+ _delta_test = mp.sqrt(lerp(_de_1**2, _de_2**2, _r))
354
+ case _:
355
+ _delta_test = lerp(_de_1, _de_2, _r)
356
+
357
+ _test_flag, _incr_decr = (
358
+ (_delta_test > _delta_star, -1)
359
+ if weighting == "cross-product-share"
360
+ else (_delta_test < _delta_star, 1)
361
+ )
362
+
363
+ if _test_flag:
364
+ _s_2 += _incr_decr * _gbd_step_sz
365
+ else:
366
+ break
367
+
368
+ # Build-up boundary points
369
+ _gbdry_points.append((_s_1, _s_2))
370
+
371
+ # Build up area terms
372
+ _s_2_oddsum += _s_2 if _s_2_oddval else 0
373
+ _s_2_evnsum += _s_2 if not _s_2_oddval else 0
374
+ _s_2_oddval = not _s_2_oddval
375
+
376
+ # Hold share points
377
+ _s_2_pre = _s_2
378
+ _s_1_pre = _s_1
379
+
380
+ if _s_2_oddval:
381
+ _s_2_evnsum -= _s_2_pre
382
+ else:
383
+ _s_2_oddsum -= _s_1_pre
384
+
385
+ _s_intcpt = _shrratio_boundary_intcpt(
386
+ _s_1_pre,
387
+ _delta_star,
388
+ _r_val,
389
+ recapture_form=recapture_form,
390
+ agg_method=agg_method,
391
+ weighting=weighting,
392
+ )
393
+
394
+ if weighting == "own-share":
395
+ _gbd_prtlarea = (
396
+ _gbd_step_sz * (4 * _s_2_oddsum + 2 * _s_2_evnsum + _s_mid + _s_2_pre) / 3
397
+ )
398
+ # Area under boundary
399
+ _gbdry_area_total = float(
400
+ 2 * (_s_1_pre + _gbd_prtlarea)
401
+ - (mp.power(_s_mid, "2") + mp.power(_s_1_pre, "2"))
402
+ )
403
+
404
+ else:
405
+ _gbd_prtlarea = (
406
+ _gbd_step_sz * (4 * _s_2_oddsum + 2 * _s_2_evnsum + _s_mid + _s_intcpt) / 3
407
+ )
408
+ # Area under boundary
409
+ _gbdry_area_total = float(2 * _gbd_prtlarea - mp.power(_s_mid, "2"))
410
+
411
+ _gbdry_points = np.vstack((_gbdry_points, (mpf("0.0"), _s_intcpt))).astype(
412
+ np.float64
413
+ )
414
+
415
+ # Points defining boundary to point-of-symmetry
416
+ return GuidelinesBoundary(
417
+ np.vstack((np.flip(_gbdry_points, 0), np.flip(_gbdry_points[1:], 1))),
418
+ round(float(_gbdry_area_total), prec),
419
+ )
420
+
421
+
422
+ def shrratio_boundary_xact_avg(
423
+ _delta_star: float = 0.075,
424
+ _r_val: float = 0.85,
425
+ /,
426
+ *,
427
+ recapture_form: Literal["inside-out", "proportional"] = "inside-out",
428
+ prec: int = 5,
429
+ ) -> GuidelinesBoundary:
430
+ """
431
+ Share combinations for the simple average GUPPI boundary with symmetric
432
+ merging-firm margins.
433
+
434
+ Notes
435
+ -----
436
+ An analytical expression for the exact average boundary is derived
437
+ and plotted from the y-intercept to the ray of symmetry as follows::
438
+
439
+ from sympy import latex, plot as symplot, solve, symbols
440
+
441
+ s_1, s_2 = symbols("s_1 s_2")
442
+
443
+ g_val, r_val, m_val = 0.06, 0.80, 0.30
444
+ d_hat = g_val / (r_val * m_val)
445
+
446
+ # recapture_form = "inside-out"
447
+ sag = solve(
448
+ (s_2 / (1 - s_1))
449
+ + (s_1 / (1 - (r_val * s_2 + (1 - r_val) * s_1)))
450
+ - 2 * d_hat,
451
+ s_2
452
+ )[0]
453
+ symplot(
454
+ sag,
455
+ (s_1, 0., d_hat / (1 + d_hat)),
456
+ ylabel=s_2
457
+ )
458
+
459
+ # recapture_form = "proportional"
460
+ sag = solve((s_2/(1 - s_1)) + (s_1/(1 - s_2)) - 2 * d_hat, s_2)[0]
461
+ symplot(
462
+ sag,
463
+ (s_1, 0., d_hat / (1 + d_hat)),
464
+ ylabel=s_2
465
+ )
466
+
467
+ Parameters
468
+ ----------
469
+ _delta_star
470
+ Share ratio (:math:`\\overline{d} / \\overline{r}`).
471
+ _r_val
472
+ Recapture ratio
473
+ recapture_form
474
+ Whether recapture-ratio is MNL-consistent ("inside-out") or has fixed
475
+ value for both merging firms ("proportional").
476
+ prec
477
+ Number of decimal places for rounding returned shares.
478
+
479
+ Returns
480
+ -------
481
+ Array of share-pairs, area under boundary, area under boundary.
482
+
483
+ """
484
+
485
+ _delta_star = mpf(f"{_delta_star}")
486
+ _s_mid = _delta_star / (1 + _delta_star)
487
+ _gbd_step_sz = mp.power(10, -prec)
488
+
489
+ _gbdry_points_start = np.array([(_s_mid, _s_mid)])
490
+ _s_1 = np.array(mp.arange(_s_mid - _gbd_step_sz, 0, -_gbd_step_sz), np.float64)
491
+ if recapture_form == "inside-out":
492
+ _s_intcpt = mp.fdiv(
493
+ mp.fsub(
494
+ 2 * _delta_star * _r_val + 1, mp.fabs(2 * _delta_star * _r_val - 1)
495
+ ),
496
+ 2 * mpf(f"{_r_val}"),
497
+ )
498
+ _nr_t1 = 1 + 2 * _delta_star * _r_val * (1 - _s_1) - _s_1 * (1 - _r_val)
499
+
500
+ _nr_sqrt_mdr = 4 * _delta_star * _r_val
501
+ _nr_sqrt_mdr2 = _nr_sqrt_mdr * _r_val
502
+ _nr_sqrt_md2r2 = _nr_sqrt_mdr2 * _delta_star
503
+
504
+ _nr_sqrt_t1 = _nr_sqrt_md2r2 * (_s_1**2 - 2 * _s_1 + 1)
505
+ _nr_sqrt_t2 = _nr_sqrt_mdr2 * _s_1 * (_s_1 - 1)
506
+ _nr_sqrt_t3 = _nr_sqrt_mdr * (2 * _s_1 - _s_1**2 - 1)
507
+ _nr_sqrt_t4 = (_s_1**2) * (_r_val**2 - 6 * _r_val + 1)
508
+ _nr_sqrt_t5 = _s_1 * (6 * _r_val - 2) + 1
509
+
510
+ _nr_t2_mdr = _nr_sqrt_t1 + _nr_sqrt_t2 + _nr_sqrt_t3 + _nr_sqrt_t4 + _nr_sqrt_t5
511
+
512
+ # Alternative grouping of terms in np.sqrt
513
+ _nr_sqrt_s1sq = (_s_1**2) * (
514
+ _nr_sqrt_md2r2 + _nr_sqrt_mdr2 - _nr_sqrt_mdr + _r_val**2 - 6 * _r_val + 1
515
+ )
516
+ _nr_sqrt_s1 = _s_1 * (
517
+ -2 * _nr_sqrt_md2r2 - _nr_sqrt_mdr2 + 2 * _nr_sqrt_mdr + 6 * _r_val - 2
518
+ )
519
+ _nr_sqrt_nos1 = _nr_sqrt_md2r2 - _nr_sqrt_mdr + 1
520
+
521
+ _nr_t2_s1 = _nr_sqrt_s1sq + _nr_sqrt_s1 + _nr_sqrt_nos1
522
+
523
+ if not np.isclose(
524
+ np.einsum("i->", _nr_t2_mdr.astype(np.float64)),
525
+ np.einsum("i->", _nr_t2_s1.astype(np.float64)),
526
+ rtol=0,
527
+ atol=0.5 * prec,
528
+ ):
529
+ raise RuntimeError(
530
+ "Calculation of sq. root term in exact average GUPPI"
531
+ f"with recapture spec, {f'"{recapture_form}"'} is incorrect."
532
+ )
533
+
534
+ _s_2 = (_nr_t1 - np.sqrt(_nr_t2_s1)) / (2 * _r_val)
535
+
536
+ else:
537
+ _s_intcpt = mp.fsub(_delta_star + 1 / 2, mp.fabs(_delta_star - 1 / 2))
538
+ _s_2 = (
539
+ (1 / 2)
540
+ + _delta_star
541
+ - _delta_star * _s_1
542
+ - np.sqrt(
543
+ ((_delta_star**2) - 1) * (_s_1**2)
544
+ + (-2 * (_delta_star**2) + _delta_star + 1) * _s_1
545
+ + (_delta_star**2)
546
+ - _delta_star
547
+ + (1 / 4)
548
+ )
549
+ )
550
+
551
+ _gbdry_points_inner = np.column_stack((_s_1, _s_2))
552
+ _gbdry_points_end = np.array([(mpf("0.0"), _s_intcpt)], np.float64)
553
+
554
+ _gbdry_points = np.vstack((
555
+ _gbdry_points_end,
556
+ np.flip(_gbdry_points_inner, 0),
557
+ _gbdry_points_start,
558
+ np.flip(_gbdry_points_inner, 1),
559
+ np.flip(_gbdry_points_end, 1),
560
+ )).astype(np.float64)
561
+ _s_2 = np.concatenate((np.array([_s_mid], np.float64), _s_2))
562
+
563
+ _gbdry_ends = [0, -1]
564
+ _gbdry_odds = np.array(range(1, len(_s_2), 2), np.int64)
565
+ _gbdry_evns = np.array(range(2, len(_s_2), 2), np.int64)
566
+
567
+ # Double the are under the curve, and subtract the double counted bit.
568
+ _gbdry_area_simpson = 2 * _gbd_step_sz * (
569
+ (4 / 3) * np.sum(_s_2.take(_gbdry_odds))
570
+ + (2 / 3) * np.sum(_s_2.take(_gbdry_evns))
571
+ + (1 / 3) * np.sum(_s_2.take(_gbdry_ends))
572
+ ) - np.power(_s_mid, 2)
573
+
574
+ _s_1_pts, _s_2_pts = np.split(_gbdry_points, 2, axis=1)
575
+ return GuidelinesBoundary(
576
+ np.column_stack((np.array(_s_1_pts), np.array(_s_2_pts))),
577
+ round(float(_gbdry_area_simpson), prec),
578
+ )
579
+
580
+
581
+ def shrratio_boundary_min(
582
+ _delta_star: float = 0.075,
583
+ _r_val: float = 0.85,
584
+ /,
585
+ *,
586
+ recapture_form: str = "inside-out",
587
+ prec: int = 10,
588
+ ) -> GuidelinesBoundary:
589
+ """
590
+ Share combinations on the minimum GUPPI boundary, with symmetric
591
+ merging-firm margins.
592
+
593
+ Notes
594
+ -----
595
+ With symmetric merging-firm margins, the maximum GUPPI boundary is
596
+ defined by the diversion ratio from the smaller merging-firm to the
597
+ larger one, and is hence unaffected by the method of estimating the
598
+ diversion ratio for the larger firm.
599
+
600
+ Parameters
601
+ ----------
602
+ _delta_star
603
+ Share ratio (:math:`\\overline{d} / \\overline{r}`).
604
+ _r_val
605
+ Recapture ratio.
606
+ recapture_form
607
+ Whether recapture-ratio is MNL-consistent ("inside-out") or has fixed
608
+ value for both merging firms ("proportional").
609
+ prec
610
+ Number of decimal places for rounding returned shares.
611
+
612
+ Returns
613
+ -------
614
+ Array of share-pairs, area under boundary.
615
+
616
+ """
617
+
618
+ _delta_star = mpf(f"{_delta_star}")
619
+ _s_intcpt = mpf("1.00")
620
+ _s_mid = _delta_star / (1 + _delta_star)
621
+
622
+ if recapture_form == "inside-out":
623
+ # ## Plot envelope of GUPPI boundaries with r_k = r_bar if s_k = min(_s_1, _s_2)
624
+ # ## See (s_i, s_j) in equation~(44), or thereabouts, in paper
625
+ _smin_nr = _delta_star * (1 - _r_val)
626
+ _smax_nr = 1 - _delta_star * _r_val
627
+ _guppi_bdry_env_dr = _smin_nr + _smax_nr
628
+ _s1_pts = np.array(
629
+ (
630
+ 0,
631
+ _smin_nr / _guppi_bdry_env_dr,
632
+ _s_mid,
633
+ _smax_nr / _guppi_bdry_env_dr,
634
+ _s_intcpt,
635
+ ),
636
+ np.float64,
637
+ )
638
+
639
+ _gbd_area = _s_mid + _s1_pts[1] * (1 - 2 * _s_mid)
640
+ else:
641
+ _s1_pts, _gbd_area = np.array((0, _s_mid, _s_intcpt), np.float64), _s_mid
642
+
643
+ return GuidelinesBoundary(
644
+ np.column_stack((_s1_pts, _s1_pts[::-1])), round(float(_gbd_area), prec)
645
+ )
646
+
647
+
648
+ def shrratio_boundary_max(
649
+ _delta_star: float = 0.075, _r_val: float = 0.85, /, *, prec: int = 10
650
+ ) -> GuidelinesBoundary:
651
+ """
652
+ Share combinations on the minimum GUPPI boundary with symmetric
653
+ merging-firm margins.
654
+
655
+ Parameters
656
+ ----------
657
+ _delta_star
658
+ Share ratio (:math:`\\overline{d} / \\overline{r}`).
659
+ _r_val
660
+ Recapture ratio.
661
+ prec
662
+ Number of decimal places for rounding returned shares.
663
+
664
+ Returns
665
+ -------
666
+ Array of share-pairs, area under boundary.
667
+
668
+ """
669
+
670
+ # _r_val is not needed for max boundary, but is specified for consistency
671
+ # of function call with other shrratio_mgnsym_boundary functions
672
+ del _r_val
673
+ _delta_star = mpf(f"{_delta_star}")
674
+ _s_intcpt = _delta_star
675
+ _s_mid = _delta_star / (1 + _delta_star)
676
+
677
+ _s1_pts = (0, _s_mid, _s_intcpt)
678
+
679
+ return GuidelinesBoundary(
680
+ np.column_stack((
681
+ np.array(_s1_pts, np.float64),
682
+ np.array(_s1_pts[::-1], np.float64),
683
+ )),
684
+ round(float(_s_intcpt * _s_mid), prec), # simplified calculation
685
+ )
686
+
687
+
688
+ def _shrratio_boundary_intcpt(
689
+ _s_2_pre: float,
690
+ _delta_star: mpf,
691
+ _r_val: mpf,
692
+ /,
693
+ *,
694
+ recapture_form: Literal["inside-out", "proportional"],
695
+ agg_method: Literal["arithmetic mean", "geometric mean", "distance"],
696
+ weighting: Literal["cross-product-share", "own-share", None],
697
+ ) -> float:
698
+ match weighting:
699
+ case "cross-product-share":
700
+ _s_intcpt: float = _delta_star
701
+ case "own-share":
702
+ _s_intcpt = mpf("1.0")
703
+ case None if agg_method == "distance":
704
+ _s_intcpt = _delta_star * mp.sqrt("2")
705
+ case None if agg_method == "arithmetic mean" and recapture_form == "inside-out":
706
+ _s_intcpt = mp.fdiv(
707
+ mp.fsub(
708
+ 2 * _delta_star * _r_val + 1, mp.fabs(2 * _delta_star * _r_val - 1)
709
+ ),
710
+ 2 * mpf(f"{_r_val}"),
711
+ )
712
+ case None if agg_method == "arithmetic mean" and recapture_form == "proportional":
713
+ _s_intcpt = mp.fsub(_delta_star + 1 / 2, mp.fabs(_delta_star - 1 / 2))
714
+ case _:
715
+ _s_intcpt = _s_2_pre
716
+
717
+ return _s_intcpt
718
+
719
+
720
+ def lerp(
721
+ _x1: int | float | mpf | NDArray[np.float64 | np.int64] = 3,
722
+ _x2: int | float | mpf | NDArray[np.float64 | np.int64] = 1,
723
+ _r: float | mpf = 0.25,
724
+ /,
725
+ ) -> float | mpf | NDArray[np.float64]:
726
+ """
727
+ From the function of the same name in the C++ standard [2]_
728
+
729
+ Constructs the weighted average, :math:`w_1 x_1 + w_2 x_2`, where
730
+ :math:`w_1 = 1 - r` and :math:`w_2 = r`.
731
+
732
+ Parameters
733
+ ----------
734
+ _x1, _x2
735
+ bounds :math:`x_1, x_2` to interpolate between.
736
+ _r
737
+ interpolation weight :math:`r` assigned to :math:`x_2`
738
+
739
+ Returns
740
+ -------
741
+ The linear interpolation, or weighted average,
742
+ :math:`x_1 + r \\cdot (x_1 - x_2) \\equiv (1 - r) \\cdot x_1 + r \\cdot x_2`.
743
+
744
+ Raises
745
+ ------
746
+ ValueError
747
+ If the interpolation weight is not in the interval, :math:`[0, 1]`.
748
+
749
+ References
750
+ ----------
751
+
752
+ .. [2] C++ Reference, https://en.cppreference.com/w/cpp/numeric/lerp
753
+
754
+ """
755
+
756
+ if not 0 <= _r <= 1:
757
+ raise ValueError("Specified interpolation weight must lie in [0, 1].")
758
+ elif _r == 0:
759
+ return _x1
760
+ elif _r == 1:
761
+ return _x2
762
+ elif _r == 0.5:
763
+ return 1 / 2 * (_x1 + _x2)
764
+ else:
765
+ return _r * _x2 + (1 - _r) * _x1
766
+
767
+
768
+ def round_cust(
769
+ _num: float = 0.060215,
770
+ /,
771
+ *,
772
+ frac: float = 0.005,
773
+ rounding_mode: str = "ROUND_HALF_UP",
774
+ ) -> float:
775
+ """
776
+ Custom rounding, to the nearest 0.5% by default.
777
+
778
+ Parameters
779
+ ----------
780
+ _num
781
+ Number to be rounded.
782
+ frac
783
+ Fraction to be rounded to.
784
+ rounding_mode
785
+ Rounding mode, as defined in the :code:`decimal` package.
786
+
787
+ Returns
788
+ -------
789
+ The given number, rounded as specified.
790
+
791
+ Raises
792
+ ------
793
+ ValueError
794
+ If rounding mode is not defined in the :code:`decimal` package.
795
+
796
+ Notes
797
+ -----
798
+ Integer-round the quotient, :code:`(_num / frac)` using the specified
799
+ rounding mode. Return the product of the rounded quotient times
800
+ the specified precision, :code:`frac`.
801
+
802
+ """
803
+
804
+ if rounding_mode not in (
805
+ decimal.ROUND_05UP,
806
+ decimal.ROUND_CEILING,
807
+ decimal.ROUND_DOWN,
808
+ decimal.ROUND_FLOOR,
809
+ decimal.ROUND_HALF_DOWN,
810
+ decimal.ROUND_HALF_EVEN,
811
+ decimal.ROUND_HALF_UP,
812
+ decimal.ROUND_UP,
813
+ ):
814
+ raise ValueError(
815
+ f"Value, {f'"{rounding_mode}"'} is invalid for rounding_mode."
816
+ "Documentation for the, \"decimal\" built-in lists valid rounding modes."
817
+ )
818
+
819
+ _n, _f, _e = (decimal.Decimal(f"{_g}") for _g in [_num, frac, 1])
820
+
821
+ return float(_f * (_n / _f).quantize(_e, rounding=rounding_mode))
822
+
823
+
824
+ def boundary_plot(*, mktshares_plot_flag: bool = True) -> tuple[Any, ...]:
825
+ """Setup basic figure and axes for plots of safe harbor boundaries.
826
+
827
+ See, https://matplotlib.org/stable/tutorials/text/pgf.html
828
+ """
829
+
830
+ import matplotlib as mpl
831
+ import matplotlib.axes as mpa
832
+ import matplotlib.patches as mpp
833
+ import matplotlib.ticker as mpt
834
+
835
+ mpl.use("pgf")
836
+ import matplotlib.pyplot as _plt # noqa: ICN001
837
+
838
+ _plt.rcParams.update({
839
+ "pgf.rcfonts": False,
840
+ "pgf.texsystem": "lualatex",
841
+ "pgf.preamble": "\n".join([
842
+ R"\pdfvariable minorversion=7",
843
+ R"\usepackage{fontspec}",
844
+ R"\usepackage{luacode}",
845
+ R"\begin{luacode}",
846
+ R"local function embedfull(tfmdata)",
847
+ R' tfmdata.embedding = "full"',
848
+ R"end",
849
+ R"",
850
+ R"luatexbase.add_to_callback("
851
+ R' "luaotfload.patch_font", embedfull, "embedfull"'
852
+ R")",
853
+ R"\end{luacode}",
854
+ R"\usepackage{mathtools}",
855
+ R"\usepackage{unicode-math}",
856
+ R"\setmathfont[math-style=ISO]{STIX Two Math}",
857
+ R"\setmainfont{STIX Two Text}",
858
+ r"\setsansfont{Fira Sans Light}",
859
+ R"\setmonofont[Scale=MatchLowercase,]{Fira Mono}",
860
+ R"\defaultfontfeatures[\rmfamily]{",
861
+ R" Ligatures={TeX, Common},",
862
+ R" Numbers={Proportional, Lining},",
863
+ R" }",
864
+ R"\defaultfontfeatures[\sffamily]{",
865
+ R" Ligatures={TeX, Common},",
866
+ R" Numbers={Monospaced, Lining},",
867
+ R" LetterSpace=0.50,",
868
+ R" }",
869
+ R"\usepackage[",
870
+ R" activate={true, nocompatibility},",
871
+ R" tracking=true,",
872
+ R" ]{microtype}",
873
+ ]),
874
+ })
875
+
876
+ # Initialize a canvas with a single figure (set of axes)
877
+ _fig = _plt.figure(figsize=(5, 5), dpi=600)
878
+ _ax_out = _fig.add_subplot()
879
+
880
+ def _set_axis_def(
881
+ _ax1: mpa.Axes,
882
+ /,
883
+ *,
884
+ mktshares_plot_flag: bool = False,
885
+ mktshares_axlbls_flag: bool = False,
886
+ ) -> mpa.Axes:
887
+ # Set the width of axis gridlines, and tick marks:
888
+ # both axes, both major and minor ticks
889
+ # Frame, grid, and facecolor
890
+ for _spos0 in "left", "bottom":
891
+ _ax1.spines[_spos0].set_linewidth(0.5)
892
+ _ax1.spines[_spos0].set_zorder(5)
893
+ for _spos1 in "top", "right":
894
+ _ax1.spines[_spos1].set_linewidth(0.0)
895
+ _ax1.spines[_spos1].set_zorder(0)
896
+ _ax1.spines[_spos1].set_visible(False)
897
+ _ax1.set_facecolor("#E6E6E6")
898
+
899
+ _ax1.grid(linewidth=0.5, linestyle=":", color="grey", zorder=1)
900
+ _ax1.tick_params(axis="x", which="both", width=0.5)
901
+ _ax1.tick_params(axis="y", which="both", width=0.5)
902
+
903
+ # Tick marks skip, size, and rotation
904
+ # x-axis
905
+ _plt.setp(
906
+ _ax1.xaxis.get_majorticklabels(),
907
+ horizontalalignment="right",
908
+ fontsize=6,
909
+ rotation=45,
910
+ )
911
+ # y-axis
912
+ _plt.setp(
913
+ _ax1.yaxis.get_majorticklabels(), horizontalalignment="right", fontsize=6
914
+ )
915
+
916
+ if mktshares_plot_flag:
917
+ # Axis labels
918
+ if mktshares_axlbls_flag:
919
+ # x-axis
920
+ _ax1.set_xlabel("Firm 1 Market Share, $s_1$", fontsize=10)
921
+ _ax1.xaxis.set_label_coords(0.75, -0.1)
922
+ # y-axis
923
+ _ax1.set_ylabel("Firm 2 Market Share, $s_2$", fontsize=10)
924
+ _ax1.yaxis.set_label_coords(-0.1, 0.75)
925
+
926
+ # Plot the ray of symmetry
927
+ _ax1.plot(
928
+ [0, 1], [0, 1], linewidth=0.5, linestyle=":", color="grey", zorder=1
929
+ )
930
+
931
+ # Axis scale
932
+ _ax1.set_xlim(0, 1)
933
+ _ax1.set_ylim(0, 1)
934
+ _ax1.set_aspect(1.0)
935
+
936
+ # Truncate the axis frame to a triangle:
937
+ _ax1.add_patch(
938
+ mpp.Rectangle(
939
+ xy=(1.0025, 0.00),
940
+ width=1.1 * mp.sqrt(2),
941
+ height=1.1 * mp.sqrt(2),
942
+ angle=45,
943
+ color="white",
944
+ edgecolor=None,
945
+ fill=True,
946
+ clip_on=True,
947
+ zorder=5,
948
+ )
949
+ )
950
+ # Feasible space is bounded by the other diagonal:
951
+ _ax1.plot(
952
+ [0, 1], [1, 0], linestyle="-", linewidth=0.5, color="black", zorder=1
953
+ )
954
+
955
+ # Axis Tick-mark locations
956
+ # One can supply an argument to mpt.AutoMinorLocator to
957
+ # specify a fixed number of minor intervals per major interval, e.g.:
958
+ # minorLocator = mpt.AutoMinorLocator(2)
959
+ # would lead to a single minor tick between major ticks.
960
+ _minorLocator = mpt.AutoMinorLocator(5)
961
+ _majorLocator = mpt.MultipleLocator(0.05)
962
+ for _axs in _ax1.xaxis, _ax1.yaxis:
963
+ if _axs == _ax1.xaxis:
964
+ _majorticklabels_rot = 45
965
+ elif _axs == _ax1.yaxis:
966
+ _majorticklabels_rot = 0
967
+ # x-axis
968
+ _axs.set_major_locator(_majorLocator)
969
+ _axs.set_minor_locator(_minorLocator)
970
+ # It"s always x when specifying the format
971
+ _axs.set_major_formatter(mpt.StrMethodFormatter("{x:>3.0%}"))
972
+
973
+ # Hide every other tick-label
974
+ for _axl in _ax1.get_xticklabels(), _ax1.get_yticklabels():
975
+ _plt.setp(_axl[::2], visible=False)
976
+
977
+ return _ax1
978
+
979
+ _ax_out = _set_axis_def(_ax_out, mktshares_plot_flag=mktshares_plot_flag)
980
+
981
+ return _plt, _fig, _ax_out, _set_axis_def