ADR-0016: Rounding policy — full precision in compute, round only at the human display
- Status: Accepted
- Date: 2026-06-14
Context
Portfolixir stores every monetary value, quantity, price, fee, tax and FX rate as
Decimal (ADR-0003), but it has no written
rounding policy (#344): which scale and which rounding mode apply, and at which
point in a calculation rounding is allowed. Without one, different code paths could
round at different places and drift apart — a price × quantity feeds a cost basis,
which feeds an unrealized P&L, which is time-weighted across partial days and summed;
each premature rounding step accumulates error until a cash balance is off by a cent
or the “bookings of a transaction sum to zero” invariant breaks.
Constraints that apply:
- Money and rates are
Decimal, never floats (ADR-0003). Decimal columns carry explicit precision/scale: money/quotes/rates20,6, volume30,6. - FX conversion already keeps intermediate arithmetic at full precision and treats rounding as a display concern; same-currency conversion is the identity (ADR-0007). This ADR generalises that rule from FX to all operations.
- The JSON API and MCP companion return financial values as Decimal
:normalstrings, full precision, end to end (ADR-0002); their consumer is a program (e.g. an agent), not a human reading two decimals. - Derived figures must stay reproducible from stored data (ADR-0004).
Decision
Compute and persist at full precision; round only at the human display.
-
Intermediate arithmetic — never round. Every multi-step computation (price × quantity, FX triangulation, cost basis, realized/unrealized P&L, time-weighting, aggregation) carries full
Decimalprecision from start to end. No rounding between steps. This extends ADR-0007 from FX to all operation classes. -
Persistence — quantize to the column scale, mode
:half_up. Stored values are quantized only to the column’s declared scale (6 for money/quotes/rates, 6 for volume) using:half_up. Because scale 6 is finer than any real-world input, this is effectively lossless; the rule exists for the rare derived value written back. Stored amounts are never pre-rounded to a presentation scale. -
Machine boundary (JSON API / MCP) — no rounding. Responses serialize the full-precision stored/computed
Decimalas:normalstrings. The consumer decides how to present. (Reaffirms ADR-0002.) - Human boundary (LiveView/SVG UI and human-facing exports) — round here, and
only here, with scale per value class and mode
:half_up(kaufmännische Rundung, matching the broker and tax statements users reconcile against; also theDecimaldefault context):- Money: 2 decimal places (currency minor unit; 2 is the baseline — see open refinement below).
- Percentages (weights, P&L %, allocation drift): 2 decimal places.
- FX rates: 6 decimal places.
- Quantities: up to 6 decimal places, trailing zeros trimmed.
-
Invariants are checked at full precision, never on display-rounded values. The sum-to-zero of a transaction’s legs, cash reconciliation, and import idempotency are evaluated on stored full-precision
Decimal. A display-rounded figure never feeds back into a stored value or an invariant check. - Displayed breakdowns that must total a displayed sum (e.g. allocation percentages shown as summing to 100 %) use the largest-remainder method on the already-rounded display figures, at the human boundary only — never persisted and never fed back into computation.
Consequences
Easier. Errors cannot accumulate through a calculation, because nothing rounds mid-stream; cash balances and the sum-to-zero / idempotency invariants reconcile exactly. The agent (via MCP) always sees raw precision and can apply its own policy. Humans see clean two-decimal money that matches their broker statement.
Harder / accepted trade-offs. Every human-display code path must apply the
rounding explicitly at the edge — there is no “round early and forget” shortcut; a
property test (no rounding drift; legs sum to zero) guards this. Displayed parts can
look like they don’t add up unless the largest-remainder rule is applied, which is
extra display logic. Money display is fixed at 2 places as a baseline; true
per-currency minor units (e.g. JPY = 0, the existing GBX pence pseudo-currency
handled in ADR-0007) are a
later refinement, not part of this decision.
Off-limits. Rounding intermediate results; persisting presentation-rounded amounts; rounding inside the JSON API/MCP responses; using float tolerance anywhere in money math (assertions stay Decimal-exact, ADR-0003).