749 lines
26 KiB
TypeScript
749 lines
26 KiB
TypeScript
/**
|
|
* rMortgage Amortization Engine
|
|
*
|
|
* Pure math functions — no React dependencies.
|
|
* Handles amortization, tranche splitting, overpayment distribution,
|
|
* and comparison with traditional mortgages.
|
|
*/
|
|
|
|
import type {
|
|
MortgageTranche,
|
|
MortgageState,
|
|
MortgageSimulatorConfig,
|
|
AmortizationEntry,
|
|
Lender,
|
|
LendingTier,
|
|
} from './mortgage-types'
|
|
import { DEFAULT_CONFIG } from './mortgage-types'
|
|
|
|
// ─── Core Amortization Math ──────────────────────────────
|
|
|
|
/** Monthly payment for a fixed-rate fully-amortizing loan */
|
|
export function monthlyPayment(principal: number, annualRate: number, termMonths: number): number {
|
|
if (annualRate === 0) return principal / termMonths
|
|
const r = annualRate / 12
|
|
return principal * (r * Math.pow(1 + r, termMonths)) / (Math.pow(1 + r, termMonths) - 1)
|
|
}
|
|
|
|
/** Interest portion of a payment given current balance */
|
|
export function interestPortion(balance: number, annualRate: number): number {
|
|
return balance * (annualRate / 12)
|
|
}
|
|
|
|
/** Generate full amortization schedule for a single loan */
|
|
export function amortizationSchedule(
|
|
principal: number,
|
|
annualRate: number,
|
|
termMonths: number,
|
|
monthlyOverpayment: number = 0,
|
|
): AmortizationEntry[] {
|
|
const schedule: AmortizationEntry[] = []
|
|
let balance = principal
|
|
const basePayment = monthlyPayment(principal, annualRate, termMonths)
|
|
let cumulativeInterest = 0
|
|
let cumulativePrincipal = 0
|
|
|
|
for (let month = 1; month <= termMonths && balance > 0.01; month++) {
|
|
const interest = interestPortion(balance, annualRate)
|
|
const totalPayment = Math.min(balance + interest, basePayment + monthlyOverpayment)
|
|
const principalPaid = totalPayment - interest
|
|
balance = Math.max(0, balance - principalPaid)
|
|
cumulativeInterest += interest
|
|
cumulativePrincipal += principalPaid
|
|
|
|
schedule.push({
|
|
month,
|
|
payment: totalPayment,
|
|
principal: principalPaid,
|
|
interest,
|
|
balance,
|
|
cumulativeInterest,
|
|
cumulativePrincipal,
|
|
})
|
|
}
|
|
|
|
return schedule
|
|
}
|
|
|
|
// ─── Lender Name Generator ───────────────────────────────
|
|
|
|
const FIRST_NAMES = [
|
|
'Alice', 'Bob', 'Carmen', 'David', 'Elena', 'Frank', 'Grace', 'Hassan',
|
|
'Iris', 'James', 'Kira', 'Leo', 'Maya', 'Noah', 'Olga', 'Pat',
|
|
'Quinn', 'Rosa', 'Sam', 'Tara', 'Uma', 'Victor', 'Wren', 'Xavier',
|
|
'Yara', 'Zane', 'Amara', 'Basil', 'Clara', 'Diego', 'Ella', 'Felix',
|
|
'Gia', 'Hugo', 'Ivy', 'Jules', 'Kaia', 'Liam', 'Mira', 'Nico',
|
|
'Opal', 'Priya', 'Ravi', 'Sage', 'Teo', 'Una', 'Val', 'Willow',
|
|
'Xena', 'Yuri', 'Zara', 'Arlo', 'Bea', 'Cruz', 'Dahlia', 'Elio',
|
|
'Flora', 'Gael', 'Hana', 'Idris', 'June', 'Kai', 'Luz', 'Marcel',
|
|
'Noor', 'Owen', 'Pearl', 'Rio', 'Soren', 'Thea', 'Umar', 'Vera',
|
|
]
|
|
|
|
function generateLender(index: number): Lender {
|
|
const name = FIRST_NAMES[index % FIRST_NAMES.length]
|
|
const suffix = index >= FIRST_NAMES.length ? ` ${Math.floor(index / FIRST_NAMES.length) + 1}` : ''
|
|
return {
|
|
id: `lender-${index}`,
|
|
name: `${name}${suffix}`,
|
|
walletAddress: `0x${index.toString(16).padStart(40, '0')}`,
|
|
}
|
|
}
|
|
|
|
// ─── Initialize Mortgage State ───────────────────────────
|
|
|
|
export function initializeMortgage(config: MortgageSimulatorConfig = DEFAULT_CONFIG): MortgageState {
|
|
const totalPrincipal = config.propertyValue * (1 - config.downPaymentPercent / 100)
|
|
const numTranches = Math.ceil(totalPrincipal / config.trancheSize)
|
|
const defaultTermMonths = config.termYears * 12
|
|
|
|
// Create tranches with optional rate variation
|
|
const tranches: MortgageTranche[] = []
|
|
let remainingPrincipal = totalPrincipal
|
|
|
|
// Determine which tranches are funded vs open
|
|
const fundedCount = Math.round(numTranches * (config.fundingPercent / 100))
|
|
|
|
// Determine which lenders are reinvestors (among funded tranches only)
|
|
const reinvestorCount = Math.round(fundedCount * (config.reinvestorPercent / 100))
|
|
const reinvestRates = config.reinvestmentRates.length > 0 ? config.reinvestmentRates : [0.05]
|
|
|
|
// Build tier assignments: distribute tranches across tiers by allocation
|
|
const tiers = config.useVariableTerms ? config.lendingTiers : []
|
|
const tierAssignments: (LendingTier | null)[] = []
|
|
if (tiers.length > 0) {
|
|
// Normalize allocations
|
|
const totalAlloc = tiers.reduce((s, t) => s + t.allocation, 0)
|
|
let assigned = 0
|
|
for (const tier of tiers) {
|
|
const count = Math.round((tier.allocation / totalAlloc) * numTranches)
|
|
for (let j = 0; j < count && assigned < numTranches; j++) {
|
|
tierAssignments.push(tier)
|
|
assigned++
|
|
}
|
|
}
|
|
// Fill any remainder with the last tier
|
|
while (tierAssignments.length < numTranches) {
|
|
tierAssignments.push(tiers[tiers.length - 1])
|
|
}
|
|
}
|
|
|
|
for (let i = 0; i < numTranches; i++) {
|
|
const principal = Math.min(config.trancheSize, remainingPrincipal)
|
|
remainingPrincipal -= principal
|
|
|
|
// Determine rate and term from tier or base config
|
|
const tier = tierAssignments[i] ?? null
|
|
const termMonths = tier ? tier.termYears * 12 : defaultTermMonths
|
|
const tierLabel = tier ? tier.label : `${config.termYears}yr`
|
|
|
|
// Apply rate variation: spread linearly across tranches
|
|
const baseRate = tier ? tier.rate : config.interestRate
|
|
const variationOffset = config.rateVariation > 0
|
|
? config.rateVariation * (2 * (i / Math.max(numTranches - 1, 1)) - 1)
|
|
: 0
|
|
const rate = Math.max(0.01, baseRate + variationOffset)
|
|
|
|
const isFunded = i < fundedCount
|
|
const mp = monthlyPayment(principal, rate, termMonths)
|
|
|
|
// Assign reinvestment strategy: spread reinvestors evenly among funded
|
|
const isReinvestor = isFunded && i < reinvestorCount
|
|
const reinvestmentRate = isReinvestor
|
|
? reinvestRates[i % reinvestRates.length]
|
|
: undefined
|
|
|
|
tranches.push({
|
|
id: `tranche-${i}`,
|
|
lender: generateLender(i),
|
|
principal,
|
|
principalRemaining: principal,
|
|
interestRate: rate,
|
|
termMonths,
|
|
monthsElapsed: 0,
|
|
monthlyPayment: isFunded ? mp : 0,
|
|
monthlyPrincipal: isFunded ? mp - interestPortion(principal, rate) : 0,
|
|
monthlyInterest: isFunded ? interestPortion(principal, rate) : 0,
|
|
totalInterestPaid: 0,
|
|
totalPrincipalPaid: 0,
|
|
transferable: true,
|
|
listedForSale: false,
|
|
transferHistory: [],
|
|
status: 'active',
|
|
funded: isFunded,
|
|
tierLabel,
|
|
reinvestmentRate,
|
|
isReinvested: false,
|
|
reinvestmentPool: 0,
|
|
})
|
|
}
|
|
|
|
const totalMonthly = tranches.reduce((s, t) => s + t.monthlyPayment, 0)
|
|
const tradMonthly = monthlyPayment(totalPrincipal, config.interestRate, defaultTermMonths)
|
|
const tradSchedule = amortizationSchedule(totalPrincipal, config.interestRate, defaultTermMonths)
|
|
const tradTotalInterest = tradSchedule.length > 0
|
|
? tradSchedule[tradSchedule.length - 1].cumulativeInterest
|
|
: 0
|
|
|
|
return {
|
|
propertyValue: config.propertyValue,
|
|
downPayment: config.propertyValue * (config.downPaymentPercent / 100),
|
|
totalPrincipal,
|
|
trancheSize: config.trancheSize,
|
|
baseInterestRate: config.interestRate,
|
|
termMonths: defaultTermMonths,
|
|
borrower: {
|
|
id: 'borrower-0',
|
|
name: 'Borrower',
|
|
walletAddress: '0x' + 'b'.repeat(40),
|
|
},
|
|
tranches,
|
|
currentMonth: 0,
|
|
monthlyPayment: totalMonthly,
|
|
overpayment: config.overpayment,
|
|
overpaymentTarget: config.overpaymentTarget,
|
|
totalInterestPaid: 0,
|
|
totalPrincipalPaid: 0,
|
|
totalPrincipalRemaining: totalPrincipal,
|
|
tranchesRepaid: 0,
|
|
communityFundBalance: 0,
|
|
traditionalTotalInterest: tradTotalInterest,
|
|
traditionalMonthlyPayment: tradMonthly,
|
|
}
|
|
}
|
|
|
|
// ─── Advance Simulation by One Month ─────────────────────
|
|
|
|
export function advanceMonth(state: MortgageState): MortgageState {
|
|
const next = { ...state, tranches: state.tranches.map(t => ({ ...t, transferHistory: [...t.transferHistory] })) }
|
|
next.currentMonth += 1
|
|
|
|
const activeTranches = next.tranches.filter(t => t.status === 'active' && t.funded)
|
|
if (activeTranches.length === 0) return next
|
|
|
|
// Distribute overpayment
|
|
let overpaymentPool = next.overpayment
|
|
let communityContribution = 0
|
|
|
|
if (overpaymentPool > 0) {
|
|
switch (next.overpaymentTarget) {
|
|
case 'community_fund':
|
|
communityContribution = overpaymentPool
|
|
overpaymentPool = 0
|
|
break
|
|
case 'split':
|
|
communityContribution = overpaymentPool / 2
|
|
overpaymentPool = overpaymentPool / 2
|
|
break
|
|
case 'extra_principal':
|
|
// all goes to extra principal
|
|
break
|
|
}
|
|
}
|
|
|
|
// Extra principal distributed evenly across active tranches
|
|
const extraPrincipalPerTranche = activeTranches.length > 0
|
|
? overpaymentPool / activeTranches.length
|
|
: 0
|
|
|
|
let totalInterestThisMonth = 0
|
|
let totalPrincipalThisMonth = 0
|
|
|
|
// Track newly repaid reinvestors for reinvestment
|
|
const newlyRepaidReinvestors: MortgageTranche[] = []
|
|
|
|
for (const tranche of next.tranches) {
|
|
if (tranche.status === 'repaid' || !tranche.funded) continue
|
|
|
|
const interest = interestPortion(tranche.principalRemaining, tranche.interestRate)
|
|
const basePrincipal = tranche.monthlyPayment - interest
|
|
const extraPrincipal = extraPrincipalPerTranche
|
|
const principalPaid = Math.min(tranche.principalRemaining, basePrincipal + extraPrincipal)
|
|
|
|
tranche.principalRemaining = Math.max(0, tranche.principalRemaining - principalPaid)
|
|
tranche.monthsElapsed += 1
|
|
tranche.monthlyInterest = interest
|
|
tranche.monthlyPrincipal = principalPaid
|
|
tranche.totalInterestPaid += interest
|
|
tranche.totalPrincipalPaid += principalPaid
|
|
|
|
totalInterestThisMonth += interest
|
|
totalPrincipalThisMonth += principalPaid
|
|
|
|
// Reinvestors accumulate their received payments
|
|
if (tranche.reinvestmentRate != null) {
|
|
tranche.reinvestmentPool += principalPaid + interest
|
|
}
|
|
|
|
if (tranche.principalRemaining < 0.01) {
|
|
tranche.principalRemaining = 0
|
|
tranche.status = 'repaid'
|
|
if (tranche.reinvestmentRate != null) {
|
|
newlyRepaidReinvestors.push(tranche)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create reinvestment tranches from repaid reinvestors
|
|
for (const repaid of newlyRepaidReinvestors) {
|
|
const reinvestAmount = repaid.reinvestmentPool
|
|
if (reinvestAmount < 100) continue // minimum reinvestment threshold
|
|
|
|
const rate = repaid.reinvestmentRate!
|
|
const remainingTermMonths = Math.max(12, next.termMonths - next.currentMonth)
|
|
const mp = monthlyPayment(reinvestAmount, rate, remainingTermMonths)
|
|
|
|
const newTranche: MortgageTranche = {
|
|
id: `reinvest-${repaid.id}-m${next.currentMonth}`,
|
|
lender: repaid.lender, // same lender reinvests
|
|
principal: reinvestAmount,
|
|
principalRemaining: reinvestAmount,
|
|
interestRate: rate,
|
|
termMonths: remainingTermMonths,
|
|
monthsElapsed: 0,
|
|
monthlyPayment: mp,
|
|
monthlyPrincipal: mp - interestPortion(reinvestAmount, rate),
|
|
monthlyInterest: interestPortion(reinvestAmount, rate),
|
|
totalInterestPaid: 0,
|
|
totalPrincipalPaid: 0,
|
|
transferable: true,
|
|
listedForSale: false,
|
|
transferHistory: [],
|
|
status: 'active',
|
|
funded: true,
|
|
tierLabel: `reinvest@${(rate * 100).toFixed(0)}%`,
|
|
reinvestmentRate: rate, // continues reinvesting
|
|
isReinvested: true,
|
|
parentTrancheId: repaid.id,
|
|
reinvestmentPool: 0,
|
|
}
|
|
|
|
next.tranches.push(newTranche)
|
|
repaid.reinvestmentPool = 0
|
|
}
|
|
|
|
// Also create reinvestment tranches periodically for active reinvestors
|
|
// who've accumulated enough in their pool (annual reinvestment cycle)
|
|
if (next.currentMonth % 12 === 0) {
|
|
for (const tranche of next.tranches) {
|
|
if (tranche.status !== 'active' || tranche.reinvestmentRate == null) continue
|
|
if (tranche.reinvestmentPool < 500) continue // need at least $500 to reinvest
|
|
|
|
const reinvestAmount = tranche.reinvestmentPool
|
|
const rate = tranche.reinvestmentRate
|
|
const remainingTermMonths = Math.max(12, next.termMonths - next.currentMonth)
|
|
const mp = monthlyPayment(reinvestAmount, rate, remainingTermMonths)
|
|
|
|
const newTranche: MortgageTranche = {
|
|
id: `reinvest-${tranche.id}-m${next.currentMonth}`,
|
|
lender: tranche.lender,
|
|
principal: reinvestAmount,
|
|
principalRemaining: reinvestAmount,
|
|
interestRate: rate,
|
|
termMonths: remainingTermMonths,
|
|
monthsElapsed: 0,
|
|
monthlyPayment: mp,
|
|
monthlyPrincipal: mp - interestPortion(reinvestAmount, rate),
|
|
monthlyInterest: interestPortion(reinvestAmount, rate),
|
|
totalInterestPaid: 0,
|
|
totalPrincipalPaid: 0,
|
|
transferable: true,
|
|
listedForSale: false,
|
|
transferHistory: [],
|
|
status: 'active',
|
|
funded: true,
|
|
tierLabel: `reinvest@${(rate * 100).toFixed(0)}%`,
|
|
reinvestmentRate: rate,
|
|
isReinvested: true,
|
|
parentTrancheId: tranche.id,
|
|
reinvestmentPool: 0,
|
|
}
|
|
|
|
next.tranches.push(newTranche)
|
|
tranche.reinvestmentPool = 0
|
|
}
|
|
}
|
|
|
|
// Simulate some secondary market activity (transfers every ~6 months for a few tranches)
|
|
if (next.currentMonth % 6 === 0 && next.currentMonth > 0) {
|
|
const activeForSale = next.tranches.filter(t =>
|
|
t.status === 'active' && !t.isReinvested && t.monthsElapsed > 12
|
|
)
|
|
// ~5% of eligible tranches get transferred per cycle
|
|
const transferCount = Math.max(0, Math.floor(activeForSale.length * 0.05))
|
|
for (let i = 0; i < transferCount; i++) {
|
|
const idx = (next.currentMonth * 7 + i * 13) % activeForSale.length
|
|
const tranche = activeForSale[idx]
|
|
if (!tranche || tranche.transferHistory.length >= 3) continue
|
|
|
|
const buyerIdx = next.tranches.length + 100 + next.currentMonth + i
|
|
const buyer = generateLender(buyerIdx)
|
|
const premium = 1 + (0.02 + (idx % 5) * 0.01) // 2-6% premium
|
|
const price = tranche.principalRemaining * premium
|
|
|
|
tranche.transferHistory.push({
|
|
id: `transfer-${tranche.id}-m${next.currentMonth}`,
|
|
fromLenderId: tranche.lender.id,
|
|
toLenderId: buyer.id,
|
|
price,
|
|
principalRemaining: tranche.principalRemaining,
|
|
premiumPercent: (premium - 1) * 100,
|
|
date: next.currentMonth,
|
|
})
|
|
tranche.lender = buyer
|
|
}
|
|
}
|
|
|
|
next.totalInterestPaid += totalInterestThisMonth
|
|
next.totalPrincipalPaid += totalPrincipalThisMonth
|
|
next.totalPrincipalRemaining = next.tranches.reduce((s, t) => s + t.principalRemaining, 0)
|
|
next.tranchesRepaid = next.tranches.filter(t => t.status === 'repaid').length
|
|
next.communityFundBalance += communityContribution
|
|
|
|
// Recalculate monthly payment (decreases as tranches are repaid)
|
|
next.monthlyPayment = next.tranches
|
|
.filter(t => t.status === 'active')
|
|
.reduce((s, t) => s + t.monthlyPayment, 0)
|
|
|
|
return next
|
|
}
|
|
|
|
// ─── Run Full Simulation ─────────────────────────────────
|
|
|
|
export function runFullSimulation(config: MortgageSimulatorConfig): MortgageState[] {
|
|
const states: MortgageState[] = []
|
|
let state = initializeMortgage(config)
|
|
states.push(state)
|
|
|
|
const maxMonths = config.termYears * 12
|
|
for (let m = 0; m < maxMonths; m++) {
|
|
if (state.totalPrincipalRemaining < 0.01) break
|
|
state = advanceMonth(state)
|
|
states.push(state)
|
|
}
|
|
|
|
return states
|
|
}
|
|
|
|
// ─── Tranche Transfer (Secondary Market) ─────────────────
|
|
|
|
export function transferTranche(
|
|
state: MortgageState,
|
|
trancheId: string,
|
|
buyerLender: Lender,
|
|
price: number,
|
|
): MortgageState {
|
|
const next = { ...state, tranches: state.tranches.map(t => ({ ...t, transferHistory: [...t.transferHistory] })) }
|
|
|
|
const tranche = next.tranches.find(t => t.id === trancheId)
|
|
if (!tranche || tranche.status === 'repaid' || !tranche.transferable) return state
|
|
|
|
const premiumPercent = ((price - tranche.principalRemaining) / tranche.principalRemaining) * 100
|
|
|
|
tranche.transferHistory.push({
|
|
id: `transfer-${tranche.transferHistory.length}`,
|
|
fromLenderId: tranche.lender.id,
|
|
toLenderId: buyerLender.id,
|
|
price,
|
|
principalRemaining: tranche.principalRemaining,
|
|
premiumPercent,
|
|
date: Date.now(),
|
|
})
|
|
|
|
tranche.lender = buyerLender
|
|
tranche.listedForSale = false
|
|
tranche.askingPrice = undefined
|
|
|
|
return next
|
|
}
|
|
|
|
// ─── Yield Calculator for Secondary Market ───────────────
|
|
|
|
export function calculateBuyerYield(
|
|
tranche: MortgageTranche,
|
|
purchasePrice: number,
|
|
): { annualYield: number; totalReturn: number; monthsRemaining: number } {
|
|
const remainingPrincipal = tranche.principalRemaining
|
|
const monthsRemaining = tranche.termMonths - tranche.monthsElapsed
|
|
|
|
// Estimate total remaining payments
|
|
let totalPayments = 0
|
|
let balance = remainingPrincipal
|
|
for (let m = 0; m < monthsRemaining && balance > 0.01; m++) {
|
|
const interest = balance * (tranche.interestRate / 12)
|
|
const payment = tranche.monthlyPayment
|
|
const principal = Math.min(balance, payment - interest)
|
|
totalPayments += payment
|
|
balance -= principal
|
|
}
|
|
|
|
const totalReturn = totalPayments - purchasePrice
|
|
const annualYield = monthsRemaining > 0
|
|
? (totalReturn / purchasePrice) / (monthsRemaining / 12) * 100
|
|
: 0
|
|
|
|
return { annualYield, totalReturn, monthsRemaining }
|
|
}
|
|
|
|
// ─── Summary Stats ───────────────────────────────────────
|
|
|
|
export interface MortgageSummary {
|
|
// Myco
|
|
mycoTotalInterest: number
|
|
mycoMonthlyPayment: number
|
|
mycoPayoffMonths: number
|
|
avgLenderYield: number
|
|
communityRetained: number // interest that stays in community
|
|
// Traditional
|
|
tradTotalInterest: number
|
|
tradMonthlyPayment: number
|
|
tradPayoffMonths: number
|
|
// Delta
|
|
interestSaved: number
|
|
monthsSaved: number
|
|
}
|
|
|
|
export function computeSummary(states: MortgageState[]): MortgageSummary {
|
|
const final = states[states.length - 1]
|
|
const initial = states[0]
|
|
|
|
const mycoPayoffMonths = final.currentMonth
|
|
const tradSchedule = amortizationSchedule(
|
|
initial.totalPrincipal,
|
|
initial.baseInterestRate,
|
|
initial.termMonths,
|
|
)
|
|
const tradPayoffMonths = tradSchedule.length
|
|
|
|
// Average lender yield: weighted by tranche size
|
|
const totalWeight = final.tranches.reduce((s, t) => s + t.principal, 0)
|
|
const avgYield = totalWeight > 0
|
|
? final.tranches.reduce((s, t) => {
|
|
const yieldPct = t.principal > 0 ? (t.totalInterestPaid / t.principal) / (mycoPayoffMonths / 12) * 100 : 0
|
|
return s + yieldPct * (t.principal / totalWeight)
|
|
}, 0)
|
|
: 0
|
|
|
|
return {
|
|
mycoTotalInterest: final.totalInterestPaid,
|
|
mycoMonthlyPayment: initial.monthlyPayment,
|
|
mycoPayoffMonths,
|
|
avgLenderYield: avgYield,
|
|
communityRetained: final.totalInterestPaid, // all interest stays local
|
|
tradTotalInterest: final.traditionalTotalInterest,
|
|
tradMonthlyPayment: initial.traditionalMonthlyPayment,
|
|
tradPayoffMonths,
|
|
interestSaved: final.traditionalTotalInterest - final.totalInterestPaid,
|
|
monthsSaved: tradPayoffMonths - mycoPayoffMonths,
|
|
}
|
|
}
|
|
|
|
// ─── Lender Return Calculator ────────────────────────────
|
|
|
|
export interface LenderReturnScenario {
|
|
tierLabel: string
|
|
termYears: number
|
|
rate: number
|
|
investment: number
|
|
// Monthly liquidity: take all returns each month
|
|
liquid: {
|
|
monthlyPayment: number
|
|
totalInterest: number
|
|
totalReturn: number
|
|
effectiveYield: number // annualized
|
|
}
|
|
// Reinvest to term: compound returns at the same rate
|
|
reinvested: {
|
|
finalValue: number
|
|
totalInterest: number
|
|
totalReturn: number
|
|
effectiveYield: number // annualized
|
|
}
|
|
}
|
|
|
|
export function calculateLenderReturns(
|
|
investment: number,
|
|
tiers: { label: string; termYears: number; rate: number }[],
|
|
): LenderReturnScenario[] {
|
|
return tiers.map(tier => {
|
|
const termMonths = tier.termYears * 12
|
|
const r = tier.rate / 12
|
|
const mp = r === 0
|
|
? investment / termMonths
|
|
: investment * (r * Math.pow(1 + r, termMonths)) / (Math.pow(1 + r, termMonths) - 1)
|
|
|
|
// Liquid: lender receives mp each month, keeps interest, gets principal back over time
|
|
const liquidTotalPayments = mp * termMonths
|
|
const liquidTotalInterest = liquidTotalPayments - investment
|
|
const liquidEffectiveYield = investment > 0
|
|
? (liquidTotalInterest / investment) / tier.termYears * 100
|
|
: 0
|
|
|
|
// Reinvested: compound monthly payments at the tier rate
|
|
// Each monthly payment is reinvested and earns interest for remaining months
|
|
let reinvestAccum = 0
|
|
for (let m = 0; m < termMonths; m++) {
|
|
const monthsRemaining = termMonths - m - 1
|
|
// Future value of this month's payment compounded for remaining months
|
|
reinvestAccum += mp * Math.pow(1 + r, monthsRemaining)
|
|
}
|
|
const reinvestTotalInterest = reinvestAccum - investment
|
|
const reinvestEffectiveYield = investment > 0 && tier.termYears > 0
|
|
? (Math.pow(reinvestAccum / investment, 1 / tier.termYears) - 1) * 100
|
|
: 0
|
|
|
|
return {
|
|
tierLabel: tier.label,
|
|
termYears: tier.termYears,
|
|
rate: tier.rate,
|
|
investment,
|
|
liquid: {
|
|
monthlyPayment: mp,
|
|
totalInterest: liquidTotalInterest,
|
|
totalReturn: liquidTotalPayments,
|
|
effectiveYield: liquidEffectiveYield,
|
|
},
|
|
reinvested: {
|
|
finalValue: reinvestAccum,
|
|
totalInterest: reinvestTotalInterest,
|
|
totalReturn: reinvestAccum,
|
|
effectiveYield: reinvestEffectiveYield,
|
|
},
|
|
}
|
|
})
|
|
}
|
|
|
|
// ─── Borrower Affordability Calculator ───────────────────
|
|
|
|
/** Given a monthly payment, how much can you borrow at a given rate and term? */
|
|
export function maxPrincipal(monthlyBudget: number, annualRate: number, termMonths: number): number {
|
|
if (annualRate === 0) return monthlyBudget * termMonths
|
|
const r = annualRate / 12
|
|
return monthlyBudget * (Math.pow(1 + r, termMonths) - 1) / (r * Math.pow(1 + r, termMonths))
|
|
}
|
|
|
|
export interface AffordabilityScenario {
|
|
label: string
|
|
description: string
|
|
// Tier mix used
|
|
tiers: { label: string; termYears: number; rate: number; allocation: number }[]
|
|
// Results
|
|
maxLoan: number
|
|
propertyValue: number // with down payment
|
|
monthlyPayment: number
|
|
totalInterest: number
|
|
totalPaid: number
|
|
payoffYears: number
|
|
// Per-tier breakdown
|
|
breakdown: {
|
|
tierLabel: string
|
|
principal: number
|
|
monthlyPayment: number
|
|
totalInterest: number
|
|
termYears: number
|
|
rate: number
|
|
}[]
|
|
}
|
|
|
|
export function calculateAffordability(
|
|
monthlyBudget: number,
|
|
downPaymentPercent: number,
|
|
tiers: { label: string; termYears: number; rate: number; allocation: number }[],
|
|
): AffordabilityScenario[] {
|
|
const scenarios: AffordabilityScenario[] = []
|
|
|
|
// Helper: compute a scenario for a given tier mix
|
|
function compute(
|
|
label: string,
|
|
description: string,
|
|
mix: { label: string; termYears: number; rate: number; allocation: number }[],
|
|
): AffordabilityScenario {
|
|
// Each tier gets a fraction of the monthly budget proportional to its allocation
|
|
const totalAlloc = mix.reduce((s, t) => s + t.allocation, 0)
|
|
const breakdown = mix.map(tier => {
|
|
const allocFrac = tier.allocation / totalAlloc
|
|
const tierBudget = monthlyBudget * allocFrac
|
|
const termMonths = tier.termYears * 12
|
|
const principal = maxPrincipal(tierBudget, tier.rate, termMonths)
|
|
const mp = tierBudget
|
|
const totalPaid = mp * termMonths
|
|
const totalInterest = totalPaid - principal
|
|
return {
|
|
tierLabel: tier.label,
|
|
principal,
|
|
monthlyPayment: mp,
|
|
totalInterest,
|
|
termYears: tier.termYears,
|
|
rate: tier.rate,
|
|
}
|
|
})
|
|
|
|
const totalLoan = breakdown.reduce((s, b) => s + b.principal, 0)
|
|
const totalInterest = breakdown.reduce((s, b) => s + b.totalInterest, 0)
|
|
const totalPaid = breakdown.reduce((s, b) => s + b.monthlyPayment * b.termYears * 12, 0)
|
|
const longestTerm = Math.max(...mix.map(t => t.termYears))
|
|
const propertyValue = downPaymentPercent > 0
|
|
? totalLoan / (1 - downPaymentPercent / 100)
|
|
: totalLoan
|
|
|
|
return {
|
|
label,
|
|
description,
|
|
tiers: mix,
|
|
maxLoan: totalLoan,
|
|
propertyValue,
|
|
monthlyPayment: monthlyBudget,
|
|
totalInterest,
|
|
totalPaid,
|
|
payoffYears: longestTerm,
|
|
breakdown,
|
|
}
|
|
}
|
|
|
|
// Scenario 1: Default tier mix
|
|
if (tiers.length > 1) {
|
|
scenarios.push(compute('Blended', 'Default tier allocation across all terms', tiers))
|
|
}
|
|
|
|
// Scenario 2: Each single tier
|
|
for (const tier of tiers) {
|
|
scenarios.push(compute(
|
|
`All ${tier.label}`,
|
|
`Entire loan at ${(tier.rate * 100).toFixed(1)}% for ${tier.termYears} years`,
|
|
[{ ...tier, allocation: 1 }],
|
|
))
|
|
}
|
|
|
|
// Scenario 3: Short-heavy mix (if multiple tiers)
|
|
if (tiers.length >= 3) {
|
|
const sorted = [...tiers].sort((a, b) => a.termYears - b.termYears)
|
|
const shortHeavy = sorted.map((t, i) => ({
|
|
...t,
|
|
allocation: i === 0 ? 0.5 : (0.5 / (sorted.length - 1)),
|
|
}))
|
|
scenarios.push(compute(
|
|
'Short-Heavy',
|
|
`50% in shortest term (${sorted[0].label}), rest spread`,
|
|
shortHeavy,
|
|
))
|
|
}
|
|
|
|
// Scenario 4: Long-heavy mix (if multiple tiers)
|
|
if (tiers.length >= 3) {
|
|
const sorted = [...tiers].sort((a, b) => b.termYears - a.termYears)
|
|
const longHeavy = sorted.map((t, i) => ({
|
|
...t,
|
|
allocation: i === 0 ? 0.5 : (0.5 / (sorted.length - 1)),
|
|
}))
|
|
scenarios.push(compute(
|
|
'Long-Heavy',
|
|
`50% in longest term (${sorted[0].label}), rest spread`,
|
|
longHeavy,
|
|
))
|
|
}
|
|
|
|
// Sort by max loan descending
|
|
scenarios.sort((a, b) => b.maxLoan - a.maxLoan)
|
|
|
|
return scenarios
|
|
}
|