Storing EntryIntent.size_usd as f64 and converting it back to Decimal unrounded made SigningClient::build_order hard-fail 100% of the time whenever the producer emitted a non-terminating fraction — killing a live fill-data canary on 2026-07-15 (9 attempts, 0 placed).
Symptoms
- Live canary on 2026-07-15: 9 attempts, 0 orders placed, every one failing at build time.
- Error text:
Unable to build Amount with 28 decimal points, must be <= 6. - Had worked fine for weeks at 5 sizes — no code change preceded the failure.
The "28" is the dry-path number
28decimals comes fromDecimal::from_f64_retain(dry path,execute.rs:185), which retains full binary precision. The live path (execute.rs:247) usesfrom_f64and fails with a smaller-but-still→6 scale. Same root cause, different digit in the message — don’t let the number mislead you about which path fired.
Root Cause
SigningClient::build_order builds the order amount via Amount::usdc(size_dec). That calls value.normalize() and rejects any Decimal whose scale() > 6 (USDC_DECIMALS):
Unable to build Amount with {N} decimal points, must be <= 6
PM stored size as f64 and passed it straight through:
| Site | Conversion | Path |
|---|---|---|
execute.rs:247 | Decimal::from_f64(size_usd) | live |
execute.rs:185 | Decimal::from_f64_retain(size_usd) | dry |
Verified empirically against the crate:
| Input | Resulting scale | Result |
|---|---|---|
from_f64(2.0) | 0 | ok |
from_f64(0.6) | 1 | ok |
from_f64(0.234) | 3 | ok |
from_f64(1.0/3.0) | 16 | FAILS |
from_f64_retain(..) | up to 28 | FAILS |
So terminating fractions round-trip clean through f64 — which is exactly why 5 sizes worked for weeks. A non-terminating fraction (a repeating decimal produced by producer-side size scaling) blows past 6dp and fails the build deterministically, 100% of the time.
Fix
pricing::usdc_size_from_f64 rounds to 6dp with RoundingStrategy::ToZero — round DOWN, never overspend on a money order — applied at both conversion sites.
- Branch
feat/usdc-size-decimal-fix@ca07ba5 - Rolled to prod 2026-07-15 13:29 UTC
- First live fill after the roll: MATCHED (
ledger_id 29573)
The fix stops the CRASH, not the UNDERSIZE
A sub-1 minimum**. If sizes are coming out too small, that is a separate (producer-side) problem — see below.
Durable Lesson
For Agents
Storing a money quantity as
f64and converting back toDecimalis lossy in a way that can hard-fail a venue API with a decimal-places limit. Round to the venue’s precision at the boundary, with a rounding direction chosen for money safety (down for spend). Do not assume “it’s been fine” proves the conversion is safe — terminating fractions hide the bug indefinitely.
Sizing is 100% Producer-Owned in pmv2
Recorded here because a “restore the PM size floor” request is misdirected:
base_usd,min_size_usd,max_size_usdinpmv2_autotrade_configareKeyScope::Producer.- PM can WRITE them via
/autotrade conf, but NEVER READS them on the entry path — same situation asmax_entry_price. - PM has no size floor. It takes
order.pricing.size_usd × size_scaleand quantizes.
So any size-floor change belongs in the emit/producer config, not in PM. This is the same “PM is not the authority you think it is” shape as pmv2-live-caps-are-advisory-not-blocking-2026-07-09.