All checks were successful
CI / lint (push) Successful in 44s
CI / notebooks-smoke (push) Successful in 1m33s
Deploy / changes (push) Successful in 14s
CI / test (push) Successful in 3m9s
Infra CI / zotero (push) Successful in 14s
Infra CI / notebooks (push) Successful in 55s
Infra CI / api (push) Successful in 1m9s
Infra CI / docs (push) Successful in 1m23s
Infra CI / llm (push) Successful in 1m1s
Infra CI / mc (push) Successful in 16s
Infra CI / bcda (push) Successful in 1m43s
Deploy / notebooks (push) Has been skipped
Deploy / zotero (push) Has been skipped
Deploy / docs (push) Has been skipped
Package Supply Chain / pkg-supply-chain (push) Successful in 1m30s
Deploy / api (push) Successful in 1m17s
Deploy / llm (push) Successful in 1m18s
Deploy / mc (push) Has been skipped
Deploy / bcda (push) Successful in 1m42s
Deploy / report (push) Successful in 1m36s
Ports the public CMS "LEAD Model PY 2027 Alignment and Financial Methodology Paper" v1.2 (bib E4PEU3HV) as generic engines: parameters come from the paper, everything CMS computes per ACO or per year comes from the caller. No participant data, no organization-specific rules. - lead.params: Table 5.2.1 discounts, 3% Quality Withhold, 2% retention incentive, Box 5.3.3 Stop-Loss bands, Tables 5.4.1/5.4.3 corridors, 2% sequestration, base-year weights, Table 3.5.1 ACPT guardrails, risk-score growth caps, REA/PSA shares and caps, voluntary guardrail and 500 minimum, Table 2.6.1 base-year alignment minimums. - lead.settlement: Final Financial Settlement, Table 5.6.1 line by line (settle(SettlementInputs) -> SettlementLines). - lead.stop_loss: Box 5.3.3 charge, banded payout, neutrality factor, application to PY expenditure. - lead.monies_owed: Table 5.5.1 Total Monies Owed, APO reconciliation. - lead.timing: run-out end, settlement-basis rule, settlement-stage inputs. - lead.benchmark: §3 formulas — risk standardization, two-/three-way trend, base-year weights and sufficiency, voluntary guardrail, regional FFS, spending designation, REA, PSA, risk-score cap, hybrid alignment dollars, category blend. - lead.express: the same as narwhals Exprs; express.settlement.settle settles a frame of scenarios on any backend. Rounding: every dollar line rounds to the cent (half away from zero) as it is produced; PBPMs, rates and factors never do. It is the only rule under which Tables 3.3.3, 3.5.3.2, 3.5.4, 5.5.1 and 5.6.1 all reproduce (3.5.4 line 24 sums cent-rounded cells; 3.3.3 carries unrounded PBPMs). The Expr cents snaps its ÷100 with round(2): polars 2.0 divides a column by a literal as a reciprocal multiply (k × 0.01), one ulp off k/100 for ~15% of values. Missing inputs stay missing (None/null or a refused input model); the only zeros are the paper's own (Professional "N/A" discount, no Stop-Loss election, sequestration on losses, ineligible REA/PSA). Paper inconsistencies recorded in tests: Table 5.5.1 line 2 is a cent below Table 5.6.1 line 28 (.43 vs .44); Box 3.3.2 prints 1.104 then says 1.103; §3.5.1 prose widens the ACPT lower bound a year later than Table 3.5.1 (the table is followed). Tests: 283 in tests/lead — every published line to the cent, Expr builders on polars and DuckDB, bit-for-bit scalar/frame parity over 160 scenarios; 100% of src/lead.
135 lines
4.7 KiB
Python
135 lines
4.7 KiB
Python
"""lead.stop_loss reproduces Box 5.3.3: the charge calculation, the payout
|
|
illustration, and the application of Stop-Loss to PY expenditure."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from lead.money import cents
|
|
from lead.params import STOP_LOSS_ATTACHMENT_PAYOUT_SHARE, STOP_LOSS_BANDS
|
|
from lead.stop_loss import (
|
|
adjusted_payout,
|
|
apply_stop_loss,
|
|
average_payout_percentage,
|
|
band_status,
|
|
charge_pbpm,
|
|
neutrality_factor,
|
|
payout_percentage,
|
|
predicted_expenditure,
|
|
reference_years,
|
|
residual_expenditure,
|
|
stop_loss_charge,
|
|
stop_loss_payout,
|
|
trended_attachment_point,
|
|
without_stop_loss,
|
|
)
|
|
from tests.lead.paper import STOP_LOSS_CHARGE, STOP_LOSS_PAYOUT
|
|
|
|
|
|
def test_box_5_3_3_charge_calculation():
|
|
pct = average_payout_percentage(STOP_LOSS_CHARGE["RY"])
|
|
charge = stop_loss_charge(
|
|
STOP_LOSS_CHARGE["1"], STOP_LOSS_CHARGE["2"], STOP_LOSS_CHARGE["3"], pct
|
|
)
|
|
assert charge.trended_risk_adjusted_reference_expenditure == STOP_LOSS_CHARGE["4"]
|
|
assert round(charge.average_payout_percentage, 4) == STOP_LOSS_CHARGE["5"]
|
|
assert charge.stop_loss_charge == STOP_LOSS_CHARGE["6"]
|
|
|
|
|
|
def test_box_5_3_3_charge_uses_the_unrounded_average_payout_percentage():
|
|
# Line 5 prints 2.03%; multiplying line 4 by the printed rate misses line 6.
|
|
assert cents(STOP_LOSS_CHARGE["4"] * STOP_LOSS_CHARGE["5"]) == 2_943_500.94
|
|
assert cents(STOP_LOSS_CHARGE["4"] * STOP_LOSS_CHARGE["5"]) != STOP_LOSS_CHARGE["6"]
|
|
|
|
|
|
def test_box_5_3_3_payout_illustration():
|
|
residual = residual_expenditure(
|
|
STOP_LOSS_PAYOUT["actual"], STOP_LOSS_PAYOUT["predicted"]
|
|
)
|
|
assert residual == STOP_LOSS_PAYOUT["residual"]
|
|
payout = stop_loss_payout(residual, STOP_LOSS_PAYOUT["attachment_point"])
|
|
band_1, band_2 = payout.bands
|
|
assert payout.below_attachment_point == STOP_LOSS_PAYOUT["below_attachment_point"]
|
|
assert (band_1.exposure, band_2.exposure) == (
|
|
STOP_LOSS_PAYOUT["band_1_exposure"],
|
|
STOP_LOSS_PAYOUT["band_2_exposure"],
|
|
)
|
|
assert (band_1.payout, band_2.payout) == (
|
|
STOP_LOSS_PAYOUT["band_1_payout"],
|
|
STOP_LOSS_PAYOUT["band_2_payout"],
|
|
)
|
|
assert payout.total_payout == STOP_LOSS_PAYOUT["total_payout"]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"residual,status,total",
|
|
[
|
|
(-5_000.0, "below_attachment_point", 0.0),
|
|
(150_000.0, "below_attachment_point", 0.0),
|
|
(150_000.01, "band_1", 0.01),
|
|
(300_000.0, "band_1", 120_000.0),
|
|
(300_000.01, "band_2", 120_000.01),
|
|
],
|
|
)
|
|
def test_band_boundaries(residual, status, total):
|
|
assert band_status(residual, 150_000.0) == status
|
|
assert stop_loss_payout(residual, 150_000.0).total_payout == total
|
|
|
|
|
|
def test_negative_residual_is_nothing_below_the_attachment_point():
|
|
assert stop_loss_payout(-5_000.0, 150_000.0).below_attachment_point == 0.0
|
|
|
|
|
|
def test_payment_schedule_and_attachment_share():
|
|
assert [
|
|
(b.start_multiple, b.end_multiple, b.payout_rate) for b in STOP_LOSS_BANDS
|
|
] == [
|
|
(1.0, 2.0, 0.80),
|
|
(2.0, None, 1.00),
|
|
]
|
|
assert STOP_LOSS_ATTACHMENT_PAYOUT_SHARE == 0.02
|
|
|
|
|
|
def test_reference_years_leave_a_one_year_gap():
|
|
assert reference_years(2027) == (2023, 2024, 2025)
|
|
assert reference_years(2030) == (2026, 2027, 2028)
|
|
|
|
|
|
def test_predicted_expenditure_and_trended_attachment_point():
|
|
assert predicted_expenditure(1_000.0, 1.2, 12) == 14_400.00
|
|
assert trended_attachment_point(150_000.0, 1.05) == 157_500.00
|
|
|
|
|
|
def test_payout_percentages():
|
|
assert payout_percentage(196.0, 10_000.0) == 0.0196
|
|
assert payout_percentage(1.0, 0.0) is None
|
|
assert average_payout_percentage([0.02, None, 0.03]) is None
|
|
assert average_payout_percentage([]) is None
|
|
|
|
|
|
def test_charge_pbpm_and_neutrality_factor():
|
|
assert charge_pbpm(2_948_334.28, 132_000) == pytest.approx(22.3359, abs=1e-4)
|
|
assert charge_pbpm(1.0, 0) is None
|
|
assert neutrality_factor(93.0, 100.0) == 0.93
|
|
# No payouts model-wide: the ratio is undefined, not 1.0.
|
|
assert neutrality_factor(93.0, 0.0) is None
|
|
|
|
|
|
def test_application_of_stop_loss_lines_16_to_22():
|
|
applied = apply_stop_loss(135_793_983.00, 2_948_334.28, 2_900_000.00, 0.930)
|
|
assert applied.adjusted_stop_loss_payout == 2_697_000.00
|
|
assert applied.net_stop_loss_impact == 251_334.28
|
|
assert applied.py_expenditure_after_stop_loss == 136_045_317.28
|
|
|
|
|
|
def test_adjusted_payout_without_a_factor():
|
|
assert adjusted_payout(0.0, None) == 0.0
|
|
with pytest.raises(ValueError, match="neutrality factor"):
|
|
adjusted_payout(10.0, None)
|
|
|
|
|
|
def test_without_stop_loss():
|
|
applied = without_stop_loss(100.0)
|
|
assert applied.py_expenditure_after_stop_loss == 100.0
|
|
assert applied.stop_loss_neutrality_factor is None
|