Financial Models skill for Claude Code
Live formulas, one input sheet, a balance check at zero. Claude builds models the way a careful analyst does: every input on one labelled sheet, one copied formula per row across a shared monthly time axis, an MRR waterfall with churn applied to opening balances, and linked statements whose balance check reads zero in every period. A bundled linter finds numbers typed inside formulas, constants on calculation sheets and inconsistent rows before anyone sees the file.
- name
financial-models - size259 lines · ≈3,200 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Builds and reviews spreadsheet financial models for startups and SaaS businesses - assumptions separated from calculations, one consistent monthly time axis, no hardcoded numbers inside formulas, input and formula colour conventions, linked three statements with balance checks, MRR waterfalls, churn, CAC payback and LTV with honest caveats, and scenario and sensitivity tables, built with openpyxl so every formula stays live. Use when asked for a startup financial model, three-statement model, SaaS revenue model, runway or burn forecast, fundraising model, or a review of an existing model's structure. Try asking
- Build a 36-month SaaS revenue and runway model from these assumptions
- Turn our P&L forecast into a three-statement model with a balance check
- Review this fundraising model for hardcoded numbers and broken links before I send it
Use it when
- You need a startup or SaaS forecast that investors or finance can audit
- An existing model has pasted values, hardcodes, or a balance sheet that does not balance
- You want scenarios and sensitivity on runway, ARR, or CAC payback
- Monthly MRR, churn, CAC payback, and LTV need defining with their caveats
Skip it when
- The task is Excel file mechanics such as styling, validation, or charts — use xlsx-spreadsheets instead
- You are deciding price points or packaging — use pricing-strategy instead
How to check the result
The model linter reports no hardcodes, external links or stray constants, every check row reads zero after recalculation in all scenarios, and one hand-computed month matches the sheet.
The common mistake it prevents
Converting annual churn to monthly by dividing by 12 and applying it to closing MRR, which overstates retention and churns customers in the month they sign.
A model is done well when a stranger can change any assumption on one input sheet, watch every statement update through live formulas, see the balance check read zero in every period, and trace any output number back to a labelled input in three clicks. Precision is not the goal; transparent, auditable cause and effect is.
Ground rules
- Inputs live only on input sheets. Calculation sheets contain formulas only.
- No number typed inside a formula except 0 and 1.
=C12*1.08is a defect; the 8% is an input with a label, a unit, and a source note. - One formula per row, copied unchanged across every period column. If the first period needs different logic (opening balance), isolate it in its own column or row.
- Same column means same period on every time-based sheet.
- Never invent the user’s numbers. Placeholder assumptions are allowed only when marked as placeholders (input fill plus a “PLACEHOLDER” note) and listed in the summary.
- Write formulas with openpyxl, never computed values pasted in. A model of values is a report, not a model.
Workflow
- [ ] 1. Pin down purpose, horizon, granularity, and model type
- [ ] 2. Collect actuals and assumptions with sources; mark placeholders
- [ ] 3. Lay out sheets and the shared time axis
- [ ] 4. Build drivers: revenue, headcount, operating costs
- [ ] 5. Build P&L, then balance sheet and cash flow (three-statement only)
- [ ] 6. Add checks, scenarios, and sensitivity
- [ ] 7. Lint, recalculate, stress test, and write the model notes
1. Scope questions
Ask before building; defaults in brackets:
| Question | Why it matters |
|---|---|
| Purpose: fundraise, budget, runway, pricing decision? | Sets detail level and outputs |
| Horizon and granularity [36 months monthly, annual roll-up] | Fixes the time axis |
| Model type: SaaS revenue model or full three-statement? | Three-statement adds BS and CF |
| Billing: monthly, annual upfront, usage? | Annual prepay creates deferred revenue and cash timing gaps |
| Currency, fiscal year start, actuals available? | Actuals go left of forecast with a flag row |
If the user only needs runway, build revenue, costs, and cash; skip the balance sheet.
2. Sheet layout
| Sheet | Contents |
|---|---|
Cover | Purpose, author, version, date, colour legend, scenario in use, placeholder list |
Assumptions | Every input: label, value, unit, source/note; scenario columns |
Revenue | Customer and MRR build per segment or plan |
Headcount | One row per role or hire: start month, salary, burden %, department |
Opex | Non-headcount costs by department, driven by headcount or revenue % |
PL | Revenue, COGS, gross profit, opex, EBITDA, D&A, interest, tax, net income |
BS, CF | Three-statement models only |
Checks | Every check in one place with a master flag on Cover |
Sensitivity | Scenario outputs and grids |
Flow runs top to bottom and left to right: inputs, then drivers, then statements, then outputs.
3. Time axis
- Row 1 to 4 of every time sheet carries the same header block: period start date,
period number, fiscal year, actual/forecast flag. Period 1 sits in the same column
(for example
E) on every sheet. - Build dates from one input:
E1 = Assumptions!start_date,F1 = EDATE(E1,1). - Annual roll-ups use
SUMIFS(row, year_row, year)for flows and the last month’s value for balances. Never mix monthly and annual columns in one block. - Convert rates explicitly. Monthly churn from annual is
1-(1-annual)^(1/12), notannual/12. Monthly growth from annual is(1+annual)^(1/12)-1.
4. Colour and format conventions
| Cell type | Font | Fill |
|---|---|---|
| Hardcoded input | Blue 0000FF | Light yellow FFF2CC optional |
| Formula on the same sheet | Black | none |
| Link to another sheet | Green 008000 | none |
| Check cell | Black | Green when OK, red when broken (conditional format) |
This is the common banking convention; state it on Cover. Number formats: currency with
no decimals for money, 0.0% for rates, negative numbers in parentheses.
SaaS revenue build
Use an MRR waterfall per segment, all monthly:
Opening MRR = previous closing MRR
+ New MRR = new customers x starting ARPA
+ Expansion MRR = opening MRR x monthly expansion rate
- Contraction MRR = opening MRR x monthly contraction rate
- Churned MRR = opening MRR x monthly revenue churn rate
= Closing MRR
Customers: opening + new - churned (logo churn applied to opening customers)
New customers = leads x conversion, or S&M spend / blended CAC (pick one, say which)
Apply churn to opening balances, not closing ones, or new customers churn in the month they arrive.
| Metric | Formula | Caveat to state in the notes |
|---|---|---|
| ARR | closing MRR x 12 | Run-rate, not revenue recognised or cash |
| Gross revenue retention | (opening MRR - contraction - churn) / opening MRR, over 12 months on a cohort | Cannot exceed 100% |
| Net revenue retention | (opening + expansion - contraction - churn) / opening, cohort basis | Exclude new customers |
| CAC | fully loaded sales and marketing cost / new customers in the period | Say whether salaries and tools are included; lag spend if sales cycle is long |
| CAC payback (months) | CAC / (new ARPA x gross margin %) | Gross-margin basis, not revenue basis |
| LTV | ARPA x gross margin % / monthly revenue churn | 1/churn assumes constant churn forever; cap lifetime (for example 5 years) and show the cap |
| LTV:CAC | LTV / CAC | 3:1 is an industry heuristic, not a law; meaningless with under ~12 months of churn data |
For early-stage companies with little churn history, flag that LTV is not yet measurable and lead with payback instead.
Three-statement linkage
- Net income from
PLflows into retained earnings:RE = prior RE + net income - dividends. - Cash flow uses the indirect method: net income + D&A - increase in receivables + increase in payables + increase in deferred revenue - capex + equity and debt raised.
- Closing cash on
CFis the cash line onBS. Never plug cash. - Receivables from days sales outstanding:
revenue / days_in_period x DSO. Annual upfront billing books cash at invoice and recognises revenue monthly; the difference sits in deferred revenue. - Avoid circularity. Compute interest on opening cash or opening debt, not on the average that depends on this period’s cash. If the user insists on average balances, add a circuit-breaker input and say so.
Checks
Put each check in Checks and roll them up into one master flag:
Balance check = ROUND(total assets - total liabilities - total equity, 2) = 0 every period
Cash check = CF closing cash - BS cash = 0 every period
Retained earn. = BS RE - (prior RE + PL net income) = 0
Revenue tie = PL revenue - Revenue sheet total = 0
Headcount tie = Opex payroll - Headcount payroll total = 0
Runway flag = first month where closing cash < 0 (shown on Cover)
Master = IF(SUM(ABS(all check rows)) < 0.01, "OK", "ERROR")
Scenarios and sensitivity
- Scenarios: put Base, Upside, Downside columns side by side on
Assumptionsand a selector cell (scenario= 1, 2, 3). The live column is=INDEX(D5:F5, 1, scenario)or=CHOOSE(scenario, D5, E5, F5). Every calculation references the live column only. - Sensitivity on closed-form metrics (LTV:CAC by churn x ARPA, payback by CAC x margin): build the grid with explicit formulas, one per cell, referencing row and column headers.
- Sensitivity on whole-model outputs (runway, month-24 cash): openpyxl cannot author a
working Excel What-If Data Table. Either tell the user to add one in Excel
(Data > What-If Analysis > Data Table) pointing at the labelled cells, or generate one
workbook per input combination, recalculate each, and write the resulting values into a
static grid clearly labelled “values from runs on
<date>”. - Show scenario outputs as a small table: ARR month 12/24/36, cash low point, runway months, breakeven month.
Building with openpyxl
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill
from openpyxl.utils import get_column_letter
from openpyxl.workbook.defined_name import DefinedName
BLUE, GREEN = Font(color="0000FF"), Font(color="008000")
INPUT_FILL = PatternFill("solid", fgColor="FFF2CC")
MONTHS, FIRST = 36, 5 # period 1 lives in column E
wb = Workbook()
a = wb.active
a.title = "Assumptions"
a.append(["Input", "Value", "Unit", "Source / note"])
inputs = [("start_date", None, "date", "Model start"), ("start_mrr", 0, "USD", "PLACEHOLDER"),
("new_customers", 0, "per month", "PLACEHOLDER"), ("arpa", 0, "USD/month", "PLACEHOLDER"),
("monthly_churn", 0, "%", "PLACEHOLDER")]
for i, (name, val, unit, note) in enumerate(inputs, start=2):
a.append([name, val, unit, note])
a[f"B{i}"].font, a[f"B{i}"].fill = BLUE, INPUT_FILL
wb.defined_names[name] = DefinedName(name, attr_text=f"Assumptions!$B${i}")
r = wb.create_sheet("Revenue")
for m in range(MONTHS):
col = get_column_letter(FIRST + m)
prev = get_column_letter(FIRST + m - 1)
r[f"{col}1"] = "=start_date" if m == 0 else f"=EDATE({prev}1,1)"
r[f"{col}6"] = "=start_mrr" if m == 0 else f"={prev}10" # opening MRR
r[f"{col}7"] = "=new_customers*arpa" # new MRR
r[f"{col}8"] = f"=-{col}6*monthly_churn" # churned MRR
r[f"{col}10"] = f"=SUM({col}6:{col}8)" # closing MRR
r[f"{col}1"].number_format = "mmm yy"
r.freeze_panes = "E2"
wb.save("model.xlsx")
Defined names make formulas readable (=new_customers*arpa) and survive row inserts.
Put labels in columns A to D and leave the time axis starting at column E everywhere.
For file mechanics (styles, validation, charts, recalculation), see the
xlsx-spreadsheets skill.
Gotchas
- Revenue, bookings, billings, and cash are four different numbers. Annual prepay makes cash lead revenue; enterprise net-60 terms make it lag.
- Headcount costs need a burden rate (payroll tax, benefits, equipment) on top of salary, and start in the hire month, not month 1.
- Growth applied to a growing base compounds silently; 10% monthly is roughly 3.1x a year. Show the implied annual figure next to any monthly growth input.
- Churn of 0% (or very small) makes uncapped LTV explode. Cap it.
IFERRORaround whole formulas hides broken links. Use it only where a known divide-by-zero is expected (for example ratios in month 1).- Avoid
OFFSETandINDIRECT: volatile, slow, and invisible to precedent tracing. - Signs: pick one convention (costs negative or costs positive with subtraction in totals) and keep it everywhere; mixed signs are the most common cause of a broken balance check.
- Ten-decimal outputs imply precision the inputs do not have. Round displays, not formulas.
Verification
-
Lint the structure. The bundled script reads the .xlsx directly (stdlib only):
python3 scripts/model_lint.py model.xlsx --allow 0,1,12 --input-sheets "Assumptions,Cover"It reports numbers typed inside formulas (HARDCODE), links to other workbooks (EXTERNAL), typed constants on calculation sheets (CONSTANT), and rows whose formulas are not one copied pattern (PATTERN). Exit code 1 on any HARDCODE, EXTERNAL, or CONSTANT finding. Fix each, or move the number to
Assumptions. -
Recalculate (openpyxl writes formulas without values) and confirm no error cells and that every check reads zero; the xlsx-spreadsheets skill covers recalculation.
-
Hand-check one forecast month end to end: recompute closing MRR and cash with a calculator or Python from the inputs and compare.
-
Stress test: set growth to 0, churn to 10% a month, CAC doubled. The model must still balance, show no errors, and flag the runway month.
-
Flip the scenario selector through every value and confirm outputs change and checks stay at zero.
Output contract
Deliver the workbook plus these notes in chat or on Cover:
## Model notes: <company> <model type> v<n> (<date>)
- Purpose and horizon: <36 months monthly from 2027-01, annual roll-up>
- Sheets: Cover, Assumptions, Revenue, Headcount, Opex, PL, BS, CF, Checks, Sensitivity
- Key outputs (Base): ARR M12/M24/M36, cash low point and month, runway, breakeven month
- Placeholders still to replace: <list with cell names>
- Method choices: churn on opening MRR; interest on opening cash; LTV capped at 60 months
- Checks: all zero across all periods and scenarios (recalculated with <tool>) / not recalculated
- Known limits: <no cohort data, CAC excludes founder time, tax simplified> Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
scripts/model_lint.py
#!/usr/bin/env python3
"""Lint a spreadsheet financial model (.xlsx) for structural mistakes.
Standard library only: reads the .xlsx zip/XML directly, so it works without
openpyxl or Excel. Checks:
HARDCODE numeric literals inside formulas (e.g. =B5*1.08), except allowed ones
EXTERNAL formulas that reference another workbook ([1]Sheet!A1)
CONSTANT typed numbers on calculation sheets (inputs belong on input sheets)
PATTERN a row whose formulas do not share one relative (R1C1) pattern
Usage:
python3 model_lint.py model.xlsx
python3 model_lint.py model.xlsx --allow 0,1,12 --input-sheets "Assumptions,Inputs"
python3 model_lint.py model.xlsx --json
Exit code 1 if any HARDCODE, EXTERNAL or CONSTANT finding exists; PATTERN
findings are warnings (the first period column often differs legitimately).
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import zipfile
import xml.etree.ElementTree as ET
from collections import defaultdict
NS = {
"m": "http://schemas.openxmlformats.org/spreadsheetml/2006/main",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
"rel": "http://schemas.openxmlformats.org/package/2006/relationships",
}
DEFAULT_INPUT_RE = re.compile(r"assum|input|driver|scenario|setting|cover|readme", re.I)
STRING_RE = re.compile(r'"(?:[^"]|"")*"')
QUOTED_SHEET_RE = re.compile(r"'(?:[^']|'')+'!")
SHEET_RE = re.compile(r"\b[A-Za-z_][\w.]*!")
EXTERNAL_RE = re.compile(r"\[\d+\]|\[[^\]]+\.xls[xmb]?\]", re.I)
ROW_RANGE_RE = re.compile(r"\$?\d+:\$?\d+")
COL_RANGE_RE = re.compile(r"\$?\b[A-Z]{1,3}:\$?[A-Z]{1,3}\b")
REF_RE = re.compile(r"(\$?)\b([A-Z]{1,3})(\$?)(\d+)\b(?!\s*\()")
NUM_RE = re.compile(r"(?<![\w.])(\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)(?![\w(])")
CELL_RE = re.compile(r"([A-Z]{1,3})(\d+)")
def col_to_num(col: str) -> int:
n = 0
for ch in col:
n = n * 26 + (ord(ch) - 64)
return n
def sheet_paths(z: zipfile.ZipFile) -> list[tuple[str, str]]:
wb = ET.fromstring(z.read("xl/workbook.xml"))
rels = ET.fromstring(z.read("xl/_rels/workbook.xml.rels"))
target = {r.get("Id"): r.get("Target") for r in rels.findall("rel:Relationship", NS)}
out = []
for s in wb.findall("m:sheets/m:sheet", NS):
rid = s.get(f"{{{NS['r']}}}id")
t = target.get(rid, "")
t = t.lstrip("/")
path = t if t.startswith("xl/") else f"xl/{t}"
if "worksheets/" in path:
out.append((s.get("name"), path))
return out
def relative_pattern(formula: str, row: int, col: int) -> str:
def repl(m: re.Match) -> str:
cabs, cname, rabs, rnum = m.groups()
c = f"C{col_to_num(cname)}" if cabs else f"C[{col_to_num(cname) - col}]"
r = f"R{rnum}" if rabs else f"R[{int(rnum) - row}]"
return r + c
return REF_RE.sub(repl, STRING_RE.sub('""', formula))
def literals(formula: str) -> list[str]:
f = STRING_RE.sub('""', formula)
f = QUOTED_SHEET_RE.sub("", f)
f = SHEET_RE.sub("", f)
f = ROW_RANGE_RE.sub("", f)
f = COL_RANGE_RE.sub("", f)
f = REF_RE.sub("", f)
return NUM_RE.findall(f)
def lint(path: str, allow: set[float], input_sheets: set[str] | None) -> list[dict]:
findings: list[dict] = []
with zipfile.ZipFile(path) as z:
for name, spath in sheet_paths(z):
is_input = (name in input_sheets) if input_sheets else bool(DEFAULT_INPUT_RE.search(name))
root = ET.fromstring(z.read(spath))
rows: dict[int, list[tuple[int, str, str]]] = defaultdict(list)
for c in root.iter(f"{{{NS['m']}}}c"):
ref = c.get("r", "")
m = CELL_RE.fullmatch(ref)
if not m:
continue
col, row = col_to_num(m.group(1)), int(m.group(2))
fel = c.find("m:f", NS)
if fel is not None and fel.text:
ftext = fel.text
if EXTERNAL_RE.search(ftext):
findings.append({"type": "EXTERNAL", "sheet": name, "cell": ref, "detail": "=" + ftext})
bad = [n for n in literals(ftext) if float(n) not in allow]
if bad:
findings.append({"type": "HARDCODE", "sheet": name, "cell": ref,
"detail": f"={ftext} (literals: {', '.join(bad)})"})
rows[row].append((col, ref, relative_pattern(ftext, row, col)))
elif fel is None and not is_input:
v = c.find("m:v", NS)
if v is not None and c.get("t") in (None, "n") and row > 1 and col > 1:
try:
val = float(v.text or "")
except ValueError:
continue
# Year/period header rows such as 2026, 2027 are common; skip integers 1900-2100.
if val.is_integer() and 1900 <= val <= 2100:
continue
findings.append({"type": "CONSTANT", "sheet": name, "cell": ref,
"detail": f"typed value {v.text} on a calculation sheet"})
for row, cells in sorted(rows.items()):
if len(cells) < 3:
continue
groups: dict[str, list[str]] = defaultdict(list)
for _, ref, pat in sorted(cells):
groups[pat].append(ref)
if len(groups) > 1:
main = max(groups.values(), key=len)
odd = [r for refs in groups.values() if refs is not main for r in refs]
findings.append({"type": "PATTERN", "sheet": name, "cell": f"row {row}",
"detail": f"{len(groups)} formula patterns; differs from the majority at {', '.join(odd[:8])}"})
return findings
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("xlsx")
ap.add_argument("--allow", default="0,1", help="comma-separated literals allowed in formulas (default 0,1)")
ap.add_argument("--input-sheets", default="", help="comma-separated input sheet names (default: names matching assum|input|driver|scenario)")
ap.add_argument("--json", action="store_true", help="print findings as JSON")
a = ap.parse_args()
allow = {float(x) for x in a.allow.split(",") if x.strip()}
inputs = {s.strip() for s in a.input_sheets.split(",") if s.strip()} or None
try:
findings = lint(a.xlsx, allow, inputs)
except (zipfile.BadZipFile, KeyError, FileNotFoundError) as e:
print(f"error: cannot read {a.xlsx} as .xlsx ({e})", file=sys.stderr)
return 2
if a.json:
print(json.dumps(findings, indent=2))
else:
for f in findings:
print(f"{f['type']:9} {f['sheet']}!{f['cell']}: {f['detail']}")
counts = defaultdict(int)
for f in findings:
counts[f["type"]] += 1
summary = ", ".join(f"{k}={v}" for k, v in sorted(counts.items())) or "no findings"
print(f"\n{a.xlsx}: {summary}")
blocking = any(f["type"] in ("HARDCODE", "EXTERNAL", "CONSTANT") for f in findings)
return 1 if blocking else 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.