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:
Jeff Emmett 2026-04-06 14:10:59 -04:00
parent 8321a9015a
commit 391d3a0cb6
5 changed files with 2590 additions and 4 deletions

File diff suppressed because it is too large Load Diff

View File

@ -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
}

View File

@ -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
}

View File

@ -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,
}));
});

View File

@ -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(