* feat: C-Suite expansion — 8 new executive advisory roles Add COO, CPO, CMO, CFO, CRO, CISO, CHRO advisors and Executive Mentor. Expands C-level advisory from 2 to 10 roles with 74 total files. Each role includes: - SKILL.md (lean, <5KB, ~1200 tokens for context efficiency) - Reference docs (loaded on demand, not at startup) - Python analysis scripts (stdlib only, runnable CLI) Executive Mentor features /em: slash commands (challenge, board-prep, hard-call, stress-test, postmortem) with devil's advocate agent. 21 Python tools, 24 reference frameworks, 28,379 total lines. All SKILL.md files combined: ~17K tokens (8.5% of 200K context window). Badge: 88 → 116 skills * feat: C-Suite orchestration layer + 18 complementary skills ORCHESTRATION (new): - cs-onboard: Founder interview → company-context.md - chief-of-staff: Routing, synthesis, inter-agent orchestration - board-meeting: 6-phase multi-agent deliberation protocol - decision-logger: Two-layer memory (raw transcripts + approved decisions) - agent-protocol: Inter-agent invocation with loop prevention - context-engine: Company context loading + anonymization CROSS-CUTTING CAPABILITIES (new): - board-deck-builder: Board/investor update assembly - scenario-war-room: Cascading multi-variable what-if modeling - competitive-intel: Systematic competitor tracking + battlecards - org-health-diagnostic: Cross-functional health scoring (8 dimensions) - ma-playbook: M&A strategy (acquiring + being acquired) - intl-expansion: International market entry frameworks CULTURE & COLLABORATION (new): - culture-architect: Values → behaviors, culture code, health assessment - company-os: EOS/Scaling Up operating system selection + implementation - founder-coach: Founder development, delegation, blind spots - strategic-alignment: Strategy cascade, silo detection, alignment scoring - change-management: ADKAR-based change rollout framework - internal-narrative: One story across employees/investors/customers UPGRADES TO EXISTING ROLES: - All 10 roles get reasoning technique directives - All 10 roles get company-context.md integration - All 10 roles get board meeting isolation rules - CEO gets stage-adaptive temporal horizons (seed→C) Key design decisions: - Two-layer memory prevents hallucinated consensus from rejected ideas - Phase 2 isolation: agents think independently before cross-examination - Executive Mentor (The Critic) sees all perspectives, others don't - 25 Python tools total (stdlib only, no dependencies) 52 new files, 10 modified, 10,862 new lines. Total C-suite ecosystem: 134 files, 39,131 lines. * fix: connect all dots — Chief of Staff routes to all 28 skills - Added complementary skills registry to routing-matrix.md - Chief of Staff SKILL.md now lists all 28 skills in ecosystem - Added integration tables to scenario-war-room and competitive-intel - Badge: 116 → 134 skills - README: C-Level Advisory count 10 → 28 Quality audit passed: ✅ All 10 roles: company-context, reasoning, isolation, invocation ✅ All 6 phases in board meeting ✅ Two-layer memory with DO_NOT_RESURFACE ✅ Loop prevention (no self-invoke, max depth 2, no circular) ✅ All /em: commands present ✅ All complementary skills cross-reference roles ✅ Chief of Staff routes to every skill in ecosystem * refactor: CEO + CTO advisors upgraded to C-suite parity Both roles now match the structural standard of all new roles: - CEO: 11.7KB → 6.8KB SKILL.md (heavy content stays in references) - CTO: 10KB → 7.2KB SKILL.md (heavy content stays in references) Added to both: - Integration table (who they work with and when) - Key diagnostic questions - Structured metrics dashboard table - Consistent section ordering (Keywords → Quick Start → Responsibilities → Questions → Metrics → Red Flags → Integration → Reasoning → Context) CEO additions: - Stage-adaptive temporal horizons (seed=3m/6m/12m → B+=1y/3y/5y) - Cross-references to culture-architect and board-deck-builder CTO additions: - Key Questions section (7 diagnostic questions) - Structured metrics table (DORA + debt + team + architecture + cost) - Cross-references to all peer roles All 10 roles now pass structural parity: ✅ Keywords ✅ QuickStart ✅ Questions ✅ Metrics ✅ RedFlags ✅ Integration * feat: add proactive triggers + output artifacts to all 10 roles Every C-suite role now specifies: - Proactive Triggers: 'surface these without being asked' — context-driven early warnings that make advisors proactive, not reactive - Output Artifacts: concrete deliverables per request type (what you ask → what you get) CEO: runway alerts, board prep triggers, strategy review nudges CTO: deploy frequency monitoring, tech debt thresholds, bus factor flags COO: blocker detection, scaling threshold warnings, cadence gaps CPO: retention curve monitoring, portfolio dog detection, research gaps CMO: CAC trend monitoring, positioning gaps, budget staleness CFO: runway forecasting, burn multiple alerts, scenario planning gaps CRO: NRR monitoring, pipeline coverage, pricing review triggers CISO: audit overdue alerts, compliance gaps, vendor risk CHRO: retention risk, comp band gaps, org scaling thresholds Executive Mentor: board prep triggers, groupthink detection, hard call surfacing This transforms the C-suite from reactive advisors into proactive partners. * feat: User Communication Standard — structured output for all roles Defines 3 output formats in agent-protocol/SKILL.md: 1. Standard Output: Bottom Line → What → Why → How to Act → Risks → Your Decision 2. Proactive Alert: What I Noticed → Why It Matters → Action → Urgency (🔴🟡⚪) 3. Board Meeting: Decision Required → Perspectives → Agree/Disagree → Critic → Action Items 10 non-negotiable rules: - Bottom line first, always - Results and decisions only (no process narration) - What + Why + How for every finding - Actions have owners and deadlines ('we should consider' is banned) - Decisions framed as options with trade-offs - Founder is the highest authority — roles recommend, founder decides - Risks are concrete (if X → Y, costs $Z) - Max 5 bullets per section - No jargon without explanation - Silence over fabricated updates All 10 roles reference this standard. Chief of Staff enforces it as a quality gate. Board meeting Phase 4 uses the Board Meeting Output format. * feat: Internal Quality Loop — verification before delivery No role presents to the founder without passing verification: Step 1: Self-Verification (every role, every time) - Source attribution: where did each data point come from? - Assumption audit: [VERIFIED] vs [ASSUMED] tags on every finding - Confidence scoring: 🟢 high / 🟡 medium / 🔴 low per finding - Contradiction check against company-context + decision log - 'So what?' test: every finding needs a business consequence Step 2: Peer Verification (cross-functional) - Financial claims → CFO validates math - Revenue projections → CRO validates pipeline backing - Technical feasibility → CTO validates - People/hiring impact → CHRO validates - Skip for single-domain, low-stakes questions Step 3: Critic Pre-Screen (high-stakes only) - Irreversible decisions, >20% runway impact, strategy changes - Executive Mentor finds weakest point before founder sees it - Suspicious consensus triggers mandatory pre-screen Step 4: Course Correction (after founder feedback) - Approve → log + assign actions - Modify → re-verify changed parts - Reject → DO_NOT_RESURFACE + learn why - 30/60/90 day post-decision review Board meeting contributions now require self-verified format with confidence tags and source attribution on every finding. * fix: resolve PR review issues 1, 4, and minor observation Issue 1: c-level-advisor/CLAUDE.md — completely rewritten - Was: 2 skills (CEO, CTO only), dated Nov 2025 - Now: full 28-skill ecosystem map with architecture diagram, all roles/orchestration/cross-cutting/culture skills listed, design decisions, integration with other domains Issue 4: Root CLAUDE.md — updated all stale counts - 87 → 134 skills across all 3 references - C-Level: 2 → 33 (10 roles + 5 mentor commands + 18 complementary) - Tool count: 160+ → 185+ - Reference count: 200+ → 250+ Minor observation: Documented plugin.json convention - Explained in c-level-advisor/CLAUDE.md that only executive-mentor has plugin.json because only it has slash commands (/em: namespace) - Other skills are invoked by name through Chief of Staff or directly Also fixed: README.md 88+ → 134 in two places (first line + skills section) * fix: update all plugin/index registrations for 28-skill C-suite 1. c-level-advisor/.claude-plugin/plugin.json — v2.0.0 - Was: 2 skills, generic description - Now: all 28 skills listed with descriptions, all 25 scripts, namespace 'cs', full ecosystem description 2. .codex/skills-index.json — added 18 complementary skills - Was: 10 roles only - Now: 28 total c-level entries (10 roles + 6 orchestration + 6 cross-cutting + 6 culture) - Each with full description for skill discovery 3. .claude-plugin/marketplace.json — updated c-level-skills entry - Was: generic 2-skill description - Now: v2.0.0, full 28-skill ecosystem description, skills_count: 28, scripts_count: 25 * feat: add root SKILL.md for c-level-advisor ClawHub package --------- Co-authored-by: Leo <leo@openclaw.ai>
530 lines
20 KiB
Python
530 lines
20 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Unit Economics Analyzer
|
||
========================
|
||
Per-cohort LTV, per-channel CAC, payback periods, and LTV:CAC ratios.
|
||
Never blended averages — those hide what's actually happening.
|
||
|
||
Usage:
|
||
python unit_economics_analyzer.py
|
||
python unit_economics_analyzer.py --csv
|
||
|
||
Stdlib only. No dependencies.
|
||
"""
|
||
|
||
import argparse
|
||
import csv
|
||
import io
|
||
import sys
|
||
from dataclasses import dataclass, field
|
||
from typing import Optional
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Data structures
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@dataclass
|
||
class CohortData:
|
||
"""
|
||
Revenue data for a group of customers acquired in the same period.
|
||
Revenue is tracked monthly: revenue[0] = month 1, revenue[1] = month 2, etc.
|
||
"""
|
||
label: str # e.g. "Q1 2024"
|
||
acquisition_period: str # human-readable label
|
||
customers_acquired: int
|
||
total_cac_spend: float # total S&M spend to acquire this cohort
|
||
monthly_revenue: list[float] # revenue per month from this cohort
|
||
gross_margin_pct: float = 0.70 # blended gross margin for this cohort
|
||
|
||
|
||
@dataclass
|
||
class ChannelData:
|
||
"""Acquisition cost and customer data for a single channel."""
|
||
channel: str
|
||
spend: float
|
||
customers_acquired: int
|
||
avg_arpa: float # average revenue per account (monthly)
|
||
gross_margin_pct: float = 0.70
|
||
avg_monthly_churn: float = 0.02 # monthly churn rate for customers from this channel
|
||
|
||
|
||
@dataclass
|
||
class UnitEconomicsResult:
|
||
"""Computed unit economics for a cohort or channel."""
|
||
label: str
|
||
customers: int
|
||
cac: float
|
||
arpa: float # average revenue per account per month
|
||
gross_margin_pct: float
|
||
monthly_churn: float
|
||
ltv: float
|
||
ltv_cac_ratio: float
|
||
payback_months: float
|
||
# Cohort-specific
|
||
m1_revenue: Optional[float] = None
|
||
m6_revenue: Optional[float] = None
|
||
m12_revenue: Optional[float] = None
|
||
m24_revenue: Optional[float] = None
|
||
m12_ltv: Optional[float] = None # realized LTV through month 12
|
||
retention_m6: Optional[float] = None # % of M1 revenue retained at M6
|
||
retention_m12: Optional[float] = None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Calculators
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def calc_ltv(arpa: float, gross_margin_pct: float, monthly_churn: float) -> float:
|
||
"""
|
||
LTV = (ARPA × Gross Margin) / Monthly Churn Rate
|
||
Assumes constant churn (simplified; cohort method is more accurate).
|
||
"""
|
||
if monthly_churn <= 0:
|
||
return float("inf")
|
||
return (arpa * gross_margin_pct) / monthly_churn
|
||
|
||
|
||
def calc_payback(cac: float, arpa: float, gross_margin_pct: float) -> float:
|
||
"""
|
||
CAC Payback (months) = CAC / (ARPA × Gross Margin)
|
||
"""
|
||
denominator = arpa * gross_margin_pct
|
||
if denominator <= 0:
|
||
return float("inf")
|
||
return cac / denominator
|
||
|
||
|
||
def analyze_cohort(cohort: CohortData) -> UnitEconomicsResult:
|
||
"""Compute full unit economics for a cohort."""
|
||
n = cohort.customers_acquired
|
||
if n == 0:
|
||
raise ValueError(f"Cohort {cohort.label}: customers_acquired cannot be 0")
|
||
|
||
cac = cohort.total_cac_spend / n
|
||
|
||
# ARPA from month 1 revenue
|
||
m1_rev = cohort.monthly_revenue[0] if cohort.monthly_revenue else 0
|
||
arpa = m1_rev / n if n > 0 else 0
|
||
|
||
# Observed monthly churn from cohort data
|
||
# Use revenue decline from M1 to M12 to estimate churn
|
||
months_available = len(cohort.monthly_revenue)
|
||
if months_available >= 12:
|
||
m12_rev = cohort.monthly_revenue[11]
|
||
# Revenue retention over 12 months: (M12/M1)^(1/11) per month on average
|
||
# Implied monthly retention rate
|
||
if m1_rev > 0 and m12_rev > 0:
|
||
monthly_retention = (m12_rev / m1_rev) ** (1 / 11)
|
||
monthly_churn = 1 - monthly_retention
|
||
else:
|
||
monthly_churn = 0.02 # default
|
||
elif months_available >= 6:
|
||
m6_rev = cohort.monthly_revenue[5]
|
||
if m1_rev > 0 and m6_rev > 0:
|
||
monthly_retention = (m6_rev / m1_rev) ** (1 / 5)
|
||
monthly_churn = 1 - monthly_retention
|
||
else:
|
||
monthly_churn = 0.02
|
||
else:
|
||
monthly_churn = 0.02 # default if < 6 months data
|
||
|
||
# Clamp to reasonable range
|
||
monthly_churn = max(0.001, min(monthly_churn, 0.30))
|
||
|
||
ltv = calc_ltv(arpa, cohort.gross_margin_pct, monthly_churn)
|
||
payback = calc_payback(cac, arpa, cohort.gross_margin_pct)
|
||
ltv_cac = ltv / cac if cac > 0 else float("inf")
|
||
|
||
# Snapshot revenues
|
||
def rev_at(month_idx: int) -> Optional[float]:
|
||
if months_available > month_idx:
|
||
return cohort.monthly_revenue[month_idx]
|
||
return None
|
||
|
||
m6 = rev_at(5)
|
||
m12 = rev_at(11)
|
||
m24 = rev_at(23)
|
||
|
||
# Realized LTV through observed months (actual gross profit)
|
||
m12_ltv = sum(cohort.monthly_revenue[:12]) * cohort.gross_margin_pct if months_available >= 12 else None
|
||
|
||
# Retention rates
|
||
ret_m6 = (m6 / m1_rev) if (m6 is not None and m1_rev > 0) else None
|
||
ret_m12 = (m12 / m1_rev) if (m12 is not None and m1_rev > 0) else None
|
||
|
||
return UnitEconomicsResult(
|
||
label=cohort.label,
|
||
customers=n,
|
||
cac=cac,
|
||
arpa=arpa,
|
||
gross_margin_pct=cohort.gross_margin_pct,
|
||
monthly_churn=monthly_churn,
|
||
ltv=ltv,
|
||
ltv_cac_ratio=ltv_cac,
|
||
payback_months=payback,
|
||
m1_revenue=m1_rev,
|
||
m6_revenue=m6,
|
||
m12_revenue=m12,
|
||
m24_revenue=m24,
|
||
m12_ltv=m12_ltv,
|
||
retention_m6=ret_m6,
|
||
retention_m12=ret_m12,
|
||
)
|
||
|
||
|
||
def analyze_channel(ch: ChannelData) -> UnitEconomicsResult:
|
||
"""Compute unit economics for an acquisition channel."""
|
||
if ch.customers_acquired == 0:
|
||
raise ValueError(f"Channel {ch.channel}: customers_acquired cannot be 0")
|
||
|
||
cac = ch.spend / ch.customers_acquired
|
||
ltv = calc_ltv(ch.avg_arpa, ch.gross_margin_pct, ch.avg_monthly_churn)
|
||
payback = calc_payback(cac, ch.avg_arpa, ch.gross_margin_pct)
|
||
ltv_cac = ltv / cac if cac > 0 else float("inf")
|
||
|
||
return UnitEconomicsResult(
|
||
label=ch.channel,
|
||
customers=ch.customers_acquired,
|
||
cac=cac,
|
||
arpa=ch.avg_arpa,
|
||
gross_margin_pct=ch.gross_margin_pct,
|
||
monthly_churn=ch.avg_monthly_churn,
|
||
ltv=ltv,
|
||
ltv_cac_ratio=ltv_cac,
|
||
payback_months=payback,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Blended metrics (for comparison)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def blended_cac(channels: list[ChannelData]) -> float:
|
||
total_spend = sum(c.spend for c in channels)
|
||
total_customers = sum(c.customers_acquired for c in channels)
|
||
return total_spend / total_customers if total_customers > 0 else 0
|
||
|
||
|
||
def blended_ltv(channels: list[ChannelData]) -> float:
|
||
"""Weighted average LTV by customers acquired."""
|
||
total_customers = sum(c.customers_acquired for c in channels)
|
||
if total_customers == 0:
|
||
return 0
|
||
weighted = sum(
|
||
calc_ltv(c.avg_arpa, c.gross_margin_pct, c.avg_monthly_churn) * c.customers_acquired
|
||
for c in channels
|
||
)
|
||
return weighted / total_customers
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Reporting
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def fmt(value: float, prefix: str = "$", decimals: int = 0) -> str:
|
||
if value == float("inf"):
|
||
return "∞"
|
||
if abs(value) >= 1_000_000:
|
||
return f"{prefix}{value/1_000_000:.2f}M"
|
||
if abs(value) >= 1_000:
|
||
return f"{prefix}{value/1_000:.1f}K"
|
||
return f"{prefix}{value:.{decimals}f}"
|
||
|
||
|
||
def pct(value: Optional[float]) -> str:
|
||
if value is None:
|
||
return "n/a"
|
||
return f"{value*100:.1f}%"
|
||
|
||
|
||
def rating(ltv_cac: float, payback: float) -> str:
|
||
if ltv_cac == float("inf"):
|
||
return "∞"
|
||
if ltv_cac >= 5 and payback <= 12:
|
||
return "🟢 Excellent"
|
||
if ltv_cac >= 3 and payback <= 18:
|
||
return "🟡 Good"
|
||
if ltv_cac >= 2 and payback <= 24:
|
||
return "🟠 Marginal"
|
||
return "🔴 Poor"
|
||
|
||
|
||
def print_cohort_analysis(results: list[UnitEconomicsResult]) -> None:
|
||
print("\n" + "="*80)
|
||
print(" COHORT ANALYSIS")
|
||
print("="*80)
|
||
print(f" {'Cohort':<12} {'Cust':>5} {'CAC':>8} {'ARPA/mo':>9} {'Churn/mo':>10} "
|
||
f"{'LTV':>10} {'LTV:CAC':>8} {'Payback':>9} {'Ret@M12':>8}")
|
||
print(" " + "-"*88)
|
||
for r in results:
|
||
payback_str = f"{r.payback_months:.1f}mo" if r.payback_months != float("inf") else "∞"
|
||
ltv_str = fmt(r.ltv) if r.ltv != float("inf") else "∞"
|
||
ltv_cac_str = f"{r.ltv_cac_ratio:.1f}x" if r.ltv_cac_ratio != float("inf") else "∞"
|
||
print(
|
||
f" {r.label:<12} {r.customers:>5} {fmt(r.cac):>8} {fmt(r.arpa):>9} "
|
||
f"{pct(r.monthly_churn):>10} {ltv_str:>10} {ltv_cac_str:>8} "
|
||
f"{payback_str:>9} {pct(r.retention_m12):>8}"
|
||
)
|
||
|
||
# Trend analysis
|
||
print("\n Cohort Trend (is the business getting better or worse?):")
|
||
if len(results) >= 3:
|
||
ltv_cac_values = [r.ltv_cac_ratio for r in results if r.ltv_cac_ratio != float("inf")]
|
||
cac_values = [r.cac for r in results]
|
||
churn_values = [r.monthly_churn for r in results]
|
||
|
||
if len(ltv_cac_values) >= 2:
|
||
ltv_cac_trend = "↑ Improving" if ltv_cac_values[-1] > ltv_cac_values[0] else "↓ Deteriorating"
|
||
else:
|
||
ltv_cac_trend = "n/a"
|
||
|
||
cac_trend = "↓ Decreasing (good)" if cac_values[-1] < cac_values[0] else "↑ Increasing"
|
||
churn_trend = "↓ Improving" if churn_values[-1] < churn_values[0] else "↑ Worsening"
|
||
|
||
print(f" LTV:CAC: {ltv_cac_trend}")
|
||
print(f" CAC: {cac_trend}")
|
||
print(f" Churn rate: {churn_trend}")
|
||
|
||
|
||
def print_channel_analysis(results: list[UnitEconomicsResult], channels: list[ChannelData]) -> None:
|
||
print("\n" + "="*80)
|
||
print(" CHANNEL ANALYSIS (Per-Channel vs Blended)")
|
||
print("="*80)
|
||
print(f" {'Channel':<22} {'Spend':>9} {'Cust':>5} {'CAC':>8} {'LTV':>10} {'LTV:CAC':>8} {'Payback':>9} {'Rating'}")
|
||
print(" " + "-"*90)
|
||
for r, ch in zip(results, channels):
|
||
payback_str = f"{r.payback_months:.1f}mo" if r.payback_months != float("inf") else "∞"
|
||
ltv_str = fmt(r.ltv) if r.ltv != float("inf") else "∞"
|
||
ltv_cac_str = f"{r.ltv_cac_ratio:.1f}x" if r.ltv_cac_ratio != float("inf") else "∞"
|
||
print(
|
||
f" {r.label:<22} {fmt(ch.spend):>9} {r.customers:>5} {fmt(r.cac):>8} "
|
||
f"{ltv_str:>10} {ltv_cac_str:>8} {payback_str:>9} {rating(r.ltv_cac_ratio, r.payback_months)}"
|
||
)
|
||
|
||
# Blended comparison
|
||
b_cac = blended_cac(channels)
|
||
b_ltv = blended_ltv(channels)
|
||
b_ltv_cac = b_ltv / b_cac if b_cac > 0 else 0
|
||
total_spend = sum(c.spend for c in channels)
|
||
total_customers = sum(c.customers_acquired for c in channels)
|
||
avg_payback = sum(
|
||
calc_payback(b_cac, c.avg_arpa, c.gross_margin_pct) * c.customers_acquired
|
||
for c in channels
|
||
) / total_customers
|
||
|
||
print(" " + "-"*90)
|
||
print(
|
||
f" {'BLENDED (dangerous)':<22} {fmt(total_spend):>9} {total_customers:>5} "
|
||
f"{fmt(b_cac):>8} {fmt(b_ltv):>10} {b_ltv_cac:.1f}x{'':<7} "
|
||
f"{avg_payback:.1f}mo{'':<4} {rating(b_ltv_cac, avg_payback)}"
|
||
)
|
||
print("\n ⚠️ Blended numbers hide channel-level problems. Manage channels individually.")
|
||
|
||
# Budget reallocation
|
||
print("\n Recommended Budget Reallocation:")
|
||
sorted_results = sorted(zip(results, channels), key=lambda x: x[0].ltv_cac_ratio, reverse=True)
|
||
for r, ch in sorted_results:
|
||
if r.ltv_cac_ratio >= 3:
|
||
action = "✅ Scale"
|
||
elif r.ltv_cac_ratio >= 2:
|
||
action = "🔄 Optimize"
|
||
else:
|
||
action = "❌ Cut / pause"
|
||
print(f" {ch.channel:<22} LTV:CAC = {r.ltv_cac_ratio:.1f}x → {action}")
|
||
|
||
|
||
def export_csv_results(cohort_results: list[UnitEconomicsResult], channel_results: list[UnitEconomicsResult]) -> str:
|
||
buf = io.StringIO()
|
||
writer = csv.writer(buf)
|
||
writer.writerow(["Type", "Label", "Customers", "CAC", "ARPA_Monthly", "Gross_Margin_Pct",
|
||
"Monthly_Churn", "LTV", "LTV_CAC_Ratio", "Payback_Months",
|
||
"Retention_M6", "Retention_M12"])
|
||
for r in cohort_results:
|
||
writer.writerow(["cohort", r.label, r.customers, round(r.cac, 2), round(r.arpa, 2),
|
||
r.gross_margin_pct, round(r.monthly_churn, 4),
|
||
round(r.ltv, 2) if r.ltv != float("inf") else "inf",
|
||
round(r.ltv_cac_ratio, 2) if r.ltv_cac_ratio != float("inf") else "inf",
|
||
round(r.payback_months, 2) if r.payback_months != float("inf") else "inf",
|
||
round(r.retention_m6, 3) if r.retention_m6 else "",
|
||
round(r.retention_m12, 3) if r.retention_m12 else ""])
|
||
for r in channel_results:
|
||
writer.writerow(["channel", r.label, r.customers, round(r.cac, 2), round(r.arpa, 2),
|
||
r.gross_margin_pct, round(r.monthly_churn, 4),
|
||
round(r.ltv, 2) if r.ltv != float("inf") else "inf",
|
||
round(r.ltv_cac_ratio, 2) if r.ltv_cac_ratio != float("inf") else "inf",
|
||
round(r.payback_months, 2) if r.payback_months != float("inf") else "inf",
|
||
"", ""])
|
||
return buf.getvalue()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Sample data
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def make_sample_cohorts() -> list[CohortData]:
|
||
"""
|
||
Series A SaaS company, 8 quarters of cohort data.
|
||
Shows a business improving on all dimensions over time.
|
||
"""
|
||
return [
|
||
CohortData(
|
||
label="Q1 2023", acquisition_period="Jan-Mar 2023",
|
||
customers_acquired=12, total_cac_spend=54_000,
|
||
gross_margin_pct=0.68,
|
||
monthly_revenue=[
|
||
10_200, 9_600, 9_100, 8_700, 8_300, 8_000, # M1-M6
|
||
7_800, 7_600, 7_400, 7_200, 7_000, 6_800, # M7-M12
|
||
6_700, 6_600, 6_500, 6_400, 6_300, 6_200, # M13-M18
|
||
6_100, 6_000, 5_900, 5_800, 5_700, 5_600, # M19-M24
|
||
],
|
||
),
|
||
CohortData(
|
||
label="Q2 2023", acquisition_period="Apr-Jun 2023",
|
||
customers_acquired=15, total_cac_spend=60_000,
|
||
gross_margin_pct=0.69,
|
||
monthly_revenue=[
|
||
13_500, 12_900, 12_500, 12_100, 11_800, 11_500,
|
||
11_300, 11_100, 10_900, 10_700, 10_500, 10_300,
|
||
10_200, 10_100, 10_000, 9_900, 9_800, 9_700,
|
||
],
|
||
),
|
||
CohortData(
|
||
label="Q3 2023", acquisition_period="Jul-Sep 2023",
|
||
customers_acquired=18, total_cac_spend=63_000,
|
||
gross_margin_pct=0.70,
|
||
monthly_revenue=[
|
||
16_200, 15_800, 15_400, 15_100, 14_800, 14_600,
|
||
14_400, 14_200, 14_000, 13_900, 13_800, 13_700,
|
||
13_600, 13_500, 13_400, 13_300,
|
||
],
|
||
),
|
||
CohortData(
|
||
label="Q4 2023", acquisition_period="Oct-Dec 2023",
|
||
customers_acquired=22, total_cac_spend=70_400,
|
||
gross_margin_pct=0.71,
|
||
monthly_revenue=[
|
||
20_900, 20_500, 20_200, 19_900, 19_700, 19_500,
|
||
19_300, 19_100, 19_000, 18_900, 18_800, 18_700,
|
||
],
|
||
),
|
||
CohortData(
|
||
label="Q1 2024", acquisition_period="Jan-Mar 2024",
|
||
customers_acquired=28, total_cac_spend=81_200,
|
||
gross_margin_pct=0.72,
|
||
monthly_revenue=[
|
||
27_200, 26_900, 26_600, 26_400, 26_200, 26_000,
|
||
25_800, 25_700, 25_600, 25_500,
|
||
],
|
||
),
|
||
CohortData(
|
||
label="Q2 2024", acquisition_period="Apr-Jun 2024",
|
||
customers_acquired=34, total_cac_spend=91_800,
|
||
gross_margin_pct=0.72,
|
||
monthly_revenue=[
|
||
33_300, 33_000, 32_800, 32_600, 32_400, 32_200,
|
||
],
|
||
),
|
||
CohortData(
|
||
label="Q3 2024", acquisition_period="Jul-Sep 2024",
|
||
customers_acquired=40, total_cac_spend=100_000,
|
||
gross_margin_pct=0.73,
|
||
monthly_revenue=[
|
||
39_600, 39_400, 39_200,
|
||
],
|
||
),
|
||
CohortData(
|
||
label="Q4 2024", acquisition_period="Oct-Dec 2024",
|
||
customers_acquired=47, total_cac_spend=112_800,
|
||
gross_margin_pct=0.73,
|
||
monthly_revenue=[
|
||
47_000,
|
||
],
|
||
),
|
||
]
|
||
|
||
|
||
def make_sample_channels() -> list[ChannelData]:
|
||
"""
|
||
Q4 2024 channel breakdown. Blended looks fine; per-channel reveals problems.
|
||
"""
|
||
return [
|
||
ChannelData("Organic / SEO", spend=9_500, customers_acquired=14, avg_arpa=950, gross_margin_pct=0.73, avg_monthly_churn=0.015),
|
||
ChannelData("Paid Search (SEM)", spend=48_000, customers_acquired=18, avg_arpa=980, gross_margin_pct=0.73, avg_monthly_churn=0.020),
|
||
ChannelData("Paid Social", spend=32_000, customers_acquired=8, avg_arpa=900, gross_margin_pct=0.72, avg_monthly_churn=0.025),
|
||
ChannelData("Content / Inbound", spend=11_000, customers_acquired=6, avg_arpa=1100, gross_margin_pct=0.74, avg_monthly_churn=0.012),
|
||
ChannelData("Outbound SDR", spend=22_000, customers_acquired=4, avg_arpa=1200, gross_margin_pct=0.73, avg_monthly_churn=0.022),
|
||
ChannelData("Events / Webinars", spend=18_500, customers_acquired=3, avg_arpa=1050, gross_margin_pct=0.72, avg_monthly_churn=0.028),
|
||
ChannelData("Partner / Referral", spend=7_800, customers_acquired=7, avg_arpa=1000, gross_margin_pct=0.73, avg_monthly_churn=0.013),
|
||
]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Entry point
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def main() -> None:
|
||
parser = argparse.ArgumentParser(description="Unit Economics Analyzer")
|
||
parser.add_argument("--csv", action="store_true", help="Export results as CSV to stdout")
|
||
args = parser.parse_args()
|
||
|
||
cohorts = make_sample_cohorts()
|
||
channels = make_sample_channels()
|
||
|
||
print("\n" + "="*80)
|
||
print(" UNIT ECONOMICS ANALYZER")
|
||
print(" Sample Company: Series A SaaS | Q4 2024 Snapshot")
|
||
print(" Gross Margin: ~72% | Monthly Churn: derived from cohort data")
|
||
print("="*80)
|
||
|
||
cohort_results = [analyze_cohort(c) for c in cohorts]
|
||
channel_results = [analyze_channel(c) for c in channels]
|
||
|
||
print_cohort_analysis(cohort_results)
|
||
print_channel_analysis(channel_results, channels)
|
||
|
||
# Health summary
|
||
print("\n" + "="*80)
|
||
print(" HEALTH SUMMARY")
|
||
print("="*80)
|
||
latest = cohort_results[-1]
|
||
prev = cohort_results[-4] if len(cohort_results) >= 4 else cohort_results[0]
|
||
|
||
print(f"\n Latest Cohort ({latest.label}):")
|
||
print(f" CAC: {fmt(latest.cac)}")
|
||
ltv_str = fmt(latest.ltv) if latest.ltv != float("inf") else "∞"
|
||
ltv_cac_str = f"{latest.ltv_cac_ratio:.1f}x" if latest.ltv_cac_ratio != float("inf") else "∞"
|
||
payback_str = f"{latest.payback_months:.1f} months" if latest.payback_months != float("inf") else "∞"
|
||
print(f" LTV: {ltv_str}")
|
||
print(f" LTV:CAC: {ltv_cac_str} (target: > 3x)")
|
||
print(f" CAC Payback: {payback_str} (target: < 18mo)")
|
||
print(f" Rating: {rating(latest.ltv_cac_ratio, latest.payback_months)}")
|
||
|
||
# Trend vs 4 quarters ago
|
||
print(f"\n Trend vs {prev.label}:")
|
||
cac_delta = (latest.cac - prev.cac) / prev.cac * 100
|
||
ltv_delta_str = "n/a"
|
||
if latest.ltv != float("inf") and prev.ltv != float("inf"):
|
||
ltv_delta = (latest.ltv - prev.ltv) / prev.ltv * 100
|
||
ltv_delta_str = f"{ltv_delta:+.1f}%"
|
||
cac_str = "↓ Better" if cac_delta < 0 else "↑ Worse"
|
||
print(f" CAC: {cac_delta:+.1f}% ({cac_str})")
|
||
print(f" LTV: {ltv_delta_str}")
|
||
|
||
print("\n Benchmark Reference:")
|
||
print(" LTV:CAC > 5x → Scale aggressively")
|
||
print(" LTV:CAC 3-5x → Healthy; grow at current pace")
|
||
print(" LTV:CAC 2-3x → Marginal; optimize before scaling")
|
||
print(" LTV:CAC < 2x → Acquiring unprofitably; stop and fix")
|
||
print(" Payback < 12mo → Outstanding capital efficiency")
|
||
print(" Payback 12-18mo → Good for B2B SaaS")
|
||
print(" Payback > 24mo → Requires long-dated capital to scale")
|
||
|
||
if args.csv:
|
||
print("\n\n--- CSV EXPORT ---\n")
|
||
sys.stdout.write(export_csv_results(cohort_results, channel_results))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|