arcadia-suite-sv/server/valuation/engine.ts

485 lines
13 KiB
TypeScript

import {
CALCULATION_WEIGHTS,
SCENARIO_PROBABILITIES,
SCENARIO_GROWTH_ADJUSTMENTS,
MAX_GOVERNANCE_UPLIFT,
MAX_WACC_REDUCTION,
} from "./constants";
export interface FinancialData {
year: number;
isProjection: number;
revenue: number;
grossRevenue?: number;
cogs?: number;
grossProfit?: number;
operatingExpenses?: number;
ebitda: number;
ebit?: number;
netIncome?: number;
totalAssets?: number;
totalLiabilities?: number;
totalEquity?: number;
cash?: number;
debt?: number;
workingCapital?: number;
capex?: number;
depreciation?: number;
freeCashFlow?: number;
cashFlowOperations?: number;
headcount?: number;
growthRate?: number;
}
export interface AssumptionData {
riskFreeRate: number;
betaUnlevered: number;
marketPremium: number;
countryRisk: number;
sizePremium: number;
specificRisk: number;
costOfDebt: number;
taxRate: number;
equityRatio: number;
debtRatio: number;
terminalGrowth: number;
projectionYears: number;
}
export interface GovernanceCriterion {
currentScore: number;
targetScore: number;
weight: number;
valuationImpactPct: number;
equityImpactPct: number;
roeImpactPct: number;
}
export interface AssetData {
bookValue: number;
marketValue: number;
appraisedValue?: number;
}
export interface SectorMultiples {
evEbitda: number;
evRevenue: number;
}
export interface ValuationResult {
method: string;
enterpriseValue: number;
equityValue: number;
terminalValue?: number;
netDebt: number;
weight: number;
details: Record<string, any>;
}
export interface SensitivityCell {
wacc: number;
growth: number;
enterpriseValue: number;
equityValue: number;
}
function n(v: any): number {
const parsed = parseFloat(v);
return isNaN(parsed) ? 0 : parsed;
}
export function calculateWACC(assumptions: AssumptionData): number {
const ke =
assumptions.riskFreeRate +
assumptions.betaUnlevered * assumptions.marketPremium +
assumptions.countryRisk +
assumptions.sizePremium +
assumptions.specificRisk;
const kd = assumptions.costOfDebt * (1 - assumptions.taxRate);
const wacc = ke * assumptions.equityRatio + kd * assumptions.debtRatio;
return Math.max(wacc, 0.01);
}
export function calculateTerminalValue(
lastFCF: number,
wacc: number,
terminalGrowth: number,
): number {
if (wacc <= terminalGrowth) return lastFCF * 20;
return (lastFCF * (1 + terminalGrowth)) / (wacc - terminalGrowth);
}
export function calculateDCF(
projectedFCFs: number[],
wacc: number,
terminalGrowth: number,
netDebt: number,
): ValuationResult {
let pvFCFs = 0;
const discountedFCFs: number[] = [];
for (let i = 0; i < projectedFCFs.length; i++) {
const discountFactor = Math.pow(1 + wacc, i + 1);
const pv = projectedFCFs[i] / discountFactor;
discountedFCFs.push(pv);
pvFCFs += pv;
}
const lastFCF = projectedFCFs[projectedFCFs.length - 1] || 0;
const tv = calculateTerminalValue(lastFCF, wacc, terminalGrowth);
const pvTV = tv / Math.pow(1 + wacc, projectedFCFs.length);
const enterpriseValue = pvFCFs + pvTV;
const equityValue = enterpriseValue - netDebt;
return {
method: "dcf",
enterpriseValue,
equityValue,
terminalValue: tv,
netDebt,
weight: 0,
details: {
wacc,
terminalGrowth,
discountedFCFs,
pvFCFs,
pvTerminalValue: pvTV,
},
};
}
export function calculateMultiples(
ebitda: number,
revenue: number,
multiples: SectorMultiples,
netDebt: number,
): { evEbitda: ValuationResult; evRevenue: ValuationResult } {
const evByEbitda = ebitda * multiples.evEbitda;
const evByRevenue = revenue * multiples.evRevenue;
return {
evEbitda: {
method: "ev_ebitda",
enterpriseValue: evByEbitda,
equityValue: evByEbitda - netDebt,
netDebt,
weight: 0,
details: { ebitda, multiple: multiples.evEbitda },
},
evRevenue: {
method: "ev_revenue",
enterpriseValue: evByRevenue,
equityValue: evByRevenue - netDebt,
netDebt,
weight: 0,
details: { revenue, multiple: multiples.evRevenue },
},
};
}
export function calculatePatrimonial(
totalEquity: number,
netDebt: number,
): ValuationResult {
return {
method: "patrimonial",
enterpriseValue: totalEquity + netDebt,
equityValue: totalEquity,
netDebt,
weight: 0,
details: { totalEquity },
};
}
export function calculateAssetValue(
assets: AssetData[],
netDebt: number,
): ValuationResult {
const totalMarket = assets.reduce(
(sum, a) => sum + (a.appraisedValue || a.marketValue || a.bookValue || 0),
0,
);
return {
method: "assets",
enterpriseValue: totalMarket,
equityValue: totalMarket - netDebt,
netDebt,
weight: 0,
details: {
totalAssetValue: totalMarket,
assetCount: assets.length,
},
};
}
export function calculateGovernanceImpact(criteria: GovernanceCriterion[]): {
currentScore: number;
projectedScore: number;
valuationUplift: number;
waccReduction: number;
equityImpact: number;
roeImpact: number;
} {
if (!criteria.length)
return {
currentScore: 0,
projectedScore: 0,
valuationUplift: 0,
waccReduction: 0,
equityImpact: 0,
roeImpact: 0,
};
const totalWeight = criteria.reduce((s, c) => s + n(c.weight), 0);
if (totalWeight === 0)
return {
currentScore: 0,
projectedScore: 0,
valuationUplift: 0,
waccReduction: 0,
equityImpact: 0,
roeImpact: 0,
};
let currentWeighted = 0;
let projectedWeighted = 0;
let valuationImpact = 0;
let equityImpact = 0;
let roeImpact = 0;
for (const c of criteria) {
const w = n(c.weight) / totalWeight;
currentWeighted += n(c.currentScore) * w;
projectedWeighted += n(c.targetScore) * w;
const improvement = (n(c.targetScore) - n(c.currentScore)) / 10;
valuationImpact += improvement * (n(c.valuationImpactPct) / 100);
equityImpact += improvement * (n(c.equityImpactPct) / 100);
roeImpact += improvement * (n(c.roeImpactPct) / 100);
}
const uplift = Math.min(valuationImpact, MAX_GOVERNANCE_UPLIFT);
const scoreImprovement = (projectedWeighted - currentWeighted) / 10;
const waccRed = Math.min(scoreImprovement * MAX_WACC_REDUCTION, MAX_WACC_REDUCTION);
return {
currentScore: currentWeighted,
projectedScore: projectedWeighted,
valuationUplift: uplift,
waccReduction: waccRed,
equityImpact,
roeImpact,
};
}
export function generateProjections(
historicalData: FinancialData[],
assumptions: Partial<AssumptionData>,
years: number = 5,
): FinancialData[] {
const sorted = historicalData
.filter((d) => !d.isProjection)
.sort((a, b) => a.year - b.year);
if (sorted.length === 0) return [];
const last = sorted[sorted.length - 1];
let revenueGrowth = 0;
if (sorted.length >= 2) {
const first = sorted[0];
const n_years = last.year - first.year;
if (n_years > 0 && n(first.revenue) > 0) {
revenueGrowth = Math.pow(n(last.revenue) / n(first.revenue), 1 / n_years) - 1;
}
}
if (revenueGrowth <= 0) revenueGrowth = 0.05;
const ebitdaMargin = n(last.revenue) > 0 ? n(last.ebitda) / n(last.revenue) : 0.15;
const netMargin = n(last.revenue) > 0 ? n(last.netIncome) / n(last.revenue) : 0.08;
const capexRatio = n(last.revenue) > 0 ? Math.abs(n(last.capex)) / n(last.revenue) : 0.05;
const deprRatio = n(last.revenue) > 0 ? n(last.depreciation) / n(last.revenue) : 0.03;
const projections: FinancialData[] = [];
let prevRevenue = n(last.revenue);
for (let i = 1; i <= years; i++) {
const revenue = prevRevenue * (1 + revenueGrowth);
const ebitda = revenue * ebitdaMargin;
const depreciation = revenue * deprRatio;
const ebit = ebitda - depreciation;
const netIncome = revenue * netMargin;
const capex = revenue * capexRatio;
const wcChange = (revenue - prevRevenue) * 0.1;
const fcf = ebitda - capex - wcChange;
projections.push({
year: last.year + i,
isProjection: 1,
revenue,
ebitda,
ebit,
netIncome,
capex,
depreciation,
freeCashFlow: fcf,
totalEquity: n(last.totalEquity) * Math.pow(1.05, i),
totalAssets: n(last.totalAssets) * Math.pow(1.03, i),
totalLiabilities: n(last.totalLiabilities),
cash: n(last.cash) * Math.pow(1.02, i),
debt: n(last.debt) * 0.95,
workingCapital: n(last.workingCapital) + wcChange,
growthRate: revenueGrowth,
});
prevRevenue = revenue;
}
return projections;
}
export function sensitivityAnalysis(
baseFCFs: number[],
baseWACC: number,
baseGrowth: number,
netDebt: number,
gridSize: number = 5,
): SensitivityCell[] {
const cells: SensitivityCell[] = [];
const step = 0.01;
const halfGrid = Math.floor(gridSize / 2);
for (let wi = -halfGrid; wi <= halfGrid; wi++) {
for (let gi = -halfGrid; gi <= halfGrid; gi++) {
const wacc = baseWACC + wi * step;
const growth = baseGrowth + gi * step * 0.5;
if (wacc <= growth || wacc <= 0) continue;
const result = calculateDCF(baseFCFs, wacc, growth, netDebt);
cells.push({
wacc,
growth,
enterpriseValue: result.enterpriseValue,
equityValue: result.equityValue,
});
}
}
return cells;
}
export function runFullValuation(params: {
financials: FinancialData[];
assumptions: AssumptionData;
multiples: SectorMultiples;
assets?: AssetData[];
governanceCriteria?: GovernanceCriterion[];
projectType: "simple" | "governance";
scenario: "conservative" | "base" | "optimistic";
}): {
results: ValuationResult[];
weightedEV: number;
weightedEquity: number;
governanceImpact?: ReturnType<typeof calculateGovernanceImpact>;
} {
const {
financials,
assumptions,
multiples,
assets,
governanceCriteria,
projectType,
scenario,
} = params;
const projected = financials.filter((f) => f.isProjection).sort((a, b) => a.year - b.year);
const historical = financials.filter((f) => !f.isProjection).sort((a, b) => a.year - b.year);
const lastHistorical = historical[historical.length - 1];
const growthAdj = SCENARIO_GROWTH_ADJUSTMENTS[scenario];
const adjustedFCFs = projected.map((f) => {
const base = n(f.freeCashFlow);
return base * (1 + growthAdj);
});
const netDebt = lastHistorical
? n(lastHistorical.debt) - n(lastHistorical.cash)
: 0;
let wacc = calculateWACC(assumptions);
let govImpact: ReturnType<typeof calculateGovernanceImpact> | undefined;
if (projectType === "governance" && governanceCriteria?.length) {
govImpact = calculateGovernanceImpact(governanceCriteria);
wacc = Math.max(wacc - govImpact.waccReduction, 0.01);
}
const weights = CALCULATION_WEIGHTS[projectType];
const results: ValuationResult[] = [];
if (adjustedFCFs.length > 0) {
const dcf = calculateDCF(adjustedFCFs, wacc, assumptions.terminalGrowth, netDebt);
dcf.weight = weights.dcf;
results.push(dcf);
}
const lastEbitda = lastHistorical ? n(lastHistorical.ebitda) : 0;
const lastRevenue = lastHistorical ? n(lastHistorical.revenue) : 0;
if (lastEbitda > 0) {
const { evEbitda, evRevenue } = calculateMultiples(lastEbitda, lastRevenue, multiples, netDebt);
evEbitda.weight = weights.ev_ebitda;
evRevenue.weight = weights.ev_revenue;
results.push(evEbitda, evRevenue);
}
if (projectType === "governance") {
const gWeights = weights as typeof CALCULATION_WEIGHTS.governance;
if (lastHistorical) {
const patri = calculatePatrimonial(n(lastHistorical.totalEquity), netDebt);
patri.weight = gWeights.patrimonial;
results.push(patri);
}
if (assets?.length) {
const assetVal = calculateAssetValue(assets, netDebt);
assetVal.weight = gWeights.assets;
results.push(assetVal);
}
}
let weightedEV = 0;
let weightedEquity = 0;
const totalWeight = results.reduce((s, r) => s + r.weight, 0);
for (const r of results) {
const normalizedWeight = totalWeight > 0 ? r.weight / totalWeight : 0;
weightedEV += r.enterpriseValue * normalizedWeight;
weightedEquity += r.equityValue * normalizedWeight;
}
if (govImpact && govImpact.valuationUplift > 0) {
const projectedEV = weightedEV * (1 + govImpact.valuationUplift);
const projectedEquity = weightedEquity * (1 + govImpact.valuationUplift);
return {
results,
weightedEV: projectedEV,
weightedEquity: projectedEquity,
governanceImpact: govImpact,
};
}
return { results, weightedEV, weightedEquity, governanceImpact: govImpact };
}
export function calculateScenarioWeighted(
scenarioResults: { scenario: string; ev: number; equity: number }[],
): { weightedEV: number; weightedEquity: number } {
let wEV = 0;
let wEq = 0;
for (const s of scenarioResults) {
const prob = SCENARIO_PROBABILITIES[s.scenario as keyof typeof SCENARIO_PROBABILITIES] || 0;
wEV += s.ev * prob;
wEq += s.equity * prob;
}
return { weightedEV: wEV, weightedEquity: wEq };
}