04. 表形式の出力と累積計算
テーマ: 住宅ローンの元利均等返済スケジュール
学習点: ジェネレータ関数(yield), 名前付きタプル, 集計, 表の整形
依存: 標準ライブラリのみ / 難易度: 基礎
実行方法
uv run 04_loan_amortization.py
スクリプト冒頭の PEP 723 メタデータ(# /// script)により、必要なライブラリは
uv が自動的に仮想環境へ導入します。事前の pip install は不要です。
解説
何をするプログラムか
住宅ローンで最も一般的な「元利均等返済」は、毎月の返済額を一定にする方式です。返済額のうち利息と元本の内訳は毎月変わり、当初は利息の割合が大きく、返済が進むにつれて元本の割合が増えていきます。このスクリプトは、借入 3,500 万円・年利 1.5%・35 年(420 回)という現実的な条件で毎月の返済額を計算し、各回の「返済額・うち利息・うち元本・残高」を並べた返済予定表(償還表)を作ります。
さらに総返済額と総利息を集計し、年利を 0.5%〜3.0% に振ったときに総利息がどれだけ変わるかという金利感応度の比較も行います。
コードの読みどころ
schedule()はジェネレータ関数です。yield Row(m, a, interest, prin, bal)が 1 回分の返済明細を返すため、420 回分をあらかじめリストに貯めずに 1 行ずつ生成できます。残高balを更新しながら回す、まさに「累積計算」の形です。- 各行は
class Row(NamedTuple)で定義した名前付きタプルです。row.interestのようにフィールド名でアクセスでき、row[2]のような添字よりも意図が明確になります。 monthly_payment()は公式 A = P·i / (1 − (1+i)^(−n)) をそのまま実装し、if i == 0:で金利ゼロのときのゼロ除算を単純な割り算に切り替えています。rows[:3] + rows[-2:]というスライスで、420 行のうち最初の 3 回と最後の 2 回だけを表示します。全行印字せずに構造を見せる実用的な手法です。- 集計は
sum(x.interest for x in rows)のようにジェネレータ式で書きます。金利感応度の節ではsum(x.interest for x in schedule(P, rr, Y))と、リスト化せずジェネレータを直接sum()に渡しています。
理論的背景
毎月返済額の公式 A = P·i / (1 − (1+i)^(−n))(i は月利、n は回数)は、年金現価の関係式から導かれます。毎月 A 円を n 回受け取る年金の現在価値は P = A·(1 − (1+i)^(−n))/i(年金現価係数 × A)であり、「借入額 = 将来の全返済額の現在価値」と置いてこの式を A について解いたものが上の公式です。つまり毎月返済額は、借入額を年金現価係数で割った値になっています。各回の利息は直前残高 × 月利で決まるため、残高が減るほど利息が減り、その分だけ元本返済が加速します。
実行結果の見方
毎月返済額は 107,165 円で 420 回一定です。第 1 回は利息 43,750 円・元本 63,415 円ですが、第 420 回では利息 134 円・元本 107,031 円と内訳が逆転し、残高はちょうど 0 になります。総返済額は約 4,501 万円で、利息だけで 1,001 万円、元本の 28.6% に相当します。
金利感応度の表では、年利 0.5% なら総利息 316 万円、3.0% なら 2,157 万円と、金利 6 倍で総利息は約 6.8 倍になります。35 年という長期の借入では、わずか 0.5% の金利差が数百万円の差になることが読み取れます。
ソースコード
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""04: 表形式の出力と累積計算 ---------------------------------------------
テーマ: 住宅ローンの元利均等返済スケジュール
学習点: ジェネレータ関数(yield), 名前付きタプル, 集計, 表の整形
根拠: 元利均等の毎月返済額 A = P * i / (1 - (1+i)^-n) (i=月利, n=回数)
これは年金現価係数の逆数で、P = A * (1-(1+i)^-n)/i を A について解いたもの。
"""
from typing import Iterator, NamedTuple
class Row(NamedTuple):
month: int
payment: float
interest: float
principal: float
balance: float
def monthly_payment(principal: float, annual_rate: float, years: int) -> float:
i = annual_rate / 12
n = years * 12
if i == 0:
return principal / n
return principal * i / (1 - (1 + i) ** (-n))
def schedule(principal: float, annual_rate: float, years: int) -> Iterator[Row]:
"""毎月の返済明細を1行ずつ生成する(ジェネレータ=遅延評価)。"""
i = annual_rate / 12
a = monthly_payment(principal, annual_rate, years)
bal = principal
for m in range(1, years * 12 + 1):
interest = bal * i
prin = a - interest
bal = max(bal - prin, 0.0)
yield Row(m, a, interest, prin, bal)
def main() -> None:
P, r, Y = 35_000_000, 0.015, 35
a = monthly_payment(P, r, Y)
print(f"借入 {P:,}円 / 年利 {r:.2%} / {Y}年({Y*12}回)")
print(f"毎月返済額: {a:,.0f} 円\n")
rows = list(schedule(P, r, Y))
print(f"{'回':>4}{'返済額':>12}{'うち利息':>12}{'うち元本':>12}{'残高':>15}")
for row in rows[:3] + rows[-2:]:
print(f"{row.month:>4}{row.payment:>12,.0f}{row.interest:>12,.0f}"
f"{row.principal:>12,.0f}{row.balance:>15,.0f}")
total_i = sum(x.interest for x in rows)
print(f"\n総返済額 {sum(x.payment for x in rows):,.0f}円 "
f"(うち利息 {total_i:,.0f}円 = 元本の {total_i / P:.1%})")
print("\n[金利感応度] 総利息の比較")
for rr in [0.005, 0.010, 0.015, 0.020, 0.030]:
ti = sum(x.interest for x in schedule(P, rr, Y))
print(f" 年利 {rr:.2%}: 月々 {monthly_payment(P, rr, Y):>9,.0f}円 / "
f"総利息 {ti:>13,.0f}円")
if __name__ == "__main__":
main()
実行結果
借入 35,000,000円 / 年利 1.50% / 35年(420回)
毎月返済額: 107,165 円
回 返済額 うち利息 うち元本 残高
1 107,165 43,750 63,415 34,936,585
2 107,165 43,671 63,494 34,873,092
3 107,165 43,591 63,573 34,809,518
419 107,165 267 106,897 107,031
420 107,165 134 107,031 0
総返済額 45,009,113円 (うち利息 10,009,113円 = 元本の 28.6%)
[金利感応度] 総利息の比較
年利 0.50%: 月々 90,855円 / 総利息 3,159,050円
年利 1.00%: 月々 98,800円 / 総利息 6,495,998円
年利 1.50%: 月々 107,165円 / 総利息 10,009,113円
年利 2.00%: 月々 115,942円 / 総利息 13,695,627円
年利 3.00%: 月々 134,698円 / 総利息 21,572,978円