Files
magnus919_agent-skills/financial-modeling/scripts/saas-metrics.py
Magnus HedemarkGitHubfactory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
a45952d9c1 fix(skill): guard churn prints in saas-metrics human-readable output (#259)
* feat(skill): beef up financial-modeling with templates/scripts/evals

Add a schema-valid eval manifest (6 cases: unit-economics review, pricing
decision, fundraising scenario, SaaS metrics interpretation, model sanity
check, runway and burn analysis), four fillable templates (unit-economics
record, pricing decision record, fundraising scenario, model sanity
checklist), a stdlib SaaS-metrics calculator (ARR, monthly and annualized
logo churn, NDR, Rule of 40) with a unittest suite, and a README Quick
Start documenting the script. Closes #241.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* fix(skill): guard churn prints in saas-metrics human output

print_human indexed monthly_logo_churn_pct and annualized_logo_churn_pct
unconditionally while compute_metrics only populates them when churn
inputs are given, so human-readable runs without churn inputs (--mrr
alone, --mrr + NDR, --mrr + growth/margin) crashed with a KeyError (exit
1), violating the script's documented 0/2 exit-code contract. Guard both
churn print lines with `if 'monthly_logo_churn_pct' in metrics:`,
mirroring the existing NDR and Rule-of-40 guards, and add a regression
test class covering human output with churn omitted.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

---------

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-08-03 15:47:17 -04:00

243 lines
7.9 KiB
Python

#!/usr/bin/env python3
"""SaaS operating-metrics calculator for financial-modeling.
Computes the headline SaaS operating metrics from stated inputs:
ARR = monthly recurring revenue x 12
logo churn = monthly rate (customers churned / starting customers, or given
directly as a percentage), annualized as 1 - (1 - monthly)^12
NDR = (starting MRR + expansion - contraction - churned MRR)
/ starting MRR
Rule of 40 = revenue growth rate (%) + profit margin (%)
The tool is a computation aid, not financial advice. Every result is only as
trustworthy as the input definitions and period alignment: state the revenue
definition, customer population, and period for each input before acting on a
number. All monetary inputs are in the same currency and period.
Exit codes:
0 success
2 usage or validation error
"""
import argparse
import json
import sys
ANNUAL_MONTHS = 12
def annualize_monthly_rate(monthly_fraction):
"""Annualize a stable monthly rate: 1 - (1 - monthly) ** 12."""
return 1.0 - (1.0 - monthly_fraction) ** ANNUAL_MONTHS
def compute_metrics(
mrr,
customers=None,
churned_customers=None,
churn_pct=None,
expansion=None,
contraction=None,
churned_mrr=None,
growth_pct=None,
margin_pct=None,
):
"""Compute the requested SaaS metrics from validated inputs."""
metrics = {"mrr": mrr, "arr": mrr * ANNUAL_MONTHS}
if churn_pct is not None:
monthly_fraction = churn_pct / 100.0
metrics["churn_source"] = "churn-pct"
elif customers is not None and churned_customers is not None:
monthly_fraction = churned_customers / customers
metrics["churn_source"] = "customers"
else:
monthly_fraction = None
if monthly_fraction is not None:
metrics["monthly_logo_churn_pct"] = round(monthly_fraction * 100.0, 4)
metrics["annualized_logo_churn_pct"] = round(
annualize_monthly_rate(monthly_fraction) * 100.0, 4
)
if expansion is not None:
ndr = (mrr + expansion - contraction - churned_mrr) / mrr
metrics["ndr_pct"] = round(ndr * 100.0, 4)
if growth_pct is not None and margin_pct is not None:
metrics["rule_of_40"] = round(growth_pct + margin_pct, 4)
return metrics
def build_parser():
parser = argparse.ArgumentParser(
prog="saas-metrics.py",
description=(
"Compute SaaS operating metrics from stated inputs: ARR (monthly recurring "
"revenue annualized), monthly and annualized logo churn, net dollar "
"retention (NDR), and the Rule of 40 (revenue growth plus profit margin). "
"Exit 0 on success, 2 on usage or validation errors."
),
epilog=(
"Example: python3 saas-metrics.py --mrr 120000 --customers 480 "
"--churned-customers 10 --expansion 9000 --contraction 3000 "
"--churned-mrr 4200 --growth-pct 38 --margin-pct 6 --json"
),
)
parser.add_argument(
"--mrr",
type=float,
required=True,
metavar="AMOUNT",
help="starting monthly recurring revenue, the basis for ARR and NDR (required)",
)
churn_group = parser.add_mutually_exclusive_group()
churn_group.add_argument(
"--churn-pct",
type=float,
metavar="PCT",
help="monthly logo churn rate as a percentage, e.g. 2.1 for 2.1%%",
)
churn_group.add_argument(
"--churned-customers",
type=float,
metavar="COUNT",
help="customers churned in the period (requires --customers)",
)
parser.add_argument(
"--customers",
type=float,
metavar="COUNT",
help="starting customer count, the denominator for monthly logo churn",
)
parser.add_argument(
"--expansion",
type=float,
metavar="AMOUNT",
help="expansion (upsell) revenue in the period, for NDR",
)
parser.add_argument(
"--contraction",
type=float,
metavar="AMOUNT",
help="contraction (downgrade) revenue in the period, for NDR",
)
parser.add_argument(
"--churned-mrr",
type=float,
metavar="AMOUNT",
help="recurring revenue lost to churn in the period, for NDR",
)
parser.add_argument(
"--growth-pct",
type=float,
metavar="PCT",
help="recurring revenue growth rate as a percentage, for Rule of 40",
)
parser.add_argument(
"--margin-pct",
type=float,
metavar="PCT",
help="profit margin (EBITDA or free cash flow) as a percentage, for Rule of 40",
)
parser.add_argument("--json", action="store_true", help="emit a machine-readable JSON report")
return parser
def validate_inputs(args):
"""Return an error message, or None when the inputs are consistent."""
if args.mrr < 0:
return "--mrr must be non-negative"
if args.churn_pct is not None:
if not 0 <= args.churn_pct <= 100:
return "--churn-pct must be between 0 and 100"
elif args.churned_customers is not None or args.customers is not None:
if args.churned_customers is None or args.customers is None:
return "--churned-customers and --customers must be provided together"
if args.customers <= 0:
return "--customers must be positive"
if args.churned_customers < 0 or args.churned_customers > args.customers:
return "--churned-customers must be between 0 and --customers"
ndr_inputs = (args.expansion, args.contraction, args.churned_mrr)
if any(value is not None for value in ndr_inputs):
if not all(value is not None for value in ndr_inputs):
return "--expansion, --contraction, and --churned-mrr must be provided together"
if any(value < 0 for value in ndr_inputs):
return "NDR components must be non-negative"
if args.mrr == 0:
return "NDR requires --mrr greater than 0"
if args.churned_mrr > args.mrr:
return "--churned-mrr cannot exceed --mrr"
if (args.growth_pct is None) != (args.margin_pct is None):
return "--growth-pct and --margin-pct must be provided together"
return None
def build_report(args, metrics):
inputs = {
"mrr": args.mrr,
"customers": args.customers,
"churned_customers": args.churned_customers,
"churn_pct": args.churn_pct,
"expansion": args.expansion,
"contraction": args.contraction,
"churned_mrr": args.churned_mrr,
"growth_pct": args.growth_pct,
"margin_pct": args.margin_pct,
}
return {"tool": "saas-metrics.py", "inputs": inputs, "metrics": metrics}
def format_money(value):
return f"${value:,.2f}"
def print_human(report):
metrics = report["metrics"]
print(f"ARR (annualized recurring revenue): {format_money(metrics['arr'])}")
if "monthly_logo_churn_pct" in metrics:
print(f"Monthly logo churn: {metrics['monthly_logo_churn_pct']:.2f}%")
print(f"Annualized logo churn: {metrics['annualized_logo_churn_pct']:.2f}%")
if "ndr_pct" in metrics:
print(f"NDR (net dollar retention): {metrics['ndr_pct']:.2f}%")
if "rule_of_40" in metrics:
print(f"Rule of 40 (growth + margin): {metrics['rule_of_40']:.2f}")
def main(argv=None):
parser = build_parser()
args = parser.parse_args(argv)
error = validate_inputs(args)
if error is not None:
parser.error(error)
metrics = compute_metrics(
mrr=args.mrr,
customers=args.customers,
churned_customers=args.churned_customers,
churn_pct=args.churn_pct,
expansion=args.expansion,
contraction=args.contraction,
churned_mrr=args.churned_mrr,
growth_pct=args.growth_pct,
margin_pct=args.margin_pct,
)
report = build_report(args, metrics)
if args.json:
print(json.dumps(report, indent=2))
else:
print_human(report)
return 0
if __name__ == "__main__":
sys.exit(main())