一键导入
excel-to-engine
Convert a complex financial Excel model into a JavaScript computation engine with auto-generated test suite and interactive dashboard
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
Convert a complex financial Excel model into a JavaScript computation engine with auto-generated test suite and interactive dashboard
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
基于 SOC 职业分类
| name | excel-to-engine |
| description | Convert a complex financial Excel model into a JavaScript computation engine with auto-generated test suite and interactive dashboard |
| triggers | ["convert this Excel model","build an engine from this spreadsheet","financial model to code","excel to javascript engine","turn this spreadsheet into code","generate engine from Excel","create computation engine"] |
Convert a financial Excel model (.xlsx) into a JavaScript computation engine with calibration, automated tests, and an interactive dashboard.
This skill runs a 4-phase pipeline:
engine.js with calibrated computations matching Excel at base casetests/eval.mjs that validates engine accuracy against Excelnpm install xlsxBefore analyzing anything, ask the user these questions:
.xlsx files in the working directory and list them. Ask: "I found these Excel files — which should I work with?"This context dramatically improves analysis accuracy.
If a comparator (compare-outputs.mjs) or control baseline exists in the project:
issuancePrice or numFutureAcquisitions).Financial models use inconsistent terminology across firms and sectors. When analyzing an Excel model, map any of these equivalent terms to the standardized engine output field names.
All of these refer to the same economic concept — a performance-based payout to operators/managers carved from investment returns:
| Term | Context |
|---|---|
| MIP (Management Incentive Plan) | PE operating companies |
| Profit Interest Plan / Profit Share | Pass-through entities, LLCs |
| Promote | Real estate partnerships |
| Carried Interest Pool | Operating company level (not fund level) |
| Performance Allocation | Tax/legal documents |
| Value Creation Plan (VCP) | European PE |
| Long-Term Incentive Plan (LTIP) | Corporate, listed companies |
| Phantom Equity Plan | Non-equity-issuing entities |
| Co-Investment Plan | GP/management co-invest structures |
Map all of these to mip.payment, mip.triggered, mip.valuePerShare in the engine output.
| Standardized | Equivalent Terms |
|---|---|
| Distribution Waterfall | Promote Structure, Carried Interest Waterfall |
| Return Hurdle | Preferred Return, Pref, Hurdle Rate |
| Catch-Up | GP Catch-Up, Make-Whole |
| Residual Split | Back-End Split, Tail Economics |
| GP Promote | GP Carry, GP Performance Fee |
| LP Preferred Return | LP Pref, LP Hurdle |
| GP Co-Invest | GP Commitment, GP Capital |
Map to waterfall.lpTotal, waterfall.gpCarry, waterfall.tiers.
| Standardized | Equivalent Terms |
|---|---|
| MOIC | MoC, Multiple on Invested Capital, Money Multiple, Return Multiple |
| IRR | Internal Rate of Return, Annualized Return |
| Gross | Pre-Carry, Pre-Promote, Pre-Fee |
| Net | Post-Carry, Post-Promote, Post-Fee, After Carry |
Map to returns.grossMOIC, returns.netMOIC, returns.grossIRR, returns.netIRR.
| Standardized | Equivalent Terms |
|---|---|
| Issuance Price | Strike Price, Grant Price, Unit Price, Share Price |
| PPS (Price Per Share) | Per Share, Per Unit, Value Per Share |
| Pool | Allocation Pool, Share Pool, Unit Pool |
| Dilution | Pool Percentage, Participation Rate |
Map to perShare.gross, perShare.net, mip.valuePerShare.
When analyzing an Excel model, if you encounter any term in the "Equivalent Terms" column, treat it as the standardized term in the "Standardized" column. Use the standardized engine output field names (grossMOIC, netIRR, lpTotal, gpCarry, mipPayment, etc.) regardless of what the Excel model calls them.
Read the Excel workbook and produce a model-map.json that describes the model structure.
Before diving into detailed sheets, search for tabs named "Summary", "Cheat Sheet", "Overview", "Dashboard", or "Key Metrics". These often contain the base case inputs and outputs in a condensed format, saving significant analysis time. Extract base case values from these tabs first, then cross-reference with detail sheets only as needed.
For workbooks with many identically-structured sheets (e.g., per-asset P&Ls), use the fingerprinter to auto-detect row mappings instead of manually inspecting each sheet:
import {
loadWorkbook, buildModelMap, fingerprintWorkbook,
detectYearRow, extractMultiYear, extractByYear,
detectEscalation, classifyAsset, matchLabel,
} from './lib/excel-parser.mjs';
const wb = loadWorkbook('/path/to/model.xlsx');
// 1. Fingerprint all sheets — finds common row patterns across similar sheets
const { sheetMaps, commonPattern, commonSheets, sheetGroups } = fingerprintWorkbook(wb);
// commonPattern: { revenue: { row: 48, label: "Total Revenue" }, ebitda: { row: 67 }, ... }
// commonSheets: ["Asset 1", "Asset 2", ..., "Asset 37"]
// 2. Detect year columns on any sheet in the group
const yearInfo = detectYearRow(wb, commonSheets[0]);
// { yearRow: 39, columnMap: { E: 2023, F: 2023, G: 2024, H: 2025, I: 2026 }, years: [2023, 2024, ...] }
// 3. Extract all fields for a specific reference year
const data = extractByYear(wb, 'Asset 1', 2026, { fieldMap: commonPattern, yearInfo });
// { fields: { revenue: { value: 1500000 }, ebitda: { value: 850000 } }, yearColumn: 'I' }
// 4. Extract multi-year time series for a field
const rentByYear = extractMultiYear(wb, 'Asset 1', commonPattern.rent.row, yearInfo.columnMap);
// { 2023: 500000, 2024: 515000, 2025: 530450, 2026: 546364 }
// 5. Detect escalation rates (catches rent escalation, revenue growth)
const escalation = detectEscalation(rentByYear);
// { rates: { "2023-2024": 0.03, "2024-2025": 0.03 }, avgRate: 0.03, isEscalating: true }
// 6. Auto-classify asset type (leased vs managed)
const classification = classifyAsset(data.fields);
// { classification: 'leased', confidence: 0.85, signals: ['has positive rent → leased', ...] }
Why this matters: The hardest part of building engines from multi-asset models is figuring out which rows contain which data across dozens of identical sheets. The fingerprinter automates this by fuzzy-matching row labels to canonical financial terms, then confirming the pattern is consistent across sheets.
Always specify a reference year when extracting data. The default should be the first full stabilized projection year (typically current year + 1). This avoids pulling stale closing-date values that miss years of escalation.
Common pitfall: Pulling rent values from the acquisition date column instead of the projection year column. A 3% annual escalation over 3-4 years means rents are 10-15% higher at the reference date. The detectEscalation() function helps flag fields where the starting value differs significantly from the projected value.
const wb = loadWorkbook('/path/to/model.xlsx');
const modelMap = buildModelMap(wb, {
inputSheets: ['Assumptions', 'Inputs'], // Adjust to actual sheet names
outputSheets: ['Summary', 'Returns'], // Adjust to actual sheet names
});
const { commonPattern, commonSheets, sheetGroups } = fingerprintWorkbook(wb);
// If sheetGroups shows N sheets with the same pattern, these are per-asset sheets
Review detected inputs and outputs. The parser identifies:
Detect financial patterns — The parser looks for:
Detect year columns and extract by reference year — Don't default to the first data column:
const yearInfo = detectYearRow(wb, commonSheets[0]);
// Pick the first full stabilized projection year
const referenceYear = Math.max(...yearInfo.years.filter(y => y <= new Date().getFullYear() + 1));
for (const sheetName of commonSheets) {
const data = extractByYear(wb, sheetName, referenceYear, { fieldMap: commonPattern, yearInfo });
const type = classifyAsset(data.fields);
console.log(`${sheetName}: ${type.classification} (${type.confidence})`);
}
model-map.json with this structure:{
"version": "1.1.0",
"modelName": "Example Fund Model",
"generatedAt": "2025-01-15T10:30:00Z",
"excelFile": "model.xlsx",
"referenceYear": 2026,
"sheets": ["Assumptions", "Cash Flows", "Waterfall", "Summary"],
"sheetGroups": [
{
"sheets": ["Asset 1", "Asset 2", "Asset 3"],
"pattern": { "revenue": { "row": 48 }, "ebitda": { "row": 67 } },
"count": 3
}
],
"yearColumns": { "E": 2023, "F": 2024, "G": 2025, "H": 2026 },
"inputs": [
{
"name": "Acquisition Price",
"sheet": "Assumptions",
"cell": "C5",
"type": "number",
"format": "currency",
"baseCase": 50000000,
"range": [25000000, 100000000],
"referencedBy": 12
}
],
"outputs": [
{
"name": "Gross MOIC",
"key": "returns.grossMOIC",
"sheet": "Summary",
"cell": "C15",
"type": "number",
"format": "multiple",
"baseCase": 2.15
}
],
"assets": [
{
"sheet": "Asset 1",
"classification": "leased",
"classificationConfidence": 0.85,
"fields": { "revenue": 1500000, "rent": 546364, "rentCover": 1.56 }
}
],
"intermediateCount": 234,
"patterns": {
"hasIRR": true,
"hasMOIC": true,
"hasWaterfall": true,
"hasSensitivity": false
}
}
referencedBy count and let the user curate.format hints: "currency", "percent", "multiple", "integer", "years"key to outputs mapping to the engine return structure (e.g., "returns.grossMOIC")range for each input should be a reasonable sensitivity range (50%-200% of base case for most; tighter for percentages)After extracting data, auto-generate a comparator that reads back from the Excel and validates. This catches errors before they propagate into the engine:
{ totalChecks, passed, failed, warnings }This should run as part of Phase 1, not as a separate step. The eval in Phase 3 tests the engine; cross-sheet validation tests the extraction.
Why this matters: The #1 source of engine failures is not static accuracy — it's getting the response curve wrong when inputs change. Waterfall hurdles, MIP thresholds, and debt covenants create nonlinear breakpoints where a single-point calibration factor breaks down. An engine that matches perfectly at base case can be 50-80% wrong near a waterfall breakpoint.
After extracting base case values, also extract outputs at multiple input values:
import { extractSurface, compareSurfaces, detectBreakpoints } from './lib/sensitivity.mjs';
// Define the key input range to test (e.g., exit multiple from 1.0x to 3.0x)
const inputConfig = {
exitMultiple: { min: 14, max: 26, steps: 7 }, // or whatever the model's primary driver is
};
// If the Excel has a sensitivity/data table, read it directly into this format:
const excelSurface = {
baseCaseInputs: BASE_CASE,
baseCaseOutputs: { 'returns.grossMOIC': 2.15, 'waterfall.gpCarry': 5200000, ... },
inputGrid: { exitMultiple: [14, 16, 18, 20, 22, 24, 26] },
points: [
{ inputs: { ...BASE_CASE, exitMultiple: 14 }, outputs: { 'returns.grossMOIC': 1.52, ... } },
{ inputs: { ...BASE_CASE, exitMultiple: 16 }, outputs: { 'returns.grossMOIC': 1.78, ... } },
// ... one point per grid value
],
};
// If no data table exists, manually read Excel output cells at each input value
// (change the input cell in Excel, read the output cells, record the values)
How to read sensitivity data from Excel:
sensitivity-surface.json alongside model-map.jsonDetect breakpoints early:
const breakpoints = detectBreakpoints(excelSurface, 'waterfall.gpCarry');
// Tells you where the waterfall hurdle crossing is, where MIP triggers, etc.
// Use these to set up the waterfall tiers correctly in Phase 2
This surface data will be used in Phase 2 for multi-point calibration instead of single-point calibration.
Create engine.js as an ES module. Use templates/engine-template.js as the starting skeleton.
Copy the template to the project directory as engine.js
Fill in BASE_CASE from model-map.json inputs:
export const BASE_CASE = {
acquisitionPrice: 50_000_000,
equityInvested: 25_000_000,
holdPeriodYears: 5,
exitCapRate: 0.055,
// ... all inputs from model-map.json
};
Implement _computeRaw(inputs) — the core calculation logic:
computeIRR() from lib/irr.mjs for IRR calculationscomputeWaterfall() from lib/waterfall.mjs for distribution waterfallsSet EXCEL_TARGETS with known-good values read from the Excel file:
const EXCEL_TARGETS = {
'returns.grossMOIC': 2.15,
'returns.netMOIC': 1.89,
'returns.grossIRR': 0.2134,
'returns.netIRR': 0.1847,
'waterfall.gpCarry': 5_200_000,
};
The engine must return this structure:
{
inputs: { ...inputs },
returns: {
grossMOIC, // Gross multiple on invested capital
netMOIC, // Net multiple (after fees and carry)
grossIRR, // Gross internal rate of return
netIRR, // Net IRR to LPs
},
exitValuation: {
grossExitValue, // Total exit proceeds
netProceeds, // After debt payoff
// ... model-specific fields
},
waterfall: {
lpTotal, // Total LP distributions
gpCarry, // Total GP carried interest
tiers, // Per-tier breakdown
},
mip: { // Management Incentive Plan (if applicable)
triggered, // Boolean
payment, // Total MIP payment
valuePerShare, // Per-share value
},
equityCashFlows: {
years, // [0, 1, 2, ..., N]
draws, // Negative cash flows (investments)
distributions, // Positive cash flows (returns)
},
perShare: {
gross,
net,
},
}
lib/irr.mjs for IRR, lib/waterfall.mjs for waterfalls, lib/calibration.mjs for calibration.The most common source of engine failure is incorrect base case values. You MUST extract the EXACT base case from the Excel, not approximate or round them.
18.22, use 18.22 — NOT 18 or 18.2. Read the cell value directly.from openpyxl import load_workbook
wb = load_workbook('model.xlsx', data_only=True)
ws = wb['Assumptions']
exit_multiple = ws['G7'].value # Gets the exact computed value
Net equity proceeds follow this universal formula across ALL financial models:
Net Proceeds = Gross Exit Value - Transaction Costs - Debt Payoff + Cash at Exit
Where:
Common mistake: Using "net debt" (debt - cash) instead of separately handling debt and cash. This causes a ~15% error in net proceeds.
Models define equity basis differently. You MUST determine which definition the Excel uses:
| Definition | Meaning | Typical Context |
|---|---|---|
| Total Commitment | Total equity pledged by LPs | Fund-level models |
| Equity Deployed | Capital actually drawn/invested | Operating company models |
| Peak Equity | Maximum cumulative equity outstanding | Waterfall/promote models |
| Equity at Cost | Sum of all equity draws (no distributions netted) | Cash flow models |
Look in the Excel's "Equity" or "Cash Flow" sheet for the cell that feeds into MOIC: MOIC = Net Proceeds / [equity basis]. That denominator IS the equity basis. Read it directly.
Distribution waterfalls are the #1 source of large deviations. Do NOT simplify the waterfall.
lib/waterfall.mjs with the exact tier structure:import { computeWaterfall } from './lib/waterfall.mjs';
const waterfall = computeWaterfall(netProceeds, equityBasis, [
{ name: 'Preferred Return', hurdle: 0.08, lpSplit: 1.0, gpSplit: 0.0 },
{ name: 'Catch-Up', hurdle: 0.0, lpSplit: 0.5, gpSplit: 0.5 },
{ name: 'Residual 80/20', hurdle: 0.08, lpSplit: 0.8, gpSplit: 0.2 },
{ name: 'Above 12%', hurdle: 0.12, lpSplit: 0.8, gpSplit: 0.2 },
]);
waterfall.lpTotal + waterfall.gpCarry MUST equal netProceeds. If it doesn't, your tier parameters are wrong.Calibration is NOT optional. Without it, engines typically deviate 10-30% from Excel. Here's exactly how to implement it:
// 1. Define Excel target values (read from Excel cells)
const EXCEL_TARGETS = {
grossMOIC: 2.35, // from Excel cell N50 (or wherever MOIC is displayed)
netIRR: 0.1923, // from Excel cell S50
gpCarry: 43_411_674, // from GPP Promote sheet total carry
mipPayment: 51_876_337, // from Equity sheet MIP cell
};
// 2. Run the engine at base case WITHOUT calibration to get raw outputs
const rawResult = _computeRaw(BASE_CASE);
// 3. Compute calibration scale factors
const _cal = {};
for (const [key, excelValue] of Object.entries(EXCEL_TARGETS)) {
const rawValue = getNestedValue(rawResult, key); // e.g., rawResult.returns.grossMOIC
_cal[key] = (rawValue !== 0) ? excelValue / rawValue : 1.0;
}
// 4. In computeModel(), apply calibration to raw outputs:
export function computeModel(inputs = {}) {
const raw = _computeRaw({ ...BASE_CASE, ...inputs });
// Apply calibration
raw.returns.grossMOIC *= _cal.grossMOIC;
raw.waterfall.gpCarry *= _cal.gpCarry;
raw.mip.payment *= _cal.mipPayment;
// ... etc
return raw;
}
At base case, calibrated outputs will EXACTLY match Excel. At non-base-case inputs, they'll be close (within 2-5%) because the calibration scale factors are multiplicative.
Single-point calibration (above) assumes the error is constant across the input range. This fails at waterfall breakpoints, MIP thresholds, and other nonlinearities — the engine can be 50%+ wrong near hurdle crossings even though it's exact at base case.
If you extracted a sensitivity surface in Phase 1 (see "Sensitivity Surface Extraction"), use multi-point calibration instead:
import { multiPointCalibrate, extractSurface, compareSurfaces, printSensitivityReport } from './lib/sensitivity.mjs';
// excelSurface was built in Phase 1 from Excel data tables or manual extraction
const { corrections, apply } = multiPointCalibrate(
computeModel, // your engine's raw compute function
BASE_CASE,
excelSurface, // the Excel response surface
{ primaryInput: 'exitMultiple' } // which input drives the key breakpoints
);
// In computeModel(), apply piecewise corrections instead of flat scale factors:
export function computeModel(inputs = {}) {
const raw = _computeRaw({ ...BASE_CASE, ...inputs });
return apply(raw, inputs); // applies segment-specific corrections
}
This fits piecewise-linear corrections that adapt across the input range, with segment boundaries at detected breakpoints. In testing, this improves accuracy from ~40% to ~100% across the full input range.
When to use which:
waterfall.lpTotal is the TOTAL amount LPs receive after the GP takes their carry:
lpTotal = netProceeds - gpCarry
This is NOT the sum of LP distributions from individual waterfall tiers. It's the residual.
Verify: lpTotal + gpCarry === netProceeds (must balance exactly).
A common mistake is summing tier-level LP distributions instead of computing netProceeds - gpCarry. The tier distributions are intermediate calculations; lpTotal is the final LP take-home.
The exit valuation MUST change when exitYear changes. A model exiting in 2028 has lower asset values than one exiting in 2031 because:
How to implement: The Excel has year-by-year projections. Read the exit value components (NOI, revenue, etc.) for EACH possible exit year, not just the base case. Store these as lookup arrays:
// NOI by exit year (read from Excel for each year)
const NOI_BY_YEAR = {
2028: 58_000_000,
2029: 71_997_341, // base case
2030: 85_000_000,
2031: 95_000_000,
};
// In computeModel:
const noi = NOI_BY_YEAR[exitYear] || interpolate(exitYear, NOI_BY_YEAR);
const grossExit = noi * ownedExitMultiple + otherSegments;
If you use a single base-case exit value for all years, your engine will produce identical outputs regardless of exit year — which is wrong and causes ~60% of scenarios to fail.
The MIP (Management Incentive Plan) payment formula is:
mipPayment = dilutionRate × max(0, lpTotal - mipHurdle × equityBasis)
Where:
dilutionRate = typically 10-15% (read from Excel — look for "Class B %", "MIP %", "dilution")lpTotal = LP total AFTER carry (from waterfall)mipHurdle = typically 1.40x (MOIC threshold — look for "hurdle", "return threshold")equityBasis = the equity basis used in MOIC calculationCommon mistakes:
(grossMOIC - hurdle) × equityBasis × rate — this is wrong because grossMOIC already includes carrynetProceeds instead of lpTotal — MIP comes from LP excess, not total proceedslpTotal > hurdle × equityBasis — MIP should be zero when returns are below hurdleThe mip.valuePerShare is simply mipPayment / totalMIPShares where totalMIPShares is a fixed number from the Excel (the incentive pool size).
For models with multiple investment series (Series 1, Series 2, etc.), the issuancePrice input determines how many shares are issued:
totalShares = totalCommitment / issuancePrice
This affects:
perShare.gross = grossPerShareValue = netProceeds / totalSharesperShare.net = netPerShareValue = lpTotal / totalSharesmip.valuePerShare = mipPayment / mipPoolShares (where mipPoolShares may also depend on issuancePrice)If issuancePrice doesn't affect your outputs, your secondary series engine will produce identical results for all issuance prices — which fails 33% of scenarios for that series.
"Gross MOIC" can mean different things depending on context:
Check the comparator and control baseline to confirm which definition is expected. In many PE models, "Gross MOIC" is netProceeds / equityBasis (before carry is split out), while "Net MOIC" is lpTotal / equityBasis (after carry). Getting this wrong cascades into every downstream metric.
lib/waterfall.mjs uses simplified annual compounding. Many Excel models use monthly cash flows with monthly compounding and interim distributions. The library may overestimate carry by 20-30% due to this timing difference.
Options:
waterfall.gpCarry and waterfall.lpTotal to force-match Excel valuesWhen the Excel model is too complex to replicate formula-by-formula (monthly waterfalls, circular references, macro-driven logic), use a data-driven approach:
grossExit = NOI × multiple + constant (linear); carry = f(netProceeds, equityBasis) (tiered)This achieves higher accuracy than physics-based replication for complex models, and is faster to build.
Boolean outputs (like mip.triggered) cannot be linearly interpolated. A MIP can be "triggered" (plan exists) with $0 payment (returns below hurdle). Use explicit threshold logic:
mip.triggered = lpTotal > (mipHurdle * equityBasis);
mip.payment = mip.triggered ? dilutionRate * (lpTotal - mipHurdle * equityBasis) : 0;
Do NOT set triggered = (mipPayment > 0) — the plan can be triggered with zero payment if returns exactly equal the hurdle.
A common mistake is calibrating MOIC and IRR but forgetting to calibrate:
waterfall.lpTotal — must match Excel's LP totalwaterfall.gpCarry — must match Excel's total carrymip.payment — must match Excel's MIP paymentexitValuation.netProceeds — must match Excel's net proceedsCalibrate every output that has a known Excel target value. The more targets you calibrate, the higher your score.
After generating the initial engine, enter an interactive improvement loop. Do NOT skip this phase. The initial engine will typically score 30-60% — the loop gets it to 95%+.
import { selfEval, printComparisonTable, printMenu } from './lib/self-eval.mjs';
import { computeModel, BASE_CASE, EXCEL_TARGETS } from './engine.js';
const result = selfEval(computeModel, BASE_CASE, EXCEL_TARGETS);
printComparisonTable(result);
printMenu(result.score, 0);
Present the comparison table and ask what they want to do:
When the user picks option 1 or 2:
diagnoseFailures() to get prioritized fix suggestionsWhen user accepts (option 3):
engine-eval.jsonRun the eval EARLY — even before the engine is complete. A skeleton engine that crashes on unknown inputs tells you what you're missing. Better to discover you need issuancePrice support after 5 minutes than after 30 minutes of polishing other calculations.
Generate tests/eval.mjs that validates the engine against Excel.
tests/eval.mjs with these test categories:import XLSX from 'xlsx';
import { computeModel, BASE_CASE } from '../engine.js';
import { readCell } from '../lib/excel-parser.mjs';
const EXCEL_PATH = '../path/to/model.xlsx';
const TOLERANCE = 0.01; // 1% tolerance
// ---- Test 1: Base Case Accuracy ----
// Read expected values directly from Excel cells
// Compare engine output at BASE_CASE against Excel within tolerance
// ---- Test 2: Input Cascade ----
// For each input, vary it across its range (5 steps)
// Verify engine doesn't throw errors and outputs are reasonable
// ---- Test 3: Monotonicity Invariants ----
// Higher acquisition price → lower MOIC (all else equal)
// Higher equity → lower leverage → different IRR profile
// Higher exit cap rate → lower exit value
// ---- Test 4: Internal Consistency ----
// LP distributions + GP carry = Net proceeds
// Gross returns > Net returns (fees reduce returns)
// MOIC > 0 when proceeds > 0
// IRR sign matches MOIC direction (MOIC > 1 → IRR > 0)
async function runEval() {
const wb = XLSX.readFile(EXCEL_PATH);
const results = {
baseCaseResults: [],
monotonicityResults: [],
consistencyResults: [],
tolerance: TOLERANCE,
timestamp: new Date().toISOString(),
};
// Run all tests...
// Print clear pass/fail report
// Write results to tests/eval-results.json for the dashboard
console.log('\n' + '='.repeat(60));
console.log(allPassed ? ' ALL TESTS PASSED' : ' SOME TESTS FAILED');
console.log('='.repeat(60));
// Write results for dashboard consumption
fs.writeFileSync('tests/eval-results.json', JSON.stringify(results, null, 2));
}
node tests/eval.mjsIf you extracted a sensitivity surface in Phase 1, add sensitivity validation to the test suite:
import { extractSurface, compareSurfaces, printSensitivityReport } from '../lib/sensitivity.mjs';
// Load the Excel surface (saved in Phase 1)
const excelSurface = JSON.parse(fs.readFileSync('sensitivity-surface.json', 'utf8'));
// Extract engine surface with the same grid
const engineSurface = extractSurface(computeModel, BASE_CASE, {
exitMultiple: { min: 14, max: 26, steps: 7 },
});
// Compare — checks levels AND slopes
const comparison = compareSurfaces(engineSurface, excelSurface);
printSensitivityReport(comparison);
// Fail if slope errors are too high (this catches dynamics errors, not just levels)
const slopeFails = comparison.summary.slopeFailCount;
if (slopeFails > 0) {
console.log(`⚠️ ${slopeFails} slope checks failed — engine responds differently than Excel to input changes`);
}
This is the key test that catches the class of bug where the engine matches at base case but diverges when inputs change. Slope errors > 15% typically indicate a waterfall tier is wrong, a hurdle is at the wrong level, or a nonlinear relationship was linearized.
These are common financial invariants. Adapt to the specific model:
| Input Change | Expected Output Direction |
|---|---|
| Higher acquisition price | Lower MOIC, lower IRR |
| Higher exit value | Higher MOIC, higher IRR |
| Longer hold period | Lower IRR (same MOIC) |
| Higher leverage | Higher equity MOIC (if profitable) |
| Higher management fees | Lower net returns |
| Higher cap rate (exit) | Lower exit value |
| Higher preferred return | More to LP, less GP carry |
LP distributions + GP carry ≈ Net distributable proceedsGross MOIC > Net MOIC (fees and carry reduce returns)Gross IRR > Net IRRMOIC > 1.0 ↔ IRR > 0Total cash in = Total equity drawnWaterfall tiers sum = Total distributedGenerate an interactive HTML dashboard from templates/dashboard/.
Copy the dashboard template to the project's dashboard/ directory
Replace template placeholders in app.js:
{{ENGINE_PATH}} → relative path to engine.js (e.g., '../engine.js'){{MODEL_MAP_PATH}} → relative path to model-map.json (e.g., '../model-map.json'){{EVAL_DATA_PATH}} → relative path to eval results (e.g., '../tests/eval-results.json')Replace title placeholder in index.html:
{{MODEL_NAME}} → model name from model-map.jsonTest the dashboard by opening dashboard/index.html in a browser:
Tab 1 — Model Explorer:
Tab 2 — Eval Results:
The dashboard uses:
Just open index.html in a browser. For local development, use a simple server:
npx serve dashboard/
After running the full pipeline, the project should look like:
your-model/
├── engine.js ← Generated computation engine
├── model-map.json ← Model structure definition
├── tests/
│ ├── eval.mjs ← Test suite
│ └── eval-results.json ← Test results (generated by eval.mjs)
├── dashboard/
│ ├── index.html ← Interactive dashboard
│ ├── styles.css
│ └── app.js
└── lib/ ← Shared libraries (symlinked or copied)
├── irr.mjs
├── waterfall.mjs
├── calibration.mjs
└── excel-parser.mjs
After the engine passes testing, show the user how to use it:
import { computeModel } from './engine.js';
const result = computeModel({ exitMultiple: 22 });
console.log(`Gross IRR: ${(result.returns.grossIRR * 100).toFixed(1)}%`);
<script type="module">
import { computeModel } from './engine.js';
const result = computeModel({ exitMultiple: 22 });
document.getElementById('irr').textContent = (result.returns.grossIRR * 100).toFixed(1) + '%';
</script>
import { computeModel } from './engine.js';
app.get('/api/model', (req, res) => res.json(computeModel(req.query)));
Open dashboard/index.html in any browser — no build step needed.
If the user asks for a Python version, generate engine.py with:
BASE_CASE dictionaryEXCEL_TARGETS dictionarycompute_model(inputs) function signatureThis is opt-in only — do NOT generate Python by default.
eval.mjs after Phase 2 to see how close you are. Iterate on the engine logic until base case accuracy is within 1%.