Coverage for domain/pricing.py: 92%

250 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-04 14:06 +0000

1"""Product & Pricing Engine (PPE) + Rate Stack + Agentic Recommendation. 

2 

3Port of ``arive-spec/src/domain/pricing.ts`` (spec §16.2, §13.1). 

4Pure, side-effect-free financial domain logic using Decimal exactness with half-up rounding. 

5Zero dependencies on Django or external databases. 

6""" 

7 

8from __future__ import annotations 

9 

10from collections.abc import Sequence 

11from dataclasses import dataclass, field 

12from datetime import date 

13from decimal import ROUND_HALF_UP, Decimal 

14 

15from domain.calc import monthly_pi 

16from domain.enums import ( 

17 CompType, 

18 MortgageType, 

19 OccupancyType, 

20 PricingChannel, 

21 PricingGoal, 

22 RateType, 

23) 

24 

25Number = Decimal | int | str | float 

26 

27_CENTS = Decimal("0.01") 

28_MILLS = Decimal("0.001") 

29_HUNDRED = Decimal("100") 

30ZERO = Decimal("0") 

31 

32 

33def _d(value: Number) -> Decimal: 

34 """Coerce number to Decimal safely, routing floats through str to prevent binary float inaccuracies.""" 

35 if isinstance(value, Decimal): 

36 return value 

37 if isinstance(value, float): 37 ↛ 38line 37 didn't jump to line 38 because the condition on line 37 was never true

38 return Decimal(str(value)) 

39 return Decimal(value) 

40 

41 

42def round2(n: Number) -> Decimal: 

43 """Round to 2 decimal places using standard ROUND_HALF_UP.""" 

44 return _d(n).quantize(_CENTS, rounding=ROUND_HALF_UP) 

45 

46 

47def round3(n: Number) -> Decimal: 

48 """Round to 3 decimal places using standard ROUND_HALF_UP (mortgage price/rate convention).""" 

49 return _d(n).quantize(_MILLS, rounding=ROUND_HALF_UP) 

50 

51 

52@dataclass(frozen=True) 

53class RateProductInput: 

54 """Specification of an investor rate product.""" 

55 

56 id: str 

57 investor: str 

58 name: str 

59 mortgage_type: MortgageType | str 

60 term_months: int 

61 base_rate: Decimal 

62 base_price: Decimal # price in points of par (100.000 = par) 

63 min_fico: int 

64 max_ltv: Decimal 

65 occupancies: tuple[str, ...] 

66 comp_type: CompType | str = CompType.LENDER_PAID 

67 comp_pct: Decimal = Decimal("1.25") 

68 

69 def __post_init__(self) -> None: 

70 object.__setattr__(self, "base_rate", round3(self.base_rate)) 

71 object.__setattr__(self, "base_price", round3(self.base_price)) 

72 object.__setattr__(self, "max_ltv", round2(self.max_ltv)) 

73 object.__setattr__(self, "comp_pct", round2(self.comp_pct)) 

74 object.__setattr__( 

75 self, 

76 "occupancies", 

77 tuple(str(o) for o in self.occupancies), 

78 ) 

79 

80 

81@dataclass(frozen=True) 

82class PricingScenario: 

83 """Pricing scenario inputs from loan parameters.""" 

84 

85 loan_amount: Decimal 

86 property_value: Decimal 

87 fico: int 

88 occupancy: OccupancyType | str 

89 term_months: int = 360 

90 monthly_taxes: Decimal = ZERO 

91 monthly_hazard: Decimal = ZERO 

92 monthly_hoa: Decimal = ZERO 

93 monthly_mi: Decimal = ZERO 

94 channel: PricingChannel | str = PricingChannel.WHOLESALE 

95 comp_type: CompType | str | None = None 

96 rate_type: RateType | str = RateType.FIXED 

97 lock_period_days: int = 30 

98 number_of_units: int = 1 

99 non_occupant_co_borrower: bool = False 

100 first_time_homebuyer: bool = False 

101 self_employed: bool = False 

102 

103 def __post_init__(self) -> None: 

104 object.__setattr__(self, "loan_amount", round2(self.loan_amount)) 

105 object.__setattr__(self, "property_value", round2(self.property_value)) 

106 object.__setattr__(self, "monthly_taxes", round2(self.monthly_taxes)) 

107 object.__setattr__(self, "monthly_hazard", round2(self.monthly_hazard)) 

108 object.__setattr__(self, "monthly_hoa", round2(self.monthly_hoa)) 

109 object.__setattr__(self, "monthly_mi", round2(self.monthly_mi)) 

110 

111 

112@dataclass(frozen=True) 

113class PriceAdjustment: 

114 code: str 

115 description: str 

116 adjustment_type: str # "PRICE" | "RATE" 

117 amount: Decimal 

118 

119 

120@dataclass(frozen=True) 

121class ClosingCostItem: 

122 label: str 

123 amount: Decimal 

124 

125 

126@dataclass(frozen=True) 

127class ClosingCostBreakdown: 

128 origination_amt: Decimal 

129 third_party_amt: Decimal 

130 prepaids_amt: Decimal 

131 total: Decimal 

132 items: list[ClosingCostItem] = field(default_factory=list) 

133 

134 # Aliases for backward parity with arive-spec 

135 @property 

136 def lender_fees(self) -> Decimal: 

137 return self.origination_amt 

138 

139 @property 

140 def title(self) -> Decimal: 

141 return self.third_party_amt 

142 

143 @property 

144 def prepaids(self) -> Decimal: 

145 return self.prepaids_amt 

146 

147 

148@dataclass(frozen=True) 

149class LoCompDetail: 

150 comp_type: str 

151 pct: Decimal 

152 amt: Decimal 

153 

154 

155@dataclass(frozen=True) 

156class RateStackItem: 

157 rate: Decimal 

158 price: Decimal 

159 points_pct: Decimal 

160 

161 

162@dataclass(frozen=True) 

163class PricedProduct: 

164 product: RateProductInput 

165 rate: Decimal 

166 price: Decimal 

167 pi: Decimal 

168 apr: Decimal 

169 piti: Decimal 

170 lo_comp: Decimal 

171 lo_comp_detail: LoCompDetail 

172 closing_costs: ClosingCostBreakdown 

173 adjustments: list[PriceAdjustment] 

174 rate_stack: list[RateStackItem] 

175 lock_period_days: int 

176 rate_sheet_id: str 

177 eligible: bool 

178 reasons: list[str] 

179 

180 

181@dataclass(frozen=True) 

182class IneligibleProduct: 

183 product_id: str 

184 product_name: str 

185 reasons: list[str] 

186 

187 

188@dataclass(frozen=True) 

189class PricingResult: 

190 count: int 

191 products: list[PricedProduct] 

192 ineligible_products: list[IneligibleProduct] 

193 

194 

195@dataclass(frozen=True) 

196class Recommendation: 

197 best: PricedProduct 

198 rationale: str 

199 alternatives: list[PricedProduct] 

200 

201 

202def approximate_apr( 

203 loan_amount: Number, 

204 note_rate: Number, 

205 term_months: int, 

206 finance_charge_dollars: Number, 

207) -> Decimal: 

208 """Solve for the exact APR via bisection numerical method over payment annuity stream.""" 

209 amt = _d(loan_amount) 

210 rate = _d(note_rate) 

211 charges = _d(finance_charge_dollars) 

212 n = term_months or 360 

213 

214 pmt = monthly_pi(amt, rate, n) 

215 financed = amt - charges 

216 

217 if financed <= 0 or pmt <= 0: 217 ↛ 218line 217 didn't jump to line 218 because the condition on line 217 was never true

218 return round2(rate) 

219 

220 def pv(i: float) -> float: 

221 if i == 0: 221 ↛ 222line 221 didn't jump to line 222 because the condition on line 221 was never true

222 return float(pmt * n) 

223 return float(pmt) * (1.0 - (1.0 + i) ** (-n)) / i 

224 

225 lo, hi = 0.0, 0.02 # up to 24% annual APR 

226 target = float(financed) 

227 

228 for _ in range(60): 

229 mid = (lo + hi) / 2.0 

230 if pv(mid) > target: 

231 lo = mid 

232 else: 

233 hi = mid 

234 

235 annual_apr = ((lo + hi) / 2.0) * 12.0 * 100.0 

236 return round2(Decimal(str(annual_apr))) 

237 

238 

239def price_adjustments(s: PricingScenario) -> list[PriceAdjustment]: 

240 """Calculate Fannie Mae / Freddie Mac LLPA price adjustments based on scenario risks.""" 

241 if s.property_value <= 0: 241 ↛ 242line 241 didn't jump to line 242 because the condition on line 241 was never true

242 return [] 

243 

244 ltv = (s.loan_amount / s.property_value) * _HUNDRED 

245 adj: list[PriceAdjustment] = [] 

246 

247 # FICO Score adjustments 

248 if s.fico < 680: 

249 adj.append( 

250 PriceAdjustment( 

251 code="LLPA_FICO", 

252 description=f"Credit score {s.fico} < 680", 

253 adjustment_type="PRICE", 

254 amount=Decimal("1.25"), 

255 ) 

256 ) 

257 elif s.fico < 740: 

258 adj.append( 

259 PriceAdjustment( 

260 code="LLPA_FICO", 

261 description=f"Credit score {s.fico} (680–739)", 

262 adjustment_type="PRICE", 

263 amount=Decimal("0.50"), 

264 ) 

265 ) 

266 

267 # LTV adjustment 

268 if ltv > Decimal("80.00"): 

269 adj.append( 

270 PriceAdjustment( 

271 code="LLPA_LTV", 

272 description=f"LTV {round2(ltv)}% > 80%", 

273 adjustment_type="PRICE", 

274 amount=Decimal("0.75"), 

275 ) 

276 ) 

277 

278 # Occupancy adjustments 

279 occ_str = str(s.occupancy) 

280 if occ_str == OccupancyType.INVESTMENT.value: 

281 adj.append( 

282 PriceAdjustment( 

283 code="LLPA_OCC", 

284 description="Investment property", 

285 adjustment_type="PRICE", 

286 amount=Decimal("2.125"), 

287 ) 

288 ) 

289 elif occ_str == OccupancyType.SECOND_HOME.value: 

290 adj.append( 

291 PriceAdjustment( 

292 code="LLPA_OCC", 

293 description="Second home", 

294 adjustment_type="PRICE", 

295 amount=Decimal("1.125"), 

296 ) 

297 ) 

298 

299 # Multi-unit property adjustment 

300 if s.number_of_units >= 2: 

301 adj.append( 

302 PriceAdjustment( 

303 code="LLPA_UNITS", 

304 description=f"{s.number_of_units}-unit property", 

305 adjustment_type="PRICE", 

306 amount=Decimal("1.00"), 

307 ) 

308 ) 

309 

310 # Non-occupant co-borrower 

311 if s.non_occupant_co_borrower: 

312 adj.append( 

313 PriceAdjustment( 

314 code="LLPA_NONOCC", 

315 description="Non-occupant co-borrower", 

316 adjustment_type="PRICE", 

317 amount=Decimal("0.375"), 

318 ) 

319 ) 

320 

321 return adj 

322 

323 

324def price_scenario( 

325 s: PricingScenario, products: Sequence[RateProductInput] 

326) -> list[PricedProduct]: 

327 """Price all candidate rate products against a borrower pricing scenario.""" 

328 if s.loan_amount <= 0 or s.property_value <= 0: 

329 return [] 

330 

331 ltv = round2((s.loan_amount / s.property_value) * _HUNDRED) 

332 adjustments = price_adjustments(s) 

333 total_adj = sum((a.amount for a in adjustments), ZERO) 

334 escrow = s.monthly_taxes + s.monthly_hazard + s.monthly_hoa + s.monthly_mi 

335 

336 results: list[PricedProduct] = [] 

337 

338 for p in products: 

339 reasons: list[str] = [] 

340 if s.fico < p.min_fico: 

341 reasons.append(f"FICO {s.fico} below {p.min_fico}") 

342 if ltv > p.max_ltv: 

343 reasons.append(f"LTV {ltv}% above {p.max_ltv}%") 

344 if str(s.occupancy) not in p.occupancies: 

345 reasons.append(f"Occupancy {s.occupancy} not allowed") 

346 

347 term = p.term_months or s.term_months 

348 price = round3(p.base_price - total_adj) 

349 pi = monthly_pi(s.loan_amount, p.base_rate, term) 

350 

351 # Points cost relative to par (100.00). Below par = borrower cost (positive discount points) 

352 points_cost = round2(((Decimal("100") - price) / _HUNDRED) * s.loan_amount) 

353 origination_amt = round2(max(ZERO, points_cost) + Decimal("1295")) 

354 title_amt = round2(s.loan_amount * Decimal("0.004") + Decimal("650")) 

355 prepaids_amt = round2(escrow * Decimal("3") + s.monthly_hazard * Decimal("12")) 

356 

357 items = [ 

358 ClosingCostItem(label="Origination charges (A)", amount=origination_amt), 

359 ClosingCostItem(label="Third-party services (B/C)", amount=title_amt), 

360 ClosingCostItem(label="Prepaids & escrow (F/G)", amount=prepaids_amt), 

361 ] 

362 total_closing_costs = round2(origination_amt + title_amt + prepaids_amt) 

363 closing_costs = ClosingCostBreakdown( 

364 origination_amt=origination_amt, 

365 third_party_amt=title_amt, 

366 prepaids_amt=prepaids_amt, 

367 total=total_closing_costs, 

368 items=items, 

369 ) 

370 

371 comp_pct = p.comp_pct if p.comp_pct > ZERO else Decimal("1.25") 

372 lo_comp = round2(s.loan_amount * (comp_pct / _HUNDRED)) 

373 comp_type_val = str(s.comp_type or p.comp_type) 

374 lo_comp_detail = LoCompDetail( 

375 comp_type=comp_type_val, 

376 pct=comp_pct, 

377 amt=lo_comp, 

378 ) 

379 

380 finance_charge = origination_amt + ( 

381 lo_comp if comp_type_val == CompType.BORROWER_PAID.value else ZERO 

382 ) 

383 apr = approximate_apr(s.loan_amount, p.base_rate, term, finance_charge) 

384 piti = round2(pi + escrow) 

385 

386 # Rate stack: 9 rungs around base note rate in 0.125% steps (slope: 4 pts price / 1% rate) 

387 deltas = ( 

388 Decimal("-0.500"), 

389 Decimal("-0.375"), 

390 Decimal("-0.250"), 

391 Decimal("-0.125"), 

392 Decimal("0.000"), 

393 Decimal("0.125"), 

394 Decimal("0.250"), 

395 Decimal("0.375"), 

396 Decimal("0.500"), 

397 ) 

398 rate_stack = [ 

399 RateStackItem( 

400 rate=round3(p.base_rate + d), 

401 price=round3(price - d * Decimal("4")), 

402 points_pct=round3(Decimal("100") - (price - d * Decimal("4"))), 

403 ) 

404 for d in deltas 

405 ] 

406 

407 today_str = date.today().isoformat() 

408 rate_sheet_id = f"RS-{p.id}-{today_str}" 

409 

410 results.append( 

411 PricedProduct( 

412 product=p, 

413 rate=p.base_rate, 

414 price=price, 

415 pi=pi, 

416 apr=apr, 

417 piti=piti, 

418 lo_comp=lo_comp, 

419 lo_comp_detail=lo_comp_detail, 

420 closing_costs=closing_costs, 

421 adjustments=adjustments, 

422 rate_stack=rate_stack, 

423 lock_period_days=s.lock_period_days, 

424 rate_sheet_id=rate_sheet_id, 

425 eligible=len(reasons) == 0, 

426 reasons=reasons, 

427 ) 

428 ) 

429 

430 return results 

431 

432 

433def to_pricing_result(priced: Sequence[PricedProduct]) -> PricingResult: 

434 """Format priced products into a full PricingResult object.""" 

435 eligible = [p for p in priced if p.eligible] 

436 ineligible = [ 

437 IneligibleProduct( 

438 product_id=p.product.id, 

439 product_name=p.product.name, 

440 reasons=p.reasons, 

441 ) 

442 for p in priced 

443 if not p.eligible 

444 ] 

445 return PricingResult( 

446 count=len(eligible), 

447 products=eligible, 

448 ineligible_products=ineligible, 

449 ) 

450 

451 

452def price_manual( 

453 s: PricingScenario, 

454 investor: str, 

455 name: str, 

456 rate: Number, 

457 price: Number, 

458 term_months: int | None = None, 

459 comp_pct: Number = Decimal("1.25"), 

460) -> PricedProduct: 

461 """Price a manual / non-integrated lender scenario.""" 

462 clean_investor = investor.replace(" ", "_") 

463 product = RateProductInput( 

464 id=f"MANUAL-{clean_investor}", 

465 investor=investor, 

466 name=name, 

467 mortgage_type=MortgageType.CONVENTIONAL, 

468 term_months=term_months or s.term_months, 

469 base_rate=_d(rate), 

470 base_price=_d(price), 

471 min_fico=0, 

472 max_ltv=Decimal("100.00"), 

473 occupancies=(str(s.occupancy),), 

474 comp_type=CompType.BORROWER_PAID, 

475 comp_pct=_d(comp_pct), 

476 ) 

477 return price_scenario(s, [product])[0] 

478 

479 

480def recommend( 

481 priced: Sequence[PricedProduct], goal: PricingGoal | str 

482) -> Recommendation | None: 

483 """Agentic recommendation pick: rank eligible products according to borrower goal.""" 

484 eligible = [p for p in priced if p.eligible] 

485 if not eligible: 485 ↛ 486line 485 didn't jump to line 486 because the condition on line 485 was never true

486 return None 

487 

488 goal_str = str(goal) 

489 

490 def score(p: PricedProduct) -> Decimal: 

491 if goal_str == PricingGoal.LOWEST_PAYMENT.value: 

492 return -p.pi 

493 if goal_str == PricingGoal.BEST_LONG_TERM.value: 493 ↛ 494line 493 didn't jump to line 494 because the condition on line 493 was never true

494 return -(p.rate * Decimal(p.product.term_months or 360)) 

495 if goal_str == PricingGoal.LEAST_CASH_TO_CLOSE.value: 

496 return p.price 

497 if goal_str == PricingGoal.FASTEST_CLOSE.value: 497 ↛ 498line 497 didn't jump to line 498 because the condition on line 497 was never true

498 is_conv = Decimal("1") if str(p.product.mortgage_type) == MortgageType.CONVENTIONAL.value else ZERO 

499 return is_conv - (p.rate / _HUNDRED) 

500 if goal_str == PricingGoal.MAX_CASH_OUT.value: 500 ↛ 502line 500 didn't jump to line 502 because the condition on line 500 was always true

501 return p.product.max_ltv 

502 if goal_str == PricingGoal.CREDIT_SENSITIVE.value: 

503 return -Decimal(p.product.min_fico) 

504 return ZERO 

505 

506 ranked = sorted(eligible, key=score, reverse=True) 

507 best = ranked[0] 

508 

509 rationales: dict[str, str] = { 

510 PricingGoal.LOWEST_PAYMENT.value: f"Lowest monthly P&I (${best.pi}) among eligible products.", 

511 PricingGoal.BEST_LONG_TERM.value: f"Lowest lifetime interest at {best.rate}% over {(best.product.term_months or 360) // 12} years.", 

512 PricingGoal.LEAST_CASH_TO_CLOSE.value: f"Best price ({best.price}) applies the most credit toward closing costs.", 

513 PricingGoal.FASTEST_CLOSE.value: f"{best.product.mortgage_type} product with Day-1 Certainty eligibility for a faster close.", 

514 PricingGoal.MAX_CASH_OUT.value: f"Allows up to {best.product.max_ltv}% — the most equity for cash-out.", 

515 PricingGoal.CREDIT_SENSITIVE.value: f"Most forgiving FICO floor ({best.product.min_fico}).", 

516 } 

517 

518 return Recommendation( 

519 best=best, 

520 rationale=rationales.get(goal_str, "Best matching product for your scenario."), 

521 alternatives=ranked[1:3], 

522 )