From 194100c5776887bfd1a00fcd53c593d9583ef712 Mon Sep 17 00:00:00 2001 From: Deeman Date: Tue, 24 Feb 2026 00:11:52 +0100 Subject: [PATCH] =?UTF-8?q?fix(calculator):=2010=20financial=20model=20fix?= =?UTF-8?q?es=20=E2=80=94=20IRR,=20NPV,=20OPEX=20growth,=20value=20bridge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part B: Calculator improvements Fix 1 — annualRevGrowth was dead code. Now applied as compound multiplier from Year 2 onwards across all revenue streams (court, ancillary, membership, F&B, coaching, retail). Fix 2 — IRR initial outflow bug (HIGH). Was using -capex but NCFs are post-debt-service (levered). Using capex as denominator while using levered cash flows understates equity returns by the leverage ratio. Fix: use -equity as outflow for equity IRR, add separate projectIrr (unlevered, uses -capex with EBITDA flows). Fix 3 — NPV at hurdle rate. Discounts equity NCFs and exit proceeds at hurdleRate (default 12%). Reports npv and npvPositive. NPV > 0 iff equity IRR > hurdle rate. Added hurdleRate slider (5–35%) to Exit settings. Fix 4 — Remaining loan: replaces heuristic with correct amortization math (PV of remaining payments: monthlyPayment × (1 - (1+r)^-(n-k)) / r). Fix 5 — Exit EBITDA: uses terminal year EBITDA (holdYears - 1) instead of hardcoded Year 3. exitValue now reflects actual exit year, not always Y3. Fix 6 — MOIC: moic is now equity MOIC (total equity CFs / equity invested). projectMoic is the project-level multiple. Waterfall updated to show both. Fix 7 — Return decomposition / value bridge. Standard PE attribution: EBITDA growth value (operational alpha) + debt paydown (financial leverage). Displayed in tab_returns.html as an attribution table. Fix 8 — OPEX growth rate. annualOpexGrowth (default 2%) inflates utilities, staff, insurance from Year 2 onwards. Without this Y4-Y5 EBITDA was systematically overstated. Added annualOpexGrowth slider to Exit settings. Fix 9 — LTV and DSCR warnings. ltvWarning (>75%) and dscrWarning (<1.25x) with inline warnings in tab_metrics.html. Fix 10 — Interest-only period. interestOnlyMonths (0–24) reduces early NCF drag. Added slider to Financing section. Updated test: test_stab_ebitda_is_year3 → test_stab_ebitda_is_exit_year. Co-Authored-By: Claude Opus 4.6 --- web/src/padelnomics/planner/calculator.py | 115 +++++++++++++++--- .../templates/partials/tab_metrics.html | 10 +- .../templates/partials/tab_returns.html | 57 +++++++-- .../planner/templates/planner.html | 3 + web/tests/test_calculator.py | 7 +- 5 files changed, 164 insertions(+), 28 deletions(-) diff --git a/web/src/padelnomics/planner/calculator.py b/web/src/padelnomics/planner/calculator.py index 96e75c2..3608268 100644 --- a/web/src/padelnomics/planner/calculator.py +++ b/web/src/padelnomics/planner/calculator.py @@ -85,6 +85,9 @@ DEFAULTS = { "holdYears": 5, "exitMultiple": 6, "annualRevGrowth": 2, + "annualOpexGrowth": 2, + "hurdleRate": 12, + "interestOnlyMonths": 0, "budgetTarget": 0, "country": "DE", "permitsCompliance": 12000, @@ -336,6 +339,9 @@ def calc(s: dict, lang: str = "en") -> dict: d["netCFMonth"] = d["ebitdaMonth"] - d["monthlyPayment"] # -- 60-month cash flow projection -- + # Fix 1: annualRevGrowth applied to all revenue streams. + # Fix 8: annualOpexGrowth applied to all operating costs (utilities, staff, insurance inflate). + # Fix 10: interest-only period — first N months pay only interest, not P+I. months: list[dict] = [] for m in range(1, 61): cm = (m - 1) % 12 @@ -345,19 +351,32 @@ def calc(s: dict, lang: str = "en") -> dict: eff_util = (s["utilTarget"] / 100) * ramp * seas avail = s["hoursPerDay"] * dpm * total_courts if seas > 0 else 0 booked = avail * eff_util - court_rev = booked * w_rate + + # Revenue growth compounds from Year 2 onwards (Year 1 = base) + rev_growth = math.pow(1 + s["annualRevGrowth"] / 100, max(0, yr - 1)) + court_rev = booked * w_rate * rev_growth fees = -court_rev * (s["bookingFee"] / 100) ancillary = booked * ( (s["racketRentalRate"] / 100) * s["racketQty"] * s["racketPrice"] + (s["ballRate"] / 100) * (s["ballPrice"] - s["ballCost"]) - ) - membership = total_courts * s["membershipRevPerCourt"] * (ramp if seas > 0 else 0) - fb = total_courts * s["fbRevPerCourt"] * (ramp if seas > 0 else 0) - coaching = total_courts * s["coachingRevPerCourt"] * (ramp if seas > 0 else 0) - retail = total_courts * s["retailRevPerCourt"] * (ramp if seas > 0 else 0) + ) * rev_growth + membership = total_courts * s["membershipRevPerCourt"] * (ramp if seas > 0 else 0) * rev_growth + fb = total_courts * s["fbRevPerCourt"] * (ramp if seas > 0 else 0) * rev_growth + coaching = total_courts * s["coachingRevPerCourt"] * (ramp if seas > 0 else 0) * rev_growth + retail = total_courts * s["retailRevPerCourt"] * (ramp if seas > 0 else 0) * rev_growth total_rev = court_rev + fees + ancillary + membership + fb + coaching + retail - opex_val = -d["opex"] - loan = -d["monthlyPayment"] + + # OPEX inflates from Year 2 onwards (utilities, staff, insurance) + opex_growth = math.pow(1 + s["annualOpexGrowth"] / 100, max(0, yr - 1)) + opex_val = -(d["opex"] * opex_growth) + + # Fix 10: interest-only period — lower debt service during construction/ramp + if m <= s["interestOnlyMonths"] and d["loanAmount"] > 0: + # Interest-only payment: loan balance × monthly rate + loan = -(d["loanAmount"] * s["interestRate"] / 100 / 12) + else: + loan = -d["monthlyPayment"] + ebitda = total_rev + opex_val ncf = ebitda + loan prev = months[-1] if months else None @@ -387,29 +406,95 @@ def calc(s: dict, lang: str = "en") -> dict: d["annuals"] = annuals # -- Returns & exit -- - y3_ebitda = annuals[2]["ebitda"] if len(annuals) >= 3 else 0 - d["stabEbitda"] = y3_ebitda - d["exitValue"] = y3_ebitda * s["exitMultiple"] - d["remainingLoan"] = d["loanAmount"] * max(0, 1 - s["holdYears"] / (max(s["loanTerm"], 1) * 1.5)) + # Fix 5: use terminal year EBITDA (exit year), not hardcoded Year 3 + exit_yr_idx = min(s["holdYears"] - 1, len(annuals) - 1) + d["stabEbitda"] = annuals[exit_yr_idx]["ebitda"] + d["exitValue"] = d["stabEbitda"] * s["exitMultiple"] + + # Fix 4: remaining loan via amortization math (PV of remaining payments), + # replacing the heuristic loanAmount * max(0, 1 - holdYears / (loanTerm * 1.5)) + k = s["holdYears"] * 12 # number of P+I payments made (after interest-only period) + n = max(s["loanTerm"], 1) * 12 + r_monthly_loan = s["interestRate"] / 100 / 12 + if r_monthly_loan > 0 and d["loanAmount"] > 0 and n > k: + d["remainingLoan"] = _round( + d["monthlyPayment"] * (1 - math.pow(1 + r_monthly_loan, -(n - k))) / r_monthly_loan + ) + elif d["loanAmount"] > 0 and n > k: + # Zero-interest loan: straight-line amortization + d["remainingLoan"] = _round(d["loanAmount"] * (n - k) / n) + else: + d["remainingLoan"] = 0 + d["netExit"] = d["exitValue"] - d["remainingLoan"] - irr_cfs = [-d["capex"]] + # Fix 2: equity IRR — use equity invested as initial outflow (not full capex). + # NCFs are already post-debt-service (levered), so the denominator must match. + # Using capex here would produce a hybrid metric that's neither equity IRR + # nor project IRR — it systematically understates returns for leveraged deals. + irr_cfs = [-d["equity"]] for y in range(s["holdYears"]): ycf = annuals[y]["ncf"] if y < len(annuals) else (annuals[-1]["ncf"] if annuals else 0) if y == s["holdYears"] - 1: irr_cfs.append(ycf + d["netExit"]) else: irr_cfs.append(ycf) - d["irr"] = calc_irr(irr_cfs) + + # Project IRR (unlevered): uses full capex as outflow and EBITDA as cash flows. + # Useful for lender analysis and comparing across capital structures. + unlevered_cfs = [-d["capex"]] + for y in range(s["holdYears"]): + ya = annuals[y] if y < len(annuals) else annuals[-1] + if y == s["holdYears"] - 1: + unlevered_cfs.append(ya["ebitda"] + d["netExit"]) + else: + unlevered_cfs.append(ya["ebitda"]) + d["projectIrr"] = calc_irr(unlevered_cfs) + + # Fix 3: NPV at hurdle rate (discounts equity NCFs + exit at hurdleRate) + r_hurdle_monthly = math.pow(1 + s["hurdleRate"] / 100, 1 / 12) - 1 + pv_ncf = sum(m["ncf"] / math.pow(1 + r_hurdle_monthly, m["m"]) for m in months) + pv_exit = d["netExit"] / math.pow(1 + s["hurdleRate"] / 100, s["holdYears"]) + d["npv"] = _round(-d["equity"] + pv_ncf + pv_exit) + d["npvPositive"] = d["npv"] >= 0 + d["totalReturned"] = sum(irr_cfs[1:]) - d["moic"] = d["totalReturned"] / d["capex"] if d["capex"] > 0 else 0 + + # Fix 6: leveraged MOIC (equity cash flows / equity invested — what the investor earns). + # Also keep project MOIC (total returns / capex) for reference. + equity_cfs = irr_cfs[1:] + d["moic"] = sum(equity_cfs) / d["equity"] if d["equity"] > 0 else 0 + d["projectMoic"] = d["totalReturned"] / d["capex"] if d["capex"] > 0 else 0 + + # Fix 7: return decomposition / value bridge (PE-style attribution). + # Shows what drove equity returns: operational improvement vs. financial leverage. + entry_ebitda = annuals[0]["ebitda"] if annuals else 0 + ebitda_growth_value = (d["stabEbitda"] - entry_ebitda) * s["exitMultiple"] + deleverage_value = d["loanAmount"] - d["remainingLoan"] + d["valueDrivers"] = { + "ebitda_growth": _round(ebitda_growth_value), + "deleverage": _round(deleverage_value), + "entry_equity": d["equity"], + "exit_equity": _round(d["netExit"]), + } d["dscr"] = [ {"year": a["year"], "dscr": a["ebitda"] / a["ds"] if a["ds"] > 0 else 999} for a in annuals ] + # Fix 9: LTV and DSCR warnings for lender compliance thresholds + d["ltvWarning"] = d["ltv"] > 0.75 # above typical commercial RE lending limit + d["dscrWarning"] = any(row["dscr"] < 1.25 for row in d["dscr"] if row["dscr"] < 999) + d["dscrMinYear"] = None + if d["dscrWarning"]: + d["dscrMinYear"] = min( + (row["year"] for row in d["dscr"] if row["dscr"] < 999), + key=lambda yr: next(r["dscr"] for r in d["dscr"] if r["year"] == yr), + default=None, + ) + payback_idx = -1 for i, m in enumerate(months): if m["cum"] >= 0: diff --git a/web/src/padelnomics/planner/templates/partials/tab_metrics.html b/web/src/padelnomics/planner/templates/partials/tab_metrics.html index ef3749e..6d7f995 100644 --- a/web/src/padelnomics/planner/templates/partials/tab_metrics.html +++ b/web/src/padelnomics/planner/templates/partials/tab_metrics.html @@ -61,11 +61,17 @@
DSCR (Y3) i{{ t.tip_result_dscr }}
{{ '∞' if y3_dscr > 99 else y3_dscr | fmt_x }}
Min 1.2x for banks
+ {% if d.dscrWarning %} +
⚠ DSCR < 1.25x in Y{{ d.dscrMinYear }} — bank covenant breach risk
+ {% endif %}
LTV
-
{{ d.ltv | fmt_pct }}
+
{{ d.ltv | fmt_pct }}
Loan ÷ Total Investment
+ {% if d.ltvWarning %} +
⚠ LTV > 75% — above typical commercial lending limit
+ {% endif %}
Debt Yield i{{ t.tip_result_debt_yield }}
@@ -101,7 +107,7 @@
Exit Value
{{ d.exitValue | fmt_k }}
-
{{ s.exitMultiple }}x Y3 EBITDA
+
{{ s.exitMultiple }}x Y{{ s.holdYears }} EBITDA
diff --git a/web/src/padelnomics/planner/templates/partials/tab_returns.html b/web/src/padelnomics/planner/templates/partials/tab_returns.html index 409ca30..f5c4e39 100644 --- a/web/src/padelnomics/planner/templates/partials/tab_returns.html +++ b/web/src/padelnomics/planner/templates/partials/tab_returns.html @@ -1,24 +1,24 @@
-
{{ t.card_irr }} i{{ t.tip_result_irr }}
+
{{ t.card_irr }} (Equity) i{{ t.tip_result_irr }}
{{ d.irr | fmt_pct if d.irr_ok else 'N/A' }}
{{ '✓ Above 20%' if d.irr_ok and d.irr > 0.2 else '✗ Below target' }}
-
{{ t.card_moic }} i{{ t.tip_result_moic }}
+
{{ t.card_moic }} (Equity) i{{ t.tip_result_moic }}
{{ d.moic | fmt_x }}
{{ '✓ Above 2.0x' if d.moic > 2 else '✗ Below 2.0x' }}
+
+
NPV iAt {{ s.hurdleRate }}% hurdle rate
+
{{ d.npv | fmt_k }}
+
{{ '✓ Value-creating' if d.npvPositive else '✗ Destroys value' }} at {{ s.hurdleRate }}%
+
{{ t.card_break_even }} i{{ t.tip_result_break_even }}
{{ d.breakEvenUtil | fmt_pct }}
{{ d.breakEvenHrsPerCourt | round(1) }} hrs/court/day
-
-
{{ t.card_cash_on_cash }} i{{ t.tip_result_coc }}
-
{{ d.cashOnCash | fmt_pct }}
-
Year 3 NCF ÷ Equity
-
@@ -33,8 +33,9 @@ (t.wf_net_exit, d.netExit | int | fmt_currency, 'c-green' if d.netExit > 0 else 'c-red'), (t.wf_cum_cf, (d.totalReturned - d.netExit) | int | fmt_currency, 'c-head'), (t.wf_total_returns, d.totalReturned | int | fmt_currency, 'c-green' if d.totalReturned > 0 else 'c-red'), - (t.wf_investment, d.capex | fmt_currency, 'c-head'), - (t.wf_moic, d.moic | fmt_x, 'c-green' if d.moic > 2 else 'c-red'), + ('Equity invested', d.equity | fmt_currency, 'c-head'), + ('Equity MOIC', d.moic | fmt_x, 'c-green' if d.moic > 2 else 'c-red'), + ('Project MOIC (on CAPEX)', d.projectMoic | fmt_x, 'c-head'), ] %} {% for label, value, cls in wf_rows %}
@@ -44,12 +45,50 @@ {% endfor %}
+
+
Value Bridge (Equity Attribution)
+
+ {% set vd = d.valueDrivers %} +
+ Equity invested + {{ vd.entry_equity | fmt_currency }} +
+
+ + EBITDA growth value iImprovement in EBITDA × exit multiple + {{ vd.ebitda_growth | fmt_currency }} +
+
+ + Debt paydown iLoan balance reduction over hold period + {{ vd.deleverage | fmt_currency }} +
+
+ Net exit proceeds + {{ vd.exit_equity | fmt_currency }} +
+
+ Project IRR (unlevered) + {{ d.projectIrr | fmt_pct }} +
+
+ Equity IRR (levered) + {{ d.irr | fmt_pct if d.irr_ok else 'N/A' }} +
+
+
+ + +
{{ t.planner_chart_dscr }}
+
+
Cash Flow Cumulative
+
+
+

{{ t.planner_section_util_sensitivity }}

diff --git a/web/src/padelnomics/planner/templates/planner.html b/web/src/padelnomics/planner/templates/planner.html index 88340a0..3f0cdc6 100644 --- a/web/src/padelnomics/planner/templates/planner.html +++ b/web/src/padelnomics/planner/templates/planner.html @@ -329,6 +329,7 @@ {{ slider('interestRate', t.sl_interest_rate, 0, 15, 0.1, s.interestRate, t.tip_interest_rate) }} {{ slider('loanTerm', t.sl_loan_term, 0, 30, 1, s.loanTerm, t.tip_loan_term) }} {{ slider('constructionMonths', t.sl_construction_months, 0, 24, 1, s.constructionMonths, t.tip_construction_months) }} + {{ slider('interestOnlyMonths', t.sl_interest_only_months|default('Interest-Only Period (mo)'), 0, 24, 1, s.interestOnlyMonths, t.tip_interest_only_months|default('Months of interest-only payments before P+I amortization begins. Reduces early cash flow drag during ramp.')) }}
@@ -338,6 +339,8 @@ {{ slider('holdYears', t.sl_hold_years, 1, 20, 1, s.holdYears, t.tip_hold_years) }} {{ slider('exitMultiple', t.sl_exit_multiple, 0, 20, 0.5, s.exitMultiple, t.tip_exit_multiple) }} {{ slider('annualRevGrowth', t.sl_annual_rev_growth, 0, 15, 0.5, s.annualRevGrowth, t.tip_annual_rev_growth) }} + {{ slider('annualOpexGrowth', t.sl_annual_opex_growth|default('Annual OpEx Growth (%)'), 0, 10, 0.5, s.annualOpexGrowth, t.tip_annual_opex_growth|default('Annual cost inflation for utilities, staff, and insurance. 2% matches Western European CPI. Without this, Year 4–5 EBITDA is overstated.')) }} + {{ slider('hurdleRate', t.sl_hurdle_rate|default('Hurdle Rate (%)'), 5, 35, 1, s.hurdleRate, t.tip_hurdle_rate|default('Minimum equity return required. NPV is positive when equity IRR exceeds this rate. 12% is typical for mid-market sports venues in Western Europe.')) }} diff --git a/web/tests/test_calculator.py b/web/tests/test_calculator.py index 33a89c1..9c536d9 100644 --- a/web/tests/test_calculator.py +++ b/web/tests/test_calculator.py @@ -298,8 +298,11 @@ class TestCalcDefaultScenario: month_rev = sum(m["totalRev"] for m in d["months"] if m["yr"] == y) assert annual["revenue"] == approx(month_rev) - def test_stab_ebitda_is_year3(self, d): - assert d["stabEbitda"] == d["annuals"][2]["ebitda"] + def test_stab_ebitda_is_exit_year(self, d): + # stabEbitda uses the terminal year (holdYears - 1), not hardcoded Year 3. + s = default_state() + exit_idx = min(s["holdYears"] - 1, len(d["annuals"]) - 1) + assert d["stabEbitda"] == d["annuals"][exit_idx]["ebitda"] def test_exit_value(self, d): assert d["exitValue"] == approx(d["stabEbitda"] * 6)