| | | @@ 変更前 3行目〜 / 変更後 3行目〜 @@ |
| 3 | 3 | | |
| 4 | 4 | | /** |
| 5 | 5 | | * 利率の変換(年利・月利・日利)。 |
| 6 | | − | * v1: 利率を is_numeric で確かめて float に読み、単利は割り算、複利は ** で累乗・累乗根を計算して、 |
| 7 | | − | * 桁数を log10 の floor で決め、number_format で書いてから最後の0を取る素直な書き方。 |
| 6 | + | * v2: 利率の文字列を float に読まず、小数点を外した数字の列(分子)と 10^桁数(分母)の正確な分数として10進のまま読む。 |
| 7 | + | * 上限の比較も分子と「上限 × 10^桁数」の整数で比べる。 |
| 8 | + | * …5 ちょうどになりうる値(単利の3つと、入力と同じ単位の複利)は「分子 ÷ 分母」の整数の筆算で、 |
| 9 | + | * 桁 e を整数の比較で決め、0.5 ちょうどは大きい方へ四捨五入する。 |
| 10 | + | * 入力と違う単位の複利(累乗・累乗根)は float で計算する。累乗根は無理数になり、整数の累乗も小数が10桁以上続くので、 |
| 11 | + | * 有効数字5桁の …5 ちょうどにはならない。rate が 0 なら複利も "0"。 |
| 12 | + | * v1 の float では c005 の 12.003 ÷ 12 = 1.00025 が 1.0002499999999999 になって simple_month が 1.0002 に落ち、 |
| 13 | + | * c016 の月利 0.123455% を12乗してから12乗根で戻すと 0.123455 より少し小さくなって compound_month が 0.12345 に落ちた。 |
| 14 | + | * c006 の 0.33 × 365 ÷ 12 = 10.0375(float では 10.03749999…)は、number_format が「1000 倍してから丸める」ため |
| 15 | + | * 掛け算の誤差で 10037.5 ちょうどになり、偶然 10.038 で通っていた(Go・JavaScript の v1 は 10.037 に落ちた)。 |
| 16 | + | * 長い入力では分子・分母が int の上限(約 9.2 × 10^18)を超えうるので、整数は数字の列のまま筆算する |
| 17 | + | * (bcmath・gmp は入っていない環境があるので使わない)。 |
| 8 | 18 | | */ |
| 19 | + | const DECIMAL = '/^[0-9]+(\.[0-9]+)?$/D'; // D: $ を末尾の改行の前で一致させない |
| 9 | 20 | | const N = ['year' => 1, 'month' => 12, 'day' => 365]; |
| 10 | | − | const LIMIT = ['year' => 1000, 'month' => 100, 'day' => 1]; |
| 21 | + | const LIMIT = ['year' => '1000', 'month' => '100', 'day' => '1']; |
| 11 | 22 | | |
| 12 | | − | function fmt(float $p): string |
| 23 | + | /** 先頭の 0 を外す(0 は "0") */ |
| 24 | + | function digitsNorm(string $a): string |
| 13 | 25 | | { |
| 14 | | − | if ($p == 0) { |
| 26 | + | $a = ltrim($a, '0'); |
| 27 | + | return $a === '' ? '0' : $a; |
| 28 | + | } |
| 29 | + | |
| 30 | + | /** 0 以上の整数(数字の列)の大小。-1・0・1 */ |
| 31 | + | function digitsCmp(string $a, string $b): int |
| 32 | + | { |
| 33 | + | return strlen($a) <=> strlen($b) ?: strcmp($a, $b) <=> 0; |
| 34 | + | } |
| 35 | + | |
| 36 | + | function digitsAdd(string $a, string $b): string |
| 37 | + | { |
| 38 | + | $n = max(strlen($a), strlen($b)); |
| 39 | + | $a = str_pad($a, $n, '0', STR_PAD_LEFT); |
| 40 | + | $b = str_pad($b, $n, '0', STR_PAD_LEFT); |
| 41 | + | $out = ''; |
| 42 | + | $carry = 0; |
| 43 | + | for ($i = $n - 1; $i >= 0; $i--) { |
| 44 | + | $s = (int) $a[$i] + (int) $b[$i] + $carry; |
| 45 | + | $out = ($s % 10) . $out; |
| 46 | + | $carry = intdiv($s, 10); |
| 47 | + | } |
| 48 | + | return digitsNorm(($carry ? '1' : '') . $out); |
| 49 | + | } |
| 50 | + | |
| 51 | + | /** $a - $b($a >= $b のとき) */ |
| 52 | + | function digitsSub(string $a, string $b): string |
| 53 | + | { |
| 54 | + | $b = str_pad($b, strlen($a), '0', STR_PAD_LEFT); |
| 55 | + | $out = ''; |
| 56 | + | $borrow = 0; |
| 57 | + | for ($i = strlen($a) - 1; $i >= 0; $i--) { |
| 58 | + | $d = (int) $a[$i] - (int) $b[$i] - $borrow; |
| 59 | + | $borrow = $d < 0 ? 1 : 0; |
| 60 | + | $out = ($d + 10 * $borrow) . $out; |
| 61 | + | } |
| 62 | + | return digitsNorm($out); |
| 63 | + | } |
| 64 | + | |
| 65 | + | /** 数字の列 × 小さい int(365 まで) */ |
| 66 | + | function digitsMulSmall(string $a, int $m): string |
| 67 | + | { |
| 68 | + | $out = ''; |
| 69 | + | $carry = 0; |
| 70 | + | for ($i = strlen($a) - 1; $i >= 0; $i--) { |
| 71 | + | $s = (int) $a[$i] * $m + $carry; |
| 72 | + | $out = ($s % 10) . $out; |
| 73 | + | $carry = intdiv($s, 10); |
| 74 | + | } |
| 75 | + | return digitsNorm(($carry ? (string) $carry : '') . $out); |
| 76 | + | } |
| 77 | + | |
| 78 | + | /** 筆算の割り算。[商, 余り] */ |
| 79 | + | function digitsDivMod(string $a, string $b): array |
| 80 | + | { |
| 81 | + | $q = ''; |
| 82 | + | $r = '0'; |
| 83 | + | for ($i = 0, $n = strlen($a); $i < $n; $i++) { |
| 84 | + | $r = digitsNorm($r . $a[$i]); |
| 85 | + | $d = 0; |
| 86 | + | while (digitsCmp($r, $b) >= 0) { |
| 87 | + | $r = digitsSub($r, $b); |
| 88 | + | $d++; |
| 89 | + | } |
| 90 | + | $q .= $d; |
| 91 | + | } |
| 92 | + | return [digitsNorm($q), $r]; |
| 93 | + | } |
| 94 | + | |
| 95 | + | /** n ÷ 10^d を書き、小数の最後の0と小数点を取る */ |
| 96 | + | function show(string $n, int $d): string |
| 97 | + | { |
| 98 | + | $s = str_pad($n, $d + 1, '0', STR_PAD_LEFT); |
| 99 | + | return rtrim(rtrim(substr($s, 0, -$d) . '.' . substr($s, -$d), '0'), '.'); |
| 100 | + | } |
| 101 | + | |
| 102 | + | /** 小数の桁数 d(有効数字5桁・2以上8以下)。10^e <= 値 < 10^(e+1) の e を決めて 5 − e − 1 */ |
| 103 | + | function places(int $e): int |
| 104 | + | { |
| 105 | + | return min(max(5 - $e - 1, 2), 8); |
| 106 | + | } |
| 107 | + | |
| 108 | + | /** |
| 109 | + | * 正確な値 $num ÷ ($den × 10^$scale)(0 以上)を四捨五入(0.5 ちょうどは大きい方へ)して書く。 |
| 110 | + | * $den は 1・12・365。 |
| 111 | + | */ |
| 112 | + | function fmtExact(string $num, int $den, int $scale): string |
| 113 | + | { |
| 114 | + | if ($num === '0') { |
| 15 | 115 | | return '0'; |
| 16 | 116 | | } |
| 17 | | − | $d = 5 - (int) floor(log10(abs($p))) - 1; |
| 18 | | − | $d = min(max($d, 2), 8); |
| 19 | | − | $s = number_format($p, $d, '.', ''); |
| 20 | | − | return rtrim(rtrim($s, '0'), '.'); |
| 117 | + | // e は -4 以下なら d = 8、2 以上なら d = 2 なので、-3〜2 の境目だけ比べればよい。 |
| 118 | + | // 値 >= 10^k ⇔ num × 10^3 >= den × 10^(scale + k + 3) |
| 119 | + | $e = -4; |
| 120 | + | for ($k = -3; $k <= 2; $k++) { |
| 121 | + | if (digitsCmp($num . '000', $den . str_repeat('0', $scale + $k + 3)) >= 0) { |
| 122 | + | $e = $k; |
| 123 | + | } |
| 124 | + | } |
| 125 | + | $d = places($e); |
| 126 | + | // 値 × 10^d = (num × 10^d) ÷ (den × 10^scale)。余りの2倍が割る数以上なら切り上げ |
| 127 | + | $q = (string) $den . str_repeat('0', $scale); |
| 128 | + | [$n, $r] = digitsDivMod($num . str_repeat('0', $d), $q); |
| 129 | + | if (digitsCmp(digitsAdd($r, $r), $q) >= 0) { |
| 130 | + | $n = digitsAdd($n, '1'); |
| 131 | + | } |
| 132 | + | return show($n, $d); |
| 133 | + | } |
| 134 | + | |
| 135 | + | /** float の $p(…5 ちょうどにならない値)を書く */ |
| 136 | + | function fmtFloat(float $p): string |
| 137 | + | { |
| 138 | + | if ($p <= 0) { |
| 139 | + | return '0'; |
| 140 | + | } |
| 141 | + | $d = places((int) floor(log10($p))); |
| 142 | + | return show(sprintf('%.0f', round($p * 10 ** $d)), $d); |
| 21 | 143 | | } |
| 22 | 144 | | |
| 23 | 145 | | /** @param array{rate: string, unit: string} $in */ |
| | | @@ 変更前 25行目〜 / 変更後 147行目〜 @@ |
| 25 | 147 | | { |
| 26 | 148 | | $rate = $in['rate'] ?? null; |
| 27 | 149 | | $unit = $in['unit'] ?? null; |
| 28 | | − | if (!is_string($unit) || !isset(N[$unit]) || !is_numeric($rate)) { |
| 150 | + | if (!is_string($unit) || !isset(N[$unit]) || !is_string($rate) || !preg_match(DECIMAL, $rate)) { |
| 29 | 151 | | return ['error' => 'invalid_input']; |
| 30 | 152 | | } |
| 31 | | − | $raw = (float) $rate; |
| 32 | | − | if ($raw < 0) { |
| 33 | | − | return ['error' => 'invalid_input']; |
| 34 | | − | } |
| 35 | | − | if ($raw > LIMIT[$unit]) { |
| 153 | + | [$int, $frac] = explode('.', $rate . '.'); |
| 154 | + | $num = digitsNorm($int . $frac); // 利率(%)= num ÷ 10^scale |
| 155 | + | $scale = strlen($frac); |
| 156 | + | if (digitsCmp($num, LIMIT[$unit] . str_repeat('0', $scale)) > 0) { |
| 36 | 157 | | return ['error' => 'out_of_range']; |
| 37 | 158 | | } |
| 38 | 159 | | $n = N[$unit]; |
| 39 | | − | $r = $raw / 100; |
| 40 | | − | $aSimple = $r * $n; |
| 41 | | − | $aCompound = (1 + $r) ** $n - 1; |
| 42 | | − | return [ |
| 43 | | − | 'simple_year' => fmt($aSimple * 100), |
| 44 | | − | 'simple_month' => fmt($aSimple / 12 * 100), |
| 45 | | − | 'simple_day' => fmt($aSimple / 365 * 100), |
| 46 | | − | 'compound_year' => fmt($aCompound * 100), |
| 47 | | − | 'compound_month' => fmt(((1 + $aCompound) ** (1 / 12) - 1) * 100), |
| 48 | | − | 'compound_day' => fmt(((1 + $aCompound) ** (1 / 365) - 1) * 100), |
| 160 | + | $simple = digitsMulSmall($num, $n); // 単利の年利(%)= simple ÷ 10^scale |
| 161 | + | $out = [ |
| 162 | + | 'simple_year' => fmtExact($simple, 1, $scale), |
| 163 | + | 'simple_month' => fmtExact($simple, 12, $scale), |
| 164 | + | 'simple_day' => fmtExact($simple, 365, $scale), |
| 49 | 165 | | ]; |
| 166 | + | if ($num === '0') { |
| 167 | + | return $out + ['compound_year' => '0', 'compound_month' => '0', 'compound_day' => '0']; |
| 168 | + | } |
| 169 | + | $g = (1 + (float) $rate / 100) ** $n; // 複利で1年に何倍になるか |
| 170 | + | $same = fmtExact($num, 1, $scale); |
| 171 | + | $out['compound_year'] = $unit === 'year' ? $same : fmtFloat(($g - 1) * 100); |
| 172 | + | $out['compound_month'] = $unit === 'month' ? $same : fmtFloat(($g ** (1 / 12) - 1) * 100); |
| 173 | + | $out['compound_day'] = $unit === 'day' ? $same : fmtFloat(($g ** (1 / 365) - 1) * 100); |
| 174 | + | return $out; |
| 50 | 175 | | } |