Skip to main content

pyproforma

A Python library for building financial models — a code-first alternative to Excel for pro formas, projections, and structured financial tables.

pip install pyproforma

Why

Spreadsheets are the default tool for financial modeling, but they have real problems: no version control, no testing, formulas hidden inside cells, and no easy way to generate the same model for multiple scenarios. pyproforma is designed for analysts who want the benefits of code — reproducibility, testability, version history — without giving up the tabular output that finance people actually use.


How it works

Define a model by subclassing ProformaModel and declaring line items as class attributes. Instantiate it with a list of periods and the library calculates everything.

from pyproforma import ProformaModel, FixedLine, FormulaLine, ScalarLine, Format

class IncomeStatement(ProformaModel):
    default_periods = [2024, 2025, 2026]

    tax_rate = ScalarLine(value=0.21, label="Tax Rate")

    revenue = FixedLine(
        values={2024: 500_000, 2025: 550_000, 2026: 605_000},
        label="Revenue",
        tags=["operating"],
        value_format=Format.CURRENCY_NO_DECIMALS,
    )
    cogs = FormulaLine(
        formula=lambda li, t: li.revenue[t] * 0.55,
        label="Cost of Goods Sold",
        value_format=Format.CURRENCY_NO_DECIMALS,
    )
    gross_profit = FormulaLine(
        formula=lambda li, t: li.revenue[t] - li.cogs[t],
        label="Gross Profit",
        value_format=Format.CURRENCY_NO_DECIMALS,
    )
    net_income = FormulaLine(
        formula=lambda li, t: li.gross_profit[t] * (1 - li.tax_rate),
        label="Net Income",
        value_format=Format.CURRENCY_NO_DECIMALS,
    )

model = IncomeStatement()  # uses default_periods

Access results with dot notation (primary) or bracket notation (useful when the name is in a variable):

model.net_income[2025]      # 173_745.0  — dot notation
model["net_income"][2025]   # same value — bracket notation

model.tax_rate.value        # 0.21  — scalars have .value, not [t]

model.periods               # [2024, 2025, 2026]
model.line_item_names       # ["revenue", "cogs", "gross_profit", "net_income"]
model.scalar_names          # ["tax_rate"]

Line item types

Type Use
FixedLine(values, ...) Hardcoded values per period
FormulaLine(formula, ...) Calculated from other items via a lambda
ScalarLine(value, ...) A single value shared across all periods
InputLine(default, ...) Period-indexed values supplied at instantiation
ScalarInputLine(default, ...) A single value supplied at instantiation

Formula lambdas receive li (the model namespace) and t (the current period). Period-indexed items use li.name[t]; scalars use li.name (no [t]). Reference prior periods with li.name[t-1].


Scenario inputs

InputLine and ScalarInputLine let callers supply values at instantiation without subclassing again — useful for scenario analysis.

from pyproforma import InputLine, ScalarInputLine

class FlexModel(ProformaModel):
    default_periods = [2024, 2025, 2026]

    margin = ScalarInputLine(default=0.45, label="Gross Margin")
    revenue = FixedLine(
        values={2024: 500_000, 2025: 550_000, 2026: 605_000},
        label="Revenue",
        value_format=Format.CURRENCY_NO_DECIMALS,
    )
    gross_profit = FormulaLine(
        formula=lambda li, t: li.revenue[t] * li.margin,
        label="Gross Profit",
        value_format=Format.CURRENCY_NO_DECIMALS,
    )

base   = FlexModel()                  # uses default margin of 0.45
upside = FlexModel(margin=0.52)       # override at instantiation

Use model.compare() to diff two instances:

comparison = base.compare(upside, labels=["Base", "Upside"])

Time-series formulas

Seed a value in the first period and let the formula compound from there — no if t == first_year guards needed:

revenue = FormulaLine(
    formula=lambda li, t: li.revenue[t-1] * (1 + li.growth_rate),
    values={2024: 500_000},   # engine uses this for 2024; formula runs from 2025 onward
    label="Revenue",
)

Tags

Tag line items to group them without fixed categories:

water_sales = FixedLine(values={...}, tags=["revenue"])
power_sales = FixedLine(values={...}, tags=["revenue"])

# Sum all "revenue"-tagged items in a formula
total_revenue = FormulaLine(formula=lambda li, t: li.tag["revenue"][t])

Tags also work in table templates:

from pyproforma import TagTotalRow
TagTotalRow(tag="revenue", label="Total Revenue")

Tables

Generate formatted tables for HTML, Excel, or pandas. from_template gives full control over layout:

from pyproforma import HeaderRow, LabelRow, ItemRow, BlankRow, LineItemsTotalRow

table = model.tables.from_template([
    HeaderRow(),
    LabelRow("Income Statement"),
    ItemRow("revenue"),
    ItemRow("cogs", reverse_sign=True),   # display as positive deduction
    ItemRow("gross_profit", bold=True, borders="top"),
    BlankRow(),
    ItemRow("net_income", bold=True),
])

table.show()                           # inline in Jupyter
table.to_excel("output.xlsx")          # Excel with formatting preserved
table.to_dataframe()                   # pandas DataFrame

Convenience builders for common layouts:

model.tables.line_items().show()                              # all line items
model.tables.line_item("net_income", include_percent_change=True).show()
model.tables.precedents("net_income").show()                  # formula dependency tree

Charts

model.charts.line_item("net_income", chart_type="bar").show()
model.charts.line_items(["revenue", "gross_profit", "net_income"]).show()

Charts return a ChartSpec which can also render to a matplotlib Figure:

fig = model.charts.line_item("net_income").figure()

Requires pip install pyproforma[charts].


Number formatting

Named format constants flow through to both HTML and Excel output:

from pyproforma import Format

ItemRow("revenue",    value_format=Format.CURRENCY_NO_DECIMALS)  # $500,000
ItemRow("revenue",    value_format=Format.THOUSANDS_K)           # 500.0K
ItemRow("margin",     value_format=Format.PERCENT_ONE_DECIMAL)   # 45.0%
ItemRow("net_income", value_format=Format.MILLIONS_M)            # $0.2M

Custom formats via NumberFormatSpec:

from pyproforma import NumberFormatSpec

fmt = NumberFormatSpec(decimals=1, scale="millions", prefix="$", suffix="M")
# 500_000 → "$0.5M"

Explorer

A lightweight Flask web app for browsing any model interactively:

from pyproforma.explorer import create_app

app = create_app(model)
app.run(debug=True)

Requires pip install pyproforma[explorer]. The app shows all line items, their values, formula sources, and lets you update InputLine / ScalarInputLine values live. You can also pass named tables, charts, and views to build a richer dashboard.

CLI

Launch the explorer straight from the command line, no wrapper script needed — point it at a .py file with a module-level model:

pyproforma my_model.py

Pass -c config.yaml to drive tables, charts, views, and a home page from a YAML file instead of Python:

tables:
  Income Statement:
    rows:
      - row_type: header
      - row_type: item
        name: revenue
      - row_type: item
        name: net_income
        bold: true
        top_border: single

charts:
  Revenue vs Net Income:
    names: [revenue, net_income]

views:
  Overview:
    - - type: chart
        ref: Revenue vs Net Income
      - type: table
        ref: Income Statement

home_view: Overview
pyproforma my_model.py -c config.yaml

Scenarios & compare mode

Add a scenarios: block (each entry maps to constructor kwargs, e.g. InputLine / ScalarInputLine overrides) and the CLI switches to a multi-model app — a "Scenario" dropdown lets you browse each one read-only, plus an "All (Compare)" view:

scenarios:
  Upside:
    margin: 0.52
  Downside:
    margin: 0.38

Add an optional compare: block to curate cross-scenario tables/charts/views (built on ModelComparison under the hood — value rows, absolute/percent difference rows, per-scenario chart series):

compare:
  tables:
    Margin Impact:
      items: [gross_profit, net_income]
  charts:
    Net Income: { item: net_income, chart_type: bar }
  views:
    Summary:
      - - { type: chart, ref: "Net Income" }
      - - { type: table, ref: "Margin Impact" }

Without a config file, compare mode still works from Python:

from pyproforma.compare import ModelComparison

comparison = ModelComparison(base, upside, downside, labels=["Base", "Upside", "Downside"])
comparison.table(["revenue", "net_income"])   # value + difference rows, one column per period
comparison.chart("net_income").show()         # one series per model

Installation

pip install pyproforma                   # core only
pip install pyproforma[charts]           # + matplotlib
pip install pyproforma[excel]            # + openpyxl
pip install pyproforma[explorer]         # + Flask
pip install pyproforma[pandas]           # + pandas

Requires Python 3.9+.


Status

Active development. Core modeling, table export, charts, and the Flask explorer are all stable. Feedback welcome — open an issue on GitHub.

License

MIT

Metadata

Release files for pyproforma 0.3.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for pyproforma 0.3.0
File Size Uploaded
pyproforma-0.3.0.tar.gz 82.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for pyproforma 0.3.0
File Interpreter ABI Platform
pyproforma-0.3.0-py3-none-any.whl Python 3 none any Details

Total release size: 184.9 kB

Release files / pyproforma-0.3.0.tar.gz

Download URL pyproforma-0.3.0.tar.gz
Size 82.9 kB
Tags Source
SHA-256 checksum
How to use checksums
65cda0e805a62439bc12abf3a78fbd37a02faf219e7a49151b0ab7ba519c3794
BLAKE2b-256 checksum
How to use checksums
fe07ebcc16792d4bb086f4b5c0d7fa1455cd5e4fcf97aece76002e2548d5bd16
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.14.0

Release files / pyproforma-0.3.0-py3-none-any.whl

Download URL pyproforma-0.3.0-py3-none-any.whl
Size 102.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
293589b06a1a971e841e6626d608415072deb91335a0bf1e840c615f56957b61
BLAKE2b-256 checksum
How to use checksums
7fdbbd173504cb153d8674b985affdaa500d212f2789abee0c1cd767b76051ce
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.14.0

Release history Release notifications | RSS feed

0.3.2

2 release files

0.3.1

2 release files

This release

0.3.0 This release

2 release files

0.2.5

2 release files

0.2.4

2 release files

0.2.2

2 release files

0.2.1

2 release files

0.2.0

2 release files

0.1.17

2 release files

0.1.15

2 release files

0.1.13

2 release files

0.1.10

2 release files

0.1.9

2 release files

0.1.8

2 release files

0.1.7

2 release files

0.1.6

2 release files

0.1.5

2 release files

0.1.4

2 release files

0.1.3

2 release files

0.1.2

2 release files

0.1.1

2 release files

0.1.0

2 release files

0.0.1

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page