Files
claude-skills-reference/c-level-advisor/cro-advisor/scripts/revenue_forecast_model.py
Alireza Rezvani 466aa13a7b feat: C-Suite expansion — 8 new executive advisory roles (2→10) (#264)
* 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>
2026-03-06 01:35:08 +01:00

572 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Revenue Forecast Model
======================
Pipeline-based revenue forecasting for B2B SaaS.
Models:
- Weighted pipeline (stage probability × deal value)
- Historical win rate adjustment (calibrate to actuals)
- Scenario analysis (conservative / base / upside)
- Monthly and quarterly projection with confidence ranges
Usage:
python revenue_forecast_model.py
python revenue_forecast_model.py --csv pipeline.csv
python revenue_forecast_model.py --scenario conservative
Input format (CSV):
deal_id, name, stage, arr_value, close_date, rep, segment
Stdlib only. No dependencies.
"""
import csv
import sys
import json
import argparse
import statistics
from datetime import date, datetime, timedelta
from collections import defaultdict
from io import StringIO
# ---------------------------------------------------------------------------
# Stage configuration
# ---------------------------------------------------------------------------
DEFAULT_STAGE_PROBABILITIES = {
"discovery": 0.10,
"qualification": 0.25,
"demo": 0.40,
"proposal": 0.55,
"poc": 0.65,
"negotiation": 0.80,
"verbal_commit": 0.92,
"closed_won": 1.00,
"closed_lost": 0.00,
}
SCENARIO_MULTIPLIERS = {
"conservative": 0.85, # Win rate 15% below historical
"base": 1.00, # Historical win rate
"upside": 1.15, # Win rate 15% above historical
}
# ---------------------------------------------------------------------------
# Data model
# ---------------------------------------------------------------------------
class Deal:
def __init__(self, deal_id, name, stage, arr_value, close_date, rep="", segment=""):
self.deal_id = deal_id
self.name = name
self.stage = stage.lower().replace(" ", "_").replace("/", "_")
self.arr_value = float(arr_value)
self.close_date = self._parse_date(close_date)
self.rep = rep
self.segment = segment
@staticmethod
def _parse_date(value):
for fmt in ("%Y-%m-%d", "%m/%d/%Y", "%d/%m/%Y", "%Y/%m/%d"):
try:
return datetime.strptime(str(value), fmt).date()
except ValueError:
continue
raise ValueError(f"Cannot parse date: {value!r}")
@property
def quarter(self):
q = (self.close_date.month - 1) // 3 + 1
return f"Q{q} {self.close_date.year}"
@property
def month_key(self):
return self.close_date.strftime("%Y-%m")
def weighted_value(self, stage_probs, scenario="base"):
prob = stage_probs.get(self.stage, 0.0)
multiplier = SCENARIO_MULTIPLIERS.get(scenario, 1.0)
# Clamp probability to [0, 1]
adjusted = min(1.0, max(0.0, prob * multiplier))
return self.arr_value * adjusted
def is_open(self):
return self.stage not in ("closed_won", "closed_lost")
def is_closed_won(self):
return self.stage == "closed_won"
# ---------------------------------------------------------------------------
# Win rate calibration
# ---------------------------------------------------------------------------
def calculate_historical_win_rates(deals):
"""
Calculate actual win rates per stage from closed deals.
Returns a dict: stage → win_rate (float).
Requires deals that were at each stage and are now closed won/lost.
"""
# In a real implementation, you'd have historical stage-at-point-in-time data.
# Here we approximate: among closed deals, what fraction were won?
closed = [d for d in deals if not d.is_open()]
if not closed:
return {}
won = [d for d in closed if d.is_closed_won()]
overall_rate = len(won) / len(closed) if closed else 0.0
# Stage-level calibration: adjust default probs by actual overall rate
# (In production: use CRM historical stage-level conversion data)
calibrated = {}
for stage, default_prob in DEFAULT_STAGE_PROBABILITIES.items():
if overall_rate > 0:
calibrated[stage] = min(1.0, default_prob * (overall_rate / 0.25))
else:
calibrated[stage] = default_prob
return calibrated
# ---------------------------------------------------------------------------
# Forecast engine
# ---------------------------------------------------------------------------
class ForecastEngine:
def __init__(self, deals, stage_probs=None):
self.deals = deals
self.stage_probs = stage_probs or DEFAULT_STAGE_PROBABILITIES
def open_deals(self):
return [d for d in self.deals if d.is_open()]
def closed_won_deals(self):
return [d for d in self.deals if d.is_closed_won()]
def pipeline_by_month(self, scenario="base"):
"""Returns dict: month_key → weighted ARR."""
result = defaultdict(float)
for deal in self.open_deals():
result[deal.month_key] += deal.weighted_value(self.stage_probs, scenario)
return dict(sorted(result.items()))
def pipeline_by_quarter(self, scenario="base"):
"""Returns dict: quarter → weighted ARR."""
result = defaultdict(float)
for deal in self.open_deals():
result[deal.quarter] += deal.weighted_value(self.stage_probs, scenario)
return dict(sorted(result.items()))
def coverage_ratio(self, quota, period_filter=None):
"""
Pipeline coverage = total pipeline ÷ quota.
period_filter: if set, only include deals with close_date in that period.
"""
pipeline = sum(
d.arr_value for d in self.open_deals()
if period_filter is None or d.quarter == period_filter
)
return pipeline / quota if quota else 0.0
def scenario_summary(self, periods=None):
"""
Returns dict: period → {conservative, base, upside, open_pipeline}.
periods: list of month_keys to include; if None, all months.
"""
summaries = {}
all_months = sorted(set(d.month_key for d in self.open_deals()))
target_months = periods or all_months
for month in target_months:
deals_in_month = [d for d in self.open_deals() if d.month_key == month]
if not deals_in_month:
continue
summaries[month] = {
"deal_count": len(deals_in_month),
"open_pipeline": sum(d.arr_value for d in deals_in_month),
"conservative": sum(d.weighted_value(self.stage_probs, "conservative") for d in deals_in_month),
"base": sum(d.weighted_value(self.stage_probs, "base") for d in deals_in_month),
"upside": sum(d.weighted_value(self.stage_probs, "upside") for d in deals_in_month),
}
return summaries
def rep_performance(self):
"""Returns dict: rep → {pipeline, weighted_base, deal_count, avg_deal_size}."""
rep_data = defaultdict(lambda: {"pipeline": 0.0, "weighted_base": 0.0,
"deal_count": 0, "deals": []})
for deal in self.open_deals():
rep_data[deal.rep]["pipeline"] += deal.arr_value
rep_data[deal.rep]["weighted_base"] += deal.weighted_value(self.stage_probs, "base")
rep_data[deal.rep]["deal_count"] += 1
rep_data[deal.rep]["deals"].append(deal.arr_value)
result = {}
for rep, data in rep_data.items():
deals = data["deals"]
result[rep] = {
"pipeline": data["pipeline"],
"weighted_base": data["weighted_base"],
"deal_count": data["deal_count"],
"avg_deal_size": statistics.mean(deals) if deals else 0.0,
}
return result
def segment_breakdown(self, scenario="base"):
"""Returns dict: segment → weighted ARR."""
result = defaultdict(float)
for deal in self.open_deals():
result[deal.segment or "unspecified"] += deal.weighted_value(self.stage_probs, scenario)
return dict(result)
def stage_distribution(self):
"""Returns dict: stage → {count, total_arr, avg_arr}."""
result = defaultdict(lambda: {"count": 0, "total_arr": 0.0})
for deal in self.open_deals():
result[deal.stage]["count"] += 1
result[deal.stage]["total_arr"] += deal.arr_value
out = {}
for stage, data in result.items():
out[stage] = {
"count": data["count"],
"total_arr": data["total_arr"],
"avg_arr": data["total_arr"] / data["count"] if data["count"] else 0,
"probability": self.stage_probs.get(stage, 0.0),
}
return out
def confidence_interval(self, scenario="base", iterations=1000):
"""
Monte Carlo simulation to generate confidence interval around base forecast.
Each deal wins/loses based on its probability; runs iterations times.
Returns (p10, p50, p90) of total expected ARR.
"""
import random
random.seed(42)
totals = []
for _ in range(iterations):
total = 0.0
for deal in self.open_deals():
prob = min(1.0, self.stage_probs.get(deal.stage, 0.0) * SCENARIO_MULTIPLIERS[scenario])
if random.random() < prob:
total += deal.arr_value
totals.append(total)
totals.sort()
n = len(totals)
return (
totals[int(n * 0.10)], # P10 (conservative)
totals[int(n * 0.50)], # P50 (median)
totals[int(n * 0.90)], # P90 (upside)
)
# ---------------------------------------------------------------------------
# Reporting
# ---------------------------------------------------------------------------
def fmt_currency(value):
if value >= 1_000_000:
return f"${value / 1_000_000:.2f}M"
if value >= 1_000:
return f"${value / 1_000:.1f}K"
return f"${value:.0f}"
def fmt_pct(value):
return f"{value * 100:.1f}%"
def print_header(title):
width = 70
print()
print("=" * width)
print(f" {title}")
print("=" * width)
def print_section(title):
print(f"\n--- {title} ---")
def print_report(engine, quota=None, current_quarter=None):
open_deals = engine.open_deals()
won_deals = engine.closed_won_deals()
print_header("REVENUE FORECAST MODEL")
print(f" Generated: {date.today().isoformat()}")
print(f" Open deals: {len(open_deals)}")
print(f" Closed Won (in dataset): {len(won_deals)}")
total_pipeline = sum(d.arr_value for d in open_deals)
total_won = sum(d.arr_value for d in won_deals)
print(f" Total open pipeline: {fmt_currency(total_pipeline)}")
print(f" Total closed won: {fmt_currency(total_won)}")
# ── Coverage ratio
if quota:
print_section("PIPELINE COVERAGE")
q = current_quarter or "this quarter"
ratio = engine.coverage_ratio(quota, period_filter=current_quarter)
status = "✅ Healthy" if ratio >= 3.0 else ("⚠️ Thin" if ratio >= 2.0 else "🔴 Critical")
print(f" Quota target: {fmt_currency(quota)}")
print(f" Coverage ratio: {ratio:.1f}x {status}")
print(f" (Minimum healthy = 3x; < 2x = pipeline emergency)")
# ── Stage distribution
print_section("STAGE DISTRIBUTION")
stage_dist = engine.stage_distribution()
col_w = [28, 8, 14, 12, 10]
header = f" {'Stage':<{col_w[0]}} {'Deals':>{col_w[1]}} {'Pipeline':>{col_w[2]}} {'Avg Size':>{col_w[3]}} {'Win Prob':>{col_w[4]}}"
print(header)
print(" " + "-" * (sum(col_w) + 4))
for stage, data in sorted(stage_dist.items(), key=lambda x: -x[1]["total_arr"]):
print(f" {stage:<{col_w[0]}} {data['count']:>{col_w[1]}} "
f"{fmt_currency(data['total_arr']):>{col_w[2]}} "
f"{fmt_currency(data['avg_arr']):>{col_w[3]}} "
f"{fmt_pct(data['probability']):>{col_w[4]}}")
# ── Scenario forecast by month
print_section("MONTHLY FORECAST — ALL SCENARIOS")
summaries = engine.scenario_summary()
col_w2 = [10, 8, 14, 14, 14, 14]
h2 = (f" {'Month':<{col_w2[0]}} {'Deals':>{col_w2[1]}} "
f"{'Pipeline':>{col_w2[2]}} {'Conservative':>{col_w2[3]}} "
f"{'Base':>{col_w2[4]}} {'Upside':>{col_w2[5]}}")
print(h2)
print(" " + "-" * (sum(col_w2) + 5))
for month, data in summaries.items():
print(f" {month:<{col_w2[0]}} {data['deal_count']:>{col_w2[1]}} "
f"{fmt_currency(data['open_pipeline']):>{col_w2[2]}} "
f"{fmt_currency(data['conservative']):>{col_w2[3]}} "
f"{fmt_currency(data['base']):>{col_w2[4]}} "
f"{fmt_currency(data['upside']):>{col_w2[5]}}")
# ── Quarterly rollup
print_section("QUARTERLY FORECAST ROLLUP")
q_conservative = defaultdict(float)
q_base = defaultdict(float)
q_upside = defaultdict(float)
q_pipeline = defaultdict(float)
q_count = defaultdict(int)
for deal in open_deals:
q_conservative[deal.quarter] += deal.weighted_value(engine.stage_probs, "conservative")
q_base[deal.quarter] += deal.weighted_value(engine.stage_probs, "base")
q_upside[deal.quarter] += deal.weighted_value(engine.stage_probs, "upside")
q_pipeline[deal.quarter] += deal.arr_value
q_count[deal.quarter] += 1
quarters = sorted(q_base.keys())
col_w3 = [10, 8, 14, 14, 14, 14]
h3 = (f" {'Quarter':<{col_w3[0]}} {'Deals':>{col_w3[1]}} "
f"{'Pipeline':>{col_w3[2]}} {'Conservative':>{col_w3[3]}} "
f"{'Base':>{col_w3[4]}} {'Upside':>{col_w3[5]}}")
print(h3)
print(" " + "-" * (sum(col_w3) + 5))
for q in quarters:
print(f" {q:<{col_w3[0]}} {q_count[q]:>{col_w3[1]}} "
f"{fmt_currency(q_pipeline[q]):>{col_w3[2]}} "
f"{fmt_currency(q_conservative[q]):>{col_w3[3]}} "
f"{fmt_currency(q_base[q]):>{col_w3[4]}} "
f"{fmt_currency(q_upside[q]):>{col_w3[5]}}")
# ── Monte Carlo confidence interval
print_section("CONFIDENCE INTERVAL (Monte Carlo, 1,000 simulations)")
p10, p50, p90 = engine.confidence_interval("base")
print(f" P10 (conservative floor): {fmt_currency(p10)}")
print(f" P50 (median expected): {fmt_currency(p50)}")
print(f" P90 (upside ceiling): {fmt_currency(p90)}")
print(f" Range spread: {fmt_currency(p90 - p10)}")
# ── Rep performance
print_section("REP PIPELINE PERFORMANCE")
rep_perf = engine.rep_performance()
if rep_perf:
col_w4 = [20, 8, 14, 14, 12]
h4 = (f" {'Rep':<{col_w4[0]}} {'Deals':>{col_w4[1]}} "
f"{'Pipeline':>{col_w4[2]}} {'Weighted':>{col_w4[3]}} {'Avg Size':>{col_w4[4]}}")
print(h4)
print(" " + "-" * (sum(col_w4) + 4))
for rep, data in sorted(rep_perf.items(), key=lambda x: -x[1]["pipeline"]):
print(f" {rep:<{col_w4[0]}} {data['deal_count']:>{col_w4[1]}} "
f"{fmt_currency(data['pipeline']):>{col_w4[2]}} "
f"{fmt_currency(data['weighted_base']):>{col_w4[3]}} "
f"{fmt_currency(data['avg_deal_size']):>{col_w4[4]}}")
# ── Segment breakdown
print_section("SEGMENT BREAKDOWN (Base Forecast)")
seg = engine.segment_breakdown("base")
for segment, value in sorted(seg.items(), key=lambda x: -x[1]):
bar_len = int((value / total_pipeline) * 30) if total_pipeline else 0
bar = "" * bar_len
print(f" {segment:<20} {fmt_currency(value):>12} {bar}")
# ── Red flags
print_section("FORECAST HEALTH FLAGS")
flags = []
if total_pipeline > 0:
coverage = total_pipeline / quota if quota else None
if coverage and coverage < 2.0:
flags.append("🔴 Pipeline coverage below 2x — serious shortfall risk this quarter")
elif coverage and coverage < 3.0:
flags.append("⚠️ Pipeline coverage below 3x — limited buffer for slippage")
# Stage concentration risk
early_stage_pct = sum(
d.arr_value for d in open_deals
if engine.stage_probs.get(d.stage, 0) < 0.30
) / total_pipeline
if early_stage_pct > 0.60:
flags.append(f"⚠️ {fmt_pct(early_stage_pct)} of pipeline in early stages (< 30% probability)")
# Deal concentration
deal_values = sorted([d.arr_value for d in open_deals], reverse=True)
if deal_values and deal_values[0] / total_pipeline > 0.25:
flags.append(f"⚠️ Top deal is {fmt_pct(deal_values[0]/total_pipeline)} of pipeline — concentration risk")
# Spread between scenarios
total_conservative = sum(d.weighted_value(engine.stage_probs, "conservative") for d in open_deals)
total_upside = sum(d.weighted_value(engine.stage_probs, "upside") for d in open_deals)
spread = (total_upside - total_conservative) / total_conservative if total_conservative else 0
if spread > 0.40:
flags.append(f"⚠️ High scenario spread ({fmt_pct(spread)}) — forecast confidence is low")
if flags:
for f in flags:
print(f" {f}")
else:
print(" ✅ No critical flags detected")
print()
# ---------------------------------------------------------------------------
# Sample data
# ---------------------------------------------------------------------------
SAMPLE_CSV = """deal_id,name,stage,arr_value,close_date,rep,segment
D001,Acme Corp ERP Integration,negotiation,85000,2026-03-15,Sarah Chen,Enterprise
D002,TechStart PLG Expansion,proposal,28000,2026-03-28,Marcus Webb,Mid-Market
D003,Global Retail Co,verbal_commit,220000,2026-03-10,Sarah Chen,Enterprise
D004,BioLab Analytics,poc,62000,2026-04-05,Jamie Park,Mid-Market
D005,FinServ Holdings,demo,150000,2026-04-20,Sarah Chen,Enterprise
D006,MidWest Logistics,qualification,35000,2026-04-30,Marcus Webb,Mid-Market
D007,Edu Platform Inc,negotiation,42000,2026-03-25,Jamie Park,SMB
D008,Healthcare Connect,proposal,95000,2026-05-15,Sarah Chen,Enterprise
D009,Startup Hub Network,demo,18000,2026-04-10,Marcus Webb,SMB
D010,CloudOps Systems,poc,75000,2026-05-01,Jamie Park,Mid-Market
D011,National Bank Corp,verbal_commit,310000,2026-03-31,Sarah Chen,Enterprise
D012,RetailTech Co,qualification,22000,2026-05-20,Marcus Webb,SMB
D013,InsurTech Platform,negotiation,88000,2026-04-15,Jamie Park,Mid-Market
D014,GovTech Solutions,proposal,175000,2026-06-01,Sarah Chen,Enterprise
D015,AgriData Systems,demo,31000,2026-05-10,Marcus Webb,Mid-Market
D016,Legal AI Corp,poc,55000,2026-04-25,Jamie Park,Mid-Market
D017,Closed Won Deal,closed_won,120000,2026-02-15,Sarah Chen,Enterprise
D018,Lost Deal,closed_lost,45000,2026-02-20,Marcus Webb,Mid-Market
"""
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def load_deals_from_csv(csv_text):
reader = csv.DictReader(StringIO(csv_text))
deals = []
errors = []
for i, row in enumerate(reader, start=2):
try:
deal = Deal(
deal_id=row.get("deal_id", f"row_{i}"),
name=row.get("name", ""),
stage=row.get("stage", ""),
arr_value=row.get("arr_value", 0),
close_date=row.get("close_date", ""),
rep=row.get("rep", ""),
segment=row.get("segment", ""),
)
deals.append(deal)
except (ValueError, KeyError) as e:
errors.append(f" Row {i}: {e}")
if errors:
print("⚠️ Skipped rows with errors:")
for err in errors:
print(err)
return deals
def main():
parser = argparse.ArgumentParser(
description="Revenue Forecast Model — pipeline-based ARR forecasting"
)
parser.add_argument(
"--csv", metavar="FILE",
help="CSV file with pipeline data (uses sample data if not provided)"
)
parser.add_argument(
"--quota", type=float, default=1_000_000,
help="Quarterly quota target in ARR (default: $1,000,000)"
)
parser.add_argument(
"--quarter", metavar="QUARTER",
help='Current quarter filter e.g. "Q2 2026" (optional)'
)
parser.add_argument(
"--scenario", choices=["conservative", "base", "upside"],
default="base",
help="Primary scenario to report (default: base)"
)
parser.add_argument(
"--json", action="store_true",
help="Output forecast as JSON instead of formatted report"
)
args = parser.parse_args()
# Load data
if args.csv:
try:
with open(args.csv, "r", encoding="utf-8") as f:
csv_text = f.read()
except FileNotFoundError:
print(f"Error: File not found: {args.csv}", file=sys.stderr)
sys.exit(1)
else:
print("No --csv provided. Using sample pipeline data.\n")
csv_text = SAMPLE_CSV
deals = load_deals_from_csv(csv_text)
if not deals:
print("No deals loaded. Exiting.", file=sys.stderr)
sys.exit(1)
# Calibrate win rates from closed deals
historical_probs = calculate_historical_win_rates(deals)
stage_probs = historical_probs if historical_probs else DEFAULT_STAGE_PROBABILITIES
engine = ForecastEngine(deals, stage_probs=stage_probs)
if args.json:
output = {
"generated": date.today().isoformat(),
"quota": args.quota,
"open_pipeline": sum(d.arr_value for d in engine.open_deals()),
"coverage_ratio": engine.coverage_ratio(args.quota, args.quarter),
"monthly_forecast": engine.scenario_summary(),
"quarterly_base": engine.pipeline_by_quarter("base"),
"confidence_interval": dict(zip(
["p10", "p50", "p90"],
engine.confidence_interval("base")
)),
"rep_performance": engine.rep_performance(),
"segment_breakdown": engine.segment_breakdown("base"),
}
print(json.dumps(output, indent=2))
else:
print_report(engine, quota=args.quota, current_quarter=args.quarter)
if __name__ == "__main__":
main()