Project Finance & Financial ModellingCash-flow waterfalls, debt sizing and coverage ratios · Lesson 14 of 20

Lab: project finance calculations in Python

Article · 25 min · 8 min lecture

Video lecture

Lab: project finance calculations in Python

8 chapters · about 8 min · full transcript

Coming soon

Chapter 1 of 8

A shadow model you can trust

  • Sculpted debt, schedule, DSCR
  • LLCR, PLCR, project and equity IRR
  • Monte Carlo on CFADS and lock-up risk

The narrated lecture is in production

Every chapter is scripted and ready. Browse the chapters and read the full transcript now — the video will appear here when it’s published.

Chapters

What you will build

A short Python notebook that reproduces the core project finance calculations you have learned, so you can check a spreadsheet model independently: a CFADS profile, sculpted debt sizing against a target DSCR and a gearing cap, a full repayment schedule with interest and principal, DSCR by year, LLCR and PLCR, project and equity IRR, and a Monte Carlo simulation of CFADS that shows how often minimum DSCR would breach a lock-up level. Numbers are illustrative; the method is what you reuse.

A notebook does not replace the lender-audited spreadsheet model. It is an independent check (a "shadow model") and a fast way to explore questions such as "what happens to minimum DSCR if yield is more volatile than we assumed?"

Setup

python -m venv .venv && source .venv/bin/activate      # Windows: .venv\Scripts\activate
pip install numpy numpy-financial pandas jupyter
jupyter notebook

numpy-financial provides npv, irr, pmt and related functions. Note one convention that differs from Excel: npf.npv(rate, values) treats the first value as time 0, while Excel's NPV() discounts the first value by one period.

Cell 0 and 1: inputs

import numpy as np
import numpy_financial as npf
import pandas as pd

# Cell 1: annual CFADS profile (USD M, illustrative) and debt terms
years = np.arange(1, 11)                        # 10 operating years; debt repaid over years 1-8
cfads = np.array([10.0, 11.0, 12.0, 12.0, 13.0, 13.0, 13.5, 13.5, 14.0, 14.0])
tenor, rate, target_dscr = 8, 0.07, 1.30
capex, gearing_cap = 75.0, 0.75
repay = years <= tenor

Cell 2: sculpted debt sizing

Maximum debt service in each repayment year is CFADS ÷ target DSCR. Discounting those amounts at the debt rate gives the DSCR-based debt capacity; the debt is the lower of that and the gearing cap.

# Cell 2: sculpted debt sizing
ds_max = np.where(repay, cfads / target_dscr, 0.0)          # debt service each year
df = 1 / (1 + rate) ** years
debt_dscr = float((ds_max * df).sum())                      # PV of max debt service at the debt rate
debt = min(debt_dscr, gearing_cap * capex)
print(f"DSCR-sized debt {debt_dscr:.2f}M | gearing cap {gearing_cap * capex:.2f}M | debt {debt:.2f}M")

Output: DSCR-sized debt of about 55.46M against a gearing cap of 56.25M, so the DSCR test binds.

Cell 3: repayment schedule and DSCR

# Cell 3: repayment schedule (interest on opening balance, principal = debt service - interest)
scale = debt / debt_dscr                                    # < 1 if the gearing cap binds
bal, rows = debt, []
for y, c, ds in zip(years, cfads, ds_max * scale):
    interest = bal * rate
    principal = ds - interest if ds else 0.0
    rows.append((y, c, bal, interest, principal, ds))
    bal -= principal
sched = pd.DataFrame(rows, columns=["year", "CFADS", "opening", "interest", "principal", "debt_service"])
sched["DSCR"] = np.where(sched.debt_service > 0, sched.CFADS / sched.debt_service, np.nan)
print(sched.round(2).to_string(index=False))
print(f"Closing balance after year {tenor}: {bal:.4f}")      # should be ~0

Every repayment year shows DSCR 1.30x and the balance reaches zero at the end of year 8, which is exactly what sculpting should produce. If the gearing cap had bound instead, scale would be below 1 and DSCRs would sit above the target.

Cell 4: LLCR and PLCR

# Cell 4: LLCR and PLCR at the start of operations
llcr = npf.npv(rate, np.r_[0, cfads[repay]]) / debt         # npf.npv treats the first value as t=0
plcr = npf.npv(rate, np.r_[0, cfads]) / debt
print(f"LLCR {llcr:.2f}x | PLCR {plcr:.2f}x")

LLCR equals the target DSCR (1.30x) because debt was sculpted at that DSCR and the LLCR is discounted at the same rate. PLCR is higher (about 1.57x) because it includes the two tail years after maturity.

Cell 5: project and equity IRR

# Cell 5: project and equity IRR (construction in one year 0 for simplicity)
project_cf = np.r_[-capex, cfads]
equity_cf = np.r_[-(capex - debt), cfads - np.r_[sched.debt_service.values]]
print(f"Project IRR {npf.irr(project_cf):.2%} | Equity IRR {npf.irr(equity_cf):.2%}")

With the simplifying assumption that all capex is spent in year 0, project IRR is about 10.1% and equity IRR about 15.2%: leverage lifts the equity return, with the risk you saw in the capital structure lesson. A real model spreads capex over construction, includes fees, IDC, reserves and tax.

Cell 6: Monte Carlo on CFADS

Two sources of uncertainty: the long-term average yield (a single draw per simulation, applied to every year) and year-to-year variability. Opex and debt service are fixed, so lower revenue falls straight through to CFADS.

# Cell 6: Monte Carlo on CFADS: yield uncertainty with fixed opex and debt service
rng = np.random.default_rng(11)
N = 20_000
revenue_base = cfads + 4.0                                  # assume 4.0M fixed opex each year
long_term = rng.normal(1.0, 0.05, (N, 1))                   # uncertainty in the long-term average yield
annual = rng.normal(0.0, 0.06, (N, len(years)))             # year-to-year variability
sim_cfads = revenue_base * (long_term + annual) - 4.0
sim_dscr = sim_cfads[:, repay] / sched.debt_service.values[repay]
min_dscr = sim_dscr.min(axis=1)
print(f"P50 min DSCR {np.percentile(min_dscr, 50):.2f}x | P10 {np.percentile(min_dscr, 10):.2f}x")
print(f"P(any year below 1.10x lock-up) {np.mean(min_dscr < 1.10):.0%} | P(any year below 1.00x) {np.mean(min_dscr < 1.0):.0%}")

Typical output: the median of minimum DSCR across the repayment years is about 1.15x, well below the 1.30x sizing target, and roughly a 31% chance that at least one year falls below a 1.10x lock-up level. That is the quantitative reason lenders size debt on P90 (or similar) cash flows rather than P50: a loan sculpted on P50 CFADS will very often breach its lock-up in at least one year.

Reading and presenting the results

OutputMeaningPresent it as
DSCR-sized vs gearing-capped debtWhich constraint binds"Debt of 55.5M is DSCR-constrained"
LLCR at CODForward cover over the loan life"LLCR 1.30x, equal to the sizing DSCR"
PLCRCover including the tail"Tail provides additional cover (PLCR 1.57x)"
Min DSCR distributionResilience to yield uncertainty"About a 3 in 10 chance of at least one lock-up year if sized on P50"

Common mistakes

  • Using npf.npv and Excel NPV() interchangeably without adjusting for the time-0 convention.
  • Sizing on P50 CFADS and reporting only the base-case DSCR.
  • Forgetting that the Monte Carlo ranges must come from the yield or traffic adviser's uncertainty analysis, not from guesswork.
  • Treating a shadow model as the model of record; the audited spreadsheet remains the reference.

How to measure success

  • The notebook reproduces the spreadsheet model's debt size, DSCRs and LLCR within rounding for the same inputs.
  • The Monte Carlo inputs are traceable to the adviser's reports.
  • Findings from the notebook (for example lock-up probability) are discussed with the modelling team, not used on their own.

Key takeaways

  • A Python shadow model independently checks the spreadsheet model of record; it does not replace it.
  • numpy-financial npv treats the first value as time 0, unlike Excel NPV, which discounts the first value by one period.
  • Sculpted debt produces a flat DSCR at target and an LLCR equal to the sizing DSCR in the base case.
  • Monte Carlo on CFADS shows that debt sculpted on P50 often breaches lock-up in at least one year, which is why lenders size on P90.

Check your understanding

Quick questions to lock in the lesson. They don’t count towards your certificate.

  1. Cash flows are [-1000, 300, 350, 400, 300]. Which call returns the correct NPV at 10%?
  2. Debt is sculpted at 1.30x on P50 CFADS. A Monte Carlo shows median minimum DSCR of 1.15x. Why?
  3. Your notebook LLCR differs from the model by 0.03x. What is the best next step?

Put it into practice

Build the notebook, then add a 12-month grace period (repay over years 2–9). Record how debt size, LLCR and the lock-up probability change and explain why.

Enrol for free to save your progress

Reading is always free. Enrol to keep your place, take the final assessment and earn a verifiable certificate.