mergeron 2025.739290.6__py3-none-any.whl → 2025.739290.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.

@@ -17,6 +17,7 @@ from scipy.spatial.distance import minkowski as distance_function # type: ignor
17
17
  from sympy import lambdify, simplify, solve, symbols # type: ignore
18
18
 
19
19
  from .. import DEFAULT_REC_RATIO, VERSION, ArrayDouble # noqa: TID252
20
+ from . import GuidelinesBoundary, MPFloat
20
21
  from . import guidelines_boundary_functions as gbf
21
22
 
22
23
  __version__ = VERSION
@@ -505,7 +506,7 @@ def shrratio_boundary_xact_avg_mp( # noqa: PLR0914
505
506
  )
506
507
  )
507
508
 
508
- bdry_inner = np.column_stack((_s_1, s_2))
509
+ bdry_inner = np.stack((_s_1, s_2), axis=1)
509
510
  bdry_end = np.array([(mpf("0.0"), s_intcpt)])
510
511
 
511
512
  bdry = np.vstack((
@@ -529,3 +530,209 @@ def shrratio_boundary_xact_avg_mp( # noqa: PLR0914
529
530
  ) - mp.power(_s_mid, 2)
530
531
 
531
532
  return gbf.GuidelinesBoundary(bdry, float(mp.nstr(bdry_area_simpson, dps)))
533
+
534
+
535
+ # shrratio_boundary_wtd_avg_autoroot
536
+ # this function is about half as fast as the manual one! ... and a touch less precise
537
+ def _shrratio_boundary_wtd_avg_autoroot( # noqa: PLR0914
538
+ _delta_star: float = 0.075,
539
+ _r_val: float = DEFAULT_REC_RATIO,
540
+ /,
541
+ *,
542
+ agg_method: Literal[
543
+ "arithmetic mean", "geometric mean", "distance"
544
+ ] = "arithmetic mean",
545
+ weighting: Literal["own-share", "cross-product-share", None] = "own-share",
546
+ recapture_form: Literal["inside-out", "proportional"] = "inside-out",
547
+ dps: int = 5,
548
+ ) -> GuidelinesBoundary:
549
+ """
550
+ Share combinations on the share-weighted average diversion ratio boundary.
551
+
552
+ Parameters
553
+ ----------
554
+ _delta_star
555
+ Share ratio (:math:`\\overline{d} / \\overline{r}`)
556
+ _r_val
557
+ recapture ratio
558
+ agg_method
559
+ Whether "arithmetic mean", "geometric mean", or "distance".
560
+ weighting
561
+ Whether "own-share" or "cross-product-share" (or None for simple, unweighted average).
562
+ recapture_form
563
+ Whether recapture-ratio is MNL-consistent ("inside-out") or has fixed
564
+ value for both merging firms ("proportional").
565
+ dps
566
+ Number of decimal places for rounding returned shares and area.
567
+
568
+ Returns
569
+ -------
570
+ Array of share-pairs, area under boundary.
571
+
572
+ Notes
573
+ -----
574
+ An analytical expression for the share-weighted arithmetic mean boundary
575
+ is derived and plotted from y-intercept to the ray of symmetry as follows::
576
+
577
+ from sympy import plot as symplot, solve, symbols
578
+ s_1, s_2 = symbols("s_1 s_2", positive=True)
579
+
580
+ g_val, r_val, m_val = 0.06, 0.80, 0.30
581
+ delta_star = g_val / (r_val * m_val)
582
+
583
+ # recapture_form == "inside-out"
584
+ oswag = solve(
585
+ s_1 * s_2 / (1 - s_1)
586
+ + s_2 * s_1 / (1 - (r_val * s_2 + (1 - r_val) * s_1))
587
+ - (s_1 + s_2) * delta_star,
588
+ s_2
589
+ )[0]
590
+ symplot(
591
+ oswag,
592
+ (s_1, 0., d_hat / (1 + d_hat)),
593
+ ylabel=s_2
594
+ )
595
+
596
+ cpswag = solve(
597
+ s_2 * s_2 / (1 - s_1)
598
+ + s_1 * s_1 / (1 - (r_val * s_2 + (1 - r_val) * s_1))
599
+ - (s_1 + s_2) * delta_star,
600
+ s_2
601
+ )[1]
602
+ symplot(
603
+ cpwag,
604
+ (s_1, 0.0, d_hat / (1 + d_hat)), ylabel=s_2
605
+ )
606
+
607
+ # recapture_form == "proportional"
608
+ oswag = solve(
609
+ s_1 * s_2 / (1 - s_1)
610
+ + s_2 * s_1 / (1 - s_2)
611
+ - (s_1 + s_2) * delta_star,
612
+ s_2
613
+ )[0]
614
+ symplot(
615
+ oswag,
616
+ (s_1, 0., d_hat / (1 + d_hat)),
617
+ ylabel=s_2
618
+ )
619
+
620
+ cpswag = solve(
621
+ s_2 * s_2 / (1 - s_1)
622
+ + s_1 * s_1 / (1 - s_2)
623
+ - (s_1 + s_2) * delta_star,
624
+ s_2
625
+ )[1]
626
+ symplot(
627
+ cpswag,
628
+ (s_1, 0.0, d_hat / (1 + d_hat)),
629
+ ylabel=s_2
630
+ )
631
+
632
+
633
+ """
634
+
635
+ _delta_star, _r_val = (mpf(f"{_v}") for _v in (_delta_star, _r_val))
636
+ _s_mid = mp.fdiv(_delta_star, 1 + _delta_star)
637
+
638
+ # initial conditions
639
+ bdry = [(_s_mid, _s_mid)]
640
+ s_1_pre, s_2_pre = _s_mid, _s_mid
641
+ s_2_oddval, s_2_oddsum, s_2_evnsum = True, 0.0, 0.0
642
+
643
+ # parameters for iteration
644
+ _step_size = mp.power(10, -dps)
645
+ theta_ = _step_size * (10 if weighting == "cross-product-share" else 1)
646
+ for s_1 in mp.arange(_s_mid - _step_size, 0, -_step_size):
647
+
648
+ def delta_test(x: MPFloat) -> MPFloat:
649
+ _de_1 = x / (1 - s_1)
650
+ _de_2 = (
651
+ s_1 / (1 - gbf.lerp(s_1, x, _r_val))
652
+ if recapture_form == "inside-out"
653
+ else s_1 / (1 - x)
654
+ )
655
+ _w = (
656
+ mp.fdiv(s_1 if weighting == "cross-product-share" else x, s_1 + x)
657
+ if weighting
658
+ else 0.5
659
+ )
660
+
661
+ match agg_method:
662
+ case "geometric mean":
663
+ delta_test = mp.expm1(
664
+ gbf.lerp(mp.log1p(_de_1), mp.log1p(_de_2), _w)
665
+ )
666
+ case "distance":
667
+ delta_test = mp.sqrt(gbf.lerp(_de_1**2, _de_2**2, _w))
668
+ case _:
669
+ delta_test = gbf.lerp(_de_1, _de_2, _w)
670
+
671
+ return _delta_star - delta_test
672
+
673
+ try:
674
+ s_2 = mp.findroot(
675
+ delta_test,
676
+ x0=(s_2_pre * (1 - theta_), s_2_pre * (1 + theta_)),
677
+ tol=mp.sqrt(_step_size),
678
+ solver="ridder",
679
+ )
680
+ except (mp.ComplexResult, ValueError, ZeroDivisionError) as _e:
681
+ print(s_1, s_2_pre)
682
+ raise _e
683
+
684
+ # Build-up boundary points
685
+ bdry.append((s_1, s_2))
686
+
687
+ # Build up area terms
688
+ s_2_oddsum += s_2 if s_2_oddval else 0
689
+ s_2_evnsum += s_2 if not s_2_oddval else 0
690
+ s_2_oddval = not s_2_oddval
691
+
692
+ # Hold share points
693
+ s_2_pre = s_2
694
+ s_1_pre = s_1
695
+
696
+ if (s_1_pre + s_2_pre) > mpf("0.99875"):
697
+ # Loss of accuracy at 3-9s and up
698
+ break
699
+
700
+ if s_2_oddval:
701
+ s_2_evnsum -= s_2_pre
702
+ else:
703
+ s_2_oddsum -= s_1_pre
704
+
705
+ _s_intcpt = gbf._shrratio_boundary_intcpt(
706
+ s_2_pre,
707
+ _delta_star,
708
+ _r_val,
709
+ recapture_form=recapture_form,
710
+ agg_method=agg_method,
711
+ weighting=weighting,
712
+ )
713
+
714
+ if weighting == "own-share":
715
+ gbd_prtlarea = (
716
+ _step_size * (4 * s_2_oddsum + 2 * s_2_evnsum + _s_mid + s_2_pre) / 3
717
+ )
718
+ # Area under boundary
719
+ bdry_area_total = float(
720
+ 2 * (s_1_pre + gbd_prtlarea)
721
+ - (mp.power(_s_mid, "2") + mp.power(s_1_pre, "2"))
722
+ )
723
+
724
+ else:
725
+ gbd_prtlarea = (
726
+ _step_size * (4 * s_2_oddsum + 2 * s_2_evnsum + _s_mid + _s_intcpt) / 3
727
+ )
728
+ # Area under boundary
729
+ bdry_area_total = float(2 * gbd_prtlarea - mp.power(_s_mid, "2"))
730
+
731
+ bdry.append((mpf("0.0"), _s_intcpt))
732
+ bdry_array = np.array(bdry, float)
733
+
734
+ # Points defining boundary to point-of-symmetry
735
+ return GuidelinesBoundary(
736
+ np.vstack((bdry_array[::-1], bdry_array[1:, ::-1]), dtype=float),
737
+ round(float(bdry_area_total), dps),
738
+ )
mergeron/data/__init__.py CHANGED
@@ -12,10 +12,9 @@ from .. import _PKG_NAME, VERSION # noqa: TID252
12
12
 
13
13
  __version__ = VERSION
14
14
 
15
+ data_resources = resources.files(f"{_PKG_NAME}.data")
15
16
 
16
- DAMODARAN_MARGIN_WORKBOOK = resources.files(f"{_PKG_NAME}.data").joinpath(
17
- "damodaran_margin_data.xls"
18
- )
17
+ DAMODARAN_MARGIN_WORKBOOK = data_resources / "damodaran_margin_data.xls"
19
18
  """
20
19
  Python object pointing to included copy of Prof. Damodaran's margin data
21
20
 
@@ -36,9 +35,7 @@ Use as, for example:
36
35
  shutil.copy2(DAMODARAN_MARGIN_WORKBOOK, Path.home() / f"{DAMODARAN_MARGIN_WORKBOOK.name}")
37
36
  """
38
37
 
39
- FTC_MERGER_INVESTIGATIONS_DATA = resources.files(f"{_PKG_NAME}.data").joinpath(
40
- "ftc_merger_investigations_data.zip"
41
- )
38
+ FTC_MERGER_INVESTIGATIONS_DATA = data_resources / "ftc_merger_investigations_data.zip"
42
39
  """
43
40
  FTC merger investigtions data published in 2004, 2007, 2008, and 2013
44
41
 
@@ -46,7 +43,7 @@ NOTES
46
43
  -----
47
44
  Raw data tables published by the FTC are loaded into a nested distionary, organized by
48
45
  data period, table type, and table number. Each table is stored as a numerical array
49
- (:module:`numpy` arrray), with additonal attrubutes for the industry group and additonal
46
+ (:mod:`numpy` arrray), with additonal attrubutes for the industry group and additonal
50
47
  evidence noted in the source data.
51
48
 
52
49
  Data for additonal data periods (time spans) not reported in the source data,
mergeron/gen/__init__.py CHANGED
@@ -13,7 +13,7 @@ from operator import attrgetter
13
13
 
14
14
  import h5py # type: ignore
15
15
  import numpy as np
16
- from attrs import Attribute, Converter, cmp_using, field, frozen, validators
16
+ from attrs import Attribute, Converter, cmp_using, field, frozen
17
17
  from numpy.random import SeedSequence
18
18
 
19
19
  from .. import ( # noqa: TID252
@@ -588,45 +588,39 @@ class INVResolution(str, Enameled):
588
588
  class UPPTestRegime:
589
589
  """Configuration for UPP tests."""
590
590
 
591
- resolution: INVResolution = field(
592
- kw_only=False,
593
- default=INVResolution.ENFT,
594
- validator=validators.in_([INVResolution.CLRN, INVResolution.ENFT]),
595
- )
596
- """Whether to test clearance, enforcement, or both."""
597
-
598
- guppi_aggregator: UPPAggrSelector = field(
599
- kw_only=False, default=UPPAggrSelector.MIN
600
- )
601
- """Aggregator for GUPPI test."""
602
-
603
- divr_aggregator: UPPAggrSelector = field(kw_only=False, default=UPPAggrSelector.MIN)
604
- """Aggregator for diversion ratio test."""
605
-
591
+ resolution: INVResolution = field(kw_only=False, default=INVResolution.ENFT)
592
+ """Whether to test clearance, enforcement."""
606
593
 
607
- @frozen
608
- class UPPTestsRaw:
609
- """Container for arrays marking test failures and successes
610
-
611
- A test success is a draw ("market") that meeets the
612
- specified test criterion, and a test failure is
613
- one that does not; test criteria are evaluated in
614
- :func:`enforcement_stats.gen_upp_arrays`.
615
- """
594
+ @resolution.validator
595
+ def _resvdtr(
596
+ _i: UPPTestRegime, _a: Attribute[INVResolution], _v: INVResolution
597
+ ) -> None:
598
+ if _v == INVResolution.BOTH:
599
+ raise ValueError(
600
+ "GUPPI test cannot be performed with both resolutions; only useful for reporting"
601
+ )
602
+ elif _v not in {INVResolution.CLRN, INVResolution.ENFT}:
603
+ raise ValueError(
604
+ f"Must be one of, {INVResolution.CLRN!r} or {INVResolution.ENFT!r}"
605
+ )
616
606
 
617
- guppi_test_simple: ArrayBoolean
618
- """True if GUPPI estimate meets criterion"""
607
+ guppi_aggregator: UPPAggrSelector = field(kw_only=False)
608
+ """Aggregator for GUPPI test."""
619
609
 
620
- guppi_test_compound: ArrayBoolean
621
- """True if both GUPPI estimate and diversion ratio estimate
622
- meet criterion
623
- """
610
+ @guppi_aggregator.default
611
+ def __gad(_i: UPPTestRegime) -> UPPAggrSelector:
612
+ return (
613
+ UPPAggrSelector.MIN
614
+ if _i.resolution == INVResolution.ENFT
615
+ else UPPAggrSelector.MAX
616
+ )
624
617
 
625
- cmcr_test: ArrayBoolean
626
- """True if CMCR estimate meets criterion"""
618
+ divr_aggregator: UPPAggrSelector = field(kw_only=False)
619
+ """Aggregator for diversion ratio test."""
627
620
 
628
- ipr_test: ArrayBoolean
629
- """True if IPR (partial price-simulation) estimate meets criterion"""
621
+ @divr_aggregator.default
622
+ def __dad(_i: UPPTestRegime) -> UPPAggrSelector:
623
+ return _i.guppi_aggregator
630
624
 
631
625
 
632
626
  @frozen
@@ -27,6 +27,7 @@ from ..core import guidelines_boundaries as gbl # noqa: TID252
27
27
  from ..core.guidelines_boundaries import HMGThresholds # noqa: TID252
28
28
  from . import (
29
29
  FM2Constraint,
30
+ INVResolution, # noqa: F401
30
31
  MarketSampleData,
31
32
  PCMDistribution,
32
33
  PCMSpec,
@@ -396,7 +397,7 @@ class MarketSample:
396
397
  for _k in ("by_firm_count", "by_delta", "by_conczone")
397
398
  ])
398
399
  upp_test_results = UPPTestsCounts(*[
399
- np.column_stack((
400
+ np.hstack((
400
401
  (_gv := getattr(res_list_stacks, _g.name))[0, :, :_h],
401
402
  np.einsum("ijk->jk", _gv[:, :, _h:], dtype=np.int64),
402
403
  ))
@@ -453,15 +454,6 @@ class MarketSample:
453
454
  )
454
455
 
455
456
  if not _ndt:
456
- # byte_stream = io.BytesIO()
457
- # with h5py.File(byte_stream, "w") as h5f:
458
- # for _a in self.dataset.__attrs_attrs__:
459
- # if all((
460
- # (_arr := getattr(self.dataset, _a.name)).any(),
461
- # not np.isnan(_arr).all(),
462
- # )):
463
- # h5f.create_dataset(_a.name, data=_arr, fletcher32=True)
464
-
465
457
  with (zpath / f"{name_root}_dataset.h5").open("wb") as _hfh:
466
458
  _hfh.write(self.dataset.to_h5bin())
467
459
 
@@ -490,10 +482,7 @@ class MarketSample:
490
482
  if _dt:
491
483
  with _dp.open("rb") as _hfh:
492
484
  object.__setattr__( # noqa: PLC2801
493
- market_sample_,
494
- "dataset",
495
- # MarketSampleData(**{_a: h5f[_a][:] for _a in h5f}),
496
- MarketSampleData.from_h5f(_hfh),
485
+ market_sample_, "dataset", MarketSampleData.from_h5f(_hfh)
497
486
  )
498
487
  if _et:
499
488
  object.__setattr__( # noqa: PLC2801
@@ -722,7 +722,7 @@ def _gen_margin_data(
722
722
  del beta_min, beta_max
723
723
 
724
724
  if dist_firm2_pcm == FM2Constraint.SYM:
725
- pcm_array = np.column_stack((pcm_array,) * _frmshr_array.shape[1])
725
+ pcm_array = np.hstack((pcm_array,) * _frmshr_array.shape[1])
726
726
  if dist_firm2_pcm == FM2Constraint.MNL:
727
727
  # Impose FOCs from profit-maximization with MNL demand
728
728
  if dist_type_pcm == PCMDistribution.EMPR:
@@ -7,7 +7,7 @@ import enum
7
7
  from collections.abc import Mapping
8
8
 
9
9
  import numpy as np
10
- from scipy.interpolate import interp1d # type: ignore
10
+ from scipy.interpolate import make_interp_spline # type: ignore
11
11
 
12
12
  from .. import VERSION, ArrayBIGINT, Enameled, this_yaml # noqa: TID252
13
13
  from ..core import ftc_merger_investigations_data as fid # noqa: TID252
@@ -77,7 +77,7 @@ HHI_DELTA_KNOTS = np.array(
77
77
  )
78
78
  HHI_POST_ZONE_KNOTS = np.array([0, 1800, 2400, 10001], dtype=np.int64)
79
79
  hhi_delta_ranger, hhi_zone_post_ranger = (
80
- interp1d(_f / 1e4, _f, kind="previous", assume_sorted=True)
80
+ make_interp_spline(_f / 1e4, _f, k=0)
81
81
  for _f in (HHI_DELTA_KNOTS, HHI_POST_ZONE_KNOTS)
82
82
  )
83
83
 
@@ -193,7 +193,7 @@ def enf_cnts_obs_byfirmcount(
193
193
  case INVResolution.BOTH:
194
194
  stats_kept_indxs = [-1, -3, -2]
195
195
 
196
- return np.column_stack([cnts_array[:, :ndim_in], cnts_array[:, stats_kept_indxs]])
196
+ return np.hstack([cnts_array[:, :ndim_in], cnts_array[:, stats_kept_indxs]])
197
197
 
198
198
 
199
199
  def enf_cnts_obs_byhhianddelta(
@@ -226,7 +226,7 @@ def enf_cnts_obs_byhhianddelta(
226
226
  case INVResolution.BOTH:
227
227
  stats_kept_indxs = [-1, -3, -2]
228
228
 
229
- return np.column_stack([cnts_array[:, :ndim_in], cnts_array[:, stats_kept_indxs]])
229
+ return np.hstack([cnts_array[:, :ndim_in], cnts_array[:, stats_kept_indxs]])
230
230
 
231
231
 
232
232
  def table_no_lku(
@@ -256,11 +256,16 @@ def table_no_lku(
256
256
 
257
257
 
258
258
  def enf_cnts_byfirmcount(_cnts_array: ArrayBIGINT, /) -> ArrayBIGINT:
259
+ if not _cnts_array[:, 0].any():
260
+ return np.array([], int)
261
+
259
262
  ndim_in = 1
260
263
  return np.vstack([
261
264
  np.concatenate([
262
265
  (_i,),
263
- np.einsum("ij->j", _cnts_array[_cnts_array[:, 0] == _i][:, ndim_in:]),
266
+ np.einsum(
267
+ "ij->j", _cnts_array[_cnts_array[:, 0] == _i][:, ndim_in:], dtype=int
268
+ ),
264
269
  ])
265
270
  for _i in np.unique(_cnts_array[:, 0])
266
271
  ])
@@ -271,14 +276,16 @@ def enf_cnts_bydelta(_cnts_array: ArrayBIGINT, /) -> ArrayBIGINT:
271
276
  return np.vstack([
272
277
  np.concatenate([
273
278
  (_k,),
274
- np.einsum("ij->j", _cnts_array[_cnts_array[:, 1] == _k][:, ndim_in:]),
279
+ np.einsum(
280
+ "ij->j", _cnts_array[_cnts_array[:, 1] == _k][:, ndim_in:], dtype=int
281
+ ),
275
282
  ])
276
283
  for _k in HHI_DELTA_KNOTS[:-1]
277
284
  ])
278
285
 
279
286
 
280
287
  def enf_cnts_byconczone(_cnts_array: ArrayBIGINT, /) -> ArrayBIGINT:
281
- if not _cnts_array.any():
288
+ if not _cnts_array[:, 0].any() or np.isnan(_cnts_array[:, 0]).all():
282
289
  return np.array([], int)
283
290
  # Step 1: Tag and agg. from HHI-post and Delta to zone triple
284
291
  # NOTE: Although you could just map and not (partially) aggregate in this step,
@@ -315,7 +322,9 @@ def enf_cnts_byconczone(_cnts_array: ArrayBIGINT, /) -> ArrayBIGINT:
315
322
  np.array(
316
323
  (
317
324
  *zone_val,
318
- *np.einsum("ij->j", _cnts_array[:, _ndim_in:][conc_test]),
325
+ *np.einsum(
326
+ "ij->j", _cnts_array[:, _ndim_in:][conc_test], dtype=int
327
+ ),
319
328
  ),
320
329
  dtype=int,
321
330
  ),
@@ -326,10 +335,10 @@ def enf_cnts_byconczone(_cnts_array: ArrayBIGINT, /) -> ArrayBIGINT:
326
335
  # Logical-and of multiple vectors:
327
336
  hhi_zone_test = (
328
337
  1
329
- * np.column_stack([
338
+ * np.stack([
330
339
  cnts_byhhipostanddelta[:, _idx] == _val
331
340
  for _idx, _val in enumerate(zone_val)
332
- ])
341
+ ], axis=1)
333
342
  ).prod(axis=1) == 1
334
343
 
335
344
  cnts_byconczone = np.vstack((
@@ -338,7 +347,9 @@ def enf_cnts_byconczone(_cnts_array: ArrayBIGINT, /) -> ArrayBIGINT:
338
347
  (
339
348
  zone_val,
340
349
  np.einsum(
341
- "ij->j", cnts_byhhipostanddelta[hhi_zone_test][:, _nkeys:]
350
+ "ij->j",
351
+ cnts_byhhipostanddelta[hhi_zone_test][:, _nkeys:],
352
+ dtype=int,
342
353
  ),
343
354
  ),
344
355
  dtype=int,