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.Decimalexclusively for money and rates. Never construct aDecimalfrom afloat(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
loggingwith the%(asctime)s | %(levelname)s | %(name)s | %(message)sformat) 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 on entered value at rate is simply:
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.
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:
- Float refusal at the boundary. Pass
entered_value=1050.13as afloatand assertTypeErrorfromto_decimal. The whole guarantee collapses if afloatconstruction (Decimal(1050.13)) is allowed in; assert the error text names the offending value. - 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 is5.01, not5.00. Python’s built-inroundreturns5.0here via banker’s rounding — proof the stdlib default is wrong for CBP. - String vs. float construction parity. Compute the duty for the same line built from
Decimal("0.07")and — in a throwaway check — fromDecimal(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. - Entered-value quantization first. Feed a raw value with three decimals (USD 4,999.999) and assert it snaps to
5000.00before the rate applies, so the rate multiplies the ACE-precision value, not the raw one. - Rate range guard. Pass a rate of
1.5(mis-entered as 150% instead of the fraction0.015) and assertValueError. A percentage entered as a whole number instead of a fraction is a frequent and costly data-entry defect. - Cross-check against a duty-free line. A
Decimal("0")rate must returnduty == 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
Decimalfromfloatis the silent killer.Decimal(0.07)is0.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. Theto_decimalguard exists solely to make this impossible; do not bypass it with a directDecimal(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. OnlyDecimal.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 quantizedDecimalthrough unchanged. - Rate stored as percent vs. fraction. A snapshot that stores
6.5(percent) rather than0.065(fraction) inflates duty 100-fold. The range guard catches values above 1, but a0.65stored where0.065was 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
¢/kgor$/unitterm, 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.
Related
- Evaluating compound duty formulas with CBP rounding
- Handling missing HTS codes in ETL pipelines
- Rule of Origin Logic Engines
- Entry Summary Filing Schemas
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.