From f847d914f76dc38ec519ebbba893554f2e25ba68 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sat, 14 Mar 2026 01:50:14 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20rMortgage=20simulator=20=E2=80=94=20var?= =?UTF-8?q?iable-term=20lending,=20lender=20return=20calculator,=20partial?= =?UTF-8?q?=20funding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/mortgage/page.tsx | 10 + components/mortgage/ComparisonPanel.tsx | 129 ++++ components/mortgage/LenderDetail.tsx | 217 +++++++ components/mortgage/LenderGrid.tsx | 155 +++++ .../mortgage/LenderReturnCalculator.tsx | 294 +++++++++ components/mortgage/MortgageControls.tsx | 284 ++++++++ components/mortgage/MortgageFlowViz.tsx | 410 ++++++++++++ components/mortgage/MortgageSimulator.tsx | 194 ++++++ components/mortgage/MycelialNetworkViz.tsx | 536 +++++++++++++++ lib/mortgage-engine.ts | 613 ++++++++++++++++++ lib/mortgage-types.ts | 173 +++++ 11 files changed, 3015 insertions(+) create mode 100644 app/mortgage/page.tsx create mode 100644 components/mortgage/ComparisonPanel.tsx create mode 100644 components/mortgage/LenderDetail.tsx create mode 100644 components/mortgage/LenderGrid.tsx create mode 100644 components/mortgage/LenderReturnCalculator.tsx create mode 100644 components/mortgage/MortgageControls.tsx create mode 100644 components/mortgage/MortgageFlowViz.tsx create mode 100644 components/mortgage/MortgageSimulator.tsx create mode 100644 components/mortgage/MycelialNetworkViz.tsx create mode 100644 lib/mortgage-engine.ts create mode 100644 lib/mortgage-types.ts diff --git a/app/mortgage/page.tsx b/app/mortgage/page.tsx new file mode 100644 index 0000000..0669f0d --- /dev/null +++ b/app/mortgage/page.tsx @@ -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 +} diff --git a/components/mortgage/ComparisonPanel.tsx b/components/mortgage/ComparisonPanel.tsx new file mode 100644 index 0000000..9985354 --- /dev/null +++ b/components/mortgage/ComparisonPanel.tsx @@ -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 ( +
+ {/* Progress */} +
+

Progress

+
+ + + + +
+ {/* Overall progress bar */} +
+
+
+
+
+
+ + {/* Community Fund */} + {state.communityFundBalance > 0 && ( +
+

Community Fund

+
+
{fmt(state.communityFundBalance)}
+
Overflow directed to community resilience
+
+
+ )} + + {/* Comparison */} +
+

+ rMortgage vs Traditional +

+
+ + 0} + /> + 0} + /> + +
+
+ + {/* Key insight */} +
+

Community Wealth

+
{fmt(summary.communityRetained)}
+

+ Interest that stays in the community instead of flowing to a distant institution. + {summary.interestSaved > 0 && ( + <> Plus {fmt(summary.interestSaved)} saved through distributed rates. + )} +

+
+
+ ) +} + +function StatCard({ label, value, sub, color }: { label: string; value: string; sub?: string; color?: string }) { + return ( +
+
{label}
+
+ {value} + {sub && {sub}} +
+
+ ) +} + +function CompareRow({ label, myco, trad, highlight }: { label: string; myco: string; trad: string; highlight?: boolean }) { + return ( +
+
{label}
+
+ {myco} +
+
+ {trad} +
+
+ ) +} diff --git a/components/mortgage/LenderDetail.tsx b/components/mortgage/LenderDetail.tsx new file mode 100644 index 0000000..30efea2 --- /dev/null +++ b/components/mortgage/LenderDetail.tsx @@ -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 ( +
+ {/* Header */} +
+
+

{tranche.lender.name}

+

{tranche.lender.walletAddress.slice(0, 10)}...

+
+ +
+ + {/* Key Stats */} +
+ + + + + + +
+ + {/* Progress Bar */} +
+
+ Principal Repayment + {fmtPct(repaidPct)} +
+
+
+
+
+ + {/* Current Month Breakdown */} + {tranche.status === 'active' && ( +
+

This Month

+
+
+
Payment
+
{fmt(tranche.monthlyPayment)}
+
+
+
Principal
+
{fmt(tranche.monthlyPrincipal)}
+
+
+
Interest
+
{fmt(tranche.monthlyInterest)}
+
+
+ {/* Mini P/I bar */} +
+
+
+
+
+ Principal + Interest +
+
+ )} + + {/* Secondary Market */} +
+

Secondary Market

+ {tranche.status === 'repaid' ? ( +

Tranche fully repaid — not tradeable

+ ) : ( + <> + + {tranche.listedForSale && ( +
+
Asking: {fmt(tranche.askingPrice ?? tranche.principalRemaining)}
+
Buyer yield: {fmtPct(buyerYield.annualYield)}/yr
+
Months remaining: {buyerYield.monthsRemaining}
+
+ )} + + )} +
+ + {/* Transfer History */} + {tranche.transferHistory.length > 0 && ( +
+

+ Transfer History ({tranche.transferHistory.length}) +

+
+ {tranche.transferHistory.map((t, i) => ( +
+ + {t.fromLenderId.replace('lender-', 'L')} → {t.toLenderId.replace('lender-', 'L')} + + = 0 ? 'text-emerald-400' : 'text-red-400'}`}> + {fmt(t.price)} ({t.premiumPercent >= 0 ? '+' : ''}{t.premiumPercent.toFixed(1)}%) + +
+ ))} +
+
+ )} + + {/* Mini Amortization Chart */} +
+

Amortization

+ +
+
+ ) +} + +function Stat({ label, value, color }: { label: string; value: string; color?: string }) { + return ( +
+
{label}
+
{value}
+
+ ) +} + +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 ( + + {/* Balance line */} + + {/* Interest line */} + + {/* Current month marker */} + {currentMonth > 0 && ( + + )} + {/* Legend */} + + Balance + + Cum. Interest + + ) +} diff --git a/components/mortgage/LenderGrid.tsx b/components/mortgage/LenderGrid.tsx new file mode 100644 index 0000000..9225515 --- /dev/null +++ b/components/mortgage/LenderGrid.tsx @@ -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 ( +
+ {/* Header with filters */} +
+

+ Lenders ({tranches.length}) + {repaidCount > 0 && / {repaidCount} repaid} +

+
+ {(['all', 'active', 'repaid', 'open', 'for-sale'] as const).map(f => ( + + ))} +
+
+ + {/* Sort */} +
+ Sort: + {(['index', 'repaid', 'rate', 'remaining'] as const).map(s => ( + + ))} +
+ + {/* Grid */} +
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 ( + + ) + })} +
+
+ ) +} diff --git a/components/mortgage/LenderReturnCalculator.tsx b/components/mortgage/LenderReturnCalculator.tsx new file mode 100644 index 0000000..b682103 --- /dev/null +++ b/components/mortgage/LenderReturnCalculator.tsx @@ -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 = { + '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 ( +
+ {/* Header */} +
+

Lender Return Calculator

+

+ See what you could earn lending into the rMortgage +

+
+ + {/* Investment amount */} +
+
+ Your Investment + {fmt(investment)} +
+ setInvestment(Number(e.target.value))} + className="w-full accent-sky-500" + /> +
+ $1,000 + $25,000 +
+
+ + {/* Strategy toggle */} +
+ {([ + ['compare', 'Compare Both'], + ['liquid', 'Monthly Liquidity'], + ['reinvest', 'Reinvest to Term'], + ] as const).map(([key, label]) => ( + + ))} +
+ + {/* Results table */} + {strategy === 'compare' ? ( +
+ {scenarios.map(s => { + const color = TIER_COLORS[s.tierLabel] || '#64748b' + const reinvestGain = s.reinvested.totalInterest - s.liquid.totalInterest + return ( +
+ {/* Tier header */} +
+ {s.tierLabel} + @ {(s.rate * 100).toFixed(1)}% APR + {s.termYears} year commitment +
+ + {/* Two columns */} +
+ {/* Liquid */} +
+
+ + Monthly Liquidity +
+
+ + + +
+ +
+
+
+ + {/* Reinvest */} +
+
+ + Reinvest to Term +
+
+ + + 0 ? `+${fmt(reinvestGain)}` : fmt(reinvestGain)} + color={reinvestGain > 0 ? 'text-emerald-400' : 'text-slate-400'} + /> +
+ +
+
+
+
+
+ ) + })} +
+ ) : ( + /* Single strategy view: cards */ +
+ {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 ( +
+
+
+ {s.tierLabel} + @ {(s.rate * 100).toFixed(1)}% + {isBest && ( + Best + )} +
+
+
+ {data.effectiveYield.toFixed(2)}% +
+
effective yield/yr
+
+
+ +
+ {isLiquid ? ( + <> + + + + + ) : ( + <> + + + + + )} +
+ + {/* Visual bar: principal vs interest */} + {(() => { + const d = isLiquid ? s.liquid : s.reinvested + return ( + <> +
+
+
+
+
+ Principal + Interest +
+ + ) + })()} +
+ ) + })} +
+ )} + + {/* Summary insight */} +
+

+ {(() => { + 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 {best.tierLabel} tranche yields{' '} + {best.reinvested.effectiveYield.toFixed(2)}%/yr vs{' '} + {liquidBest.liquid.effectiveYield.toFixed(2)}%/yr with monthly liquidity. + {best.reinvested.totalInterest > liquidBest.liquid.totalInterest && ( + <> That's {fmt(best.reinvested.totalInterest - liquidBest.liquid.totalInterest)} more over the term. + )} + + ) + })()} +

+
+
+ ) +} + +function Row({ label, value, highlight, large, color }: { + label: string; value: string; highlight?: boolean; large?: boolean; color?: string +}) { + return ( +
+ {label} + + {value} + +
+ ) +} + +function MiniStat({ label, value, color }: { label: string; value: string; color?: string }) { + return ( +
+
{label}
+
{value}
+
+ ) +} diff --git a/components/mortgage/MortgageControls.tsx b/components/mortgage/MortgageControls.tsx new file mode 100644 index 0000000..306829b --- /dev/null +++ b/components/mortgage/MortgageControls.tsx @@ -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) => { + 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 ( +
+ {/* Property */} +
+ update({ propertyValue: v })} + /> + `${v}% (${formatCurrency(config.propertyValue * v / 100)})`} + onChange={v => update({ downPaymentPercent: v })} + /> +
+ Loan: {formatCurrency(totalPrincipal)} +
+
+ + {/* Tranches */} +
+ update({ trancheSize: v })} + /> +
+ = {numTranches} lenders +
+ `${v}% (${Math.round(numTranches * v / 100)} / ${numTranches})`} + onChange={v => update({ fundingPercent: v })} + /> +
+ + {/* Rate */} +
+ `${v.toFixed(2)}%`} + onChange={v => update({ interestRate: v / 100 })} + /> + v === 0 ? 'None' : `+/- ${v.toFixed(2)}%`} + onChange={v => update({ rateVariation: v / 100 })} + /> +
+ + {/* Term & Tiers */} +
+ `${v} years`} + onChange={v => update({ termYears: v })} + /> +
+ +
+ {config.useVariableTerms && ( +
+ {config.lendingTiers.map((tier, i) => ( +
+ {tier.label} + {(tier.rate * 100).toFixed(1)}% + { + const newTiers = [...config.lendingTiers] + newTiers[i] = { ...tier, allocation: Number(e.target.value) / 100 } + update({ lendingTiers: newTiers }) + }} + className="flex-1 accent-emerald-500 h-1" + /> + {(tier.allocation * 100).toFixed(0)}% +
+ ))} +
+ )} +
+ + {/* Overpayment */} +
+ update({ overpayment: v })} + /> + {config.overpayment > 0 && ( +
+ {(['extra_principal', 'community_fund', 'split'] as const).map(target => ( + + ))} +
+ )} +
+ + {/* Reinvestment */} +
+ `${v}%`} + onChange={v => update({ reinvestorPercent: v })} + /> + {config.reinvestorPercent > 0 && ( + <> +
+ Relend rates: +
+
+ {[[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 ( + + ) + })} +
+ + )} +
+ + {/* Timeline */} +
+
+ + +
+ {[1, 2, 4].map(s => ( + + ))} +
+
+ onMonthChange(Number(e.target.value))} + className="w-full accent-sky-500" + /> +
+ Month {currentMonth} ({years}y {months}m) +
+
+
+ ) +} + +// ─── Sub-components ────────────────────────────────────── + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ) +} + +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 ( +
+
+ {label} + {format(value)} +
+ onChange(Number(e.target.value))} + className="w-full accent-sky-500" + /> +
+ ) +} diff --git a/components/mortgage/MortgageFlowViz.tsx b/components/mortgage/MortgageFlowViz.tsx new file mode 100644 index 0000000..c515a44 --- /dev/null +++ b/components/mortgage/MortgageFlowViz.tsx @@ -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 = { + '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() + 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 ( + + + + + + + {tierNodes.map(tier => ( + + + + + ))} + + + + + + + + + + + {/* ─── Borrower Node ─── */} + + + + Borrower + + + {fmt(totalMonthly + state.overpayment)}/mo + + + + {/* ─── Split Node (Principal / Interest) ─── */} + + {/* Principal portion */} + 0 ? splitH * (totalPrincipalFlow / totalMonthly) : splitH / 2} + rx={2} fill={COLORS.principal} + /> + {/* Interest portion */} + 0 ? splitH * (totalPrincipalFlow / totalMonthly) : splitH / 2)} + width={nodeW} + height={totalMonthly > 0 ? splitH * (totalInterest / totalMonthly) : splitH / 2} + rx={2} fill={COLORS.interest} + /> + + Payment Split + + + Principal {fmt(totalPrincipalFlow)} + + + Interest {fmt(totalInterest)} + + + + {/* ─── Borrower → Split flow ─── */} + + + {/* ─── 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 ( + + {/* Principal flow to tier */} + {pH > 0.5 && ( + + )} + {/* Interest flow to tier */} + {iH > 0.5 && ( + + )} + + ) + }) + })()} + + {/* ─── 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 ( + { + // 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 */} + + {/* Interest portion */} + + {/* Label */} + + {isReinvest ? `Reinvest (${tier.label.replace('reinvest@', '')})` : tier.label} + + + {tier.activeCount} active / {tier.repaidCount} repaid · {fmt(tier.totalMonthlyPayment)}/mo + + {/* Reinvest pool indicator */} + {tier.reinvestPool > 100 && ( + + Pool: {fmt(tier.reinvestPool)} + + )} + + ) + })} + + {/* ─── Tier → Lender Outcome flows ─── */} + {tierNodes.map(tier => { + if (tier.totalMonthlyPayment === 0) return null + const tierEndY = tier.y + const tierEndH = tier.h + + return ( + + ) + })} + + {/* ─── Right-side labels: Lender outcomes ─── */} + + + Lenders + + {tierNodes.map(tier => ( + + + + {tier.tranches.length} × {fmt(tier.totalPrincipal / tier.tranches.length)} + + + ))} + + + {/* ─── Reinvestment feedback loop (curved arrow) ─── */} + {reinvestTotal > 100 && ( + + + + Reinvestment: {fmt(reinvestTotal)} pooled + + + + + + + + )} + + {/* ─── Community Fund ─── */} + {state.communityFundBalance > 0 && ( + + + Community Fund + {fmt(state.communityFundBalance)} + + )} + + {/* ─── Summary stats ─── */} + + + Month {state.currentMonth} · {state.tranches.length} tranches · {fmt(state.totalPrincipalRemaining)} remaining · {((state.totalPrincipalPaid / state.totalPrincipal) * 100).toFixed(1)}% repaid + + + + ) +} + +// ─── 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 ( + + ) +} diff --git a/components/mortgage/MortgageSimulator.tsx b/components/mortgage/MortgageSimulator.tsx new file mode 100644 index 0000000..c2915bf --- /dev/null +++ b/components/mortgage/MortgageSimulator.tsx @@ -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(DEFAULT_CONFIG) + const [currentMonth, setCurrentMonth] = useState(DEFAULT_CONFIG.startMonth) + const [playing, setPlaying] = useState(false) + const [speed, setSpeed] = useState(1) + const [selectedTranche, setSelectedTranche] = useState(null) + const [viewMode, setViewMode] = useState('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 ( +
+ {/* Header */} +
+
+
+

(you)rMortgage

+

+ {currentState.tranches.length} lenders × {new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(config.trancheSize)} tranches +

+
+
+ {(['mycelial', 'flow', 'grid', 'lender'] as const).map(mode => ( + + ))} +
+
+
+ + {/* Main Layout */} +
+ {/* Left: Controls (collapsible) */} + + + {/* Center: Visualization */} +
+ {viewMode === 'mycelial' ? ( + + ) : viewMode === 'flow' ? ( + + ) : viewMode === 'grid' ? ( + + ) : ( + + )} + + {/* Lender Detail (below viz when selected) */} + {selectedTrancheState && ( +
+ setSelectedTranche(null)} + onToggleForSale={handleToggleForSale} + /> +
+ )} +
+ + {/* Right: Comparison */} + +
+
+ ) +} diff --git a/components/mortgage/MycelialNetworkViz.tsx b/components/mortgage/MycelialNetworkViz.tsx new file mode 100644 index 0000000..4310191 --- /dev/null +++ b/components/mortgage/MycelialNetworkViz.tsx @@ -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() + 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(null) + const size = 700 + const layout = useMemo(() => computeLayout(state, size), [state, size]) + + const repaidPct = state.totalPrincipal > 0 ? state.totalPrincipalPaid / state.totalPrincipal : 0 + + return ( + + + {/* Radial glow for center node */} + + + + + + + {/* Mycelium pulse animation */} + + + + + + + + + {/* Animated flow dot */} + + + {/* Gradient for active hyphae */} + + + + + + + + + + + + + + + + + {/* ─── Background ring guides ─── */} + {[0.2, 0.35, 0.5].map((r, i) => ( + + ))} + + {/* ─── 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 ( + + + {/* Animated pulse along relend path */} + {isHighlighted && ( + + + + )} + + ) + })} + + {/* ─── 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 ( + + + {/* Animated flow dot along active hyphae */} + {isActive && (isSelected || isHovered) && ( + + + + )} + + ) + })} + + {/* ─── 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 ( + onSelectTranche(isSelected ? null : t)} + onMouseEnter={() => setHoveredId(t.id)} + onMouseLeave={() => setHoveredId(null)} + style={{ cursor: 'pointer' }} + > + {/* Outer ring (full tranche) */} + + + {/* Repaid fill (grows from bottom like a filling circle) */} + {repaidFrac > 0 && repaidFrac < 1 && ( + + + + )} + {repaidFrac > 0 && ( + + )} + + {/* Reinvestment glow (purple ring for reinvestors) */} + {t.reinvestmentRate != null && isActive && ( + + )} + + {/* Reinvested badge (inner dot) */} + {t.isReinvested && ( + + )} + + {/* Interest glow for high-yield nodes */} + {t.totalInterestPaid > t.principal * 0.1 && isActive && !t.reinvestmentRate && ( + + )} + + {/* For-sale marker */} + {t.listedForSale && ( + + )} + + {/* Transfer history marker */} + {t.transferHistory.length > 0 && ( + + )} + + {/* Label on hover/select */} + {(isSelected || isHovered) && ( + + {(() => { + const hasReinvest = t.reinvestmentRate != null + const boxH = hasReinvest ? 44 : 32 + return ( + <> + + + {isOpen ? 'Open Slot' : t.lender.name}{t.isReinvested ? ' (R)' : ''} + + + {fmt(t.principal)} @ {(t.interestRate * 100).toFixed(1)}%{isOpen ? ` (${t.tierLabel})` : ''} + + {hasReinvest && ( + + reinvests @ {(t.reinvestmentRate! * 100).toFixed(0)}% + {t.reinvestmentPool > 0 ? ` (${fmt(t.reinvestmentPool)} pooled)` : ''} + + )} + + ) + })()} + + )} + + ) + })} + + {/* ─── Center Node: Borrower/Property ─── */} + + {/* Glow */} + + + {/* Repaid progress ring */} + + + + {/* Inner circle */} + + + {/* House icon (simple) */} + + + {/* Labels */} + + {fmt(state.totalPrincipal)} + + + + {/* ─── Legend ─── */} + + + + + Active lender + + + + Repaid lender + + + + Open slot + + + + + Reinvested tranche + + + + Reinvestment link + + + + Payment flow + + + + For sale / transferred + + + + {/* ─── Stats overlay ─── */} + + + + Month {state.currentMonth} — {state.tranches.length} lenders + + + {(repaidPct * 100).toFixed(1)}% repaid + + + {fmt(state.totalInterestPaid)} interest earned + + + + {/* ─── Community Fund (if active) ─── */} + {state.communityFundBalance > 0 && ( + + + Community Fund + {fmt(state.communityFundBalance)} + + )} + + ) +} diff --git a/lib/mortgage-engine.ts b/lib/mortgage-engine.ts new file mode 100644 index 0000000..02ea558 --- /dev/null +++ b/lib/mortgage-engine.ts @@ -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, + }, + } + }) +} diff --git a/lib/mortgage-types.ts b/lib/mortgage-types.ts new file mode 100644 index 0000000..fb426e0 --- /dev/null +++ b/lib/mortgage-types.ts @@ -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 +}