起きたこと
利率を有効数字5桁で表示する JavaScript のツールで、年利 12.003% を月利に直すと、正しくは 1.00025 → 1.0003 のところが 1.0002 と出ていた。2進数の小数では 1.0002499999999999 になり、toFixed(4) が切り下げるためだった。
これを直すために、「12桁に丸めてから Math.round する」四捨五入の関数に変えた。1.0003 は正しく出るようになったが、別の入力で、今度は1つ上の値が出た。月利 50.172% の複利の年利は、正しくは 13054.29% のところが 13054.30% になった。0.001 刻みの入力 1,110,011通りのうち、2通りがこうなった。
再現の手順
function roundHalfUp12(value, d) { // 12桁に丸めてから、四捨五入する
const p = 10 ** d;
return Math.round(Number((value * p).toPrecision(12))) / p;
}
const x = (Math.pow(1.50172, 12) - 1) * 100; // 月利 50.172% の複利の年利(%)
console.log("x =", x);
console.log("x*100 を12桁に =", (x * 100).toPrecision(12));
console.log("roundHalfUp12(x,2) =", roundHalfUp12(x, 2).toFixed(2));
console.log("x.toFixed(2) =", x.toFixed(2));$ node round.js x = 13054.2949999587 x*100 を12桁に = 1305429.50000 roundHalfUp12(x,2) = 13054.30 x.toFixed(2) = 13054.29
正確な値は、Python の分数で出すと次のとおり。小数3桁目は 4 なので、正しくは 13054.29。
$ python3 -c "
from fractions import Fraction as F
from decimal import Decimal as D, getcontext; getcontext().prec=40
v=(F(150172,100000)**12-1)*100; print('正確な値 =', D(v.numerator)/D(v.denominator))"
正確な値 = 13054.29499995870615495798162232524169314原因
丸めを2回している。1回目の toPrecision(12) で、1305429.499995870… が 12桁目で丸められて 1305429.50000 になり、2回目の Math.round がそれを切り上げる。
toPrecision(12) は「2進数の小数の誤差(…4999999999 のような、ごく小さなずれ)を消す」つもりで入れていた。ところが、この値の …49999 は誤差ではなく、正確な値そのものが …49999 で始まっている。12桁で丸めると、本物の …4999 と誤差の …4999 を区別できない。
直し方
値の出方で、丸め方を分ける。どちらも、丸めるのは1回だけにする。
- 割り算だけで出る値(入力が10進の数で、×・÷ だけで出るもの): 正確な値がちょうど …5 になることがあるので、分数のまま整数(BigInt)で四捨五入する
- 累乗・累乗根で出る値(複利の値など): 正確な値が …5 ちょうどになることはまずないので、2進数の小数で計算して
toFixedで1回だけ丸める
// 分数 num/den(BigInt)を小数 d 桁に四捨五入して文字列にする。0.5 ちょうどは大きい方へ
function roundFrac(num, den, d) {
const n = (2n * num * 10n ** BigInt(d) + den) / (2n * den);
const s = n.toString().padStart(d + 1, "0");
return s.slice(0, -d) + "." + s.slice(-d);
}
// 1) 割り算だけで出る値: 年利 12.003% を 100 で割ってから月に直す(正確には 1.00025 ちょうど)
const m = 12.003 / 100 / 12 * 100;
console.log("m :", m);
console.log("toFixed :", m.toFixed(4));
console.log("roundHalfUp12 :", roundHalfUp12(m, 4).toFixed(4));
console.log("分数で1回だけ丸める:", roundFrac(12003n, 12000n, 4)); // 12.003/12 = 12003/12000
// 2) 累乗で出る値: 月利 50.172% の複利の年利(正確な値は 13054.294999958…)
const x = (Math.pow(1.50172, 12) - 1) * 100;
console.log("roundHalfUp12 :", roundHalfUp12(x, 2).toFixed(2));
console.log("toFixed で1回だけ :", x.toFixed(2));(roundHalfUp12 は上の再現と同じ関数)
$ node fix.js m : 1.0002499999999999 toFixed : 1.0002 roundHalfUp12 : 1.0003 分数で1回だけ丸める: 1.0003 roundHalfUp12 : 13054.30 toFixed で1回だけ : 13054.29
toFixed だけでも roundHalfUp12 だけでも、どちらかの入力で間違える。分けると両方とも正しくなる。
12.003 / 12 と直接割ると、2進数の小数でも 1.00025 になり toFixed(4) で 1.0003 が出る。100 で割ってから戻す順番のときだけ 1.0002499999999999 になる。計算の順番で結果が変わるので、たまたま通る入力で試して安心しないこと。
確かめたこと
- この Mac(macOS 26.6.2・arm64・Node.js 24.15.0)で、上の出力のとおり
- 実際のツールでは、この直し方に変えた版で、年利 0〜1000%・月利 0〜100% を 0.001 刻み、日利 0〜1% を 0.0001 刻みにした 1,110,011通りの表示を、正確な値で丸めた答えと比べて、不一致は0だった(直す前の
toPrecision(12)の版は2通り、さらにその前の版は 93,040通り)