Coverage for domain/calc.py: 96%
84 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-04 17:24 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-04 17:24 +0000
1"""Loan math — Decimal port of ``arive-spec/src/domain/calc.ts`` (spec §4.5, §16).
3All functions are pure and deterministic. We use ``Decimal`` so currency results
4are exact and reproducible rather than subject to binary floating-point drift.
6Determinism & rounding (see change ``add-platform-foundation`` design D3):
7- Arithmetic runs under the default Decimal context (precision 28 significant
8 digits) — more than enough for amortization, and identical on every platform.
9- Currency/ratio results are quantized to 2 decimal places using ``ROUND_HALF_UP``
10 (US mortgage convention), NOT Python's default banker's ``ROUND_HALF_EVEN``.
11- Guard clauses mirror the oracle exactly: non-positive principal/term/value/income
12 returns 0.
14Parity target is the *values* asserted in ``arive-spec/tests/calc.test.ts``, which
15the ported pytest suite pins with exact ``Decimal`` comparisons.
16"""
18from __future__ import annotations
20from dataclasses import dataclass, replace
21from decimal import ROUND_HALF_UP, Decimal
23Number = Decimal | int | str | float
25_CENTS = Decimal("0.01")
26_HUNDRED = Decimal(100)
27_TWELVE = Decimal(12)
28ZERO = Decimal("0")
31def _d(value: Number) -> Decimal:
32 """Coerce to Decimal safely. Floats route through ``str`` to avoid binary
33 artifacts (e.g. ``Decimal(0.1)`` -> 0.1000000000000000055…)."""
34 if isinstance(value, Decimal):
35 return value
36 if isinstance(value, float): 36 ↛ 37line 36 didn't jump to line 37 because the condition on line 36 was never true
37 return Decimal(str(value))
38 return Decimal(value)
41def round2(n: Number) -> Decimal:
42 """Round to 2 decimal places, half-up (the oracle's ``round2``)."""
43 return _d(n).quantize(_CENTS, rounding=ROUND_HALF_UP)
46def monthly_pi(principal: Number, annual_rate_pct: Number, term_months: int) -> Decimal:
47 """Monthly principal & interest for a fully-amortizing loan."""
48 p = _d(principal)
49 term = int(term_months)
50 if p <= 0 or term <= 0:
51 return ZERO
52 r = _d(annual_rate_pct) / _HUNDRED / _TWELVE
53 if r == 0:
54 return round2(p / Decimal(term))
55 factor = (Decimal(1) + r) ** term
56 return round2((p * r * factor) / (factor - Decimal(1)))
59def ltv(loan_amount: Number, value: Number) -> Decimal:
60 v = _d(value)
61 if v <= 0:
62 return ZERO
63 return round2(_d(loan_amount) / v * _HUNDRED)
66def cltv(first_lien: Number, subordinate: Number, value: Number) -> Decimal:
67 """CLTV includes subordinate financing."""
68 return ltv(_d(first_lien) + _d(subordinate), value)
71def hcltv(
72 first_lien: Number, subordinate: Number, heloc_line_max: Number, value: Number
73) -> Decimal:
74 """HCLTV includes undrawn HELOC lines."""
75 return ltv(_d(first_lien) + _d(subordinate) + _d(heloc_line_max), value)
78@dataclass(frozen=True)
79class PitiBreakdown:
80 pi: Decimal
81 taxes: Decimal
82 hazard: Decimal
83 mi: Decimal
84 hoa: Decimal
85 other: Decimal
86 total: Decimal
89def piti(
90 principal: Number,
91 rate_pct: Number,
92 term_months: int,
93 monthly_taxes: Number = 0,
94 monthly_hazard: Number = 0,
95 monthly_mi: Number = 0,
96 monthly_hoa: Number = 0,
97 monthly_other: Number = 0,
98) -> PitiBreakdown:
99 """Housing payment broken into P&I plus escrow/other components."""
100 pi = monthly_pi(principal, rate_pct, term_months)
101 taxes, hazard = _d(monthly_taxes), _d(monthly_hazard)
102 mi, hoa, other = _d(monthly_mi), _d(monthly_hoa), _d(monthly_other)
103 total = round2(pi + taxes + hazard + mi + hoa + other)
104 return PitiBreakdown(
105 pi=pi, taxes=taxes, hazard=hazard, mi=mi, hoa=hoa, other=other, total=total
106 )
109@dataclass(frozen=True)
110class DtiResult:
111 housing_pct: Decimal
112 total_pct: Decimal
115def dti(housing_expense: Number, other_monthly_debt: Number, monthly_income: Number) -> DtiResult:
116 """Housing (front-end) and total (back-end) DTI as percentages."""
117 income = _d(monthly_income)
118 if income <= 0:
119 return DtiResult(housing_pct=ZERO, total_pct=ZERO)
120 housing = _d(housing_expense)
121 return DtiResult(
122 housing_pct=round2(housing / income * _HUNDRED),
123 total_pct=round2((housing + _d(other_monthly_debt)) / income * _HUNDRED),
124 )
127def down_payment(purchase_price: Number, base_loan_amt: Number) -> Decimal:
128 """Down payment for a purchase, never negative."""
129 return round2(max(ZERO, _d(purchase_price) - _d(base_loan_amt)))
132@dataclass
133class TransactionInput:
134 """Plain, persistence-independent transaction snapshot for ``recalc_transaction``.
136 Mirrors the subset of MISMO ``TransactionInfo`` the oracle recalculates. This is
137 intentionally NOT the Django ``LoanTransaction`` model (design D4) so the math
138 stays pure and testable without a database.
139 """
141 base_loan_amt: Decimal | None = None
142 note_amt: Decimal | None = None
143 purchase_price_amt: Decimal | None = None
144 appraised_value_amt: Decimal | None = None
145 total_subordinate_financing_amt: Decimal | None = None
146 down_payment_amt: Decimal | None = None
147 ltv_ratio_percent: Decimal | None = None
148 cltv_ratio_percent: Decimal | None = None
149 hcltv_ratio_percent: Decimal | None = None
151 def __post_init__(self) -> None:
152 for name, val in list(vars(self).items()):
153 if val is not None and not isinstance(val, Decimal): 153 ↛ 154line 153 didn't jump to line 154 because the condition on line 153 was never true
154 setattr(self, name, _d(val))
157def recalc_transaction(t: TransactionInput) -> TransactionInput:
158 """Recompute derived ratios on a transaction (spec §6.2 calc step)."""
159 value = (
160 t.appraised_value_amt
161 if t.appraised_value_amt is not None
162 else (t.purchase_price_amt if t.purchase_price_amt is not None else ZERO)
163 )
164 first = t.base_loan_amt if t.base_loan_amt is not None else ZERO
165 sub = (
166 t.total_subordinate_financing_amt
167 if t.total_subordinate_financing_amt is not None
168 else ZERO
169 )
170 note = t.note_amt if t.note_amt is not None else first
171 dp = (
172 down_payment(t.purchase_price_amt, first)
173 if t.purchase_price_amt is not None
174 else t.down_payment_amt
175 )
176 return replace(
177 t,
178 note_amt=note,
179 ltv_ratio_percent=ltv(first, value),
180 cltv_ratio_percent=cltv(first, sub, value),
181 hcltv_ratio_percent=hcltv(first, sub, ZERO, value),
182 down_payment_amt=dp,
183 )