Build campaign-analytics, financial-analyst, customer-success-manager, sales-engineer, and revenue-operations skills using the Claude Skills Factory workflow. Each skill includes SKILL.md, Python CLI tools, reference guides, and asset templates. All 16 Python scripts use standard library only with --format json/text support. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
495 lines
19 KiB
Python
495 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Forecast Builder
|
|
|
|
Driver-based revenue forecasting with 13-week rolling cash flow projection,
|
|
scenario modeling (base/bull/bear), and trend analysis using simple linear
|
|
regression (standard library only).
|
|
|
|
Usage:
|
|
python forecast_builder.py forecast_data.json
|
|
python forecast_builder.py forecast_data.json --format json
|
|
python forecast_builder.py forecast_data.json --scenarios base,bull,bear
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import math
|
|
import sys
|
|
from statistics import mean
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
|
|
def safe_divide(numerator: float, denominator: float, default: float = 0.0) -> float:
|
|
"""Safely divide two numbers, returning default if denominator is zero."""
|
|
if denominator == 0 or denominator is None:
|
|
return default
|
|
return numerator / denominator
|
|
|
|
|
|
def simple_linear_regression(
|
|
x_values: List[float], y_values: List[float]
|
|
) -> Tuple[float, float, float]:
|
|
"""
|
|
Simple linear regression using standard library.
|
|
|
|
Returns (slope, intercept, r_squared).
|
|
"""
|
|
n = len(x_values)
|
|
if n < 2 or n != len(y_values):
|
|
return (0.0, 0.0, 0.0)
|
|
|
|
x_mean = mean(x_values)
|
|
y_mean = mean(y_values)
|
|
|
|
ss_xy = sum((x - x_mean) * (y - y_mean) for x, y in zip(x_values, y_values))
|
|
ss_xx = sum((x - x_mean) ** 2 for x in x_values)
|
|
ss_yy = sum((y - y_mean) ** 2 for y in y_values)
|
|
|
|
slope = safe_divide(ss_xy, ss_xx)
|
|
intercept = y_mean - slope * x_mean
|
|
|
|
# R-squared
|
|
r_squared = safe_divide(ss_xy ** 2, ss_xx * ss_yy) if ss_yy > 0 else 0.0
|
|
|
|
return (slope, intercept, r_squared)
|
|
|
|
|
|
class ForecastBuilder:
|
|
"""Driver-based revenue forecasting with scenario modeling."""
|
|
|
|
def __init__(self, data: Dict[str, Any]) -> None:
|
|
"""Initialize the forecast builder."""
|
|
self.historical: List[Dict[str, Any]] = data.get("historical_periods", [])
|
|
self.drivers: Dict[str, Any] = data.get("drivers", {})
|
|
self.assumptions: Dict[str, Any] = data.get("assumptions", {})
|
|
self.cash_flow_inputs: Dict[str, Any] = data.get("cash_flow_inputs", {})
|
|
self.scenarios_config: Dict[str, Any] = data.get("scenarios", {})
|
|
self.forecast_periods: int = data.get("forecast_periods", 12)
|
|
|
|
def analyze_trends(self) -> Dict[str, Any]:
|
|
"""Analyze historical trends using linear regression."""
|
|
if not self.historical:
|
|
return {"error": "No historical data available"}
|
|
|
|
# Extract revenue series
|
|
revenues = [p.get("revenue", 0) for p in self.historical]
|
|
periods = list(range(1, len(revenues) + 1))
|
|
|
|
slope, intercept, r_squared = simple_linear_regression(
|
|
[float(x) for x in periods],
|
|
[float(y) for y in revenues],
|
|
)
|
|
|
|
# Calculate growth rates
|
|
growth_rates = []
|
|
for i in range(1, len(revenues)):
|
|
if revenues[i - 1] > 0:
|
|
growth = (revenues[i] - revenues[i - 1]) / revenues[i - 1]
|
|
growth_rates.append(growth)
|
|
|
|
avg_growth = mean(growth_rates) if growth_rates else 0.0
|
|
|
|
# Seasonality detection (if enough data)
|
|
seasonality_index: List[float] = []
|
|
if len(revenues) >= 4:
|
|
overall_avg = mean(revenues)
|
|
if overall_avg > 0:
|
|
seasonality_index = [r / overall_avg for r in revenues[-4:]]
|
|
|
|
return {
|
|
"trend": {
|
|
"slope": round(slope, 2),
|
|
"intercept": round(intercept, 2),
|
|
"r_squared": round(r_squared, 4),
|
|
"direction": "upward" if slope > 0 else "downward" if slope < 0 else "flat",
|
|
},
|
|
"growth_rates": [round(g, 4) for g in growth_rates],
|
|
"average_growth_rate": round(avg_growth, 4),
|
|
"seasonality_index": [round(s, 4) for s in seasonality_index],
|
|
"historical_revenues": revenues,
|
|
}
|
|
|
|
def build_driver_based_forecast(
|
|
self, scenario: str = "base"
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Build a driver-based revenue forecast.
|
|
|
|
Drivers may include: units, price, customers, ARPU, conversion rate, etc.
|
|
"""
|
|
scenario_adjustments = self.scenarios_config.get(scenario, {})
|
|
growth_adjustment = scenario_adjustments.get("growth_adjustment", 0.0)
|
|
margin_adjustment = scenario_adjustments.get("margin_adjustment", 0.0)
|
|
|
|
base_revenue = 0.0
|
|
if self.historical:
|
|
base_revenue = self.historical[-1].get("revenue", 0)
|
|
|
|
# Driver-based calculation
|
|
unit_drivers = self.drivers.get("units", {})
|
|
price_drivers = self.drivers.get("pricing", {})
|
|
customer_drivers = self.drivers.get("customers", {})
|
|
|
|
base_growth = self.assumptions.get("revenue_growth_rate", 0.05)
|
|
adjusted_growth = base_growth + growth_adjustment
|
|
|
|
base_margin = self.assumptions.get("gross_margin", 0.40)
|
|
adjusted_margin = base_margin + margin_adjustment
|
|
|
|
cogs_pct = 1.0 - adjusted_margin
|
|
opex_pct = self.assumptions.get("opex_pct_revenue", 0.25)
|
|
|
|
forecast_periods: List[Dict[str, Any]] = []
|
|
current_revenue = base_revenue
|
|
|
|
# If we have unit and price drivers, use them
|
|
has_unit_drivers = bool(unit_drivers) and bool(price_drivers)
|
|
|
|
if has_unit_drivers:
|
|
base_units = unit_drivers.get("base_units", 1000)
|
|
unit_growth = unit_drivers.get("growth_rate", 0.03) + growth_adjustment
|
|
base_price = price_drivers.get("base_price", 100)
|
|
price_growth = price_drivers.get("annual_increase", 0.02)
|
|
|
|
current_units = base_units
|
|
current_price = base_price
|
|
|
|
for period in range(1, self.forecast_periods + 1):
|
|
current_units = current_units * (1 + unit_growth / 12)
|
|
if period % 12 == 0:
|
|
current_price = current_price * (1 + price_growth)
|
|
|
|
period_revenue = current_units * current_price
|
|
cogs = period_revenue * cogs_pct
|
|
gross_profit = period_revenue - cogs
|
|
opex = period_revenue * opex_pct
|
|
operating_income = gross_profit - opex
|
|
|
|
forecast_periods.append({
|
|
"period": period,
|
|
"revenue": round(period_revenue, 2),
|
|
"units": round(current_units, 0),
|
|
"price": round(current_price, 2),
|
|
"cogs": round(cogs, 2),
|
|
"gross_profit": round(gross_profit, 2),
|
|
"gross_margin": round(adjusted_margin, 4),
|
|
"opex": round(opex, 2),
|
|
"operating_income": round(operating_income, 2),
|
|
})
|
|
else:
|
|
# Simple growth-based forecast
|
|
monthly_growth = (1 + adjusted_growth) ** (1 / 12) - 1
|
|
|
|
for period in range(1, self.forecast_periods + 1):
|
|
current_revenue = current_revenue * (1 + monthly_growth)
|
|
cogs = current_revenue * cogs_pct
|
|
gross_profit = current_revenue - cogs
|
|
opex = current_revenue * opex_pct
|
|
operating_income = gross_profit - opex
|
|
|
|
forecast_periods.append({
|
|
"period": period,
|
|
"revenue": round(current_revenue, 2),
|
|
"cogs": round(cogs, 2),
|
|
"gross_profit": round(gross_profit, 2),
|
|
"gross_margin": round(adjusted_margin, 4),
|
|
"opex": round(opex, 2),
|
|
"operating_income": round(operating_income, 2),
|
|
})
|
|
|
|
total_revenue = sum(p["revenue"] for p in forecast_periods)
|
|
total_operating_income = sum(p["operating_income"] for p in forecast_periods)
|
|
|
|
return {
|
|
"scenario": scenario,
|
|
"growth_rate": round(adjusted_growth, 4),
|
|
"gross_margin": round(adjusted_margin, 4),
|
|
"forecast_periods": forecast_periods,
|
|
"total_revenue": round(total_revenue, 2),
|
|
"total_operating_income": round(total_operating_income, 2),
|
|
"average_monthly_revenue": round(
|
|
safe_divide(total_revenue, len(forecast_periods)), 2
|
|
),
|
|
}
|
|
|
|
def build_rolling_cash_flow(self, weeks: int = 13) -> Dict[str, Any]:
|
|
"""Build a 13-week rolling cash flow projection."""
|
|
cfi = self.cash_flow_inputs
|
|
|
|
opening_balance = cfi.get("opening_cash_balance", 0)
|
|
weekly_revenue = cfi.get("weekly_revenue", 0)
|
|
collection_rate = cfi.get("collection_rate", 0.85)
|
|
collection_lag_weeks = cfi.get("collection_lag_weeks", 2)
|
|
|
|
# Weekly expenses
|
|
weekly_payroll = cfi.get("weekly_payroll", 0)
|
|
weekly_rent = cfi.get("weekly_rent", 0)
|
|
weekly_operating = cfi.get("weekly_operating", 0)
|
|
weekly_other = cfi.get("weekly_other", 0)
|
|
total_weekly_expenses = weekly_payroll + weekly_rent + weekly_operating + weekly_other
|
|
|
|
# One-time items
|
|
one_time_items: List[Dict[str, Any]] = cfi.get("one_time_items", [])
|
|
|
|
weekly_projections: List[Dict[str, Any]] = []
|
|
running_balance = opening_balance
|
|
|
|
# Revenue pipeline for lagged collections
|
|
revenue_pipeline: List[float] = [0.0] * collection_lag_weeks
|
|
|
|
for week in range(1, weeks + 1):
|
|
# Revenue collections (lagged)
|
|
revenue_pipeline.append(weekly_revenue)
|
|
collections = revenue_pipeline.pop(0) * collection_rate
|
|
|
|
# One-time items for this week
|
|
one_time_inflows = 0.0
|
|
one_time_outflows = 0.0
|
|
one_time_labels: List[str] = []
|
|
for item in one_time_items:
|
|
if item.get("week") == week:
|
|
amount = item.get("amount", 0)
|
|
if amount > 0:
|
|
one_time_inflows += amount
|
|
else:
|
|
one_time_outflows += abs(amount)
|
|
one_time_labels.append(item.get("description", ""))
|
|
|
|
total_inflows = collections + one_time_inflows
|
|
total_outflows = total_weekly_expenses + one_time_outflows
|
|
net_cash_flow = total_inflows - total_outflows
|
|
running_balance += net_cash_flow
|
|
|
|
weekly_projections.append({
|
|
"week": week,
|
|
"collections": round(collections, 2),
|
|
"one_time_inflows": round(one_time_inflows, 2),
|
|
"total_inflows": round(total_inflows, 2),
|
|
"payroll": round(weekly_payroll, 2),
|
|
"rent": round(weekly_rent, 2),
|
|
"operating": round(weekly_operating, 2),
|
|
"other_expenses": round(weekly_other, 2),
|
|
"one_time_outflows": round(one_time_outflows, 2),
|
|
"total_outflows": round(total_outflows, 2),
|
|
"net_cash_flow": round(net_cash_flow, 2),
|
|
"closing_balance": round(running_balance, 2),
|
|
"notes": ", ".join(one_time_labels) if one_time_labels else "",
|
|
})
|
|
|
|
# Summary
|
|
total_inflows = sum(w["total_inflows"] for w in weekly_projections)
|
|
total_outflows = sum(w["total_outflows"] for w in weekly_projections)
|
|
min_balance = min(w["closing_balance"] for w in weekly_projections)
|
|
min_balance_week = next(
|
|
w["week"]
|
|
for w in weekly_projections
|
|
if w["closing_balance"] == min_balance
|
|
)
|
|
|
|
return {
|
|
"weeks": weeks,
|
|
"opening_balance": opening_balance,
|
|
"closing_balance": round(running_balance, 2),
|
|
"total_inflows": round(total_inflows, 2),
|
|
"total_outflows": round(total_outflows, 2),
|
|
"net_change": round(total_inflows - total_outflows, 2),
|
|
"minimum_balance": round(min_balance, 2),
|
|
"minimum_balance_week": min_balance_week,
|
|
"cash_runway_weeks": (
|
|
round(safe_divide(running_balance, total_weekly_expenses))
|
|
if total_weekly_expenses > 0
|
|
else None
|
|
),
|
|
"weekly_projections": weekly_projections,
|
|
}
|
|
|
|
def build_scenario_comparison(
|
|
self, scenarios: Optional[List[str]] = None
|
|
) -> Dict[str, Any]:
|
|
"""Build and compare multiple scenarios."""
|
|
if scenarios is None:
|
|
scenarios = ["base", "bull", "bear"]
|
|
|
|
scenario_results: Dict[str, Any] = {}
|
|
|
|
for scenario in scenarios:
|
|
scenario_results[scenario] = self.build_driver_based_forecast(scenario)
|
|
|
|
# Comparison summary
|
|
comparison: List[Dict[str, Any]] = []
|
|
for scenario in scenarios:
|
|
result = scenario_results[scenario]
|
|
comparison.append({
|
|
"scenario": scenario,
|
|
"total_revenue": result["total_revenue"],
|
|
"total_operating_income": result["total_operating_income"],
|
|
"growth_rate": result["growth_rate"],
|
|
"gross_margin": result["gross_margin"],
|
|
"avg_monthly_revenue": result["average_monthly_revenue"],
|
|
})
|
|
|
|
return {
|
|
"scenarios": scenario_results,
|
|
"comparison": comparison,
|
|
}
|
|
|
|
def run_full_forecast(
|
|
self, scenarios: Optional[List[str]] = None
|
|
) -> Dict[str, Any]:
|
|
"""Run the complete forecast analysis."""
|
|
trends = self.analyze_trends()
|
|
scenario_comparison = self.build_scenario_comparison(scenarios)
|
|
cash_flow = self.build_rolling_cash_flow()
|
|
|
|
return {
|
|
"trend_analysis": trends,
|
|
"scenario_comparison": scenario_comparison,
|
|
"rolling_cash_flow": cash_flow,
|
|
}
|
|
|
|
def format_text(self, results: Dict[str, Any]) -> str:
|
|
"""Format forecast results as human-readable text."""
|
|
lines: List[str] = []
|
|
lines.append("=" * 70)
|
|
lines.append("FINANCIAL FORECAST REPORT")
|
|
lines.append("=" * 70)
|
|
|
|
def fmt_money(val: float) -> str:
|
|
if abs(val) >= 1e9:
|
|
return f"${val / 1e9:,.2f}B"
|
|
if abs(val) >= 1e6:
|
|
return f"${val / 1e6:,.2f}M"
|
|
if abs(val) >= 1e3:
|
|
return f"${val / 1e3:,.1f}K"
|
|
return f"${val:,.2f}"
|
|
|
|
# Trend Analysis
|
|
trend = results["trend_analysis"]
|
|
if "error" not in trend:
|
|
lines.append(f"\n--- TREND ANALYSIS ---")
|
|
t = trend["trend"]
|
|
lines.append(f" Direction: {t['direction']}")
|
|
lines.append(f" R-squared: {t['r_squared']:.4f}")
|
|
lines.append(
|
|
f" Average Historical Growth: "
|
|
f"{trend['average_growth_rate'] * 100:.1f}%"
|
|
)
|
|
if trend["seasonality_index"]:
|
|
lines.append(
|
|
f" Seasonality Index (last 4): "
|
|
f"{', '.join(f'{s:.2f}' for s in trend['seasonality_index'])}"
|
|
)
|
|
|
|
# Scenario Comparison
|
|
comp = results["scenario_comparison"]["comparison"]
|
|
lines.append(f"\n--- SCENARIO COMPARISON ---")
|
|
lines.append(
|
|
f" {'Scenario':<10s} {'Revenue':>14s} {'Op. Income':>14s} "
|
|
f"{'Growth':>8s} {'Margin':>8s}"
|
|
)
|
|
lines.append(" " + "-" * 62)
|
|
for c in comp:
|
|
lines.append(
|
|
f" {c['scenario']:<10s} {fmt_money(c['total_revenue']):>14s} "
|
|
f"{fmt_money(c['total_operating_income']):>14s} "
|
|
f"{c['growth_rate'] * 100:>7.1f}% "
|
|
f"{c['gross_margin'] * 100:>7.1f}%"
|
|
)
|
|
|
|
# Base scenario detail
|
|
base = results["scenario_comparison"]["scenarios"].get("base", {})
|
|
if base and base.get("forecast_periods"):
|
|
lines.append(f"\n--- BASE CASE MONTHLY FORECAST ---")
|
|
lines.append(
|
|
f" {'Period':>6s} {'Revenue':>12s} {'Gross Profit':>12s} "
|
|
f"{'Op. Income':>12s}"
|
|
)
|
|
lines.append(" " + "-" * 48)
|
|
for p in base["forecast_periods"]:
|
|
lines.append(
|
|
f" {p['period']:>6d} {fmt_money(p['revenue']):>12s} "
|
|
f"{fmt_money(p['gross_profit']):>12s} "
|
|
f"{fmt_money(p['operating_income']):>12s}"
|
|
)
|
|
|
|
# Cash Flow
|
|
cf = results["rolling_cash_flow"]
|
|
lines.append(f"\n--- 13-WEEK ROLLING CASH FLOW ---")
|
|
lines.append(f" Opening Balance: {fmt_money(cf['opening_balance'])}")
|
|
lines.append(f" Closing Balance: {fmt_money(cf['closing_balance'])}")
|
|
lines.append(f" Net Change: {fmt_money(cf['net_change'])}")
|
|
lines.append(
|
|
f" Minimum Balance: {fmt_money(cf['minimum_balance'])} "
|
|
f"(Week {cf['minimum_balance_week']})"
|
|
)
|
|
if cf.get("cash_runway_weeks"):
|
|
lines.append(f" Cash Runway: {cf['cash_runway_weeks']:.0f} weeks")
|
|
|
|
lines.append(f"\n Weekly Detail:")
|
|
lines.append(
|
|
f" {'Wk':>3s} {'Inflows':>10s} {'Outflows':>10s} "
|
|
f"{'Net':>10s} {'Balance':>12s}"
|
|
)
|
|
lines.append(" " + "-" * 50)
|
|
for w in cf["weekly_projections"]:
|
|
notes = f" {w['notes']}" if w["notes"] else ""
|
|
lines.append(
|
|
f" {w['week']:>3d} {fmt_money(w['total_inflows']):>10s} "
|
|
f"{fmt_money(w['total_outflows']):>10s} "
|
|
f"{fmt_money(w['net_cash_flow']):>10s} "
|
|
f"{fmt_money(w['closing_balance']):>12s}{notes}"
|
|
)
|
|
|
|
lines.append("\n" + "=" * 70)
|
|
return "\n".join(lines)
|
|
|
|
|
|
def main() -> None:
|
|
"""Main entry point."""
|
|
parser = argparse.ArgumentParser(
|
|
description="Driver-based revenue forecasting with scenario modeling"
|
|
)
|
|
parser.add_argument(
|
|
"input_file",
|
|
help="Path to JSON file with forecast data",
|
|
)
|
|
parser.add_argument(
|
|
"--format",
|
|
choices=["text", "json"],
|
|
default="text",
|
|
help="Output format (default: text)",
|
|
)
|
|
parser.add_argument(
|
|
"--scenarios",
|
|
type=str,
|
|
default="base,bull,bear",
|
|
help="Comma-separated list of scenarios (default: base,bull,bear)",
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
try:
|
|
with open(args.input_file, "r") as f:
|
|
data = json.load(f)
|
|
except FileNotFoundError:
|
|
print(f"Error: File '{args.input_file}' not found.", file=sys.stderr)
|
|
sys.exit(1)
|
|
except json.JSONDecodeError as e:
|
|
print(f"Error: Invalid JSON in '{args.input_file}': {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
builder = ForecastBuilder(data)
|
|
scenarios = [s.strip() for s in args.scenarios.split(",")]
|
|
|
|
results = builder.run_full_forecast(scenarios)
|
|
|
|
if args.format == "json":
|
|
print(json.dumps(results, indent=2))
|
|
else:
|
|
print(builder.format_text(results))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|