feat(rflows): port distributed mortgage simulator from rfunds-online
Replaces the simple lending pool dashboard at /mortgage with the full rfunds-online mortgage simulator. Models community-funded mortgages with 80+ tranches, variable terms, reinvestment loops, secondary markets, and 5 visualization modes (Network, Flow, Grid, Lender calc, Borrower calc). New files: - mortgage-types.ts & mortgage-engine.ts (pure TS, copied verbatim) - folk-mortgage-simulator.ts (1639-line web component, all views) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8321a9015a
commit
391d3a0cb6
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,748 @@
|
|||
/**
|
||||
* 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
|
||||
}
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
/**
|
||||
* rMortgage Types
|
||||
*
|
||||
* Models a distributed mortgage where N lenders each hold $1k-$5k tranches
|
||||
* of a single property, using TBFF primitives (Flow/Funnel/Outcome).
|
||||
*/
|
||||
|
||||
// ─── Lender ──────────────────────────────────────────────
|
||||
|
||||
export interface Lender {
|
||||
id: string
|
||||
name: string
|
||||
walletAddress: string
|
||||
}
|
||||
|
||||
// ─── Tranche Transfer (secondary market) ─────────────────
|
||||
|
||||
export interface TrancheTransfer {
|
||||
id: string
|
||||
fromLenderId: string
|
||||
toLenderId: string
|
||||
price: number // what buyer paid
|
||||
principalRemaining: number // at time of transfer
|
||||
premiumPercent: number // (price - principalRemaining) / principalRemaining * 100
|
||||
date: number // timestamp
|
||||
}
|
||||
|
||||
// ─── Mortgage Tranche ────────────────────────────────────
|
||||
|
||||
export interface MortgageTranche {
|
||||
id: string
|
||||
lender: Lender
|
||||
principal: number // original tranche size (e.g., 5000)
|
||||
principalRemaining: number // how much principal is still owed
|
||||
interestRate: number // annual rate (e.g., 0.06 for 6%)
|
||||
termMonths: number
|
||||
monthsElapsed: number
|
||||
// Per-payment breakdown (current month)
|
||||
monthlyPayment: number
|
||||
monthlyPrincipal: number
|
||||
monthlyInterest: number
|
||||
// Cumulative
|
||||
totalInterestPaid: number
|
||||
totalPrincipalPaid: number
|
||||
// Secondary market
|
||||
transferable: boolean
|
||||
listedForSale: boolean
|
||||
askingPrice?: number
|
||||
transferHistory: TrancheTransfer[]
|
||||
// Status
|
||||
status: 'active' | 'repaid'
|
||||
funded: boolean // true if a lender has claimed this tranche
|
||||
// Lending tier
|
||||
tierLabel: string // e.g., "5yr", "10yr", etc.
|
||||
// Reinvestment
|
||||
reinvestmentRate?: number // if set, lender reinvests repayments at this rate
|
||||
isReinvested: boolean // true if this tranche was created from reinvestment
|
||||
parentTrancheId?: string // original tranche this was reinvested from
|
||||
reinvestmentPool: number // accumulated returns waiting to be reinvested
|
||||
}
|
||||
|
||||
// ─── Mortgage Simulation State ───────────────────────────
|
||||
|
||||
export interface MortgageState {
|
||||
// Property
|
||||
propertyValue: number
|
||||
downPayment: number
|
||||
totalPrincipal: number // propertyValue - downPayment
|
||||
|
||||
// Structure
|
||||
trancheSize: number // default tranche (e.g., 5000)
|
||||
baseInterestRate: number // e.g., 0.06
|
||||
termMonths: number // e.g., 360 (30 years)
|
||||
|
||||
// Borrower
|
||||
borrower: {
|
||||
id: string
|
||||
name: string
|
||||
walletAddress: string
|
||||
}
|
||||
|
||||
// All tranches
|
||||
tranches: MortgageTranche[]
|
||||
|
||||
// Simulation
|
||||
currentMonth: number
|
||||
monthlyPayment: number // total monthly payment (sum of all tranches)
|
||||
overpayment: number // extra per month above minimum
|
||||
overpaymentTarget: 'extra_principal' | 'community_fund' | 'split'
|
||||
|
||||
// Aggregates
|
||||
totalInterestPaid: number
|
||||
totalPrincipalPaid: number
|
||||
totalPrincipalRemaining: number
|
||||
tranchesRepaid: number
|
||||
|
||||
// Overflow / Community
|
||||
communityFundBalance: number
|
||||
|
||||
// Comparison
|
||||
traditionalTotalInterest: number
|
||||
traditionalMonthlyPayment: number
|
||||
}
|
||||
|
||||
// ─── Lending Term Tiers ─────────────────────────────────
|
||||
|
||||
export interface LendingTier {
|
||||
label: string // e.g., "5yr Short"
|
||||
termYears: number // e.g., 5
|
||||
rate: number // e.g., 0.035
|
||||
allocation: number // fraction of total (0-1), all tiers sum to 1
|
||||
}
|
||||
|
||||
export const DEFAULT_TIERS: LendingTier[] = [
|
||||
{ label: '2yr', termYears: 2, rate: 0.025, allocation: 0.10 },
|
||||
{ label: '5yr', termYears: 5, rate: 0.035, allocation: 0.20 },
|
||||
{ label: '10yr', termYears: 10, rate: 0.045, allocation: 0.25 },
|
||||
{ label: '15yr', termYears: 15, rate: 0.055, allocation: 0.25 },
|
||||
{ label: '30yr', termYears: 30, rate: 0.065, allocation: 0.20 },
|
||||
]
|
||||
|
||||
// ─── Simulator Controls ──────────────────────────────────
|
||||
|
||||
export interface MortgageSimulatorConfig {
|
||||
propertyValue: number
|
||||
downPaymentPercent: number
|
||||
trancheSize: number
|
||||
interestRate: number // as decimal (0.06) — base rate, overridden by tiers
|
||||
termYears: number // max term (for the full mortgage)
|
||||
overpayment: number
|
||||
overpaymentTarget: 'extra_principal' | 'community_fund' | 'split'
|
||||
// Rate variation: spread across tranches
|
||||
rateVariation: number // e.g., 0.01 means rates vary +/- 1%
|
||||
// Reinvestment: % of lenders who reinvest repayments
|
||||
reinvestorPercent: number // 0-100 (default 40)
|
||||
reinvestmentRates: number[] // rates at which reinvestors lend (e.g., [0.03, 0.05])
|
||||
// Funding level: % of tranches that have been claimed by lenders
|
||||
fundingPercent: number // 0-100 (default 85)
|
||||
// Starting point
|
||||
startMonth: number // skip to this month on load (e.g., 60 = 5 years)
|
||||
// Lending tiers: different terms & rates
|
||||
lendingTiers: LendingTier[]
|
||||
useVariableTerms: boolean // false = all same term, true = use tiers
|
||||
}
|
||||
|
||||
export const DEFAULT_CONFIG: MortgageSimulatorConfig = {
|
||||
propertyValue: 500_000,
|
||||
downPaymentPercent: 20,
|
||||
trancheSize: 5_000,
|
||||
interestRate: 0.06,
|
||||
termYears: 30,
|
||||
overpayment: 0,
|
||||
overpaymentTarget: 'extra_principal',
|
||||
rateVariation: 0,
|
||||
fundingPercent: 85,
|
||||
reinvestorPercent: 40,
|
||||
reinvestmentRates: [0.03, 0.05],
|
||||
startMonth: 60,
|
||||
lendingTiers: DEFAULT_TIERS,
|
||||
useVariableTerms: true,
|
||||
}
|
||||
|
||||
// ─── Amortization Schedule Entry ─────────────────────────
|
||||
|
||||
export interface AmortizationEntry {
|
||||
month: number
|
||||
payment: number
|
||||
principal: number
|
||||
interest: number
|
||||
balance: number
|
||||
cumulativeInterest: number
|
||||
cumulativePrincipal: number
|
||||
}
|
||||
|
|
@ -788,6 +788,10 @@ const flowsScripts = `
|
|||
<script type="module" src="/modules/rflows/folk-flows-app.js?v=4"></script>
|
||||
<script type="module" src="/modules/rflows/folk-flow-river.js?v=3"></script>`;
|
||||
|
||||
const mortgageScripts = `
|
||||
<script src="https://cdn.jsdelivr.net/npm/lz-string@1.5.0/libs/lz-string.min.js"></script>
|
||||
<script type="module" src="/modules/rflows/folk-mortgage-simulator.js?v=1"></script>`;
|
||||
|
||||
const flowsStyles = `<link rel="stylesheet" href="/modules/rflows/flows.css">`;
|
||||
|
||||
// Landing page (also serves demo via centralized /demo → space="demo" rewrite)
|
||||
|
|
@ -805,17 +809,17 @@ routes.get("/", (c) => {
|
|||
}));
|
||||
});
|
||||
|
||||
// Mortgage sub-tab
|
||||
// Mortgage sub-tab — full distributed mortgage simulator
|
||||
routes.get("/mortgage", (c) => {
|
||||
const spaceSlug = c.req.param("space") || "demo";
|
||||
return c.html(renderShell({
|
||||
title: `${spaceSlug} — Mortgage | rFlows | rSpace`,
|
||||
title: `${spaceSlug} — rMortgage | rFlows | rSpace`,
|
||||
moduleId: "rflows",
|
||||
spaceSlug,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: `<folk-flows-app space="${spaceSlug}" view="mortgage"${spaceSlug === "demo" ? ' mode="demo"' : ''}></folk-flows-app>`,
|
||||
scripts: flowsScripts,
|
||||
body: `<folk-mortgage-simulator space="${spaceSlug}"></folk-mortgage-simulator>`,
|
||||
scripts: mortgageScripts,
|
||||
styles: flowsStyles,
|
||||
}));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -466,6 +466,28 @@ export default defineConfig({
|
|||
},
|
||||
});
|
||||
|
||||
// Build mortgage simulator component
|
||||
await wasmBuild({
|
||||
configFile: false,
|
||||
root: resolve(__dirname, "modules/rflows/components"),
|
||||
resolve: {
|
||||
alias: {
|
||||
"../lib/mortgage-types": resolve(__dirname, "modules/rflows/lib/mortgage-types.ts"),
|
||||
"../lib/mortgage-engine": resolve(__dirname, "modules/rflows/lib/mortgage-engine.ts"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
emptyOutDir: false,
|
||||
outDir: resolve(__dirname, "dist/modules/rflows"),
|
||||
lib: {
|
||||
entry: resolve(__dirname, "modules/rflows/components/folk-mortgage-simulator.ts"),
|
||||
formats: ["es"],
|
||||
fileName: () => "folk-mortgage-simulator.js",
|
||||
},
|
||||
rollupOptions: { output: { entryFileNames: "folk-mortgage-simulator.js" } },
|
||||
},
|
||||
});
|
||||
|
||||
// Copy flows CSS
|
||||
mkdirSync(resolve(__dirname, "dist/modules/rflows"), { recursive: true });
|
||||
copyFileSync(
|
||||
|
|
|
|||
Loading…
Reference in New Issue