Write Python. Ship real Excel formulas.
Variable — a value.
MultiVariable — a concept, modeled as a set of Variables and sub-concepts.
Excel — one cell-traceable snapshot of a MultiVariable.
import modeleon as mo
forecast = mo.MultiVariable("Forecast")
forecast.pnl = mo.MultiVariable("P&L")
with forecast.pnl as pnl:
pnl.revenue = mo.Variable(1_000_000, unit="$")
pnl.cogs_pct = mo.Variable(0.6, display_name="COGS %")
pnl.cogs = pnl.revenue * pnl.cogs_pct
pnl.profit = pnl.revenue - pnl.cogs
forecast.to_excel("forecast.xlsx")The resulting forecast.xlsx:
| A | B | |
|---|---|---|
| 1 | Revenue ($) | 1000000 |
| 2 | COGS % | 0.6 |
| 3 | Cogs | =B1*B2 |
| 4 | Profit | =B1-B3 |
B3 is =B1*B2 — a real Excel formula, not the baked value 600000.
| Modeleon | openpyxl / xlsxwriter | pandas → Excel | |
|---|---|---|---|
| Output is a live model | ✅ formulas | ❌ values only | |
| Cross-cell dependency graph | ✅ tracked automatically | ❌ | ❌ |
Cross-sheet refs (=Sheet!Cell) |
✅ resolved by the engine | ❌ | |
| Audit-trail per cell | ✅ every value traces back | ❌ | ❌ |
The lower stack writes spreadsheets. Modeleon writes the model that produces a spreadsheet — every cell, every formula, every dependency.
pip install modeleonPython 3.12+.
A value or formula. Arithmetic tracks dependencies automatically.
revenue = mo.Variable(1_000_000)
cogs = revenue * mo.Variable(0.6)
cogs.value # 600_000.0
cogs.formula # 'revenue * 0.6'
cogs.dependencies # {'revenue'}Scalar, list, or unit-bearing — all the same Variable.
A concept. Variables are its attributes; sub-MultiVariables are its inner concepts.
forecast = mo.MultiVariable("Forecast")
forecast.assumptions = mo.MultiVariable("Assumptions", tax_rate=mo.Variable(0.25))
forecast.pnl = mo.MultiVariable("P&L")
forecast.pnl.revenue = mo.Variable(1_000_000)
forecast.pnl.taxes = forecast.pnl.revenue * forecast.assumptions.tax_rateCross-sheet refs resolve automatically.
forecast.pnl.taxes = forecast.pnl.revenue * forecast.assumptions.tax_rate— the engine emits=B1 * Assumptions!B2in Excel. Change the tax rate on Assumptions, P&L recalculates.
Reusable concepts subclass MultiVariableClass:
class Cohort(mo.MultiVariableClass):
def compute(self, start, churn, price):
self.users = mo.recurrence(start, "{prev} * (1 - {c})", c=churn)
self.revenue = self.users * pricerecurrence builds a list-valued Variable where every period references the previous period via a formula. The Excel output is a chain of cells, not a static list of values.
forecast.assumptions = mo.MultiVariable("Assumptions",
start_rev=mo.Variable(1_000_000, unit="$"),
growth=mo.Variable([0.05] * 12),
)
forecast.pnl = mo.MultiVariable("P&L")
forecast.pnl.revenue = mo.recurrence(
start=forecast.assumptions.start_rev,
formula="{prev} * (1 + {growth})",
growth=forecast.assumptions.growth,
)The 12 emitted Excel cells:
B1: =Assumptions!B1 (period 0 = start)
C1: =B1 * (1 + Assumptions!C2) (compounded period 1)
D1: =C1 * (1 + Assumptions!D2) ...
... through M1
Change a growth value on Assumptions — every later cell recalculates.
A debt block is a circle if you read it row by row: interest reads the balance, the balance reads the sweep, the sweep reads the cash left after interest. Read period by period it is not — interest reads last period's balance. Put the rows in a loop container and write them as ordinary rows; there a row may be read before the line that defines it:
m.debt = mo.MultiVariable("Debt", loop=True)
with m.debt as d:
d.interest = mo.lag(d.balance, fill=m.inp.opening) * m.inp.rate
d.pot = m.inp.cash - d.interest - m.inp.scheduled
d.sweep = mo.IF(d.pot > 0, d.pot, 0.0) * m.inp.share
d.balance = mo.lag(d.balance, fill=m.inp.opening) - m.inp.scheduled - d.sweepThe engine settles the loop when balance is assigned. The emitted
cells are the shape a hand-built workbook has always had. The loop
closes through the previous column, so Excel sees no circular reference:
Interest B2: =Inputs!B6 * Inputs!B2 C2: =B5 * Inputs!B2
Balance B5: =Inputs!B6 - Inputs!C4 - B4 C5: =B5 - Inputs!D4 - C4
A cycle that does not pass through mo.lag is refused by name — it
would need a fixed point, not an order. So is SUM/MAX/MIN over a
loop row inside the loop: those read a whole series, and a per-period
floor is mo.IF(x > 0, x, 0). A class is a loop container for every
instance with class Tranche(mo.MultiVariableClass, loop=True).
.to_excel() walks the MultiVariable tree and writes a workbook where every Variable is a cell, every formula is =A1*B2, every cross-sheet reference is =Sheet!Cell.
Standard library functions emit live formulas, not values:
mo.IRR(cf) # =IRR(B2:F2, 0.1)
mo.NPV(0.1, cf) # =NPV(0.1, B2:F2)
mo.IF(rev > 0, rev * 0.25, 0) # =IF(B1>0, B1*0.25, 0)
mo.CHOOSE(scenario, base, bull) # =CHOOSE(B1, B4, B5) in every period — one scenario cell switches the row
mo.EOMONTH(start, 1) # =EOMONTH(B1, 1)Also: IRR, NPV, XIRR, XNPV, PMT, FV, PV · SUM, MAX, MIN, AVERAGE · IF, AND, OR, NOT, CHOOSE · ABS, ROUND, INT, MOD · YEAR, MONTH, DAY, DAYS, YEARFRAC, EDATE, EOMONTH, DATE, TODAY · CONCAT, UPPER, LOWER, LEN. All emit live formulas.
Every Variable, MultiVariable, and Model has a built-in _repr_html_. In Jupyter you see a live grid of values + formulas with click-to-trace-precedents — Excel's "Trace Precedents" button, in the browser. Toggle "Formulas" to flip every cell to its underlying expression.
Try it without installing anything: Open the first-model notebook in Colab.
If Modeleon resonates — a GitHub ⭐ helps more people find it.
Apache 2.0. See LICENSE and NOTICE.
Why this exists → The case for Financial Model Engineering
Releases: CHANGELOG · GitHub releases

