Skip to main content

revenue-model-builder

CI License: MIT Python 3.9+ Dependencies: zero

中文文档:README-zh.md

A bottom-up revenue forecasting framework — turn a driver tree (market_base × penetration × share × price) into an auditable revenue model that aligns to reported totals via a structural residual line. Core engine has zero third-party dependencies (pure Python stdlib), including the Monte Carlo + sensitivity layer.

The design encodes five hard-won modeling rules (see design principles): a structural residual that absorbs un-modeled business, A/B/C data grading for traceability, incremental (not growth-rate) penetration forecasts, a certainty pyramid for prioritizing forecast inputs, and a history-first workflow.


Why

Most open-source finance tooling covers trading / backtesting (zipline, backtrader, QuantLib) or DCF valuation. Driver-based revenue forecasting — decomposing revenue into base × penetration × share × price, what sell-side analysts and PE associates actually do — has almost no open-source presence.

The closest neighbors are TAM/SAM/SOM prompt skills for AI agents (e.g. slgoodrich/agents, deanpeters/Product-Manager-Skills) — they describe the methodology in natural language, but none is a runnable engine. This project is: a minimal, pip-installable encoding of the workflow with the math enforced in code rather than left to a prompt.

A sell-side revenue model lives or dies on whether you can defend every number — "where did this penetration come from? why isn't it higher?" Manual spreadsheets answer that with cryptic comments. revenue-model-builder makes it structural: every driver carries a credibility grade and a source, the residual is a first-class line, and an alignment check catches the classic "back-solved penetration" trap before it poisons the forecast.

How it compares

revenue-model-builder market-sizing SKILLs DCF valuation libs
Runnable code engine ❌ prompt only
Focus revenue build-up market size (TAM/SAM/SOM) intrinsic value
Aligns to reported total (residual) ✅ structural n/a
A/B/C data grading per number
Uncertainty (Monte Carlo + tornado) sometimes
Core dependency footprint zero n/a usually numpy + data API

Core idea

segment_revenue = market_base × penetration × share × price
total_revenue   = Σ(segments) + residual          # residual absorbs un-modeled biz

Unit derivation: base in million units × price in yuan = million yuan (when penetration & share are fractions in [0,1]). So Segment.revenue() returns million yuan by construction.

Install

pip install -e .                  # core engine only (pure stdlib, zero deps)
pip install -e ".[excel]"         # + openpyxl, to render .xlsx output
pip install -e ".[dev]"           # + pytest, to run the test suite
pip install -e ".[backtest]"      # + statsmodels, for Holt/ARIMA backtesting

Quick start

Build a model and validate it aligns to reported totals:

from revenue_model import Driver, Segment, RevenueModel, BASE, PENETRATION, SHARE, PRICE

seg = Segment(
    name="cockpit-domestic",
    base=Driver("China passenger car sales", BASE, {2022: 22.0, 2023: 23.0},
                level="A", unit="million units", source="CAAM"),
    penetration=Driver("DMS penetration", PENETRATION, {2022: 0.04, 2023: 0.06},
                       level="B", unit="fraction", source="research institute"),
    share=Driver("market share", SHARE, {2022: 0.10, 2023: 0.12},
                 level="C", unit="fraction", source="estimate"),
    price=Driver("ASP", PRICE, {2022: 600, 2023: 620},
                 level="C", unit="yuan", source="benchmark"),
)
model = RevenueModel("DemoCo", [seg], total_revenue={2022: 110.0, 2023: 215.0})

for r in model.validate_all():
    print(r.year, f"segments={r.segment_sum:.1f}", f"residual={r.residual:.1f}",
          f"({r.residual_ratio:.0%})", r.warnings)

Run the fictional demo (NovaTech, an automotive-AI company — all data fabricated):

python -m revenue_model.demo

Render the model to a formatted .xlsx (needs the [excel] extra):

python -m revenue_model.excel_builder output.xlsx

Monte Carlo & sensitivity

Turn point forecasts into distributions and find out which assumption matters most — pure stdlib, no numpy:

from revenue_model import simulate_model, tornado

# Revenue distribution: sample uncertain drivers, multiply, repeat
mc = simulate_model(model, 2024, {
    "market share": (0.10, 0.18),      # C-grade, wide band
    "ASP": (620, 680),
}, n=20000, seed=0)
print(mc.median, mc.percentiles["p5"], mc.percentiles["p95"])  # P5/median/P95

# Tornado: per-driver bands (NOT a uniform %) -> ranked swing
for it in tornado(seg, 2024, {
    "China passenger car sales": (23.5, 24.5),   # A-grade, narrow
    "DMS penetration": (0.07, 0.12),             # B-grade
    "market share": (0.10, 0.18),                # C-grade, wide
    "ASP": (620, 680),
}):
    print(f"{it.driver:28s} swing {it.swing:.1f}")

Why per-driver bands, not a uniform ±%? Revenue is a product (base × pen × share × price), so perturbing every factor by the same percentage yields identical swings — the tornado would have zero discriminating power. A tornado is only meaningful when each band reflects that driver's real uncertainty: narrow for A-grade hard data, wide for C-grade estimates. (This is why A/B/C grading and sensitivity are linked.)

Stochastic processes (experimental)

Upgrade uniform-sampling Monte Carlo to driver-specific stochastic processes — pure stdlib, no numpy. Prices follow geometric Brownian motion; bounded ratios (penetration, share) follow a logit-OU process that stays in (0, 1); drivers can be correlated via Cholesky.

from revenue_model.stochastic import (
    GBMDriver, LogitOUDriver, CorrelatedBundle, simulate_revenue, logit)

price = GBMDriver("ASP", S0=650.0, mu=0.03, sigma=0.10)                # log-normal price
share = LogitOUDriver("market share", p0=0.14, theta=2.0,
                      mu_bar=logit(0.18), sigma=0.10)                  # bounded, mean-reverting
bundle = CorrelatedBundle([price, share], rho=[[1.0, -0.3], [-0.3, 1.0]])

mc = simulate_revenue(segment, 2024, bundle, n=20000, seed=0)         # -> MCResult
print(mc.median, mc.percentiles["p5"], mc.percentiles["p95"])

See design principles: stochastic layer for the SDEs and why logit-OU keeps bounded ratios bounded.

Experimental — the uniform Monte Carlo above remains the default. See tests/test_stochastic.py for analytic-solution validation (GBM mean, OU stationary variance, induced correlation).

Backtesting

How accurate is a revenue forecast, really? The backtest extra answers that with honest out-of-sample evaluation — fit on history, predict the next year, slide the window forward, and never let a method see the value it must predict.

Five methods head-to-head: Naive (random walk — the benchmark to beat), Linear trend, CAGR (log-linear / constant-growth), Holt exponential smoothing, and ARIMA. Pure-stdlib metrics (sMAPE / MAPE / MAE / RMSE / R² / directional accuracy); sMAPE is the headline number because it stays robust across companies of very different sizes. Naive / Linear / CAGR need nothing; Holt / ARIMA lazy-import statsmodels.

from revenue_model.backtest import (
    Naive, LinearTrend, LogLinearCAGR, HoltLinear, ARIMA,
    rolling_backtest, evaluate, score_table,
)

steps = rolling_backtest(
    years, values,
    [Naive(), LinearTrend(), LogLinearCAGR(), HoltLinear(), ARIMA()],
    min_train=8, horizon=1)
print(score_table(evaluate(steps)))

Real A-share data loads through the data extra (akshare, cached as CSV for reproducibility). Ten companies spanning six growth regimes:

method avg sMAPE wins (best / 10)
Holt / ARIMA (adaptive) ~14% 10 / 10
Naive 21% 0
Linear / CAGR (fixed trend) 36% / 31% 0

sMAPE heatmap — company × method

What this teaches about the framework itself. On the revenue total level, adaptive statistical methods dominate fixed trends — high-growth names grow exponentially, so a linear fit systematically under-predicts and even gets the direction wrong. The value of the driver decomposition is therefore not "guess the total more accurately" (statistics does that better) but locating structure: which segment rides a trend and which rides a one-off event (e.g. Luxun's 2025 Leoni acquisition — invisible to any aggregate method). Accuracy and interpretability are complements, not substitutes. See examples/backtest_demo/.

NVIDIA demo — where driver trees work, and where they break

The first U.S.-equity demo. NVIDIA is a deliberately two-faced test: same company, same base × penetration × share × price tree, same engine — Gaming hold-out sMAPE 1.0% (mature trend market) vs Data Center 60% (AI regime shift; FY2025 actual $115.2B vs forecast $18.4B). The demo then closes the loop with a Monte Carlo scenario band whose Bull tail frames the actual where the point forecast collapsed.

NVIDIA Gaming vs Data Center — actual vs driver extrapolation

Accuracy is a property of the industry, not the model. See examples/nvda_demo/ and the flagship methodology doc docs/industry-fit-analysis.md — the industry-fit matrix, five techniques for event-driven growth, and why this library chooses honesty over false precision.

Segment extraction (from annual reports)

Automate the tedious part of segment build-up — pull a segment skeleton (business lines, revenue, share, YoY, margin, a driver-type tag, driver hints) out of an annual report's "main business analysis" text via an LLM. Pure stdlib HTTP (no SDK); the LLM call is injectable, so tests/CI need no API key.

from revenue_model import extract_segments, alignment_check

# text = the "main business analysis" section (extracted upstream via PyMuPDF)
parsed = extract_segments(text, api_key="<your-llm-key>")   # load via secrets manager
print(parsed["segments"])                                   # segment skeletons
print(alignment_check(parsed))                             # Σ + residual ≈ reported total

The output matches the schema in docs/proposal-segment-extraction.md §4. Filling concrete driver values (C-grade estimates) remains a human step — see the proposal's semi-automated boundary (§7). Proprietary / non-public company data must not enter the repo; real-company demos (Luxun, NVIDIA) use only public disclosures (see DISCLAIMER.md). The fictional NovaTech is the zero-real-data default.

Design principles

# Principle What it prevents
1 Residual is structural, never back-solved Inflating penetration to "tie out" poisons the forecast
2 A/B/C data grading Opaque spreadsheets — every number is traceable
3 Incremental, not growth-rate, for penetration Bounded ratios exploding exponentially
4 Forecast certainty pyramid Treating all inputs as equally knowable
5 History first, then forecast Forecasting before the model reproduces history

Plus a validation layer (triangulation, assumption documentation, S-curves): docs/design-principles.md.

API

Driver(name, kind, values, level="C", unit="", source="")
#   kind ∈ {BASE, PENETRATION, SHARE, PRICE};  level ∈ {"A","B","C"}

Segment(name, base, penetration, share, price)
#   .revenue(year) -> float  (million yuan)

implied_driver(segment, year, target_revenue, solve_kind) -> float
#   calibrate one driver to a known revenue (e.g. reported segment revenue);
#   prefer solve_kind=PRICE/BASE over PENETRATION (avoids the back-solve trap)

RevenueModel(company, segments, total_revenue)
#   .validate(year)  -> YearResult   (segment_revenues, residual, warnings)
#   .validate_all()  -> list[YearResult]

simulate_segment(segment, year, ranges, n=10000, seed=0) -> MCResult
simulate_model(model, year, ranges, n=10000, seed=0)     -> MCResult
#   ranges: {driver_name: (low, high)};  MCResult has mean/median/stdev/percentiles

tornado(segment, year, ranges) -> list[SensitivityItem]   # ranked by swing

scenarios(mc, *, bear_p=0.10, bull_p=0.90) -> list[Scenario]  # Bear/Base/Bull from the distribution

extract_segments(text, *, api_key=None, llm=None) -> dict  # segment skeleton from annual report
alignment_check(parsed) -> dict                            # Σ + residual ≈ reported total

Project structure

revenue-model-builder/
├── revenue_model/
│   ├── driver.py        # Driver — one factor (base/pen/share/price) + ABC grade
│   ├── segment.py       # Segment — revenue = base × pen × share × price
│   ├── model.py         # RevenueModel — residual + alignment validation
│   ├── monte_carlo.py   # revenue distribution + tornado sensitivity (pure stdlib)
│   ├── extractor.py     # annual-report text -> segment skeleton (LLM, pure stdlib)
│   ├── excel_builder.py # render to .xlsx (ABC colors, IF formulas, residual)
│   ├── backtest/        # out-of-sample backtesting (metrics / methods / rolling / data)
│   └── demo.py          # NovaTech fictional example
├── tests/               # 102 tests — formula, validation, residual, MC, tornado, extractor, backtest
├── docs/
│   └── design-principles.md
└── pyproject.toml

Roadmap

  • Monte Carlo revenue distribution + sensitivity (tornado) analysis
  • Segment skeleton extraction from annual-report text (LLM)
  • Driver extrapolation API (incremental / logistic / trend-fit)
  • Driver value estimation (C-grade, from industry data)
  • Bear / Base / Bull scenarios (sliced from the Monte Carlo distribution)
  • Multi-market data source adapters (A股 tushare / US yfinance / HK)
  • Automated driver extraction from annual-report text
  • Word memo builder (historical + forecast narrative)
  • PyPI release
  • Visualization charts (distribution / tornado / waterfall / forecast)
  • Interactive Streamlit app (driver sliders -> live charts)
  • Backtesting — out-of-sample method comparison (Naive / Linear / CAGR / Holt / ARIMA)

Who is this for

Sell-side research, PE/VC investment teams, equity analysts, and students of fundamental analysis who want a reusable, auditable revenue-modeling scaffold rather than rebuilding the same spreadsheet structure by hand.

License & disclaimer

MIT — see LICENSE. This is a research/education tool, not investment advice — full statement in DISCLAIMER.md.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

revenue_model_builder-0.6.0.tar.gz (60.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

revenue_model_builder-0.6.0-py3-none-any.whl (49.8 kB view details)

Uploaded Python 3

File details

Details for the file revenue_model_builder-0.6.0.tar.gz.

File metadata

  • Download URL: revenue_model_builder-0.6.0.tar.gz
  • Upload date:
  • Size: 60.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.5

File hashes

Hashes for revenue_model_builder-0.6.0.tar.gz
Algorithm Hash digest
SHA256 b0f373b35b355a5c626bd8fd4f8326f6a8fd5fd82c0ab9bda82bb5e6ac6719fb
MD5 cbab7b8ca1d7c1ca9b3095376a5f62db
BLAKE2b-256 c7d4938e16b430546f323e120c6855fbf122cb95473220d8fbf00bcdd4ab3cac

See more details on using hashes here.

File details

Details for the file revenue_model_builder-0.6.0-py3-none-any.whl.

File metadata

File hashes

Hashes for revenue_model_builder-0.6.0-py3-none-any.whl
Algorithm Hash digest
SHA256 f4907a748689a42d065b5ca4516dc19e9d69da2b624e3cbb6a3f586affc499fb
MD5 c482b20139c1c5040223d2e4a5c477e3
BLAKE2b-256 0914fe91b859235413daab8bbbeeecc04e4d943d96f9c9a5eebc6905ac56d2d1

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page