Coverage for domain/calc.py: 96%

84 statements  

« 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). 

2 

3All functions are pure and deterministic. We use ``Decimal`` so currency results 

4are exact and reproducible rather than subject to binary floating-point drift. 

5 

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. 

13 

14Parity target is the *values* asserted in ``arive-spec/tests/calc.test.ts``, which 

15the ported pytest suite pins with exact ``Decimal`` comparisons. 

16""" 

17 

18from __future__ import annotations 

19 

20from dataclasses import dataclass, replace 

21from decimal import ROUND_HALF_UP, Decimal 

22 

23Number = Decimal | int | str | float 

24 

25_CENTS = Decimal("0.01") 

26_HUNDRED = Decimal(100) 

27_TWELVE = Decimal(12) 

28ZERO = Decimal("0") 

29 

30 

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) 

39 

40 

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) 

44 

45 

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))) 

57 

58 

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) 

64 

65 

66def cltv(first_lien: Number, subordinate: Number, value: Number) -> Decimal: 

67 """CLTV includes subordinate financing.""" 

68 return ltv(_d(first_lien) + _d(subordinate), value) 

69 

70 

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) 

76 

77 

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 

87 

88 

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 ) 

107 

108 

109@dataclass(frozen=True) 

110class DtiResult: 

111 housing_pct: Decimal 

112 total_pct: Decimal 

113 

114 

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 ) 

125 

126 

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))) 

130 

131 

132@dataclass 

133class TransactionInput: 

134 """Plain, persistence-independent transaction snapshot for ``recalc_transaction``. 

135 

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 """ 

140 

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 

150 

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)) 

155 

156 

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 )