Evaluating compound duty formulas with CBP rounding

A compound duty rate charges two amounts at once — an ad valorem percentage of the entered value plus a specific charge per unit of quantity — and CBP expects the sum rounded half-up to the whole cent on the entry summary. Get either component or the rounding step wrong and the 7501 line total disagrees with the ACE-calculated figure, which triggers a rejection or a post-summary correction. This page shows how to parse a compound rate expression like 5% + 12.5¢/kg, evaluate both legs with Python Decimal end to end, and apply CBP ROUND_HALF_UP once at the line total — not per leg — so the filed duty matches the government’s math to the cent every time.

The trap that catches naive implementations is double rounding. If you round the ad valorem leg to the cent, then round the specific leg to the cent, then add, you can land a cent away from the true half-up total of the un-rounded sum. CBP rounds the composed duty, so the arithmetic must compose first and round last. Everything below enforces that ordering.

Prerequisites

This resolver is a pure function of an entry line and a parsed rate; wire it in only once the upstream state below holds:

  • Python 3.10+ with Decimal throughout. Never use float for entered value, per-unit charges, or percentages. Binary floating point cannot represent 0.125 or a half-cent boundary exactly, so float rounding is non-deterministic at exactly the tie cases CBP cares about.
  • A classified, in-window line item. Each line must already carry a valid, effective HTS code resolved upstream — see Handling missing HTS codes in ETL pipelines. A compound rate applied to a provisional or superseded classification is meaningless.
  • A normalized rate string per HTS row. The HTSUS publishes compound rates as free text ("5% + 12.5¢/kg", "3.5% + 1.5¢/kg"). Your tariff snapshot must expose that raw text so it can be parsed deterministically; the sibling guide Computing ad valorem duty with decimal precision covers the pure-percentage case that this page generalizes.
  • Entered value and billable quantity in known units. The specific leg multiplies a per-unit charge by a quantity; the quantity’s unit of measure must match the rate’s unit (kg, liter, dozen, number) or the leg is silently wrong. Unit reconciliation happens before this step.
  • Structured logging configured (stdlib logging with the %(asctime)s | %(levelname)s | %(name)s | %(message)s format) so every evaluated formula is greppable for audit.

The formula

For an entered value VV, an ad valorem rate rr, a per-unit specific charge ss, and a billable quantity qq, the compound duty DD is the half-up rounding of the composed sum:

D=round12 ⁣(Vr+sq)D = \operatorname{round_{\tfrac{1}{2}\uparrow}}\!\bigl(V \cdot r + s \cdot q\bigr)

Concretely, for an entered value of USD 4,000.00 at 5% + 12.5¢/kg on 320 kg: the ad valorem leg is USD 200.00, the specific leg is USD 40.00, and the composed duty is USD 240.00. The rounding only bites when the composed sum lands on a fraction of a cent — and it must be applied to that sum, once.

Implementation

CompoundRate parses the published rate text into two exact Decimal components. evaluate_compound_duty composes both legs and rounds the total exactly once. Neither ever sees a float.

import logging
import re
from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_UP
from typing import Optional

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
    handlers=[logging.StreamHandler()],
)
logger = logging.getLogger("compound_duty")

CENT = Decimal("0.01")

# One percentage leg (e.g. "5%" or "3.5%") and one specific leg
# (e.g. "12.5¢/kg" or "1.5 cents/kg"). Either may be absent.
_PCT = re.compile(r"(?P<pct>\d+(?:\.\d+)?)\s*%")
_SPECIFIC = re.compile(
    r"(?P<amt>\d+(?:\.\d+)?)\s*(?P<unit_kind>¢|cents?|\$|usd)\s*/\s*(?P<uom>[a-z]+)",
    re.IGNORECASE,
)


@dataclass(frozen=True)
class CompoundRate:
    """A parsed HTSUS compound rate: ad valorem fraction + specific per-unit charge."""

    ad_valorem: Decimal          # fraction, e.g. Decimal("0.05") for 5%
    specific_per_unit: Decimal   # USD per unit, e.g. Decimal("0.125") for 12.5¢
    uom: Optional[str]           # unit of measure the specific leg bills against
    raw: str

    @classmethod
    def parse(cls, text: str) -> "CompoundRate":
        pct_match = _PCT.search(text)
        spec_match = _SPECIFIC.search(text)
        if pct_match is None and spec_match is None:
            raise ValueError(f"Unparseable rate expression: {text!r}")

        ad_valorem = (
            Decimal(pct_match.group("pct")) / Decimal("100")
            if pct_match else Decimal("0")
        )

        specific = Decimal("0")
        uom: Optional[str] = None
        if spec_match:
            amt = Decimal(spec_match.group("amt"))
            kind = spec_match.group("unit_kind").lower()
            # Cents -> dollars; keep full precision, do NOT round here.
            specific = amt / Decimal("100") if kind in ("¢", "cent", "cents") else amt
            uom = spec_match.group("uom").lower()

        return cls(ad_valorem=ad_valorem, specific_per_unit=specific, uom=uom, raw=text)


@dataclass(frozen=True)
class CompoundDuty:
    ad_valorem_leg: Decimal   # un-rounded USD
    specific_leg: Decimal     # un-rounded USD
    total: Decimal            # composed sum, rounded half-up to the cent


def evaluate_compound_duty(
    rate: CompoundRate,
    entered_value: Decimal,
    quantity: Decimal,
) -> CompoundDuty:
    """Compose both legs at full precision, then round the SUM once (CBP order)."""
    if not isinstance(entered_value, Decimal) or not isinstance(quantity, Decimal):
        raise TypeError("entered_value and quantity must be Decimal, never float.")

    ad_valorem_leg = entered_value * rate.ad_valorem
    specific_leg = rate.specific_per_unit * quantity

    # CRITICAL: add first, round last. Rounding each leg then summing can
    # drift one cent off the half-up rounding of the true composed total.
    total = (ad_valorem_leg + specific_leg).quantize(CENT, rounding=ROUND_HALF_UP)

    logger.info(
        "compound %s: adv=%s + spec=%s (%s @ %s/%s) -> %s",
        rate.raw, ad_valorem_leg, specific_leg,
        quantity, rate.specific_per_unit, rate.uom, total,
    )
    return CompoundDuty(ad_valorem_leg, specific_leg, total)

The single load-bearing line is the quantize on the summed legs. Because ad_valorem_leg and specific_leg are un-rounded Decimal values, their sum is exact, and ROUND_HALF_UP resolves the half-cent tie in the same direction CBP’s ACE engine does. Rounding either leg before the addition is the defect this design exists to prevent.

Compound duty: two legs compose, then round once The entered value flows into the ad valorem leg, computed as entered value times the percentage rate at full Decimal precision. In parallel, the billable quantity flows into the specific leg, computed as the per-unit charge times quantity, also at full precision. Both un-rounded legs feed a single summation node. Only that composed sum is rounded half-up to the cent, producing the filed compound duty. The diagram stresses that no rounding happens on either leg individually — the round-half-up box sits after the plus, never before it. entered value V Decimal USD quantity q Decimal, in UOM ad valorem leg V × r (full precision) not rounded specific leg s × q (full precision) not rounded + exact composed sum ROUND HALF_UP to the cent
Both legs are held at full Decimal precision and summed; ROUND_HALF_UP is applied once to the composed total, never per leg.

Verification steps

Run these checks against a representative set of compound-rated lines before trusting the evaluator:

  1. Round-once vs. round-each divergence. Pick an entered value and quantity where each leg has a fractional cent (e.g. 3.5% + 1.5¢/kg on USD 1,000.01 over 33 kg). Compute the duty both ways — rounding each leg then summing, versus summing then rounding. Assert your evaluator matches the sum-then-round result. If they ever differ, ACE will disagree with a per-leg implementation.
  2. Parser round-trip. Feed CompoundRate.parse the raw HTSUS strings in your snapshot ("5% + 12.5¢/kg", "3.5%", "1.5¢/kg"). Confirm the ad valorem fraction and per-unit Decimal match hand-computed values, and that a pure-percentage or pure-specific string still parses with the missing leg at Decimal("0").
  3. Half-cent tie direction. Construct a composed sum that lands exactly on a half cent (total ...0.005). Assert it rounds up, matching ROUND_HALF_UP. Python’s default ROUND_HALF_EVEN would round 0.125 to 0.12; that banker’s rounding is wrong for CBP.
  4. Type guard fires. Pass a float entered value and assert TypeError. A float that reaches quantize produces a plausible but non-reproducible cent, which is the hardest class of duty bug to detect after the fact.
  5. Unit-of-measure match. Assert the uom parsed from the rate equals the unit the quantity is denominated in. A 12.5¢/kg rate billed against a quantity in pounds is off by a factor of 2.2 with no exception raised.
  6. Zero-quantity and zero-value edges. A line with quantity == 0 must yield only the ad valorem leg; an entered value of 0 must yield only the specific leg. Confirm neither degenerates to a spurious rounding artifact.

Edge cases & gotchas

  • Double rounding is the whole point. The most common regression is a well-meaning refactor that rounds ad_valorem_leg “for display” before summing. Keep both legs un-rounded until the composed quantize. If you must expose per-leg cents in a UI, compute them separately for display only and never feed them back into the total.
  • Cents vs. dollars in the specific leg. HTSUS mixes ¢/kg and $/kg (and cents/kg spelled out). The parser divides by 100 only for the cent forms; a misclassified unit inflates the specific leg 100-fold. Assert on the parsed specific_per_unit magnitude in tests, not just that parsing succeeded.
  • Unicode cent sign. The ¢ glyph (U+00A2) arrives from some feeds as the ASCII fallback "cents" and from others as mojibake if a Latin-1 source was re-decoded as UTF-8. Normalize the rate text to UTF-8 with errors="strict" at ingestion and quarantine on decode failure rather than letting a corrupted glyph slip past the regex.
  • Quantity unit conversion drift. If you convert quantity units (pounds to kilograms) with a float factor before evaluation, you reintroduce the very imprecision Decimal avoids. Convert with a Decimal factor and only at full precision, mirroring the discipline in Computing ad valorem duty with decimal precision.
  • Preferential program overrides. Under a trade agreement, the ad valorem leg may drop to free while the specific leg still applies, or vice versa. The compound structure is correct, but the applicable rate depends on origin — determine eligibility in the Rule of Origin Logic Engines before selecting which CompoundRate to evaluate.
  • The filed figure must survive to ACE unchanged. Once evaluate_compound_duty returns a cent-exact total, pass that exact Decimal through to the entry summary — do not re-derive it downstream. The line-total contract for transmission lives in Entry Summary Filing Schemas; a re-computation there with different intermediate rounding is a classic source of a 7501-versus-ACE mismatch.

Up: Duty Formula Calculation Frameworks

Authoritative references: WCO Harmonized System 2022; HTSUS General Rules of Interpretation and column-1 compound rate expressions (USITC); CBP ACE Entry Summary duty computation; 19 CFR Part 141; ISO 4217 currency minor units.