mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-16 05:56:30 +03:00
a45952d9c1
* 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>
243 lines
7.9 KiB
Python
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())
|