Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| /** | |
| * Valuation model for the Substack Valuation Tool (/tools/substack-valuation-calculator). | |
| * | |
| * The model is a revenue-multiple valuation. It starts from annual recurring | |
| * revenue, applies a base newsletter multiple, then adjusts that multiple by | |
| * four independent factors: | |
| * | |
| * 1. Geography β audience ability-to-pay, derived from GDP per capita. | |
| * 2. Niche β how the public market prices the equivalent sector. | |
| * 3. Retention β churn, expressed as implied subscriber lifetime. | |
| * 4. Scale β the size premium larger revenue bases attract. | |
| * 5. Growth β what a buyer pays for a list that is still compounding. | |
| * | |
| * Every factor is a multiplier centred on 1.0, and every factor is raised to a | |
| * user-controlled weight so a reader who disagrees with one input can dial it | |
| * out entirely (weight 0) rather than being stuck with our opinion. | |
| * | |
| * All figures here are documented public estimates, not live data. They are | |
| * deliberately kept in one file so they can be refreshed in one place. | |
| */ | |
| // βββ Reference data ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /** Approximate nominal GDP per capita in USD (World Bank / IMF, 2024 estimates). */ | |
| export type CountryRef = { | |
| code: string; | |
| name: string; | |
| gdpPerCapita: number; | |
| }; | |
| export const COUNTRIES: CountryRef[] = [ | |
| { code: "US", name: "United States", gdpPerCapita: 86600 }, | |
| { code: "CH", name: "Switzerland", gdpPerCapita: 106000 }, | |
| { code: "IE", name: "Ireland", gdpPerCapita: 107000 }, | |
| { code: "SG", name: "Singapore", gdpPerCapita: 92900 }, | |
| { code: "NO", name: "Norway", gdpPerCapita: 90000 }, | |
| { code: "NL", name: "Netherlands", gdpPerCapita: 70300 }, | |
| { code: "AU", name: "Australia", gdpPerCapita: 65000 }, | |
| { code: "SE", name: "Sweden", gdpPerCapita: 58500 }, | |
| { code: "DE", name: "Germany", gdpPerCapita: 55900 }, | |
| { code: "CA", name: "Canada", gdpPerCapita: 54300 }, | |
| { code: "GB", name: "United Kingdom", gdpPerCapita: 52400 }, | |
| { code: "AE", name: "United Arab Emirates", gdpPerCapita: 49500 }, | |
| { code: "FR", name: "France", gdpPerCapita: 46800 }, | |
| { code: "IT", name: "Italy", gdpPerCapita: 39600 }, | |
| { code: "KR", name: "South Korea", gdpPerCapita: 36100 }, | |
| { code: "ES", name: "Spain", gdpPerCapita: 36200 }, | |
| { code: "JP", name: "Japan", gdpPerCapita: 32900 }, | |
| { code: "PL", name: "Poland", gdpPerCapita: 25000 }, | |
| { code: "TR", name: "Turkey", gdpPerCapita: 15700 }, | |
| { code: "RU", name: "Russia", gdpPerCapita: 14800 }, | |
| { code: "MX", name: "Mexico", gdpPerCapita: 14600 }, | |
| { code: "CN", name: "China", gdpPerCapita: 13700 }, | |
| { code: "AR", name: "Argentina", gdpPerCapita: 13000 }, | |
| { code: "BR", name: "Brazil", gdpPerCapita: 10300 }, | |
| { code: "ZA", name: "South Africa", gdpPerCapita: 6400 }, | |
| { code: "ID", name: "Indonesia", gdpPerCapita: 5000 }, | |
| { code: "VN", name: "Vietnam", gdpPerCapita: 4700 }, | |
| { code: "PH", name: "Philippines", gdpPerCapita: 4200 }, | |
| { code: "EG", name: "Egypt", gdpPerCapita: 3500 }, | |
| { code: "IN", name: "India", gdpPerCapita: 2900 }, | |
| { code: "KE", name: "Kenya", gdpPerCapita: 2200 }, | |
| { code: "NG", name: "Nigeria", gdpPerCapita: 1600 }, | |
| { code: "PK", name: "Pakistan", gdpPerCapita: 1600 }, | |
| // Catch-all so an audience mix can always total 100%. | |
| { code: "XX", name: "Rest of world (blended)", gdpPerCapita: 13500 }, | |
| ]; | |
| export const US_GDP_PER_CAPITA = 86600; | |
| /** | |
| * Price-to-sales ratios for the listed sector that most closely matches each | |
| * newsletter niche, against the S&P 500 blended P/S. Approximate 2025 levels. | |
| */ | |
| export type NicheRef = { | |
| id: string; | |
| name: string; | |
| /** The listed sector used as the pricing proxy. */ | |
| proxySector: string; | |
| priceToSales: number; | |
| }; | |
| export const MARKET_PRICE_TO_SALES = 3.1; | |
| export const NICHES: NicheRef[] = [ | |
| { id: "ai", name: "AI & data", proxySector: "S&P 500 Information Technology", priceToSales: 10.5 }, | |
| { id: "tech", name: "Tech & software", proxySector: "S&P 500 Software", priceToSales: 9.8 }, | |
| { id: "crypto", name: "Crypto & web3", proxySector: "Listed crypto financials", priceToSales: 8.0 }, | |
| { id: "realestate", name: "Real estate", proxySector: "S&P 500 Real Estate", priceToSales: 7.5 }, | |
| { id: "health", name: "Health & biotech", proxySector: "S&P 500 Pharma & Biotech", priceToSales: 4.5 }, | |
| { id: "media", name: "Media & culture", proxySector: "S&P 500 Communication Services", priceToSales: 4.2 }, | |
| { id: "finance", name: "Finance & investing", proxySector: "S&P 500 Financials", priceToSales: 3.6 }, | |
| { id: "education", name: "Education & careers", proxySector: "Listed education / edtech", priceToSales: 3.0 }, | |
| { id: "utilities", name: "Climate & utilities", proxySector: "S&P 500 Utilities", priceToSales: 2.9 }, | |
| { id: "business", name: "Business & marketing", proxySector: "S&P 500 Industrials", priceToSales: 2.8 }, | |
| { id: "sports", name: "Sports", proxySector: "Listed sports media", priceToSales: 2.6 }, | |
| { id: "consumer", name: "Consumer & lifestyle", proxySector: "S&P 500 Consumer Discretionary", priceToSales: 2.4 }, | |
| { id: "politics", name: "Politics & news", proxySector: "Listed news publishers", priceToSales: 1.8 }, | |
| { id: "materials", name: "Science & materials", proxySector: "S&P 500 Materials", priceToSales: 1.7 }, | |
| { id: "fiction", name: "Fiction & writing", proxySector: "Listed book publishers", priceToSales: 1.5 }, | |
| { id: "food", name: "Food & cooking", proxySector: "S&P 500 Consumer Staples", priceToSales: 1.4 }, | |
| { id: "energy", name: "Energy", proxySector: "S&P 500 Energy", priceToSales: 1.3 }, | |
| ]; | |
| /** Median revenue multiple paid for small subscription-media businesses. */ | |
| export const BASE_REVENUE_MULTIPLE = 3.2; | |
| /** | |
| * Hard band on the final multiple. Five multiplicative factors compound without | |
| * limit β an exceptional newsletter can otherwise come out at 15x or 20x ARR, | |
| * which no real buyer pays. Observed subscription-media deals essentially never | |
| * settle outside this range, so the band is applied last and reported when it | |
| * binds rather than silently swallowing the excess. | |
| */ | |
| export const MIN_REVENUE_MULTIPLE = 0.8; | |
| export const MAX_REVENUE_MULTIPLE = 8.0; | |
| /** Monthly churn a typical paid newsletter runs at, used as the retention benchmark. */ | |
| export const BENCHMARK_MONTHLY_CHURN = 3.5; | |
| /** Monthly net paid growth a typical healthy newsletter runs at. */ | |
| export const BENCHMARK_MONTHLY_GROWTH = 2.0; | |
| /** ARR at which the scale multiplier is exactly 1.0. */ | |
| export const SCALE_PIVOT_ARR = 250_000; | |
| /** | |
| * Dampening exponents. Raw ratios swing far too hard to be credible on their | |
| * own β a reader in India still pays the same USD price as a reader in Oslo, | |
| * and a niche does not become worth 8x another just because its listed proxy | |
| * trades there. These compress each raw ratio toward 1.0 before weighting. | |
| */ | |
| export const GEO_DAMPENING = 0.35; | |
| export const NICHE_DAMPENING = 0.5; | |
| export const RETENTION_DAMPENING = 0.5; | |
| export const GROWTH_DAMPENING = 0.6; | |
| // βββ Inputs ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export type AudienceSlice = { | |
| /** Country code from COUNTRIES. */ | |
| code: string; | |
| /** Share of the paid list, in percent. Shares are normalised before use. */ | |
| share: number; | |
| }; | |
| export type FactorWeights = { | |
| geography: number; | |
| niche: number; | |
| retention: number; | |
| scale: number; | |
| growth: number; | |
| }; | |
| export type ValuationInputs = { | |
| paidSubscribers: number; | |
| freeSubscribers: number; | |
| monthlyPrice: number; | |
| annualShare: number; | |
| annualDiscount: number; | |
| monthlyChurn: number; | |
| monthlyGrowthRate: number; | |
| sponsorshipRevenuePerMonth: number; | |
| nicheId: string; | |
| audience: AudienceSlice[]; | |
| weights: FactorWeights; | |
| }; | |
| export const DEFAULT_WEIGHTS: FactorWeights = { | |
| geography: 1, | |
| niche: 1, | |
| retention: 1, | |
| scale: 1, | |
| growth: 1, | |
| }; | |
| export const DEFAULT_INPUTS: ValuationInputs = { | |
| paidSubscribers: 850, | |
| freeSubscribers: 24000, | |
| monthlyPrice: 10, | |
| annualShare: 40, | |
| annualDiscount: 20, | |
| monthlyChurn: 3.5, | |
| monthlyGrowthRate: 2.5, | |
| sponsorshipRevenuePerMonth: 1200, | |
| nicheId: "finance", | |
| audience: [ | |
| { code: "US", share: 55 }, | |
| { code: "GB", share: 12 }, | |
| { code: "CA", share: 8 }, | |
| { code: "IN", share: 7 }, | |
| { code: "AU", share: 5 }, | |
| { code: "DE", share: 4 }, | |
| { code: "XX", share: 9 }, | |
| ], | |
| weights: DEFAULT_WEIGHTS, | |
| }; | |
| // βββ Output ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export type Factor = { | |
| key: keyof FactorWeights; | |
| label: string; | |
| /** The undampened, unweighted ratio β what the raw data says. */ | |
| rawRatio: number; | |
| /** After dampening and weighting: what actually hits the multiple. */ | |
| applied: number; | |
| detail: string; | |
| }; | |
| export type ValuationResult = { | |
| subscriptionArr: number; | |
| sponsorshipArr: number; | |
| arr: number; | |
| mrr: number; | |
| baseMultiple: number; | |
| /** What the five factors produced before the sanity band was applied. */ | |
| unboundedMultiple: number; | |
| finalMultiple: number; | |
| /** True when the sanity band actually bit, so the UI can say so. */ | |
| multipleClamped: boolean; | |
| valuation: number; | |
| low: number; | |
| high: number; | |
| factors: Factor[]; | |
| weightedGdpPerCapita: number; | |
| impliedLifetimeMonths: number; | |
| revenuePerSubscriber: number; | |
| /** Log-space standard deviation driving the distribution chart. */ | |
| sigma: number; | |
| }; | |
| // βββ Math ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function clamp(value: number, min: number, max: number) { | |
| return Math.min(Math.max(value, min), max); | |
| } | |
| function safe(value: number, fallback = 0) { | |
| return Number.isFinite(value) ? value : fallback; | |
| } | |
| export function normaliseAudience(audience: AudienceSlice[]): AudienceSlice[] { | |
| const total = audience.reduce((sum, slice) => sum + Math.max(slice.share, 0), 0); | |
| if (total <= 0) return [{ code: "US", share: 100 }]; | |
| return audience.map((slice) => ({ | |
| code: slice.code, | |
| share: (Math.max(slice.share, 0) / total) * 100, | |
| })); | |
| } | |
| export function gdpPerCapitaFor(code: string): number { | |
| return COUNTRIES.find((country) => country.code === code)?.gdpPerCapita ?? US_GDP_PER_CAPITA; | |
| } | |
| export function nicheFor(id: string): NicheRef { | |
| return NICHES.find((niche) => niche.id === id) ?? NICHES[0]; | |
| } | |
| export function computeValuation(inputs: ValuationInputs): ValuationResult { | |
| const { | |
| paidSubscribers, | |
| monthlyPrice, | |
| annualShare, | |
| annualDiscount, | |
| monthlyChurn, | |
| monthlyGrowthRate, | |
| sponsorshipRevenuePerMonth, | |
| nicheId, | |
| audience, | |
| weights, | |
| } = inputs; | |
| // ββ Revenue ββ | |
| // Annual plans are discounted, so blended revenue per subscriber sits below | |
| // list price whenever any share of the list is on an annual plan. | |
| const annualFraction = clamp(annualShare, 0, 100) / 100; | |
| const discountFraction = clamp(annualDiscount, 0, 100) / 100; | |
| const monthlyPlanSubs = paidSubscribers * (1 - annualFraction); | |
| const annualPlanSubs = paidSubscribers * annualFraction; | |
| const annualPlanMonthlyEquivalent = monthlyPrice * (1 - discountFraction); | |
| const subscriptionMrr = | |
| monthlyPlanSubs * monthlyPrice + annualPlanSubs * annualPlanMonthlyEquivalent; | |
| const subscriptionArr = subscriptionMrr * 12; | |
| const sponsorshipArr = Math.max(sponsorshipRevenuePerMonth, 0) * 12; | |
| const arr = subscriptionArr + sponsorshipArr; | |
| const mrr = arr / 12; | |
| // ββ Factor 1: geography ββ | |
| // Weight each country's GDP per capita by its share of the paid list, then | |
| // compare against the US benchmark. Dampened because a subscriber in a | |
| // lower-GDP country still pays the newsletter's USD list price; GDP shifts | |
| // the odds they subscribe at all, not the price they pay once they do. | |
| const normalised = normaliseAudience(audience); | |
| const weightedGdpPerCapita = normalised.reduce( | |
| (sum, slice) => sum + (slice.share / 100) * gdpPerCapitaFor(slice.code), | |
| 0 | |
| ); | |
| const geoRaw = safe(weightedGdpPerCapita / US_GDP_PER_CAPITA, 1); | |
| const geoDamped = Math.pow(clamp(geoRaw, 0.05, 3), GEO_DAMPENING); | |
| const geoApplied = Math.pow(geoDamped, clamp(weights.geography, 0, 2)); | |
| // ββ Factor 2: niche ββ | |
| // Public markets already price every category. Use the listed proxy sector's | |
| // price-to-sales against the index's, dampened so a hot sector tilts the | |
| // valuation without dominating it. | |
| const niche = nicheFor(nicheId); | |
| const nicheRaw = safe(niche.priceToSales / MARKET_PRICE_TO_SALES, 1); | |
| const nicheDamped = Math.pow(clamp(nicheRaw, 0.1, 5), NICHE_DAMPENING); | |
| const nicheApplied = Math.pow(nicheDamped, clamp(weights.niche, 0, 2)); | |
| // ββ Factor 3: retention ββ | |
| // Churn is the single strongest driver of what an acquirer will pay, because | |
| // it sets how long the revenue they are buying actually lasts. | |
| const churn = clamp(monthlyChurn, 0.1, 50); | |
| const impliedLifetimeMonths = 100 / churn; | |
| const retentionRaw = safe(BENCHMARK_MONTHLY_CHURN / churn, 1); | |
| const retentionDamped = clamp( | |
| Math.pow(clamp(retentionRaw, 0.1, 6), RETENTION_DAMPENING), | |
| 0.5, | |
| 1.8 | |
| ); | |
| const retentionApplied = Math.pow(retentionDamped, clamp(weights.retention, 0, 2)); | |
| // ββ Factor 4: scale ββ | |
| // Larger revenue bases sell for higher multiples: less key-person risk, more | |
| // buyers able to write the cheque. Log-scaled so each 10x of ARR adds a | |
| // comparable step rather than running away. | |
| const scaleRaw = | |
| arr > 0 ? clamp(1 + 0.22 * Math.log10(arr / SCALE_PIVOT_ARR), 0.65, 1.7) : 0.65; | |
| const scaleApplied = Math.pow(scaleRaw, clamp(weights.scale, 0, 2)); | |
| // ββ Factor 5: growth ββ | |
| // A list still compounding is worth more than a flat one of the same size, | |
| // because the buyer is purchasing next year's revenue, not last year's. | |
| const growth = clamp(monthlyGrowthRate, -20, 30); | |
| const annualGrowth = Math.pow(1 + growth / 100, 12) - 1; | |
| const benchmarkAnnualGrowth = Math.pow(1 + BENCHMARK_MONTHLY_GROWTH / 100, 12) - 1; | |
| const growthRaw = safe((1 + annualGrowth) / (1 + benchmarkAnnualGrowth), 1); | |
| const growthDamped = clamp( | |
| Math.pow(clamp(growthRaw, 0.1, 5), GROWTH_DAMPENING), | |
| 0.55, | |
| 1.9 | |
| ); | |
| const growthApplied = Math.pow(growthDamped, clamp(weights.growth, 0, 2)); | |
| const unboundedMultiple = | |
| BASE_REVENUE_MULTIPLE * | |
| geoApplied * | |
| nicheApplied * | |
| retentionApplied * | |
| scaleApplied * | |
| growthApplied; | |
| const finalMultiple = clamp( | |
| unboundedMultiple, | |
| MIN_REVENUE_MULTIPLE, | |
| MAX_REVENUE_MULTIPLE | |
| ); | |
| const multipleClamped = | |
| Math.abs(finalMultiple - unboundedMultiple) / Math.max(unboundedMultiple, 1e-9) > 1e-6; | |
| const valuation = arr * finalMultiple; | |
| // ββ Uncertainty ββ | |
| // Small, high-churn, sponsorship-dependent businesses have wider outcomes. | |
| const churnPenalty = clamp((churn - BENCHMARK_MONTHLY_CHURN) / 20, 0, 0.2); | |
| const sizePenalty = arr > 0 ? clamp(0.16 - 0.03 * Math.log10(arr / 25_000), 0, 0.18) : 0.18; | |
| const concentrationPenalty = arr > 0 ? clamp((sponsorshipArr / arr) * 0.18, 0, 0.18) : 0; | |
| const sigma = clamp(0.26 + churnPenalty + sizePenalty + concentrationPenalty, 0.2, 0.75); | |
| const factors: Factor[] = [ | |
| { | |
| key: "geography", | |
| label: "Geography", | |
| rawRatio: geoRaw, | |
| applied: geoApplied, | |
| detail: `Weighted audience GDP per capita of $${Math.round( | |
| weightedGdpPerCapita | |
| ).toLocaleString("en-US")} against the US benchmark of $${US_GDP_PER_CAPITA.toLocaleString( | |
| "en-US" | |
| )}.`, | |
| }, | |
| { | |
| key: "niche", | |
| label: "Niche", | |
| rawRatio: nicheRaw, | |
| applied: nicheApplied, | |
| detail: `${niche.name} priced off ${niche.proxySector} at ${niche.priceToSales.toFixed( | |
| 1 | |
| )}x sales versus the market at ${MARKET_PRICE_TO_SALES.toFixed(1)}x.`, | |
| }, | |
| { | |
| key: "retention", | |
| label: "Retention", | |
| rawRatio: retentionRaw, | |
| applied: retentionApplied, | |
| detail: `${churn.toFixed(1)}% monthly churn implies a ${impliedLifetimeMonths.toFixed( | |
| 0 | |
| )}-month subscriber lifetime against a ${BENCHMARK_MONTHLY_CHURN}% benchmark.`, | |
| }, | |
| { | |
| key: "scale", | |
| label: "Scale", | |
| rawRatio: scaleRaw, | |
| applied: scaleApplied, | |
| detail: `${ | |
| arr >= SCALE_PIVOT_ARR ? "Above" : "Below" | |
| } the $${SCALE_PIVOT_ARR.toLocaleString("en-US")} ARR pivot where the size premium is neutral.`, | |
| }, | |
| { | |
| key: "growth", | |
| label: "Growth", | |
| rawRatio: growthRaw, | |
| applied: growthApplied, | |
| detail: `${growth.toFixed(1)}% monthly growth compounds to ${(annualGrowth * 100).toFixed( | |
| 0 | |
| )}% a year against a ${(benchmarkAnnualGrowth * 100).toFixed(0)}% benchmark.`, | |
| }, | |
| ]; | |
| return { | |
| subscriptionArr, | |
| sponsorshipArr, | |
| arr, | |
| mrr, | |
| baseMultiple: BASE_REVENUE_MULTIPLE, | |
| unboundedMultiple, | |
| finalMultiple, | |
| multipleClamped, | |
| valuation, | |
| low: valuation * Math.exp(-1.2816 * sigma), | |
| high: valuation * Math.exp(1.2816 * sigma), | |
| factors, | |
| weightedGdpPerCapita, | |
| impliedLifetimeMonths, | |
| revenuePerSubscriber: paidSubscribers > 0 ? arr / paidSubscribers : 0, | |
| sigma, | |
| }; | |
| } | |
| // βββ Distribution for the bell curve βββββββββββββββββββββββββββββββββββββββββ | |
| export type DistributionPoint = { | |
| valuation: number; | |
| density: number; | |
| /** Density repeated only inside the P10βP90 band, so it can be shaded. */ | |
| bandDensity: number | null; | |
| }; | |
| /** | |
| * Lognormal probability density across the plausible valuation range. Lognormal | |
| * rather than normal because valuations cannot go below zero and the upside | |
| * tail is genuinely longer than the downside one. | |
| */ | |
| export function buildDistribution( | |
| median: number, | |
| sigma: number, | |
| points = 96 | |
| ): { curve: DistributionPoint[]; p10: number; p50: number; p90: number } { | |
| if (!(median > 0) || !(sigma > 0)) { | |
| return { curve: [], p10: 0, p50: 0, p90: 0 }; | |
| } | |
| const mu = Math.log(median); | |
| const lo = mu - 3.2 * sigma; | |
| const hi = mu + 3.2 * sigma; | |
| const p10 = Math.exp(mu - 1.2816 * sigma); | |
| const p90 = Math.exp(mu + 1.2816 * sigma); | |
| const curve: DistributionPoint[] = []; | |
| for (let i = 0; i < points; i += 1) { | |
| const logX = lo + ((hi - lo) * i) / (points - 1); | |
| const x = Math.exp(logX); | |
| // Density in log-space keeps the curve visually symmetric (a true bell) | |
| // while the axis still reads in dollars. | |
| const density = | |
| Math.exp(-((logX - mu) ** 2) / (2 * sigma ** 2)) / (sigma * Math.sqrt(2 * Math.PI)); | |
| curve.push({ | |
| valuation: x, | |
| density, | |
| bandDensity: x >= p10 && x <= p90 ? density : null, | |
| }); | |
| } | |
| return { curve, p10, p50: median, p90 }; | |
| } | |
| // βββ Bridge chart data βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export type BridgeStep = { | |
| label: string; | |
| /** Invisible offset that floats the visible bar to the right height. */ | |
| base: number; | |
| /** Height of the visible bar. */ | |
| delta: number; | |
| /** Running valuation after this step. */ | |
| running: number; | |
| direction: "start" | "up" | "down" | "total"; | |
| }; | |
| /** | |
| * Waterfall from "ARR at the base multiple" to the final valuation, one bar per | |
| * factor, so the contribution of each adjustment is legible at a glance. | |
| */ | |
| export function buildBridge(result: ValuationResult): BridgeStep[] { | |
| const start = result.arr * result.baseMultiple; | |
| const steps: BridgeStep[] = [ | |
| { | |
| label: `Base ${result.baseMultiple.toFixed(1)}x ARR`, | |
| base: 0, | |
| delta: start, | |
| running: start, | |
| direction: "start", | |
| }, | |
| ]; | |
| let running = start; | |
| for (const factor of result.factors) { | |
| const next = running * factor.applied; | |
| const delta = next - running; | |
| steps.push({ | |
| label: factor.label, | |
| base: Math.min(running, next), | |
| delta: Math.abs(delta), | |
| running: next, | |
| direction: delta >= 0 ? "up" : "down", | |
| }); | |
| running = next; | |
| } | |
| // When the sanity band bites, show it as its own step. Otherwise the bars | |
| // would walk to a total the headline figure does not agree with. | |
| if (result.multipleClamped) { | |
| const capped = result.valuation; | |
| const delta = capped - running; | |
| steps.push({ | |
| label: `Capped at ${result.finalMultiple.toFixed(1)}x`, | |
| base: Math.min(running, capped), | |
| delta: Math.abs(delta), | |
| running: capped, | |
| direction: delta >= 0 ? "up" : "down", | |
| }); | |
| running = capped; | |
| } | |
| steps.push({ | |
| label: "Valuation", | |
| base: 0, | |
| delta: running, | |
| running, | |
| direction: "total", | |
| }); | |
| return steps; | |
| } | |