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.

  • namefinancial-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.

~/.claude/skills/financial-models/SKILL.md

--- name: financial-models description: Builds and reviews spreadsheet financial models for startups and SaaS businesses - assumptions separated from calculations, one consistent… license: MIT ---

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.08 is 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:

QuestionWhy 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

SheetContents
CoverPurpose, author, version, date, colour legend, scenario in use, placeholder list
AssumptionsEvery input: label, value, unit, source/note; scenario columns
RevenueCustomer and MRR build per segment or plan
HeadcountOne row per role or hire: start month, salary, burden %, department
OpexNon-headcount costs by department, driven by headcount or revenue %
PLRevenue, COGS, gross profit, opex, EBITDA, D&A, interest, tax, net income
BS, CFThree-statement models only
ChecksEvery check in one place with a master flag on Cover
SensitivityScenario 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), not annual/12. Monthly growth from annual is (1+annual)^(1/12)-1.

4. Colour and format conventions

Cell typeFontFill
Hardcoded inputBlue 0000FFLight yellow FFF2CC optional
Formula on the same sheetBlacknone
Link to another sheetGreen 008000none
Check cellBlackGreen 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.

MetricFormulaCaveat to state in the notes
ARRclosing MRR x 12Run-rate, not revenue recognised or cash
Gross revenue retention(opening MRR - contraction - churn) / opening MRR, over 12 months on a cohortCannot exceed 100%
Net revenue retention(opening + expansion - contraction - churn) / opening, cohort basisExclude new customers
CACfully loaded sales and marketing cost / new customers in the periodSay 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
LTVARPA x gross margin % / monthly revenue churn1/churn assumes constant churn forever; cap lifetime (for example 5 years) and show the cap
LTV:CACLTV / CAC3: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 PL flows 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 CF is the cash line on BS. 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 Assumptions and 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.
  • IFERROR around whole formulas hides broken links. Use it only where a known divide-by-zero is expected (for example ratios in month 1).
  • Avoid OFFSET and INDIRECT: 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

  1. 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.

  2. Recalculate (openpyxl writes formulas without values) and confirm no error cells and that every check reads zero; the xlsx-spreadsheets skill covers recalculation.

  3. Hand-check one forecast month end to end: recompute closing MRR and cash with a calculator or Python from the inputs and compare.

  4. 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.

  5. 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 167 lines · 7.2 KB
#!/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.

esc