feat: rMortgage simulator — variable-term lending, lender return calculator, partial funding

Add full mortgage simulation engine with distributed tranches across 5 lending tiers
(2yr/5yr/10yr/15yr/30yr), reinvestment mechanics, secondary market transfers, and
overpayment routing. Includes four visualization modes: mycelial network, Sankey flow,
lender grid, and lender return calculator comparing monthly liquidity vs reinvest-to-term
strategies. Tranches can be partially funded (open slots visible in all views).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-14 01:50:14 +00:00
parent 2766984db7
commit f847d914f7
11 changed files with 3015 additions and 0 deletions

10
app/mortgage/page.tsx Normal file
View File

@ -0,0 +1,10 @@
import MortgageSimulator from '@/components/mortgage/MortgageSimulator'
export const metadata = {
title: '(you)rMortgage — Distributed Mortgage Simulator',
description: 'Simulate distributed mortgages: 100+ community lenders instead of 1 bank. Visualize flows, compare costs, explore the secondary market.',
}
export default function MortgagePage() {
return <MortgageSimulator />
}

View File

@ -0,0 +1,129 @@
'use client'
import type { MortgageSummary } from '@/lib/mortgage-engine'
import type { MortgageState } from '@/lib/mortgage-types'
interface Props {
state: MortgageState
summary: MortgageSummary
}
function fmt(n: number): string {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(n)
}
function fmtPct(n: number): string {
return `${n.toFixed(1)}%`
}
export default function ComparisonPanel({ state, summary }: Props) {
const repaidPct = state.totalPrincipal > 0
? (state.totalPrincipalPaid / state.totalPrincipal) * 100
: 0
return (
<div className="flex flex-col gap-4 text-sm">
{/* Progress */}
<div>
<h3 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Progress</h3>
<div className="grid grid-cols-2 gap-2">
<StatCard label="Principal Repaid" value={fmt(state.totalPrincipalPaid)} sub={fmtPct(repaidPct)} />
<StatCard label="Remaining" value={fmt(state.totalPrincipalRemaining)} />
<StatCard label="Interest Paid" value={fmt(state.totalInterestPaid)} color="text-amber-400" />
<StatCard label="Tranches Done" value={`${state.tranchesRepaid} / ${state.tranches.length}`} color="text-emerald-400" />
</div>
{/* Overall progress bar */}
<div className="mt-2">
<div className="h-2 bg-slate-700 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-sky-500 to-emerald-500 rounded-full transition-all duration-300"
style={{ width: `${Math.min(100, repaidPct)}%` }}
/>
</div>
</div>
</div>
{/* Community Fund */}
{state.communityFundBalance > 0 && (
<div>
<h3 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Community Fund</h3>
<div className="bg-emerald-900/30 rounded p-3 border border-emerald-800/50">
<div className="text-lg font-mono text-emerald-400">{fmt(state.communityFundBalance)}</div>
<div className="text-xs text-slate-400 mt-1">Overflow directed to community resilience</div>
</div>
</div>
)}
{/* Comparison */}
<div>
<h3 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">
rMortgage vs Traditional
</h3>
<div className="space-y-3">
<CompareRow
label="Monthly Payment"
myco={fmt(summary.mycoMonthlyPayment)}
trad={fmt(summary.tradMonthlyPayment)}
/>
<CompareRow
label="Total Interest"
myco={fmt(summary.mycoTotalInterest)}
trad={fmt(summary.tradTotalInterest)}
highlight={summary.interestSaved > 0}
/>
<CompareRow
label="Payoff"
myco={`${Math.ceil(summary.mycoPayoffMonths / 12)}y`}
trad={`${Math.ceil(summary.tradPayoffMonths / 12)}y`}
highlight={summary.monthsSaved > 0}
/>
<CompareRow
label="Avg Lender Yield"
myco={fmtPct(summary.avgLenderYield)}
trad="N/A (bank)"
/>
</div>
</div>
{/* Key insight */}
<div className="bg-gradient-to-br from-sky-900/40 to-emerald-900/40 rounded-lg p-4 border border-sky-800/30">
<h4 className="text-xs font-semibold text-sky-300 uppercase mb-2">Community Wealth</h4>
<div className="text-2xl font-mono text-emerald-400 mb-1">{fmt(summary.communityRetained)}</div>
<p className="text-xs text-slate-400">
Interest that stays in the community instead of flowing to a distant institution.
{summary.interestSaved > 0 && (
<> Plus {fmt(summary.interestSaved)} saved through distributed rates.</>
)}
</p>
</div>
</div>
)
}
function StatCard({ label, value, sub, color }: { label: string; value: string; sub?: string; color?: string }) {
return (
<div className="bg-slate-800 rounded p-2">
<div className="text-[10px] text-slate-500 uppercase">{label}</div>
<div className={`text-sm font-mono ${color ?? 'text-white'}`}>
{value}
{sub && <span className="text-xs text-slate-400 ml-1">{sub}</span>}
</div>
</div>
)
}
function CompareRow({ label, myco, trad, highlight }: { label: string; myco: string; trad: string; highlight?: boolean }) {
return (
<div className="grid grid-cols-3 gap-2 items-center">
<div className="text-xs text-slate-400">{label}</div>
<div className={`text-xs font-mono text-center px-2 py-1 rounded ${
highlight ? 'bg-emerald-900/40 text-emerald-400' : 'bg-slate-800 text-white'
}`}>
{myco}
</div>
<div className="text-xs font-mono text-center px-2 py-1 rounded bg-slate-800 text-slate-400">
{trad}
</div>
</div>
)
}

View File

@ -0,0 +1,217 @@
'use client'
import { useMemo } from 'react'
import type { MortgageTranche } from '@/lib/mortgage-types'
import { amortizationSchedule, calculateBuyerYield } from '@/lib/mortgage-engine'
interface Props {
tranche: MortgageTranche
onClose: () => void
onToggleForSale: (trancheId: string) => void
}
function fmt(n: number): string {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(n)
}
function fmtPct(n: number): string {
return `${n.toFixed(2)}%`
}
export default function LenderDetail({ tranche, onClose, onToggleForSale }: Props) {
const schedule = useMemo(() =>
amortizationSchedule(tranche.principal, tranche.interestRate, tranche.termMonths),
[tranche.principal, tranche.interestRate, tranche.termMonths],
)
const buyerYield = useMemo(() =>
calculateBuyerYield(tranche, tranche.askingPrice ?? tranche.principalRemaining),
[tranche],
)
const repaidPct = tranche.principal > 0
? (tranche.totalPrincipalPaid / tranche.principal) * 100
: 0
return (
<div className="bg-slate-800 rounded-lg p-4 border border-slate-700">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-white">{tranche.lender.name}</h3>
<p className="text-xs text-slate-400 font-mono">{tranche.lender.walletAddress.slice(0, 10)}...</p>
</div>
<button onClick={onClose} className="text-slate-400 hover:text-white text-lg">&times;</button>
</div>
{/* Key Stats */}
<div className="grid grid-cols-3 gap-3 mb-4">
<Stat label="Tranche" value={fmt(tranche.principal)} />
<Stat label="Rate" value={fmtPct(tranche.interestRate * 100)} />
<Stat label="Status" value={tranche.status} color={tranche.status === 'repaid' ? 'text-emerald-400' : 'text-sky-400'} />
<Stat label="Remaining" value={fmt(tranche.principalRemaining)} />
<Stat label="Interest Earned" value={fmt(tranche.totalInterestPaid)} />
<Stat label="Repaid" value={fmtPct(repaidPct)} />
</div>
{/* Progress Bar */}
<div className="mb-4">
<div className="flex justify-between text-xs text-slate-400 mb-1">
<span>Principal Repayment</span>
<span>{fmtPct(repaidPct)}</span>
</div>
<div className="h-3 bg-slate-700 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-300 ${
tranche.status === 'repaid' ? 'bg-emerald-500' : 'bg-sky-500'
}`}
style={{ width: `${Math.min(100, repaidPct)}%` }}
/>
</div>
</div>
{/* Current Month Breakdown */}
{tranche.status === 'active' && (
<div className="bg-slate-900 rounded p-3 mb-4">
<h4 className="text-xs font-semibold text-slate-400 uppercase mb-2">This Month</h4>
<div className="flex gap-4">
<div className="flex-1">
<div className="text-xs text-slate-500">Payment</div>
<div className="text-sm font-mono text-white">{fmt(tranche.monthlyPayment)}</div>
</div>
<div className="flex-1">
<div className="text-xs text-slate-500">Principal</div>
<div className="text-sm font-mono text-emerald-400">{fmt(tranche.monthlyPrincipal)}</div>
</div>
<div className="flex-1">
<div className="text-xs text-slate-500">Interest</div>
<div className="text-sm font-mono text-amber-400">{fmt(tranche.monthlyInterest)}</div>
</div>
</div>
{/* Mini P/I bar */}
<div className="flex h-2 rounded-full overflow-hidden mt-2">
<div
className="bg-emerald-500"
style={{ width: `${(tranche.monthlyPrincipal / tranche.monthlyPayment) * 100}%` }}
/>
<div className="bg-amber-500 flex-1" />
</div>
<div className="flex justify-between text-[10px] text-slate-500 mt-0.5">
<span>Principal</span>
<span>Interest</span>
</div>
</div>
)}
{/* Secondary Market */}
<div className="bg-slate-900 rounded p-3 mb-4">
<h4 className="text-xs font-semibold text-slate-400 uppercase mb-2">Secondary Market</h4>
{tranche.status === 'repaid' ? (
<p className="text-xs text-slate-500">Tranche fully repaid not tradeable</p>
) : (
<>
<button
onClick={() => onToggleForSale(tranche.id)}
className={`px-3 py-1.5 rounded text-xs font-medium transition-colors ${
tranche.listedForSale
? 'bg-amber-600 text-white hover:bg-amber-500'
: 'bg-slate-700 text-slate-300 hover:bg-slate-600'
}`}
>
{tranche.listedForSale ? 'Remove Listing' : 'List for Sale'}
</button>
{tranche.listedForSale && (
<div className="mt-2 text-xs text-slate-400">
<div>Asking: {fmt(tranche.askingPrice ?? tranche.principalRemaining)}</div>
<div>Buyer yield: {fmtPct(buyerYield.annualYield)}/yr</div>
<div>Months remaining: {buyerYield.monthsRemaining}</div>
</div>
)}
</>
)}
</div>
{/* Transfer History */}
{tranche.transferHistory.length > 0 && (
<div className="bg-slate-900 rounded p-3 mb-4">
<h4 className="text-xs font-semibold text-slate-400 uppercase mb-2">
Transfer History ({tranche.transferHistory.length})
</h4>
<div className="space-y-1">
{tranche.transferHistory.map((t, i) => (
<div key={i} className="flex justify-between text-xs">
<span className="text-slate-400">
{t.fromLenderId.replace('lender-', 'L')} &rarr; {t.toLenderId.replace('lender-', 'L')}
</span>
<span className={`font-mono ${t.premiumPercent >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{fmt(t.price)} ({t.premiumPercent >= 0 ? '+' : ''}{t.premiumPercent.toFixed(1)}%)
</span>
</div>
))}
</div>
</div>
)}
{/* Mini Amortization Chart */}
<div className="bg-slate-900 rounded p-3">
<h4 className="text-xs font-semibold text-slate-400 uppercase mb-2">Amortization</h4>
<AmortizationMiniChart schedule={schedule} currentMonth={tranche.monthsElapsed} />
</div>
</div>
)
}
function Stat({ label, value, color }: { label: string; value: string; color?: string }) {
return (
<div>
<div className="text-[10px] text-slate-500 uppercase">{label}</div>
<div className={`text-sm font-mono ${color ?? 'text-white'}`}>{value}</div>
</div>
)
}
function AmortizationMiniChart({ schedule, currentMonth }: { schedule: { month: number; balance: number; cumulativeInterest: number }[]; currentMonth: number }) {
if (schedule.length === 0) return null
const maxBalance = schedule[0].balance
const maxInterest = schedule[schedule.length - 1].cumulativeInterest
const maxVal = Math.max(maxBalance, maxInterest)
const h = 80
const w = 300
// Sample points (max 60 for perf)
const step = Math.max(1, Math.floor(schedule.length / 60))
const points = schedule.filter((_, i) => i % step === 0 || i === schedule.length - 1)
const balancePath = points.map((p, i) => {
const x = (p.month / schedule.length) * w
const y = h - (p.balance / maxVal) * h
return `${i === 0 ? 'M' : 'L'}${x},${y}`
}).join(' ')
const interestPath = points.map((p, i) => {
const x = (p.month / schedule.length) * w
const y = h - (p.cumulativeInterest / maxVal) * h
return `${i === 0 ? 'M' : 'L'}${x},${y}`
}).join(' ')
const currentX = (currentMonth / schedule.length) * w
return (
<svg viewBox={`0 0 ${w} ${h + 20}`} className="w-full" style={{ maxHeight: 100 }}>
{/* Balance line */}
<path d={balancePath} fill="none" stroke="#0ea5e9" strokeWidth="1.5" opacity="0.8" />
{/* Interest line */}
<path d={interestPath} fill="none" stroke="#f59e0b" strokeWidth="1.5" opacity="0.8" />
{/* Current month marker */}
{currentMonth > 0 && (
<line x1={currentX} y1={0} x2={currentX} y2={h} stroke="#94a3b8" strokeWidth="1" strokeDasharray="3,3" />
)}
{/* Legend */}
<circle cx={10} cy={h + 10} r={3} fill="#0ea5e9" />
<text x={18} y={h + 13} fill="#94a3b8" fontSize="8">Balance</text>
<circle cx={80} cy={h + 10} r={3} fill="#f59e0b" />
<text x={88} y={h + 13} fill="#94a3b8" fontSize="8">Cum. Interest</text>
</svg>
)
}

View File

@ -0,0 +1,155 @@
'use client'
import { useState, useMemo } from 'react'
import type { MortgageTranche } from '@/lib/mortgage-types'
interface Props {
tranches: MortgageTranche[]
onSelectTranche: (tranche: MortgageTranche | null) => void
selectedTrancheId: string | null
}
function formatCurrency(n: number): string {
if (n >= 1000) return `$${(n / 1000).toFixed(n % 1000 === 0 ? 0 : 1)}k`
return `$${n.toFixed(0)}`
}
export default function LenderGrid({ tranches, onSelectTranche, selectedTrancheId }: Props) {
const [sortBy, setSortBy] = useState<'index' | 'repaid' | 'rate' | 'remaining'>('index')
const [filter, setFilter] = useState<'all' | 'active' | 'repaid' | 'open' | 'for-sale'>('all')
const filtered = useMemo(() => {
let result = [...tranches]
switch (filter) {
case 'active': result = result.filter(t => t.status === 'active' && t.funded); break
case 'repaid': result = result.filter(t => t.status === 'repaid'); break
case 'open': result = result.filter(t => !t.funded); break
case 'for-sale': result = result.filter(t => t.listedForSale); break
}
switch (sortBy) {
case 'repaid': result.sort((a, b) => (b.totalPrincipalPaid / b.principal) - (a.totalPrincipalPaid / a.principal)); break
case 'rate': result.sort((a, b) => b.interestRate - a.interestRate); break
case 'remaining': result.sort((a, b) => a.principalRemaining - b.principalRemaining); break
}
return result
}, [tranches, sortBy, filter])
const repaidCount = tranches.filter(t => t.status === 'repaid').length
const openCount = tranches.filter(t => !t.funded).length
const forSaleCount = tranches.filter(t => t.listedForSale).length
return (
<div className="flex flex-col gap-3">
{/* Header with filters */}
<div className="flex items-center justify-between">
<h3 className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
Lenders ({tranches.length})
{repaidCount > 0 && <span className="text-emerald-400 ml-1">/ {repaidCount} repaid</span>}
</h3>
<div className="flex gap-1">
{(['all', 'active', 'repaid', 'open', 'for-sale'] as const).map(f => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-2 py-0.5 rounded text-xs transition-colors ${
filter === f ? 'bg-sky-600 text-white' : 'bg-slate-800 text-slate-400 hover:bg-slate-700'
}`}
>
{f === 'for-sale' ? `sale (${forSaleCount})` : f === 'open' ? `open (${openCount})` : f}
</button>
))}
</div>
</div>
{/* Sort */}
<div className="flex gap-1">
<span className="text-xs text-slate-500 mr-1">Sort:</span>
{(['index', 'repaid', 'rate', 'remaining'] as const).map(s => (
<button
key={s}
onClick={() => setSortBy(s)}
className={`px-2 py-0.5 rounded text-xs transition-colors ${
sortBy === s ? 'bg-slate-600 text-white' : 'bg-slate-800 text-slate-500 hover:bg-slate-700'
}`}
>
{s === 'index' ? '#' : s}
</button>
))}
</div>
{/* Grid */}
<div className="grid gap-1.5" style={{
gridTemplateColumns: `repeat(auto-fill, minmax(${tranches.length > 50 ? '56px' : '72px'}, 1fr))`,
}}>
{filtered.map(tranche => {
const repaidPct = tranche.principal > 0
? (tranche.totalPrincipalPaid / tranche.principal) * 100
: 0
const isSelected = tranche.id === selectedTrancheId
const isRepaid = tranche.status === 'repaid'
const isOpen = !tranche.funded
const isForSale = tranche.listedForSale
return (
<button
key={tranche.id}
onClick={() => onSelectTranche(isSelected ? null : tranche)}
className={`
relative rounded p-1.5 text-center transition-all cursor-pointer
${isSelected
? 'ring-2 ring-sky-400 bg-slate-700 scale-105 z-10'
: isOpen
? 'bg-slate-800/40 border border-dashed border-slate-600 hover:border-sky-500 hover:bg-slate-800/60'
: isRepaid
? 'bg-emerald-900/40 hover:bg-emerald-800/50'
: 'bg-slate-800 hover:bg-slate-700'
}
`}
title={isOpen
? `Open tranche: ${formatCurrency(tranche.principal)} @ ${(tranche.interestRate * 100).toFixed(1)}% — ${tranche.tierLabel}`
: `${tranche.lender.name}: ${formatCurrency(tranche.principal)} @ ${(tranche.interestRate * 100).toFixed(1)}%`
}
>
{/* Fill bar background */}
{!isOpen && (
<div
className={`absolute inset-0 rounded opacity-20 transition-all ${
isRepaid ? 'bg-emerald-400' : 'bg-sky-400'
}`}
style={{ clipPath: `inset(${100 - repaidPct}% 0 0 0)` }}
/>
)}
{/* Content */}
<div className="relative">
<div className={`text-xs font-medium truncate ${
isOpen ? 'text-slate-500 italic' : isRepaid ? 'text-emerald-300' : 'text-slate-200'
}`}>
{isOpen ? 'Open' : tranche.lender.name}
</div>
<div className={`text-[10px] font-mono ${isOpen ? 'text-slate-600' : 'text-slate-400'}`}>
{formatCurrency(tranche.principal)}
</div>
<div className={`text-[10px] font-mono ${
isOpen ? 'text-sky-600' : isRepaid ? 'text-emerald-400' : 'text-sky-400'
}`}>
{isOpen ? tranche.tierLabel : isRepaid ? '100%' : `${repaidPct.toFixed(0)}%`}
</div>
{isForSale && (
<div className="absolute -top-0.5 -right-0.5 w-3 h-3 bg-amber-500 rounded-full flex items-center justify-center text-[8px]">
$
</div>
)}
{tranche.transferHistory.length > 0 && (
<div className="absolute -top-0.5 -left-0.5 w-3 h-3 bg-purple-500 rounded-full flex items-center justify-center text-[8px]">
{tranche.transferHistory.length}
</div>
)}
</div>
</button>
)
})}
</div>
</div>
)
}

View File

@ -0,0 +1,294 @@
'use client'
import { useState, useMemo } from 'react'
import type { MortgageSimulatorConfig } from '@/lib/mortgage-types'
import { calculateLenderReturns } from '@/lib/mortgage-engine'
interface Props {
config: MortgageSimulatorConfig
}
function fmt(n: number): string {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(n)
}
function fmtDetail(n: number): string {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 2 }).format(n)
}
const TIER_COLORS: Record<string, string> = {
'2yr': '#06b6d4',
'5yr': '#10b981',
'10yr': '#3b82f6',
'15yr': '#f59e0b',
'30yr': '#ef4444',
}
export default function LenderReturnCalculator({ config }: Props) {
const [investment, setInvestment] = useState(config.trancheSize)
const [strategy, setStrategy] = useState<'compare' | 'liquid' | 'reinvest'>('compare')
const tiers = useMemo(() => {
const base = config.useVariableTerms
? config.lendingTiers.map(t => ({ label: t.label, termYears: t.termYears, rate: t.rate }))
: [{ label: `${config.termYears}yr`, termYears: config.termYears, rate: config.interestRate }]
return base
}, [config])
const scenarios = useMemo(() => calculateLenderReturns(investment, tiers), [investment, tiers])
// Find best yield for highlighting
const bestLiquid = Math.max(...scenarios.map(s => s.liquid.effectiveYield))
const bestReinvest = Math.max(...scenarios.map(s => s.reinvested.effectiveYield))
return (
<div className="flex flex-col gap-6 max-w-4xl mx-auto">
{/* Header */}
<div className="text-center">
<h2 className="text-lg font-bold text-white">Lender Return Calculator</h2>
<p className="text-sm text-slate-400 mt-1">
See what you could earn lending into the rMortgage
</p>
</div>
{/* Investment amount */}
<div className="bg-slate-800/60 rounded-lg p-4 border border-slate-700">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-slate-300">Your Investment</span>
<span className="text-xl font-mono font-bold text-white">{fmt(investment)}</span>
</div>
<input
type="range"
min={1000} max={25000} step={1000}
value={investment}
onChange={e => setInvestment(Number(e.target.value))}
className="w-full accent-sky-500"
/>
<div className="flex justify-between text-xs text-slate-500 mt-1">
<span>$1,000</span>
<span>$25,000</span>
</div>
</div>
{/* Strategy toggle */}
<div className="flex gap-2 justify-center">
{([
['compare', 'Compare Both'],
['liquid', 'Monthly Liquidity'],
['reinvest', 'Reinvest to Term'],
] as const).map(([key, label]) => (
<button
key={key}
onClick={() => setStrategy(key)}
className={`px-4 py-2 rounded-lg text-sm transition-colors ${
strategy === key
? 'bg-sky-600 text-white'
: 'bg-slate-800 text-slate-400 hover:bg-slate-700'
}`}
>
{label}
</button>
))}
</div>
{/* Results table */}
{strategy === 'compare' ? (
<div className="grid gap-4">
{scenarios.map(s => {
const color = TIER_COLORS[s.tierLabel] || '#64748b'
const reinvestGain = s.reinvested.totalInterest - s.liquid.totalInterest
return (
<div
key={s.tierLabel}
className="bg-slate-800/60 rounded-lg border border-slate-700 overflow-hidden"
>
{/* Tier header */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-slate-700/50" style={{ borderLeftWidth: 3, borderLeftColor: color }}>
<span className="text-lg font-bold" style={{ color }}>{s.tierLabel}</span>
<span className="text-sm text-slate-400">@ {(s.rate * 100).toFixed(1)}% APR</span>
<span className="text-xs text-slate-500 ml-auto">{s.termYears} year commitment</span>
</div>
{/* Two columns */}
<div className="grid grid-cols-2 divide-x divide-slate-700/50">
{/* Liquid */}
<div className="p-4">
<div className="text-xs text-slate-400 uppercase tracking-wider mb-3 flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-cyan-400 inline-block" />
Monthly Liquidity
</div>
<div className="space-y-2">
<Row label="Monthly income" value={fmtDetail(s.liquid.monthlyPayment)} />
<Row label="Total interest" value={fmt(s.liquid.totalInterest)} highlight={s.liquid.effectiveYield === bestLiquid} />
<Row label="Total received" value={fmt(s.liquid.totalReturn)} />
<div className="pt-2 border-t border-slate-700/50">
<Row
label="Effective yield"
value={`${s.liquid.effectiveYield.toFixed(2)}%/yr`}
highlight={s.liquid.effectiveYield === bestLiquid}
large
/>
</div>
</div>
</div>
{/* Reinvest */}
<div className="p-4">
<div className="text-xs text-slate-400 uppercase tracking-wider mb-3 flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-purple-400 inline-block" />
Reinvest to Term
</div>
<div className="space-y-2">
<Row label="Final value" value={fmt(s.reinvested.finalValue)} />
<Row label="Total interest" value={fmt(s.reinvested.totalInterest)} highlight={s.reinvested.effectiveYield === bestReinvest} />
<Row
label="Extra vs liquid"
value={reinvestGain > 0 ? `+${fmt(reinvestGain)}` : fmt(reinvestGain)}
color={reinvestGain > 0 ? 'text-emerald-400' : 'text-slate-400'}
/>
<div className="pt-2 border-t border-slate-700/50">
<Row
label="Effective yield"
value={`${s.reinvested.effectiveYield.toFixed(2)}%/yr`}
highlight={s.reinvested.effectiveYield === bestReinvest}
large
/>
</div>
</div>
</div>
</div>
</div>
)
})}
</div>
) : (
/* Single strategy view: cards */
<div className="grid gap-3">
{scenarios.map(s => {
const color = TIER_COLORS[s.tierLabel] || '#64748b'
const data = strategy === 'liquid' ? s.liquid : s.reinvested
const isLiquid = strategy === 'liquid'
const isBest = isLiquid
? s.liquid.effectiveYield === bestLiquid
: s.reinvested.effectiveYield === bestReinvest
return (
<div
key={s.tierLabel}
className={`bg-slate-800/60 rounded-lg p-4 border transition-colors ${
isBest ? 'border-emerald-500/50' : 'border-slate-700'
}`}
style={{ borderLeftWidth: 3, borderLeftColor: color }}
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="text-lg font-bold" style={{ color }}>{s.tierLabel}</span>
<span className="text-sm text-slate-400">@ {(s.rate * 100).toFixed(1)}%</span>
{isBest && (
<span className="text-[10px] bg-emerald-600 text-white px-1.5 py-0.5 rounded-full uppercase tracking-wider">Best</span>
)}
</div>
<div className="text-right">
<div className="text-xl font-mono font-bold text-white">
{data.effectiveYield.toFixed(2)}%
</div>
<div className="text-xs text-slate-500">effective yield/yr</div>
</div>
</div>
<div className="grid grid-cols-3 gap-3">
{isLiquid ? (
<>
<MiniStat label="Monthly" value={fmtDetail(s.liquid.monthlyPayment)} />
<MiniStat label="Total Interest" value={fmt(s.liquid.totalInterest)} color="text-amber-400" />
<MiniStat label="Total Received" value={fmt(s.liquid.totalReturn)} color="text-emerald-400" />
</>
) : (
<>
<MiniStat label="Final Value" value={fmt(s.reinvested.finalValue)} color="text-emerald-400" />
<MiniStat label="Total Interest" value={fmt(s.reinvested.totalInterest)} color="text-amber-400" />
<MiniStat label="Growth" value={`${((s.reinvested.totalReturn / investment - 1) * 100).toFixed(1)}%`} color="text-purple-400" />
</>
)}
</div>
{/* Visual bar: principal vs interest */}
{(() => {
const d = isLiquid ? s.liquid : s.reinvested
return (
<>
<div className="mt-3 h-2 bg-slate-700 rounded-full overflow-hidden flex">
<div
className="h-full bg-sky-500 rounded-l-full"
style={{ width: `${(investment / d.totalReturn) * 100}%` }}
title="Principal returned"
/>
<div
className="h-full bg-amber-500"
style={{ width: `${(d.totalInterest / d.totalReturn) * 100}%` }}
title="Interest earned"
/>
</div>
<div className="flex justify-between mt-1 text-[10px] text-slate-500">
<span>Principal</span>
<span>Interest</span>
</div>
</>
)
})()}
</div>
)
})}
</div>
)}
{/* Summary insight */}
<div className="bg-gradient-to-br from-purple-900/30 to-sky-900/30 rounded-lg p-4 border border-purple-800/30 text-center">
<p className="text-sm text-slate-300">
{(() => {
const best = scenarios.reduce((a, b) =>
b.reinvested.effectiveYield > a.reinvested.effectiveYield ? b : a
)
const liquidBest = scenarios.reduce((a, b) =>
b.liquid.effectiveYield > a.liquid.effectiveYield ? b : a
)
return (
<>
Reinvesting returns in a <strong className="text-purple-300">{best.tierLabel}</strong> tranche yields{' '}
<strong className="text-emerald-400">{best.reinvested.effectiveYield.toFixed(2)}%/yr</strong> vs{' '}
<strong className="text-cyan-400">{liquidBest.liquid.effectiveYield.toFixed(2)}%/yr</strong> with monthly liquidity.
{best.reinvested.totalInterest > liquidBest.liquid.totalInterest && (
<> That&apos;s <strong className="text-white">{fmt(best.reinvested.totalInterest - liquidBest.liquid.totalInterest)}</strong> more over the term.</>
)}
</>
)
})()}
</p>
</div>
</div>
)
}
function Row({ label, value, highlight, large, color }: {
label: string; value: string; highlight?: boolean; large?: boolean; color?: string
}) {
return (
<div className="flex justify-between items-baseline">
<span className="text-xs text-slate-400">{label}</span>
<span className={`font-mono ${large ? 'text-base font-bold' : 'text-sm'} ${
color ?? (highlight ? 'text-emerald-400' : 'text-white')
}`}>
{value}
</span>
</div>
)
}
function MiniStat({ label, value, color }: { label: string; value: string; color?: string }) {
return (
<div className="bg-slate-800 rounded p-2 text-center">
<div className="text-[10px] text-slate-500 uppercase">{label}</div>
<div className={`text-sm font-mono ${color ?? 'text-white'}`}>{value}</div>
</div>
)
}

View File

@ -0,0 +1,284 @@
'use client'
import { useCallback } from 'react'
import type { MortgageSimulatorConfig } from '@/lib/mortgage-types'
interface Props {
config: MortgageSimulatorConfig
onChange: (config: MortgageSimulatorConfig) => void
currentMonth: number
maxMonth: number
onMonthChange: (month: number) => void
playing: boolean
onPlayToggle: () => void
speed: number
onSpeedChange: (speed: number) => void
}
function formatCurrency(n: number): string {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(n)
}
export default function MortgageControls({
config, onChange, currentMonth, maxMonth,
onMonthChange, playing, onPlayToggle, speed, onSpeedChange,
}: Props) {
const update = useCallback((partial: Partial<MortgageSimulatorConfig>) => {
onChange({ ...config, ...partial })
}, [config, onChange])
const totalPrincipal = config.propertyValue * (1 - config.downPaymentPercent / 100)
const numTranches = Math.ceil(totalPrincipal / config.trancheSize)
const years = Math.floor(currentMonth / 12)
const months = currentMonth % 12
return (
<div className="flex flex-col gap-5 text-sm">
{/* Property */}
<Section title="Property">
<Slider
label="Value"
value={config.propertyValue}
min={100_000} max={2_000_000} step={25_000}
format={formatCurrency}
onChange={v => update({ propertyValue: v })}
/>
<Slider
label="Down Payment"
value={config.downPaymentPercent}
min={0} max={50} step={5}
format={v => `${v}% (${formatCurrency(config.propertyValue * v / 100)})`}
onChange={v => update({ downPaymentPercent: v })}
/>
<div className="text-slate-400 text-xs mt-1">
Loan: {formatCurrency(totalPrincipal)}
</div>
</Section>
{/* Tranches */}
<Section title="Tranches">
<Slider
label="Size"
value={config.trancheSize}
min={1_000} max={25_000} step={1_000}
format={formatCurrency}
onChange={v => update({ trancheSize: v })}
/>
<div className="text-slate-400 text-xs mt-1">
= {numTranches} lenders
</div>
<Slider
label="Funded"
value={config.fundingPercent}
min={50} max={100} step={5}
format={v => `${v}% (${Math.round(numTranches * v / 100)} / ${numTranches})`}
onChange={v => update({ fundingPercent: v })}
/>
</Section>
{/* Rate */}
<Section title="Interest">
<Slider
label="Base Rate"
value={config.interestRate * 100}
min={1} max={12} step={0.25}
format={v => `${v.toFixed(2)}%`}
onChange={v => update({ interestRate: v / 100 })}
/>
<Slider
label="Rate Spread"
value={config.rateVariation * 100}
min={0} max={3} step={0.25}
format={v => v === 0 ? 'None' : `+/- ${v.toFixed(2)}%`}
onChange={v => update({ rateVariation: v / 100 })}
/>
</Section>
{/* Term & Tiers */}
<Section title="Lending Terms">
<Slider
label="Max Term"
value={config.termYears}
min={5} max={30} step={5}
format={v => `${v} years`}
onChange={v => update({ termYears: v })}
/>
<div className="flex items-center gap-2 mt-2 mb-2">
<button
onClick={() => update({ useVariableTerms: !config.useVariableTerms })}
className={`px-2 py-1 rounded text-xs transition-colors ${
config.useVariableTerms
? 'bg-emerald-600 text-white'
: 'bg-slate-700 text-slate-300 hover:bg-slate-600'
}`}
>
{config.useVariableTerms ? 'Variable Tiers' : 'Uniform Term'}
</button>
</div>
{config.useVariableTerms && (
<div className="space-y-2 mt-2">
{config.lendingTiers.map((tier, i) => (
<div key={tier.label} className="flex items-center gap-2 text-xs">
<span className="w-10 text-slate-300 font-mono">{tier.label}</span>
<span className="text-slate-400">{(tier.rate * 100).toFixed(1)}%</span>
<input
type="range"
min={0} max={50} step={5}
value={tier.allocation * 100}
onChange={e => {
const newTiers = [...config.lendingTiers]
newTiers[i] = { ...tier, allocation: Number(e.target.value) / 100 }
update({ lendingTiers: newTiers })
}}
className="flex-1 accent-emerald-500 h-1"
/>
<span className="w-8 text-right text-slate-400">{(tier.allocation * 100).toFixed(0)}%</span>
</div>
))}
</div>
)}
</Section>
{/* Overpayment */}
<Section title="Overpayment">
<Slider
label="Extra / mo"
value={config.overpayment}
min={0} max={2000} step={50}
format={formatCurrency}
onChange={v => update({ overpayment: v })}
/>
{config.overpayment > 0 && (
<div className="flex gap-1 mt-2">
{(['extra_principal', 'community_fund', 'split'] as const).map(target => (
<button
key={target}
onClick={() => update({ overpaymentTarget: target })}
className={`px-2 py-1 rounded text-xs transition-colors ${
config.overpaymentTarget === target
? 'bg-emerald-600 text-white'
: 'bg-slate-700 text-slate-300 hover:bg-slate-600'
}`}
>
{target === 'extra_principal' ? 'Principal' : target === 'community_fund' ? 'Community' : 'Split'}
</button>
))}
</div>
)}
</Section>
{/* Reinvestment */}
<Section title="Reinvestment">
<Slider
label="Reinvestors"
value={config.reinvestorPercent}
min={0} max={80} step={10}
format={v => `${v}%`}
onChange={v => update({ reinvestorPercent: v })}
/>
{config.reinvestorPercent > 0 && (
<>
<div className="text-slate-400 text-xs mt-1 mb-2">
Relend rates:
</div>
<div className="flex gap-1">
{[[0.03, 0.05], [0.02, 0.04], [0.03], [0.05]].map((rates, i) => {
const label = rates.map(r => `${(r * 100).toFixed(0)}%`).join(' / ')
const isActive = JSON.stringify(config.reinvestmentRates) === JSON.stringify(rates)
return (
<button
key={i}
onClick={() => update({ reinvestmentRates: rates })}
className={`px-2 py-1 rounded text-xs transition-colors ${
isActive
? 'bg-purple-600 text-white'
: 'bg-slate-700 text-slate-300 hover:bg-slate-600'
}`}
>
{label}
</button>
)
})}
</div>
</>
)}
</Section>
{/* Timeline */}
<Section title="Timeline">
<div className="flex items-center gap-2 mb-2">
<button
onClick={onPlayToggle}
className="w-8 h-8 rounded bg-slate-700 hover:bg-slate-600 flex items-center justify-center text-lg transition-colors"
>
{playing ? '\u23F8' : '\u25B6'}
</button>
<button
onClick={() => onMonthChange(0)}
className="px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-xs transition-colors"
>
Reset
</button>
<div className="flex gap-1 ml-auto">
{[1, 2, 4].map(s => (
<button
key={s}
onClick={() => onSpeedChange(s)}
className={`px-2 py-1 rounded text-xs transition-colors ${
speed === s ? 'bg-sky-600 text-white' : 'bg-slate-700 text-slate-300 hover:bg-slate-600'
}`}
>
{s}x
</button>
))}
</div>
</div>
<input
type="range"
min={0} max={maxMonth}
value={currentMonth}
onChange={e => onMonthChange(Number(e.target.value))}
className="w-full accent-sky-500"
/>
<div className="text-slate-400 text-xs text-center mt-1">
Month {currentMonth} ({years}y {months}m)
</div>
</Section>
</div>
)
}
// ─── Sub-components ──────────────────────────────────────
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div>
<h3 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">{title}</h3>
{children}
</div>
)
}
function Slider({
label, value, min, max, step, format, onChange,
}: {
label: string; value: number; min: number; max: number; step: number
format: (v: number) => string; onChange: (v: number) => void
}) {
return (
<div className="mb-2">
<div className="flex justify-between mb-1">
<span className="text-slate-300">{label}</span>
<span className="text-white font-mono">{format(value)}</span>
</div>
<input
type="range"
min={min} max={max} step={step}
value={value}
onChange={e => onChange(Number(e.target.value))}
className="w-full accent-sky-500"
/>
</div>
)
}

View File

@ -0,0 +1,410 @@
'use client'
import { useMemo } from 'react'
import type { MortgageState, MortgageTranche } from '@/lib/mortgage-types'
interface Props {
state: MortgageState
selectedTrancheId: string | null
onSelectTranche: (tranche: MortgageTranche | null) => void
}
function fmt(n: number): string {
if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(1)}M`
if (n >= 1_000) return `$${(n / 1_000).toFixed(1)}k`
return `$${n.toFixed(0)}`
}
const TIER_COLORS: Record<string, string> = {
'5yr': '#10b981', // emerald
'10yr': '#3b82f6', // blue
'15yr': '#f59e0b', // amber
'30yr': '#ef4444', // red
}
const COLORS = {
borrower: '#0ea5e9',
principal: '#10b981',
interest: '#f59e0b',
community: '#8b5cf6',
reinvest: '#a855f7',
text: '#e2e8f0',
textMuted: '#94a3b8',
bg: '#0f172a',
pipe: '#334155',
}
interface TierGroup {
label: string
color: string
tranches: MortgageTranche[]
totalPrincipal: number
totalPrincipalRemaining: number
totalMonthlyPayment: number
totalMonthlyPrincipal: number
totalMonthlyInterest: number
activeCount: number
repaidCount: number
reinvestCount: number
reinvestPool: number
}
export default function MortgageFlowViz({ state, selectedTrancheId, onSelectTranche }: Props) {
const { tiers, totalMonthly, totalInterest, totalPrincipalFlow, reinvestTotal } = useMemo(() => {
// Group tranches by tier
const groups = new Map<string, MortgageTranche[]>()
for (const t of state.tranches) {
const label = t.tierLabel || 'default'
if (!groups.has(label)) groups.set(label, [])
groups.get(label)!.push(t)
}
const tiers: TierGroup[] = []
for (const [label, tranches] of groups) {
const active = tranches.filter(t => t.status === 'active')
const repaid = tranches.filter(t => t.status === 'repaid')
const reinvesting = tranches.filter(t => t.reinvestmentRate != null)
tiers.push({
label,
color: TIER_COLORS[label] || `hsl(${(tiers.length * 67) % 360}, 60%, 55%)`,
tranches,
totalPrincipal: tranches.reduce((s, t) => s + t.principal, 0),
totalPrincipalRemaining: tranches.reduce((s, t) => s + t.principalRemaining, 0),
totalMonthlyPayment: active.reduce((s, t) => s + t.monthlyPayment, 0),
totalMonthlyPrincipal: active.reduce((s, t) => s + t.monthlyPrincipal, 0),
totalMonthlyInterest: active.reduce((s, t) => s + t.monthlyInterest, 0),
activeCount: active.length,
repaidCount: repaid.length,
reinvestCount: reinvesting.length,
reinvestPool: reinvesting.reduce((s, t) => s + t.reinvestmentPool, 0),
})
}
// Sort: standard tiers first by term, then reinvest tiers
tiers.sort((a, b) => {
const aReinvest = a.label.startsWith('reinvest')
const bReinvest = b.label.startsWith('reinvest')
if (aReinvest !== bReinvest) return aReinvest ? 1 : -1
return a.label.localeCompare(b.label, undefined, { numeric: true })
})
const totalMonthly = tiers.reduce((s, t) => s + t.totalMonthlyPayment, 0)
const totalInterest = tiers.reduce((s, t) => s + t.totalMonthlyInterest, 0)
const totalPrincipalFlow = tiers.reduce((s, t) => s + t.totalMonthlyPrincipal, 0)
const reinvestTotal = tiers.reduce((s, t) => s + t.reinvestPool, 0)
return { tiers, totalMonthly, totalInterest, totalPrincipalFlow, reinvestTotal }
}, [state])
// Layout constants
const W = 800
const H = 520
const colBorrower = 60
const colSplit = 280
const colTiers = 520
const colEnd = 740
const nodeW = 20
const tierGap = 8
// Borrower node
const borrowerH = Math.max(60, totalMonthly > 0 ? 120 : 60)
const borrowerY = (H - borrowerH) / 2
// Tier nodes: height proportional to monthly payment
const maxFlow = Math.max(1, totalMonthly)
const availH = H - 60
const tierNodes = tiers.map((tier, i) => {
const h = Math.max(20, (tier.totalMonthlyPayment / maxFlow) * availH * 0.7)
return { ...tier, h, y: 0, idx: i }
})
// Position tier nodes vertically
const totalTierH = tierNodes.reduce((s, n) => s + n.h, 0) + (tierNodes.length - 1) * tierGap
let tierStartY = (H - totalTierH) / 2
for (const node of tierNodes) {
node.y = tierStartY
tierStartY += node.h + tierGap
}
// Split node (principal / interest fork)
const splitH = borrowerH
const splitY = borrowerY
return (
<svg viewBox={`0 0 ${W} ${H}`} className="w-full" style={{ minHeight: 400 }}>
<defs>
<linearGradient id="sankeyBorrower" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor={COLORS.borrower} stopOpacity="0.8" />
<stop offset="100%" stopColor={COLORS.borrower} stopOpacity="0.4" />
</linearGradient>
{tierNodes.map(tier => (
<linearGradient key={`grad-${tier.label}`} id={`tierGrad-${tier.label}`} x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor={tier.color} stopOpacity="0.6" />
<stop offset="100%" stopColor={tier.color} stopOpacity="0.3" />
</linearGradient>
))}
<linearGradient id="principalGrad" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor={COLORS.principal} stopOpacity="0.5" />
<stop offset="100%" stopColor={COLORS.principal} stopOpacity="0.2" />
</linearGradient>
<linearGradient id="interestGrad" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor={COLORS.interest} stopOpacity="0.5" />
<stop offset="100%" stopColor={COLORS.interest} stopOpacity="0.2" />
</linearGradient>
</defs>
{/* ─── Borrower Node ─── */}
<g>
<rect
x={colBorrower - nodeW / 2} y={borrowerY}
width={nodeW} height={borrowerH}
rx={4} fill={COLORS.borrower}
/>
<text x={colBorrower} y={borrowerY - 8} textAnchor="middle" fill={COLORS.text} fontSize="12" fontWeight="600">
Borrower
</text>
<text x={colBorrower} y={borrowerY + borrowerH + 16} textAnchor="middle" fill={COLORS.textMuted} fontSize="10">
{fmt(totalMonthly + state.overpayment)}/mo
</text>
</g>
{/* ─── Split Node (Principal / Interest) ─── */}
<g>
{/* Principal portion */}
<rect
x={colSplit - nodeW / 2} y={splitY}
width={nodeW}
height={totalMonthly > 0 ? splitH * (totalPrincipalFlow / totalMonthly) : splitH / 2}
rx={2} fill={COLORS.principal}
/>
{/* Interest portion */}
<rect
x={colSplit - nodeW / 2}
y={splitY + (totalMonthly > 0 ? splitH * (totalPrincipalFlow / totalMonthly) : splitH / 2)}
width={nodeW}
height={totalMonthly > 0 ? splitH * (totalInterest / totalMonthly) : splitH / 2}
rx={2} fill={COLORS.interest}
/>
<text x={colSplit} y={splitY - 8} textAnchor="middle" fill={COLORS.text} fontSize="11" fontWeight="600">
Payment Split
</text>
<text x={colSplit + nodeW / 2 + 6} y={splitY + 14} textAnchor="start" fill={COLORS.principal} fontSize="9">
Principal {fmt(totalPrincipalFlow)}
</text>
<text x={colSplit + nodeW / 2 + 6} y={splitY + splitH - 4} textAnchor="start" fill={COLORS.interest} fontSize="9">
Interest {fmt(totalInterest)}
</text>
</g>
{/* ─── Borrower → Split flow ─── */}
<SankeyLink
x1={colBorrower + nodeW / 2} y1={borrowerY} h1={borrowerH}
x2={colSplit - nodeW / 2} y2={splitY} h2={splitH}
color="url(#sankeyBorrower)"
/>
{/* ─── Split → Tier flows ─── */}
{(() => {
// Track vertical offsets at the split node for principal and interest
const principalH = totalMonthly > 0 ? splitH * (totalPrincipalFlow / totalMonthly) : splitH / 2
const interestStart = splitY + principalH
let principalOffset = 0
let interestOffset = 0
return tierNodes.map(tier => {
if (tier.totalMonthlyPayment === 0) return null
const pFrac = totalPrincipalFlow > 0 ? tier.totalMonthlyPrincipal / totalPrincipalFlow : 0
const iFrac = totalInterest > 0 ? tier.totalMonthlyInterest / totalInterest : 0
const pH = principalH * pFrac
const iH = (splitH - principalH) * iFrac
// Principal flow
const pSrcY = splitY + principalOffset
const pDstFrac = tier.totalMonthlyPayment > 0 ? tier.totalMonthlyPrincipal / tier.totalMonthlyPayment : 0.5
const pDstH = tier.h * pDstFrac
// Interest flow
const iSrcY = interestStart + interestOffset
const iDstH = tier.h - pDstH
principalOffset += pH
interestOffset += iH
return (
<g key={tier.label}>
{/* Principal flow to tier */}
{pH > 0.5 && (
<SankeyLink
x1={colSplit + nodeW / 2} y1={pSrcY} h1={pH}
x2={colTiers - nodeW / 2} y2={tier.y} h2={pDstH}
color="url(#principalGrad)"
/>
)}
{/* Interest flow to tier */}
{iH > 0.5 && (
<SankeyLink
x1={colSplit + nodeW / 2} y1={iSrcY} h1={iH}
x2={colTiers - nodeW / 2} y2={tier.y + pDstH} h2={iDstH}
color="url(#interestGrad)"
/>
)}
</g>
)
})
})()}
{/* ─── Tier Nodes ─── */}
{tierNodes.map(tier => {
const pFrac = tier.totalMonthlyPayment > 0 ? tier.totalMonthlyPrincipal / tier.totalMonthlyPayment : 0.5
const isReinvest = tier.label.startsWith('reinvest')
const selected = tier.tranches.some(t => t.id === selectedTrancheId)
return (
<g key={tier.label}
onClick={() => {
// Select first active tranche in tier
const first = tier.tranches.find(t => t.status === 'active') ?? tier.tranches[0]
onSelectTranche(selected ? null : first)
}}
style={{ cursor: 'pointer' }}
>
{/* Principal portion */}
<rect
x={colTiers - nodeW / 2} y={tier.y}
width={nodeW} height={tier.h * pFrac}
rx={2} fill={tier.color}
stroke={selected ? '#fff' : 'none'} strokeWidth={selected ? 2 : 0}
/>
{/* Interest portion */}
<rect
x={colTiers - nodeW / 2} y={tier.y + tier.h * pFrac}
width={nodeW} height={tier.h * (1 - pFrac)}
rx={2} fill={tier.color} opacity={0.5}
stroke={selected ? '#fff' : 'none'} strokeWidth={selected ? 2 : 0}
/>
{/* Label */}
<text
x={colTiers + nodeW / 2 + 8} y={tier.y + tier.h / 2 - 6}
fill={tier.color} fontSize="11" fontWeight="600"
dominantBaseline="middle"
>
{isReinvest ? `Reinvest (${tier.label.replace('reinvest@', '')})` : tier.label}
</text>
<text
x={colTiers + nodeW / 2 + 8} y={tier.y + tier.h / 2 + 8}
fill={COLORS.textMuted} fontSize="9"
dominantBaseline="middle"
>
{tier.activeCount} active / {tier.repaidCount} repaid &middot; {fmt(tier.totalMonthlyPayment)}/mo
</text>
{/* Reinvest pool indicator */}
{tier.reinvestPool > 100 && (
<text
x={colTiers + nodeW / 2 + 8} y={tier.y + tier.h / 2 + 22}
fill={COLORS.reinvest} fontSize="8"
dominantBaseline="middle"
>
Pool: {fmt(tier.reinvestPool)}
</text>
)}
</g>
)
})}
{/* ─── Tier → Lender Outcome flows ─── */}
{tierNodes.map(tier => {
if (tier.totalMonthlyPayment === 0) return null
const tierEndY = tier.y
const tierEndH = tier.h
return (
<SankeyLink
key={`out-${tier.label}`}
x1={colTiers + nodeW / 2} y1={tierEndY} h1={tierEndH}
x2={colEnd - 6} y2={tierEndY} h2={tierEndH}
color={`url(#tierGrad-${tier.label})`}
/>
)
})}
{/* ─── Right-side labels: Lender outcomes ─── */}
<g>
<text x={colEnd} y={30} fill={COLORS.text} fontSize="11" fontWeight="600">
Lenders
</text>
{tierNodes.map(tier => (
<g key={`lbl-${tier.label}`}>
<circle cx={colEnd} cy={tier.y + tier.h / 2} r={3} fill={tier.color} />
<text
x={colEnd + 8} y={tier.y + tier.h / 2}
fill={COLORS.textMuted} fontSize="9"
dominantBaseline="middle"
>
{tier.tranches.length} &times; {fmt(tier.totalPrincipal / tier.tranches.length)}
</text>
</g>
))}
</g>
{/* ─── Reinvestment feedback loop (curved arrow) ─── */}
{reinvestTotal > 100 && (
<g opacity={0.6}>
<path
d={`M${colEnd - 10},${H / 2 + 40} Q${colEnd + 30},${H / 2 + 80} ${colEnd + 30},${H - 30} Q${colEnd + 30},${H - 10} ${colTiers},${H - 10} Q${colSplit},${H - 10} ${colSplit},${H / 2 + 60}`}
fill="none"
stroke={COLORS.reinvest}
strokeWidth={Math.max(1.5, Math.min(4, reinvestTotal / 1000))}
strokeDasharray="6,4"
markerEnd="url(#arrowReinvest)"
/>
<text x={(colTiers + colSplit) / 2} y={H - 16} textAnchor="middle" fill={COLORS.reinvest} fontSize="9">
Reinvestment: {fmt(reinvestTotal)} pooled
</text>
<defs>
<marker id="arrowReinvest" viewBox="0 0 6 6" refX="6" refY="3" markerWidth="6" markerHeight="6" orient="auto">
<path d="M0,0 L6,3 L0,6 Z" fill={COLORS.reinvest} />
</marker>
</defs>
</g>
)}
{/* ─── Community Fund ─── */}
{state.communityFundBalance > 0 && (
<g>
<rect x={10} y={H - 50} width={100} height={36} rx={6} fill={COLORS.community} fillOpacity={0.15} stroke={COLORS.community} strokeWidth={1} />
<text x={60} y={H - 36} textAnchor="middle" fill={COLORS.community} fontSize="9" fontWeight="600">Community Fund</text>
<text x={60} y={H - 22} textAnchor="middle" fill={COLORS.textMuted} fontSize="9">{fmt(state.communityFundBalance)}</text>
</g>
)}
{/* ─── Summary stats ─── */}
<g>
<text x={W / 2} y={H - 8} textAnchor="middle" fill={COLORS.textMuted} fontSize="9">
Month {state.currentMonth} &middot; {state.tranches.length} tranches &middot; {fmt(state.totalPrincipalRemaining)} remaining &middot; {((state.totalPrincipalPaid / state.totalPrincipal) * 100).toFixed(1)}% repaid
</text>
</g>
</svg>
)
}
// ─── Sankey Link (curved band between two vertical bars) ─────
function SankeyLink({ x1, y1, h1, x2, y2, h2, color }: {
x1: number; y1: number; h1: number
x2: number; y2: number; h2: number
color: string
}) {
const mx = (x1 + x2) / 2
// Top edge
const topPath = `M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2}`
// Bottom edge
const botPath = `L${x2},${y2 + h2} C${mx},${y2 + h2} ${mx},${y1 + h1} ${x1},${y1 + h1} Z`
return (
<path
d={`${topPath} ${botPath}`}
fill={color}
/>
)
}

View File

@ -0,0 +1,194 @@
'use client'
import { useState, useCallback, useEffect, useRef, useMemo } from 'react'
import type { MortgageSimulatorConfig, MortgageTranche } from '@/lib/mortgage-types'
import { DEFAULT_CONFIG } from '@/lib/mortgage-types'
import { runFullSimulation, computeSummary } from '@/lib/mortgage-engine'
import MortgageControls from './MortgageControls'
import MortgageFlowViz from './MortgageFlowViz'
import LenderGrid from './LenderGrid'
import MycelialNetworkViz from './MycelialNetworkViz'
import LenderDetail from './LenderDetail'
import LenderReturnCalculator from './LenderReturnCalculator'
import ComparisonPanel from './ComparisonPanel'
type ViewMode = 'mycelial' | 'flow' | 'grid' | 'lender'
export default function MortgageSimulator() {
const [config, setConfig] = useState<MortgageSimulatorConfig>(DEFAULT_CONFIG)
const [currentMonth, setCurrentMonth] = useState(DEFAULT_CONFIG.startMonth)
const [playing, setPlaying] = useState(false)
const [speed, setSpeed] = useState(1)
const [selectedTranche, setSelectedTranche] = useState<MortgageTranche | null>(null)
const [viewMode, setViewMode] = useState<ViewMode>('mycelial')
const [controlsOpen, setControlsOpen] = useState(true)
const playRef = useRef(playing)
const speedRef = useRef(speed)
// Run full simulation when config changes
const states = useMemo(() => runFullSimulation(config), [config])
const maxMonth = states.length - 1
const currentState = states[Math.min(currentMonth, maxMonth)]
const summary = useMemo(() => computeSummary(states), [states])
// Keep refs in sync
useEffect(() => { playRef.current = playing }, [playing])
useEffect(() => { speedRef.current = speed }, [speed])
// Playback
useEffect(() => {
if (!playing) return
const interval = setInterval(() => {
setCurrentMonth(prev => {
if (prev >= maxMonth) {
setPlaying(false)
return prev
}
return prev + speedRef.current
})
}, 100)
return () => clearInterval(interval)
}, [playing, maxMonth])
// Reset month when config changes
useEffect(() => {
setCurrentMonth(Math.min(config.startMonth, maxMonth))
setPlaying(false)
setSelectedTranche(null)
}, [config, maxMonth])
const handleSelectTranche = useCallback((t: MortgageTranche | null) => {
setSelectedTranche(t)
}, [])
const handleToggleForSale = useCallback((trancheId: string) => {
// In a real app this would update the backend; for the simulator we just toggle locally
setSelectedTranche(prev => {
if (!prev || prev.id !== trancheId) return prev
return {
...prev,
listedForSale: !prev.listedForSale,
askingPrice: !prev.listedForSale ? prev.principalRemaining : undefined,
}
})
}, [])
// Keep selected tranche in sync with current state
const selectedTrancheState = useMemo(() => {
if (!selectedTranche) return null
const fromState = currentState.tranches.find(t => t.id === selectedTranche.id)
if (!fromState) return null
// Merge any local toggles (listedForSale) with simulation state
return {
...fromState,
listedForSale: selectedTranche.listedForSale,
askingPrice: selectedTranche.askingPrice,
}
}, [currentState, selectedTranche])
return (
<div className="min-h-screen bg-[#0f172a] text-slate-200">
{/* Header */}
<header className="border-b border-slate-800 px-6 py-4">
<div className="max-w-[1600px] mx-auto flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-white">(you)rMortgage</h1>
<p className="text-sm text-slate-400">
{currentState.tranches.length} lenders &times; {new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(config.trancheSize)} tranches
</p>
</div>
<div className="flex gap-2">
{(['mycelial', 'flow', 'grid', 'lender'] as const).map(mode => (
<button
key={mode}
onClick={() => setViewMode(mode)}
className={`px-3 py-1.5 rounded text-sm transition-colors ${
viewMode === mode ? 'bg-sky-600 text-white' : 'bg-slate-800 text-slate-400 hover:bg-slate-700'
}`}
>
{mode === 'mycelial' ? 'Network' : mode === 'flow' ? 'Flow' : mode === 'grid' ? 'Grid' : 'Lender'}
</button>
))}
</div>
</div>
</header>
{/* Main Layout */}
<div className="max-w-[1600px] mx-auto flex gap-0 min-h-[calc(100vh-73px)]">
{/* Left: Controls (collapsible) */}
<aside className={`flex-shrink-0 border-r border-slate-800 transition-all duration-300 ${controlsOpen ? 'w-64' : 'w-10'} relative overflow-hidden`}>
<button
onClick={() => setControlsOpen(o => !o)}
className="absolute top-2 right-2 z-10 w-6 h-6 rounded bg-slate-700 hover:bg-slate-600 flex items-center justify-center text-xs text-slate-300 transition-colors"
title={controlsOpen ? 'Minimize settings' : 'Expand settings'}
>
{controlsOpen ? '\u2039' : '\u203A'}
</button>
{!controlsOpen && (
<button
onClick={() => setControlsOpen(true)}
className="absolute inset-0 w-full h-full flex items-start justify-center pt-12 text-slate-500 hover:text-slate-300 transition-colors"
title="Expand settings"
>
<span className="[writing-mode:vertical-lr] text-xs tracking-widest uppercase font-semibold">Loan Settings</span>
</button>
)}
<div className={`p-4 overflow-y-auto h-full transition-opacity duration-200 ${controlsOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}>
<MortgageControls
config={config}
onChange={setConfig}
currentMonth={Math.min(currentMonth, maxMonth)}
maxMonth={maxMonth}
onMonthChange={setCurrentMonth}
playing={playing}
onPlayToggle={() => setPlaying(p => !p)}
speed={speed}
onSpeedChange={setSpeed}
/>
</div>
</aside>
{/* Center: Visualization */}
<main className="flex-1 p-4 overflow-y-auto">
{viewMode === 'mycelial' ? (
<MycelialNetworkViz
state={currentState}
selectedTrancheId={selectedTranche?.id ?? null}
onSelectTranche={handleSelectTranche}
/>
) : viewMode === 'flow' ? (
<MortgageFlowViz
state={currentState}
selectedTrancheId={selectedTranche?.id ?? null}
onSelectTranche={handleSelectTranche}
/>
) : viewMode === 'grid' ? (
<LenderGrid
tranches={currentState.tranches}
onSelectTranche={handleSelectTranche}
selectedTrancheId={selectedTranche?.id ?? null}
/>
) : (
<LenderReturnCalculator config={config} />
)}
{/* Lender Detail (below viz when selected) */}
{selectedTrancheState && (
<div className="mt-4 max-w-xl">
<LenderDetail
tranche={selectedTrancheState}
onClose={() => setSelectedTranche(null)}
onToggleForSale={handleToggleForSale}
/>
</div>
)}
</main>
{/* Right: Comparison */}
<aside className="w-72 flex-shrink-0 border-l border-slate-800 p-4 overflow-y-auto">
<ComparisonPanel state={currentState} summary={summary} />
</aside>
</div>
</div>
)
}

View File

@ -0,0 +1,536 @@
'use client'
import { useMemo, useState } from 'react'
import type { MortgageState, MortgageTranche } from '@/lib/mortgage-types'
interface Props {
state: MortgageState
selectedTrancheId: string | null
onSelectTranche: (tranche: MortgageTranche | null) => void
}
function fmt(n: number): string {
if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(1)}M`
if (n >= 1_000) return `$${(n / 1_000).toFixed(0)}k`
return `$${n.toFixed(0)}`
}
// Deterministic pseudo-random from seed
function seededRandom(seed: number) {
let s = seed
return () => {
s = (s * 16807 + 0) % 2147483647
return (s - 1) / 2147483646
}
}
interface NodePos {
x: number
y: number
ring: number
angle: number
tranche: MortgageTranche
}
// Generate organic offsets for mycelial feel
function computeLayout(state: MortgageState, size: number) {
const cx = size / 2
const cy = size / 2
const n = state.tranches.length
// Determine rings: inner ring for active high-flow, outer for repaid/lower
const maxRings = n <= 20 ? 1 : n <= 60 ? 2 : 3
const baseRadius = size * 0.2
const ringGap = (size * 0.32) / maxRings
const rng = seededRandom(42)
const nodes: NodePos[] = []
// Sort: active first, then by principal remaining (largest closest)
const sorted = [...state.tranches].sort((a, b) => {
if (a.status !== b.status) return a.status === 'active' ? -1 : 1
return b.principalRemaining - a.principalRemaining
})
const perRing = Math.ceil(sorted.length / maxRings)
sorted.forEach((tranche, i) => {
const ring = Math.min(Math.floor(i / perRing), maxRings - 1)
const indexInRing = i - ring * perRing
const countInRing = Math.min(perRing, sorted.length - ring * perRing)
const baseAngle = (indexInRing / countInRing) * Math.PI * 2 - Math.PI / 2
// Organic jitter
const angleJitter = (rng() - 0.5) * 0.15
const radiusJitter = (rng() - 0.5) * ringGap * 0.25
const angle = baseAngle + angleJitter
const radius = baseRadius + ring * ringGap + ringGap * 0.5 + radiusJitter
nodes.push({
x: cx + Math.cos(angle) * radius,
y: cy + Math.sin(angle) * radius,
ring,
angle,
tranche,
})
})
// Build reinvestment connections from actual data (parent → child tranche)
const relendLinks: { from: number; to: number; strength: number }[] = []
for (let i = 0; i < nodes.length; i++) {
const t = nodes[i].tranche
if (!t.isReinvested || !t.parentTrancheId) continue
// Find parent node
const parentIdx = nodes.findIndex(n => n.tranche.id === t.parentTrancheId)
if (parentIdx < 0) continue
const strength = Math.min(1, t.principal / state.trancheSize)
relendLinks.push({ from: parentIdx, to: i, strength })
}
// Also add links between same-lender tranches (reinvestor who has multiple active)
const lenderNodes = new Map<string, number[]>()
nodes.forEach((n, i) => {
const lid = n.tranche.lender.id
if (!lenderNodes.has(lid)) lenderNodes.set(lid, [])
lenderNodes.get(lid)!.push(i)
})
for (const [, indices] of lenderNodes) {
if (indices.length < 2) continue
for (let j = 1; j < indices.length; j++) {
const from = indices[0]
const to = indices[j]
// Avoid duplicate links
if (!relendLinks.some(l => (l.from === from && l.to === to) || (l.from === to && l.to === from))) {
relendLinks.push({ from, to, strength: 0.5 })
}
}
}
return { cx, cy, nodes, relendLinks }
}
// Generate an organic bezier path (like a hypha)
function hyphaPath(x1: number, y1: number, x2: number, y2: number, curvature: number = 0.3): string {
const dx = x2 - x1
const dy = y2 - y1
const mx = (x1 + x2) / 2
const my = (y1 + y2) / 2
// Perpendicular offset for organic curve
const nx = -dy * curvature
const ny = dx * curvature
return `M${x1},${y1} Q${mx + nx},${my + ny} ${x2},${y2}`
}
// Generate a wavy mycelial path between two lenders
function mycelialPath(x1: number, y1: number, x2: number, y2: number, seed: number): string {
const rng = seededRandom(seed)
const dx = x2 - x1
const dy = y2 - y1
const dist = Math.sqrt(dx * dx + dy * dy)
const nx = -dy / dist
const ny = dx / dist
const wobble = dist * 0.15
const cp1x = x1 + dx * 0.33 + nx * wobble * (rng() - 0.5)
const cp1y = y1 + dy * 0.33 + ny * wobble * (rng() - 0.5)
const cp2x = x1 + dx * 0.66 + nx * wobble * (rng() - 0.5)
const cp2y = y1 + dy * 0.66 + ny * wobble * (rng() - 0.5)
return `M${x1},${y1} C${cp1x},${cp1y} ${cp2x},${cp2y} ${x2},${y2}`
}
export default function MycelialNetworkViz({ state, selectedTrancheId, onSelectTranche }: Props) {
const [hoveredId, setHoveredId] = useState<string | null>(null)
const size = 700
const layout = useMemo(() => computeLayout(state, size), [state, size])
const repaidPct = state.totalPrincipal > 0 ? state.totalPrincipalPaid / state.totalPrincipal : 0
return (
<svg
viewBox={`0 0 ${size} ${size}`}
className="w-full h-full"
style={{ minHeight: 500, maxHeight: '75vh' }}
>
<defs>
{/* Radial glow for center node */}
<radialGradient id="centerGlow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="#0ea5e9" stopOpacity="0.3" />
<stop offset="70%" stopColor="#0ea5e9" stopOpacity="0.05" />
<stop offset="100%" stopColor="#0ea5e9" stopOpacity="0" />
</radialGradient>
{/* Mycelium pulse animation */}
<filter id="glowFilter">
<feGaussianBlur stdDeviation="2" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
{/* Animated flow dot */}
<circle id="flowDot" r="2" fill="#0ea5e9" opacity="0.8" />
{/* Gradient for active hyphae */}
<linearGradient id="hyphaActive" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#0ea5e9" stopOpacity="0.6" />
<stop offset="100%" stopColor="#10b981" stopOpacity="0.3" />
</linearGradient>
<linearGradient id="hyphaRepaid" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#10b981" stopOpacity="0.3" />
<stop offset="100%" stopColor="#10b981" stopOpacity="0.1" />
</linearGradient>
<linearGradient id="hyphaRelend" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#8b5cf6" stopOpacity="0.4" />
<stop offset="100%" stopColor="#f59e0b" stopOpacity="0.2" />
</linearGradient>
</defs>
{/* ─── Background ring guides ─── */}
{[0.2, 0.35, 0.5].map((r, i) => (
<circle
key={i}
cx={layout.cx} cy={layout.cy}
r={size * r}
fill="none"
stroke="#1e293b"
strokeWidth="0.5"
strokeDasharray="4,8"
/>
))}
{/* ─── Relend/staking connections (mycelial cross-links) ─── */}
{layout.relendLinks.map((link, i) => {
const from = layout.nodes[link.from]
const to = layout.nodes[link.to]
if (!from || !to) return null
const isHighlighted =
selectedTrancheId === from.tranche.id ||
selectedTrancheId === to.tranche.id ||
hoveredId === from.tranche.id ||
hoveredId === to.tranche.id
return (
<g key={`relend-${i}`}>
<path
d={mycelialPath(from.x, from.y, to.x, to.y, i * 7 + 31)}
fill="none"
stroke="url(#hyphaRelend)"
strokeWidth={isHighlighted ? 2 : 0.8}
opacity={isHighlighted ? 0.8 : 0.25 + link.strength * 0.3}
strokeDasharray={isHighlighted ? 'none' : '3,6'}
/>
{/* Animated pulse along relend path */}
{isHighlighted && (
<circle r="2.5" fill="#8b5cf6" opacity="0.7">
<animateMotion
path={mycelialPath(from.x, from.y, to.x, to.y, i * 7 + 31)}
dur={`${2 + link.strength}s`}
repeatCount="indefinite"
/>
</circle>
)}
</g>
)
})}
{/* ─── Hyphae: Center → Lenders ─── */}
{layout.nodes.map((node, i) => {
const t = node.tranche
const isSelected = t.id === selectedTrancheId
const isHovered = t.id === hoveredId
const isActive = t.status === 'active'
const isOpenNode = !t.funded
const flowStrength = isActive && !isOpenNode
? Math.max(0.15, t.monthlyPayment / (state.monthlyPayment || 1))
: 0
// Organic curvature varies per node
const curvature = 0.1 + (i % 5) * 0.06 * (i % 2 === 0 ? 1 : -1)
return (
<g key={`hypha-${t.id}`}>
<path
d={hyphaPath(layout.cx, layout.cy, node.x, node.y, curvature)}
fill="none"
stroke={isSelected || isHovered
? '#38bdf8'
: isOpenNode ? '#334155'
: isActive ? 'url(#hyphaActive)' : 'url(#hyphaRepaid)'}
strokeWidth={isSelected ? 2.5 : isHovered ? 2 : isOpenNode ? 0.5 : Math.max(0.5, flowStrength * 3)}
opacity={isSelected || isHovered ? 0.9 : isOpenNode ? 0.15 : isActive ? 0.4 : 0.15}
strokeDasharray={isOpenNode ? '4,6' : 'none'}
/>
{/* Animated flow dot along active hyphae */}
{isActive && (isSelected || isHovered) && (
<circle r="2" fill="#0ea5e9" opacity="0.9">
<animateMotion
path={hyphaPath(layout.cx, layout.cy, node.x, node.y, curvature)}
dur={`${1.5 + i * 0.05}s`}
repeatCount="indefinite"
/>
</circle>
)}
</g>
)
})}
{/* ─── Lender Nodes (spores) ─── */}
{layout.nodes.map((node) => {
const t = node.tranche
const isSelected = t.id === selectedTrancheId
const isHovered = t.id === hoveredId
const isActive = t.status === 'active'
const isOpen = !t.funded
const repaidFrac = t.principal > 0 ? t.totalPrincipalPaid / t.principal : 0
// Node size based on tranche principal
const baseR = Math.max(6, Math.min(16, 4 + (t.principal / state.trancheSize) * 8))
const r = isSelected ? baseR + 3 : isHovered ? baseR + 1.5 : isOpen ? baseR - 1 : baseR
return (
<g
key={t.id}
onClick={() => onSelectTranche(isSelected ? null : t)}
onMouseEnter={() => setHoveredId(t.id)}
onMouseLeave={() => setHoveredId(null)}
style={{ cursor: 'pointer' }}
>
{/* Outer ring (full tranche) */}
<circle
cx={node.x} cy={node.y} r={r}
fill={isOpen ? 'none' : isActive ? '#1e293b' : '#064e3b'}
fillOpacity={isOpen ? 0 : 0.8}
stroke={isSelected ? '#38bdf8' : isHovered ? '#64748b' : isOpen ? '#475569' : isActive ? '#334155' : '#065f46'}
strokeWidth={isSelected ? 2 : 1}
strokeDasharray={isOpen ? '3,3' : 'none'}
opacity={isOpen ? 0.5 : 1}
/>
{/* Repaid fill (grows from bottom like a filling circle) */}
{repaidFrac > 0 && repaidFrac < 1 && (
<clipPath id={`clip-${t.id}`}>
<rect
x={node.x - r} y={node.y + r - r * 2 * repaidFrac}
width={r * 2} height={r * 2 * repaidFrac}
/>
</clipPath>
)}
{repaidFrac > 0 && (
<circle
cx={node.x} cy={node.y} r={r - 1}
fill={isActive ? '#0ea5e9' : '#10b981'}
fillOpacity={0.5}
clipPath={repaidFrac < 1 ? `url(#clip-${t.id})` : undefined}
/>
)}
{/* Reinvestment glow (purple ring for reinvestors) */}
{t.reinvestmentRate != null && isActive && (
<circle
cx={node.x} cy={node.y} r={r + 3}
fill="none"
stroke="#a78bfa"
strokeWidth="1"
opacity={0.6}
strokeDasharray={t.isReinvested ? 'none' : '3,3'}
/>
)}
{/* Reinvested badge (inner dot) */}
{t.isReinvested && (
<circle
cx={node.x} cy={node.y} r={2.5}
fill="#8b5cf6"
opacity="0.9"
/>
)}
{/* Interest glow for high-yield nodes */}
{t.totalInterestPaid > t.principal * 0.1 && isActive && !t.reinvestmentRate && (
<circle
cx={node.x} cy={node.y} r={r + 3}
fill="none"
stroke="#f59e0b"
strokeWidth="0.5"
opacity={0.3 + Math.min(0.5, t.totalInterestPaid / t.principal)}
strokeDasharray="2,3"
/>
)}
{/* For-sale marker */}
{t.listedForSale && (
<circle
cx={node.x + r * 0.7} cy={node.y - r * 0.7}
r={3} fill="#f59e0b"
/>
)}
{/* Transfer history marker */}
{t.transferHistory.length > 0 && (
<circle
cx={node.x - r * 0.7} cy={node.y - r * 0.7}
r={3} fill="#8b5cf6"
/>
)}
{/* Label on hover/select */}
{(isSelected || isHovered) && (
<g>
{(() => {
const hasReinvest = t.reinvestmentRate != null
const boxH = hasReinvest ? 44 : 32
return (
<>
<rect
x={node.x - 42} y={node.y + r + 4}
width={84} height={boxH}
rx={4} fill="#0f172a" fillOpacity="0.95"
stroke={t.isReinvested ? '#8b5cf6' : '#334155'} strokeWidth="0.5"
/>
<text
x={node.x} y={node.y + r + 17}
textAnchor="middle" fill={isOpen ? '#64748b' : '#e2e8f0'} fontSize="9" fontWeight="600"
>
{isOpen ? 'Open Slot' : t.lender.name}{t.isReinvested ? ' (R)' : ''}
</text>
<text
x={node.x} y={node.y + r + 29}
textAnchor="middle" fill="#94a3b8" fontSize="8"
>
{fmt(t.principal)} @ {(t.interestRate * 100).toFixed(1)}%{isOpen ? ` (${t.tierLabel})` : ''}
</text>
{hasReinvest && (
<text
x={node.x} y={node.y + r + 41}
textAnchor="middle" fill="#a78bfa" fontSize="7"
>
reinvests @ {(t.reinvestmentRate! * 100).toFixed(0)}%
{t.reinvestmentPool > 0 ? ` (${fmt(t.reinvestmentPool)} pooled)` : ''}
</text>
)}
</>
)
})()}
</g>
)}
</g>
)
})}
{/* ─── Center Node: Borrower/Property ─── */}
<g>
{/* Glow */}
<circle cx={layout.cx} cy={layout.cy} r={size * 0.12} fill="url(#centerGlow)" />
{/* Repaid progress ring */}
<circle
cx={layout.cx} cy={layout.cy}
r={36}
fill="none"
stroke="#1e293b"
strokeWidth="4"
/>
<circle
cx={layout.cx} cy={layout.cy}
r={36}
fill="none"
stroke="#10b981"
strokeWidth="4"
strokeDasharray={`${repaidPct * 226.2} ${226.2}`}
strokeDashoffset={226.2 * 0.25}
strokeLinecap="round"
opacity="0.8"
/>
{/* Inner circle */}
<circle
cx={layout.cx} cy={layout.cy}
r={30}
fill="#0f172a"
stroke="#0ea5e9"
strokeWidth="2"
/>
{/* House icon (simple) */}
<path
d={`M${layout.cx},${layout.cy - 14} l-12,10 h4 v8 h5 v-5 h6 v5 h5 v-8 h4 z`}
fill="none"
stroke="#0ea5e9"
strokeWidth="1.5"
strokeLinejoin="round"
/>
{/* Labels */}
<text
x={layout.cx} y={layout.cy + 20}
textAnchor="middle" fill="#e2e8f0" fontSize="9" fontWeight="600"
>
{fmt(state.totalPrincipal)}
</text>
</g>
{/* ─── Legend ─── */}
<g transform={`translate(16, ${size - 120})`}>
<rect width={140} height={112} rx={6} fill="#0f172a" fillOpacity="0.9" stroke="#1e293b" strokeWidth="0.5" />
<g transform="translate(10, 14)">
<circle cx={5} cy={0} r={4} fill="#0ea5e9" fillOpacity="0.5" stroke="#334155" />
<text x={14} y={3} fill="#94a3b8" fontSize="8">Active lender</text>
</g>
<g transform="translate(10, 28)">
<circle cx={5} cy={0} r={4} fill="#10b981" fillOpacity="0.5" stroke="#065f46" />
<text x={14} y={3} fill="#94a3b8" fontSize="8">Repaid lender</text>
</g>
<g transform="translate(10, 42)">
<circle cx={5} cy={0} r={4} fill="none" stroke="#475569" strokeWidth="1" strokeDasharray="3,3" opacity="0.5" />
<text x={14} y={3} fill="#94a3b8" fontSize="8">Open slot</text>
</g>
<g transform="translate(10, 56)">
<circle cx={5} cy={0} r={4} fill="#1e293b" stroke="#a78bfa" strokeWidth="1" />
<circle cx={5} cy={0} r={2} fill="#8b5cf6" />
<text x={14} y={3} fill="#94a3b8" fontSize="8">Reinvested tranche</text>
</g>
<g transform="translate(10, 70)">
<line x1={0} y1={0} x2={10} y2={0} stroke="#8b5cf6" strokeWidth="1.5" opacity="0.6" />
<text x={14} y={3} fill="#94a3b8" fontSize="8">Reinvestment link</text>
</g>
<g transform="translate(10, 84)">
<line x1={0} y1={0} x2={10} y2={0} stroke="#0ea5e9" strokeWidth="1.5" opacity="0.6" />
<text x={14} y={3} fill="#94a3b8" fontSize="8">Payment flow</text>
</g>
<g transform="translate(10, 98)">
<circle cx={5} cy={0} r={3} fill="#f59e0b" />
<text x={14} y={3} fill="#94a3b8" fontSize="8">For sale / transferred</text>
</g>
</g>
{/* ─── Stats overlay ─── */}
<g transform={`translate(${size - 146}, ${size - 60})`}>
<rect width={130} height={52} rx={6} fill="#0f172a" fillOpacity="0.9" stroke="#1e293b" strokeWidth="0.5" />
<text x={10} y={16} fill="#94a3b8" fontSize="8">
Month {state.currentMonth} {state.tranches.length} lenders
</text>
<text x={10} y={30} fill="#10b981" fontSize="9" fontWeight="600">
{(repaidPct * 100).toFixed(1)}% repaid
</text>
<text x={10} y={43} fill="#f59e0b" fontSize="8">
{fmt(state.totalInterestPaid)} interest earned
</text>
</g>
{/* ─── Community Fund (if active) ─── */}
{state.communityFundBalance > 0 && (
<g transform={`translate(${size - 100}, 16)`}>
<rect width={84} height={36} rx={6} fill="#8b5cf6" fillOpacity="0.15" stroke="#8b5cf6" strokeWidth="1" />
<text x={42} y={15} textAnchor="middle" fill="#8b5cf6" fontSize="8" fontWeight="600">Community Fund</text>
<text x={42} y={28} textAnchor="middle" fill="#c4b5fd" fontSize="10" fontWeight="500">{fmt(state.communityFundBalance)}</text>
</g>
)}
</svg>
)
}

613
lib/mortgage-engine.ts Normal file
View File

@ -0,0 +1,613 @@
/**
* 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,
},
}
})
}

173
lib/mortgage-types.ts Normal file
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
}