Hi Abe, first of all, thanks for your work on the crate. I think its the right approach for rust. data-structures only.
Issue Summary
Account::balance, Account::available_balance and Transaction::amount are typed f32.
SimpleFIN transmits these as JSON strings ("balance": "100.23") specifically so they remain exact. deserialize_f32_str parses that string into f32, which discards that exactness before any consumer can preserve it.
The limit is a property of the format. f32 has a 24-bit mantissa, so the gap between adjacent representable values doubles at each power of two:
| magnitude |
gap between adjacent f32 values |
cents representable? |
| at 2¹⁶ = 65,536 |
$0.0078125 |
yes |
| at 2¹⁷ = 131,072 |
$0.015625 |
no — wider than a cent |
| at 2¹⁸ = 262,144 |
$0.03125 |
no |
Above $131,072, two-decimal amounts cannot be represented at all, so any balance past that point is silently altered. That threshold sits inside the normal range for mortgage, investment and retirement account balances.
Reproduction
[dependencies]
simplefin-data = "0.2.0"
serde_json = "1"
use simplefin_data::accountset::AccountSet;
fn roundtrip(v: &str) -> String {
let json = format!(
r#"{{"errlist":[],"connections":[],"accounts":[{{"id":"a","name":"n","conn_id":"c","currency":"CAD","balance":"{v}","available-balance":"{v}","balance-date":978366153,"transactions":[]}}]}}"#
);
let set: AccountSet = serde_json::from_str(&json).unwrap();
serde_json::to_value(&set).unwrap()["accounts"][0]["balance"]
.as_str().unwrap().to_string()
}
fn main() {
for v in ["99999.99", "123456.78", "131071.99", "131072.01",
"543210.98", "999999.99", "1234567.89"] {
println!("{:>12} -> {}", v, roundtrip(v));
}
}
Output — the string the crate emits differs in value from the string it was given:
99999.99 -> 99999.99 ok
123456.78 -> 123456.78 ok
131071.99 -> 131071.99 ok
131072.01 -> 131072.02 +1 cent
543210.98 -> 543211 +2 cents
999999.99 -> 1000000 +1 cent
1234567.89 -> 1234567.9 +1 cent
Scanning upward one cent at a time, the first value the crate cannot represent is 131072.01 — exactly 2¹⁷, as the table above predicts.
A consumer reconciling a computed running balance against the reported balance — a common and worthwhile integrity check — will see a permanent mismatch on any account above that threshold, for reasons unrelated to any missing transaction.
Suggested directions
rust_decimal::Decimal (feature-gated if you'd rather not take the dependency
unconditionally). Parses the wire string exactly, and is what most Rust financial code
already uses.
- Keep the raw
String and expose a typed accessor. Zero dependencies, zero loss, and it
leaves the numeric choice to the consumer.
f64 as a strictly-better stopgap — ~15–17 significant digits covers realistic currency
values, though it remains binary floating point and so is still not exact for decimal
fractions.
Option 1 or 2 would make this crate usable for ledger-grade work. Happy to open a PR if you have
a preference on direction.
Environment
simplefin-data 0.2.0
rustc 1.93.0
serde_json 1.x
Hi Abe, first of all, thanks for your work on the crate. I think its the right approach for rust. data-structures only.
Issue Summary
Account::balance,Account::available_balanceandTransaction::amountare typedf32.SimpleFIN transmits these as JSON strings (
"balance": "100.23") specifically so they remain exact.deserialize_f32_strparses that string intof32, which discards that exactness before any consumer can preserve it.The limit is a property of the format.
f32has a 24-bit mantissa, so the gap between adjacent representable values doubles at each power of two:f32valuesAbove $131,072, two-decimal amounts cannot be represented at all, so any balance past that point is silently altered. That threshold sits inside the normal range for mortgage, investment and retirement account balances.
Reproduction
Output — the string the crate emits differs in value from the string it was given:
Scanning upward one cent at a time, the first value the crate cannot represent is
131072.01— exactly 2¹⁷, as the table above predicts.A consumer reconciling a computed running balance against the reported balance — a common and worthwhile integrity check — will see a permanent mismatch on any account above that threshold, for reasons unrelated to any missing transaction.
Suggested directions
rust_decimal::Decimal(feature-gated if you'd rather not take the dependencyunconditionally). Parses the wire string exactly, and is what most Rust financial code
already uses.
Stringand expose a typed accessor. Zero dependencies, zero loss, and itleaves the numeric choice to the consumer.
f64as a strictly-better stopgap — ~15–17 significant digits covers realistic currencyvalues, though it remains binary floating point and so is still not exact for decimal
fractions.
Option 1 or 2 would make this crate usable for ledger-grade work. Happy to open a PR if you have
a preference on direction.
Environment
simplefin-data0.2.0rustc1.93.0serde_json1.x