blog2video-test / frontend /src /content /substackValuationModel.ts
Arslan1997's picture
adding free tools
9673c29
Raw
History Blame Contribute Delete
21.1 kB
/**
* 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;
}