Computing ad valorem duty with decimal precision

Pure ad valorem duty is deceptively simple — entered value times a percentage rate — yet it is the single most common place a customs pipeline silently disagrees with CBP by a cent. The culprit is almost always float: the moment an entered value or a rate touches binary floating point, values like 0.1 and half-cent boundaries stop being exact, and ROUND_HALF_UP produces a figure that ACE will not reproduce. This page shows how to compute a percentage-only duty entirely in Python Decimal, quantize the entered value and the duty to the precision ACE expects, and resolve every half-cent tie half-up so the number you file is the number CBP computes.

The stakes are narrow but unforgiving. A one-cent discrepancy on a single line is not a rounding curiosity — it is a mismatch between your filed 7501 and the ACE-calculated duty, and at scale those pennies become reconciliation work, rejected transmissions, and reasonable-care questions. The fix is not clever arithmetic; it is refusing float anywhere near money and quantizing at the right two points. This guide is the pure-percentage foundation; the compound case that adds a per-unit specific charge is covered in Evaluating compound duty formulas with CBP rounding.

Prerequisites

The computation is a pure function of an entered value and a rate; confirm this upstream state before wiring it in:

  • Python 3.10+ using decimal.Decimal exclusively for money and rates. Never construct a Decimal from a float (Decimal(0.07) inherits the float’s imprecision — 0.0700000000000000...). Construct from a string or an integer: Decimal("0.07").
  • A classified, in-window HTS code with a numeric general rate. The rate must be effective on the entry date; a provisional or superseded classification has no valid rate to apply. Gap handling is covered in Handling missing HTS codes in ETL pipelines.
  • An entered value already assembled per line. Entered value is the CBP-declared customs value — typically transaction value — computed before duty. This page assumes it is handed in as a Decimal; how it is derived from invoice figures is upstream of duty math.
  • Agreement on ACE decimal precision. ACE carries the entered value as a whole dollar or two-decimal amount depending on the field, and duty to the cent. Fix the quantization exponents once, in one place, and reuse them.
  • Structured logging configured (stdlib logging with the %(asctime)s | %(levelname)s | %(name)s | %(message)s format) so every rate application is auditable.

Why float is wrong

A percentage duty looks like a one-liner, so the temptation is round(value * rate, 2). Consider an entered value of USD 1,050.125 declared to the cent at a 7% rate. In float, 1050.13 * 0.07 does not yield the exact product; the stored operands are the nearest binary approximations, and Python’s built-in round uses banker’s rounding (ROUND_HALF_EVEN), which resolves a .005 tie to the nearest even digit — the opposite of what CBP does on some ties. Decimal constructed from strings holds the operands exactly, and quantize(..., ROUND_HALF_UP) resolves every tie upward, deterministically. The formula for a pure ad valorem duty DD on entered value VV at rate rr is simply:

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

For an entered value of USD 12,499.99 at a 6.5% rate, the exact product is USD 812.499 (approximately), and half-up rounding yields USD 812.50 — a figure a float path can land a cent below.

Implementation

quantize_entered_value snaps the customs value to ACE precision; compute_ad_valorem_duty applies the rate and quantizes the duty half-up. Both refuse float at the boundary and never introduce it internally.

import logging
from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_UP, getcontext
from typing import Union

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

# Wide context so intermediate products never lose significant digits before
# the single, explicit quantize step decides the filed precision.
getcontext().prec = 34

CENT = Decimal("0.01")          # duty is filed to the cent
ENTERED_VALUE_Q = Decimal("0.01")  # ACE two-decimal entered value


def to_decimal(value: Union[str, int, Decimal]) -> Decimal:
    """Coerce to Decimal, refusing float to keep every operand exact."""
    if isinstance(value, float):
        raise TypeError(
            f"Refusing float {value!r}; pass a str/int/Decimal so money stays exact."
        )
    return value if isinstance(value, Decimal) else Decimal(str(value))


@dataclass(frozen=True)
class AdValoremLine:
    hts_code: str
    entered_value: Decimal   # customs value, Decimal USD
    ad_valorem_rate: Decimal  # fraction, e.g. Decimal("0.065") for 6.5%


@dataclass(frozen=True)
class DutyResult:
    hts_code: str
    entered_value: Decimal   # quantized to ACE precision
    ad_valorem_rate: Decimal
    duty: Decimal            # half-up to the cent


def quantize_entered_value(raw_value: Union[str, int, Decimal]) -> Decimal:
    """Snap the customs value to ACE two-decimal precision, half-up."""
    return to_decimal(raw_value).quantize(ENTERED_VALUE_Q, rounding=ROUND_HALF_UP)


def compute_ad_valorem_duty(line: AdValoremLine) -> DutyResult:
    """Pure percentage duty: quantize the value, apply the rate, round the duty once."""
    value = quantize_entered_value(line.entered_value)
    rate = to_decimal(line.ad_valorem_rate)
    if rate < 0 or rate > 1:
        raise ValueError(f"Rate {rate} out of range; expected a fraction in [0, 1].")

    # Full-precision product, then a single explicit half-up quantize to the cent.
    duty = (value * rate).quantize(CENT, rounding=ROUND_HALF_UP)

    logger.info(
        "ad valorem %s: value=%s × rate=%s -> duty=%s",
        line.hts_code, value, rate, duty,
    )
    return DutyResult(line.hts_code, value, rate, duty)

Two quantize points carry the whole design: the entered value is snapped to ACE precision before the rate is applied, and the duty is quantized to the cent exactly once. The wide getcontext().prec = 34 guarantees the intermediate product keeps every significant digit, so the only rounding that ever happens is the one you wrote explicitly, in the direction CBP uses.

Half-up quantization of a duty on the cent number line A number line marks two adjacent cents: 812.49 on the left and 812.50 on the right, with the exact half-cent midpoint 812.495 marked between them. A full-precision computed duty of 812.499 is plotted just to the right of the midpoint. Because it lies above the midpoint, ROUND_HALF_UP snaps it to 812.50, shown by an arrow pointing to the right tick. A second annotation shows that a value landing exactly on the 812.495 midpoint also rounds up to 812.50 under half-up rounding, whereas banker's rounding would send it to the even neighbor. The diagram contrasts the deterministic upward tie-break of Decimal half-up against float behavior. 812.49 812.495 half-cent 812.50 exact product 812.499 HALF_UP quantize(Decimal("0.01"), ROUND_HALF_UP) a value at or above the midpoint rounds up — deterministically, unlike float
The exact Decimal product is snapped to the nearer cent, and any half-cent tie breaks upward — the deterministic rule ACE applies, which float plus banker's rounding does not guarantee.

Verification steps

Run these checks against a representative batch of percentage-rated lines before trusting the computation:

  1. Float refusal at the boundary. Pass entered_value=1050.13 as a float and assert TypeError from to_decimal. The whole guarantee collapses if a float construction (Decimal(1050.13)) is allowed in; assert the error text names the offending value.
  2. Half-cent tie breaks up. Construct a value and rate whose exact product ends in .005 (e.g. USD 100.10 at 5% gives 5.005). Assert the duty is 5.01, not 5.00. Python’s built-in round returns 5.0 here via banker’s rounding — proof the stdlib default is wrong for CBP.
  3. String vs. float construction parity. Compute the duty for the same line built from Decimal("0.07") and — in a throwaway check — from Decimal(0.07). Confirm they can differ, and that only the string form matches the hand-computed figure. This is the concrete demonstration of why the rate must be a string.
  4. Entered-value quantization first. Feed a raw value with three decimals (USD 4,999.999) and assert it snaps to 5000.00 before the rate applies, so the rate multiplies the ACE-precision value, not the raw one.
  5. Rate range guard. Pass a rate of 1.5 (mis-entered as 150% instead of the fraction 0.015) and assert ValueError. A percentage entered as a whole number instead of a fraction is a frequent and costly data-entry defect.
  6. Cross-check against a duty-free line. A Decimal("0") rate must return duty == Decimal("0.00") with the entered value still quantized. Confirm the zero path does not skip quantization and emit an un-normalized value downstream.

Edge cases & gotchas

  • Decimal from float is the silent killer. Decimal(0.07) is 0.0700000000000000066..., and it will quietly shift a duty by a cent on some lines and not others — the worst kind of bug because it is intermittent and reproducible only with the exact operands. The to_decimal guard exists solely to make this impossible; do not bypass it with a direct Decimal(some_float).
  • Banker’s rounding hides in the stdlib. Python’s built-in round, f"{x:.2f}", and NumPy all default to round-half-even. Only Decimal.quantize(..., ROUND_HALF_UP) gives the CBP tie-break. Never format a duty for filing with an f-string that re-rounds it — pass the quantized Decimal through unchanged.
  • Rate stored as percent vs. fraction. A snapshot that stores 6.5 (percent) rather than 0.065 (fraction) inflates duty 100-fold. The range guard catches values above 1, but a 0.65 stored where 0.065 was meant passes the guard silently — validate rate provenance at ingestion, not just at computation.
  • Specific and compound rates are out of scope here. This computation applies only to pure percentage rates. If a line’s HTSUS rate carries a ¢/kg or $/unit term, route it to the compound evaluator in Evaluating compound duty formulas with CBP rounding; applying only the ad valorem leg under-collects duty.
  • Preferential rates change the number, not the method. Under a trade agreement the applicable ad valorem rate may fall to free or a reduced column; the quantization discipline is unchanged, but which rate you apply depends on origin eligibility determined by the Rule of Origin Logic Engines. Compute duty only after origin has selected the rate column.
  • Preserve the exact figure downstream. The quantized duty must reach the entry summary as the same Decimal — do not re-derive it during transmission assembly. The entered-value and duty precision contract for ACE lives in Entry Summary Filing Schemas; a second, differently-rounded computation there is a classic cause of a 7501-versus-ACE cent mismatch.

Up: Duty Formula Calculation Frameworks

Authoritative references: WCO Harmonized System 2022; HTSUS column-1 ad valorem rates (USITC); CBP ACE Entry Summary entered-value and duty precision; 19 CFR Part 141 (entered value); ISO 4217 currency minor units.