Calculating WAC/AMP Spreads with Decimal Precision

The decision on this page is exact and unglamorous: how to compute the spread between two drug-pricing benchmarks so the answer is reproducible to the cent, run after run, across every accrual worker. WAC, AMP, and AWP arrive as per-unit prices that look like ordinary decimals and tempt engineers into float arithmetic, but a spread is subtracted at full precision, scaled by a 442-E7 Quantity Dispensed that is frequently fractional, and reconciled against a 409-D9 Ingredient Cost that must match to the penny. Binary floating point cannot represent 0.01 exactly, so a float pipeline drifts by sub-cent fractions that compound across millions of claims into a reconciliation variance an auditor will flag. This guide fixes the numeric type, the Decimal context, the quantization order, the rounding mode, and the unit-of-measure normalization that together make a WAC/AMP spread deterministic. It sits under WAC/AMP Spread Calculation.

The Decision: Which Numeric Model

Three numeric models are available for money in Python, and only one is defensible for spread math that feeds rebate accrual. The choice is not stylistic; it changes whether the same inputs produce the same cents.

Model Representation Exact for cents? Failure mode Verdict for spreads
float IEEE-754 binary64 No — 0.1 + 0.2 != 0.3 Silent sub-cent drift, non-reproducible sums Never — corrupts accrual
int cents integer minor units Yes for +/− Breaks on per-unit division and fractional 442-E7 quantities Only for already-scaled whole-cent totals
decimal.Decimal base-10, configurable precision + rounding Yes, with explicit quantize Requires discipline: no float ingress, one rounding point Correct default for all benchmark math

Decimal wins because benchmarks are base-10 quantities that must round the way an accountant expects, and because a fractional 442-E7 quantity (a 2.5 mL dispense, a 0.5 tablet) demands division and multiplication that int cents cannot express without its own rounding. The two rules that make Decimal safe are: reject float at ingress so Decimal("1249.99") is never contaminated by Decimal(1249.99), and quantize exactly once, on the final per-claim figure, so the per-unit spread never rounds before it is scaled. Everything below implements those rules against the benchmark ladder defined in the parent and resolved through the NDC-to-GPI Crosswalk Automation pipeline.

Step-by-Step Implementation

The computation is five moves: set the context, parse benchmarks without float, normalize units, subtract at full precision, then scale and quantize once.

1. Set an explicit Decimal context. Do not rely on the default. Set working precision high enough that intermediate products never lose digits, and choose the rounding mode deliberately — ROUND_HALF_UP for money the plan owes, matching the convention in Manufacturer Rebate Contract Modeling.

2. Parse every benchmark straight from a string. WAC and AMP cross the wire as JSON. A JSON number deserializes to float, so the parser must take the raw string, not the decoded number, or reject float outright.

3. Normalize units of measure before subtracting. A benchmark published per mL cannot be subtracted from one published per each. Resolve both to the 442-E7 Quantity Dispensed unit first; if a conversion factor is needed (per-gram to per-mg), apply it at full Decimal precision.

4. Subtract at full precision — no intermediate rounding. The per-unit spread is minuend − subtrahend with both operands unrounded. Quantizing here is the classic defect.

5. Scale by quantity, then quantize once. Multiply the full-precision per-unit spread by 442-E7, and only then quantize the per-claim result to cents with ROUND_HALF_UP.

python
from decimal import Decimal, ROUND_HALF_UP, getcontext
from pydantic import BaseModel, ConfigDict, field_validator

# Step 1: explicit context. 28 digits of headroom; money rounds half-up.
getcontext().prec = 28
CENTS = Decimal("0.01")

# Per-unit conversion factors into a canonical unit (example subset).
_UNIT_TO_ML = {"mL": Decimal("1"), "L": Decimal("1000")}


class Benchmark(BaseModel):
    model_config = ConfigDict(extra="forbid")
    name: str            # WAC | AMP | AWP | NADAC
    amount: Decimal      # per-unit price
    unit: str            # each | mL | gram

    # Step 2: reject float ingress; Decimal(0.1) != Decimal("0.1").
    @field_validator("amount", mode="before")
    @classmethod
    def _no_float(cls, v: object) -> Decimal:
        if isinstance(v, float):
            raise ValueError("benchmark amount must be str/Decimal, not float")
        return Decimal(str(v))


def per_claim_spread(
    minuend: Benchmark,
    subtrahend: Benchmark,
    quantity_442e7: Decimal,   # 442-E7 Quantity Dispensed
) -> Decimal:
    # Step 3: units must agree (normalization happens upstream of here).
    if minuend.unit != subtrahend.unit:
        raise ValueError(f"unit mismatch: {minuend.unit} vs {subtrahend.unit}")

    # Step 4: full-precision subtraction, NO quantize yet.
    per_unit = minuend.amount - subtrahend.amount

    # Step 5: scale to the claim, THEN quantize exactly once.
    return (per_unit * quantity_442e7).quantize(CENTS, rounding=ROUND_HALF_UP)

The load-bearing detail is the ordering of steps 4 and 5. Consider WAC 1249.99 and AMP 1187.505 per each, quantity 3. Correct: (1249.99 − 1187.505) × 3 = 62.485 × 3 = 187.455 → 187.46. Quantizing the per-unit spread first gives 62.49 × 3 = 187.47 — two cents wrong on a single claim, and the error is systematic, not random, so it accumulates in one direction across an accrual quarter. This is exactly why the parent subsystem returns the per-unit spread unrounded and forbids chaining a rounded per-unit figure into a second multiplication, the same reconciliation-safety property enforced in Rebate Accrual Reconciliation.

Banker’s Rounding vs Half-Up

ROUND_HALF_UP and ROUND_HALF_EVEN (banker’s rounding) disagree only on exact halves, but the disagreement is systematic and must be chosen, not defaulted. Python’s Decimal defaults to ROUND_HALF_EVEN, which rounds 0.125 → 0.12 and 0.135 → 0.14, spreading rounding bias evenly and minimizing drift across a large sum. ROUND_HALF_UP rounds every exact half away from zero (0.125 → 0.13), the convention most rebate contracts and invoices assume.

The rule is: use the mode the contract and the counterparty use, and apply it consistently on both the accrual and the invoice side so the two reconcile. Mixing modes — accruing with ROUND_HALF_EVEN and invoicing with ROUND_HALF_UP — produces a penny-level variance on exactly-half claims that looks like a data error but is a rounding-policy mismatch. When in doubt, ROUND_HALF_UP for money owed, declared explicitly on every quantize call so the choice is visible in code review. The full semantics are in the Python decimal module documentation.

Decimal quantization pipeline for a per-claim spread A left-to-right pipeline. Benchmark strings for WAC and AMP enter a parse stage that rejects float and produces full-precision Decimals. A unit-normalize stage checks that both units match the 442-E7 quantity unit. A subtract stage computes the per-unit spread at full precision with no rounding. A scale stage multiplies by 442-E7 Quantity Dispensed, still unrounded. A single quantize stage applies ROUND_HALF_UP to cents, producing the final per-claim spread. A callout marks the subtract and scale stages as the no-rounding zone and the quantize stage as the single rounding point. no rounding — full Decimal precision Parse reject float str → Decimal Unit normalize match 442-E7 unit Subtract minuend − subtrahend Scale × 442-E7 quantity Quantize ROUND_HALF_UP → cents (once) single rounding point "1249.99" · "1187.505" per-claim spread = 187.46

Figure: The spread pipeline — parse rejects float, units are normalized to the 442-E7 unit, subtraction and scaling stay at full Decimal precision, and a single ROUND_HALF_UP quantize produces the per-claim cents.

Verification and Testing Pattern

Correctness means exact cents and deterministic behavior on rounding edges. Drive the calculator against fixtures that pin the ordering property and the half-up boundary.

python
import pytest
from decimal import Decimal


def bm(name: str, amount: str, unit: str = "each") -> Benchmark:
    return Benchmark(name=name, amount=amount, unit=unit)


def test_scale_then_quantize_beats_quantize_then_scale():
    # WAC 1249.99, AMP 1187.505, qty 3 -> 62.485 * 3 = 187.455 -> 187.46
    result = per_claim_spread(bm("WAC", "1249.99"), bm("AMP", "1187.505"), Decimal("3"))
    assert result == Decimal("187.46")           # correct: quantized once at the end
    assert result != Decimal("187.47")           # wrong: quantizing per-unit first


def test_half_up_rounding_edge():
    # per-unit 0.125, qty 1 -> exact half -> half-up rounds away from zero.
    assert per_claim_spread(bm("WAC", "10.125"), bm("AMP", "10.000"),
                            Decimal("1")) == Decimal("0.13")


def test_negative_spread_is_signed_not_clamped():
    # Generic: NADAC above WAC -> spread is negative, must not clamp to 0.00.
    r = per_claim_spread(bm("WAC", "4.00"), bm("NADAC", "5.20"), Decimal("2"))
    assert r == Decimal("-2.40")


def test_float_ingress_is_rejected():
    with pytest.raises(ValueError):
        Benchmark(name="WAC", amount=1249.99, unit="each")   # float, not str


def test_unit_mismatch_raises():
    with pytest.raises(ValueError):
        per_claim_spread(bm("WAC", "12.40", "mL"), bm("AMP", "9.10", "each"),
                         Decimal("1"))


def test_fractional_quantity_is_exact():
    # 2.5 mL dispense: 6.00 * 2.5 = 15.00 exactly.
    r = per_claim_spread(bm("WAC", "10.00", "mL"), bm("AMP", "4.00", "mL"),
                         Decimal("2.5"))
    assert r == Decimal("15.00")

The first test is the load-bearing one: it pins the scale-then-quantize ordering that separates a correct spread from one that drifts two cents on a single claim. The negative-spread test guards the second most common defect — clamping a legitimately below-cost generic to zero.

Gotchas and PHI Guardrails

  • Float ingress from JSON. A benchmark deserialized from a JSON number is already a float before your code sees it. Parse from the raw string (Decimal(str(v))) or configure the JSON decoder with parse_float=Decimal; the field_validator above rejects float as a backstop.
  • Division order and residual cents. When a per-package benchmark must be divided to a per-unit price, divide at full precision and quantize only the final scaled figure. Dividing, rounding, then multiplying back reintroduces a residual cent that fails reconciliation against 409-D9.
  • Negative spreads. NADAC above WAC and best price above AMP are real for generics. Never max(spread, 0) — return the signed value and let Rebate Accrual Reconciliation act on it.
  • Unit-of-measure drift. A per-mL WAC against an “each” 442-E7 quantity produces a plausible but order-of-magnitude-wrong number. Normalize to the quantity’s unit before subtracting, and store the unit on every benchmark so the guard can fire.
  • Rounding-mode mismatch. Accrual and invoice sides must use the same mode (ROUND_HALF_UP unless the contract says otherwise). A silent default to ROUND_HALF_EVEN on one side creates penny variances on exact-half claims.
  • PHI guardrail. Benchmark amounts, units, and quantities are not member data, but the correlation key on any log line must be the tokenized 402-D2 reference — never 302-C2 Cardholder ID or 310-CA Patient Name, and never the raw claim payload. Log the two pricing_version stamps and the resulting cents, not the claim, honoring the boundary set in Security & Compliance Boundaries for Claims Data.

← Back to WAC/AMP Spread Calculation