fix(calculator): 10 financial model fixes — IRR, NPV, OPEX growth, value bridge

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 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-24 00:11:52 +01:00
parent 0960990373
commit 194100c577
5 changed files with 164 additions and 28 deletions

View File

@@ -85,6 +85,9 @@ DEFAULTS = {
"holdYears": 5, "holdYears": 5,
"exitMultiple": 6, "exitMultiple": 6,
"annualRevGrowth": 2, "annualRevGrowth": 2,
"annualOpexGrowth": 2,
"hurdleRate": 12,
"interestOnlyMonths": 0,
"budgetTarget": 0, "budgetTarget": 0,
"country": "DE", "country": "DE",
"permitsCompliance": 12000, "permitsCompliance": 12000,
@@ -336,6 +339,9 @@ def calc(s: dict, lang: str = "en") -> dict:
d["netCFMonth"] = d["ebitdaMonth"] - d["monthlyPayment"] d["netCFMonth"] = d["ebitdaMonth"] - d["monthlyPayment"]
# -- 60-month cash flow projection -- # -- 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] = [] months: list[dict] = []
for m in range(1, 61): for m in range(1, 61):
cm = (m - 1) % 12 cm = (m - 1) % 12
@@ -345,19 +351,32 @@ def calc(s: dict, lang: str = "en") -> dict:
eff_util = (s["utilTarget"] / 100) * ramp * seas eff_util = (s["utilTarget"] / 100) * ramp * seas
avail = s["hoursPerDay"] * dpm * total_courts if seas > 0 else 0 avail = s["hoursPerDay"] * dpm * total_courts if seas > 0 else 0
booked = avail * eff_util 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) fees = -court_rev * (s["bookingFee"] / 100)
ancillary = booked * ( ancillary = booked * (
(s["racketRentalRate"] / 100) * s["racketQty"] * s["racketPrice"] (s["racketRentalRate"] / 100) * s["racketQty"] * s["racketPrice"]
+ (s["ballRate"] / 100) * (s["ballPrice"] - s["ballCost"]) + (s["ballRate"] / 100) * (s["ballPrice"] - s["ballCost"])
) ) * rev_growth
membership = total_courts * s["membershipRevPerCourt"] * (ramp if seas > 0 else 0) membership = total_courts * s["membershipRevPerCourt"] * (ramp if seas > 0 else 0) * rev_growth
fb = total_courts * s["fbRevPerCourt"] * (ramp if seas > 0 else 0) fb = total_courts * s["fbRevPerCourt"] * (ramp if seas > 0 else 0) * rev_growth
coaching = total_courts * s["coachingRevPerCourt"] * (ramp if seas > 0 else 0) coaching = total_courts * s["coachingRevPerCourt"] * (ramp if seas > 0 else 0) * rev_growth
retail = total_courts * s["retailRevPerCourt"] * (ramp if seas > 0 else 0) retail = total_courts * s["retailRevPerCourt"] * (ramp if seas > 0 else 0) * rev_growth
total_rev = court_rev + fees + ancillary + membership + fb + coaching + retail total_rev = court_rev + fees + ancillary + membership + fb + coaching + retail
opex_val = -d["opex"]
# 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"] loan = -d["monthlyPayment"]
ebitda = total_rev + opex_val ebitda = total_rev + opex_val
ncf = ebitda + loan ncf = ebitda + loan
prev = months[-1] if months else None prev = months[-1] if months else None
@@ -387,29 +406,95 @@ def calc(s: dict, lang: str = "en") -> dict:
d["annuals"] = annuals d["annuals"] = annuals
# -- Returns & exit -- # -- Returns & exit --
y3_ebitda = annuals[2]["ebitda"] if len(annuals) >= 3 else 0 # Fix 5: use terminal year EBITDA (exit year), not hardcoded Year 3
d["stabEbitda"] = y3_ebitda exit_yr_idx = min(s["holdYears"] - 1, len(annuals) - 1)
d["exitValue"] = y3_ebitda * s["exitMultiple"] d["stabEbitda"] = annuals[exit_yr_idx]["ebitda"]
d["remainingLoan"] = d["loanAmount"] * max(0, 1 - s["holdYears"] / (max(s["loanTerm"], 1) * 1.5)) 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"] 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"]): for y in range(s["holdYears"]):
ycf = annuals[y]["ncf"] if y < len(annuals) else (annuals[-1]["ncf"] if annuals else 0) ycf = annuals[y]["ncf"] if y < len(annuals) else (annuals[-1]["ncf"] if annuals else 0)
if y == s["holdYears"] - 1: if y == s["holdYears"] - 1:
irr_cfs.append(ycf + d["netExit"]) irr_cfs.append(ycf + d["netExit"])
else: else:
irr_cfs.append(ycf) irr_cfs.append(ycf)
d["irr"] = calc_irr(irr_cfs) 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["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"] = [ d["dscr"] = [
{"year": a["year"], "dscr": a["ebitda"] / a["ds"] if a["ds"] > 0 else 999} {"year": a["year"], "dscr": a["ebitda"] / a["ds"] if a["ds"] > 0 else 999}
for a in annuals 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 payback_idx = -1
for i, m in enumerate(months): for i, m in enumerate(months):
if m["cum"] >= 0: if m["cum"] >= 0:

View File

@@ -61,11 +61,17 @@
<div class="metric-card__label">DSCR (Y3) <span class="ti">i<span class="tp">{{ t.tip_result_dscr }}</span></span></div> <div class="metric-card__label">DSCR (Y3) <span class="ti">i<span class="tp">{{ t.tip_result_dscr }}</span></span></div>
<div class="metric-card__value {{ 'c-green' if y3_dscr >= 1.2 else 'c-red' }}">{{ '∞' if y3_dscr > 99 else y3_dscr | fmt_x }}</div> <div class="metric-card__value {{ 'c-green' if y3_dscr >= 1.2 else 'c-red' }}">{{ '∞' if y3_dscr > 99 else y3_dscr | fmt_x }}</div>
<div class="metric-card__sub">Min 1.2x for banks</div> <div class="metric-card__sub">Min 1.2x for banks</div>
{% if d.dscrWarning %}
<div class="metric-card__warn c-red" style="font-size:10px;margin-top:4px">⚠ DSCR &lt; 1.25x in Y{{ d.dscrMinYear }} — bank covenant breach risk</div>
{% endif %}
</div> </div>
<div class="metric-card metric-card-sm"> <div class="metric-card metric-card-sm">
<div class="metric-card__label">LTV</div> <div class="metric-card__label">LTV</div>
<div class="metric-card__value c-head">{{ d.ltv | fmt_pct }}</div> <div class="metric-card__value {{ 'c-amber' if d.ltvWarning else 'c-head' }}">{{ d.ltv | fmt_pct }}</div>
<div class="metric-card__sub">Loan ÷ Total Investment</div> <div class="metric-card__sub">Loan ÷ Total Investment</div>
{% if d.ltvWarning %}
<div class="metric-card__warn c-amber" style="font-size:10px;margin-top:4px">⚠ LTV &gt; 75% — above typical commercial lending limit</div>
{% endif %}
</div> </div>
<div class="metric-card metric-card-sm"> <div class="metric-card metric-card-sm">
<div class="metric-card__label">Debt Yield <span class="ti">i<span class="tp">{{ t.tip_result_debt_yield }}</span></span></div> <div class="metric-card__label">Debt Yield <span class="ti">i<span class="tp">{{ t.tip_result_debt_yield }}</span></span></div>
@@ -101,7 +107,7 @@
<div class="metric-card metric-card-sm"> <div class="metric-card metric-card-sm">
<div class="metric-card__label">Exit Value</div> <div class="metric-card__label">Exit Value</div>
<div class="metric-card__value c-head">{{ d.exitValue | fmt_k }}</div> <div class="metric-card__value c-head">{{ d.exitValue | fmt_k }}</div>
<div class="metric-card__sub">{{ s.exitMultiple }}x Y3 EBITDA</div> <div class="metric-card__sub">{{ s.exitMultiple }}x Y{{ s.holdYears }} EBITDA</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,24 +1,24 @@
<div class="grid-4 mb-4"> <div class="grid-4 mb-4">
<div class="metric-card"> <div class="metric-card">
<div class="metric-card__label">{{ t.card_irr }} <span class="ti">i<span class="tp">{{ t.tip_result_irr }}</span></span></div> <div class="metric-card__label">{{ t.card_irr }} (Equity) <span class="ti">i<span class="tp">{{ t.tip_result_irr }}</span></span></div>
<div class="metric-card__value {{ 'c-green' if d.irr_ok and d.irr > 0.2 else 'c-red' }}">{{ d.irr | fmt_pct if d.irr_ok else 'N/A' }}</div> <div class="metric-card__value {{ 'c-green' if d.irr_ok and d.irr > 0.2 else 'c-red' }}">{{ d.irr | fmt_pct if d.irr_ok else 'N/A' }}</div>
<div class="metric-card__sub">{{ '✓ Above 20%' if d.irr_ok and d.irr > 0.2 else '✗ Below target' }}</div> <div class="metric-card__sub">{{ '✓ Above 20%' if d.irr_ok and d.irr > 0.2 else '✗ Below target' }}</div>
</div> </div>
<div class="metric-card"> <div class="metric-card">
<div class="metric-card__label">{{ t.card_moic }} <span class="ti">i<span class="tp">{{ t.tip_result_moic }}</span></span></div> <div class="metric-card__label">{{ t.card_moic }} (Equity) <span class="ti">i<span class="tp">{{ t.tip_result_moic }}</span></span></div>
<div class="metric-card__value {{ 'c-green' if d.moic > 2 else 'c-red' }}">{{ d.moic | fmt_x }}</div> <div class="metric-card__value {{ 'c-green' if d.moic > 2 else 'c-red' }}">{{ d.moic | fmt_x }}</div>
<div class="metric-card__sub">{{ '✓ Above 2.0x' if d.moic > 2 else '✗ Below 2.0x' }}</div> <div class="metric-card__sub">{{ '✓ Above 2.0x' if d.moic > 2 else '✗ Below 2.0x' }}</div>
</div> </div>
<div class="metric-card">
<div class="metric-card__label">NPV <span class="ti">i<span class="tp">At {{ s.hurdleRate }}% hurdle rate</span></span></div>
<div class="metric-card__value {{ 'c-green' if d.npvPositive else 'c-red' }}">{{ d.npv | fmt_k }}</div>
<div class="metric-card__sub">{{ '✓ Value-creating' if d.npvPositive else '✗ Destroys value' }} at {{ s.hurdleRate }}%</div>
</div>
<div class="metric-card"> <div class="metric-card">
<div class="metric-card__label">{{ t.card_break_even }} <span class="ti">i<span class="tp">{{ t.tip_result_break_even }}</span></span></div> <div class="metric-card__label">{{ t.card_break_even }} <span class="ti">i<span class="tp">{{ t.tip_result_break_even }}</span></span></div>
<div class="metric-card__value {{ 'c-green' if d.breakEvenUtil < 0.35 else 'c-amber' }}">{{ d.breakEvenUtil | fmt_pct }}</div> <div class="metric-card__value {{ 'c-green' if d.breakEvenUtil < 0.35 else 'c-amber' }}">{{ d.breakEvenUtil | fmt_pct }}</div>
<div class="metric-card__sub">{{ d.breakEvenHrsPerCourt | round(1) }} hrs/court/day</div> <div class="metric-card__sub">{{ d.breakEvenHrsPerCourt | round(1) }} hrs/court/day</div>
</div> </div>
<div class="metric-card">
<div class="metric-card__label">{{ t.card_cash_on_cash }} <span class="ti">i<span class="tp">{{ t.tip_result_coc }}</span></span></div>
<div class="metric-card__value {{ 'c-green' if d.cashOnCash > 0.15 else 'c-amber' }}">{{ d.cashOnCash | fmt_pct }}</div>
<div class="metric-card__sub">Year 3 NCF ÷ Equity</div>
</div>
</div> </div>
<div class="grid-2 mb-4"> <div class="grid-2 mb-4">
@@ -33,8 +33,9 @@
(t.wf_net_exit, d.netExit | int | fmt_currency, 'c-green' if d.netExit > 0 else 'c-red'), (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_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_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'), ('Equity invested', d.equity | fmt_currency, 'c-head'),
(t.wf_moic, d.moic | fmt_x, 'c-green' if d.moic > 2 else 'c-red'), ('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 %} {% for label, value, cls in wf_rows %}
<div class="waterfall-row"> <div class="waterfall-row">
@@ -44,12 +45,50 @@
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
<div class="chart-container">
<div class="chart-container__label" style="font-size:10px">Value Bridge (Equity Attribution)</div>
<div id="valueBridge" style="margin-top:10px">
{% set vd = d.valueDrivers %}
<div class="waterfall-row">
<span class="waterfall-row__label">Equity invested</span>
<span class="waterfall-row__value c-head">{{ vd.entry_equity | fmt_currency }}</span>
</div>
<div class="waterfall-row">
<span class="waterfall-row__label">+ EBITDA growth value <span class="ti" style="font-size:10px">i<span class="tp">Improvement in EBITDA × exit multiple</span></span></span>
<span class="waterfall-row__value {{ 'c-green' if vd.ebitda_growth >= 0 else 'c-red' }}">{{ vd.ebitda_growth | fmt_currency }}</span>
</div>
<div class="waterfall-row">
<span class="waterfall-row__label">+ Debt paydown <span class="ti" style="font-size:10px">i<span class="tp">Loan balance reduction over hold period</span></span></span>
<span class="waterfall-row__value c-green">{{ vd.deleverage | fmt_currency }}</span>
</div>
<div class="waterfall-row" style="border-top:1px solid var(--c-border);margin-top:4px;padding-top:4px">
<span class="waterfall-row__label"><b>Net exit proceeds</b></span>
<span class="waterfall-row__value {{ 'c-green' if vd.exit_equity > vd.entry_equity else 'c-red' }}"><b>{{ vd.exit_equity | fmt_currency }}</b></span>
</div>
<div class="waterfall-row" style="margin-top:8px">
<span class="waterfall-row__label">Project IRR (unlevered)</span>
<span class="waterfall-row__value c-head">{{ d.projectIrr | fmt_pct }}</span>
</div>
<div class="waterfall-row">
<span class="waterfall-row__label">Equity IRR (levered)</span>
<span class="waterfall-row__value {{ 'c-green' if d.irr_ok and d.irr > 0.2 else 'c-red' }}">{{ d.irr | fmt_pct if d.irr_ok else 'N/A' }}</span>
</div>
</div>
</div>
</div>
<div class="grid-2 mb-4">
<div class="chart-container"> <div class="chart-container">
<div class="chart-container__label">{{ t.planner_chart_dscr }}</div> <div class="chart-container__label">{{ t.planner_chart_dscr }}</div>
<div class="chart-h-44 chart-container__canvas"><canvas id="chartDSCR"></canvas></div> <div class="chart-h-44 chart-container__canvas"><canvas id="chartDSCR"></canvas></div>
</div> </div>
<div class="chart-container">
<div class="chart-container__label">Cash Flow Cumulative</div>
<div class="chart-h-44 chart-container__canvas"><canvas id="chartCum"></canvas></div>
</div>
</div> </div>
<script type="application/json" id="chartDSCR-data">{{ d.dscr_chart | tojson }}</script> <script type="application/json" id="chartDSCR-data">{{ d.dscr_chart | tojson }}</script>
<script type="application/json" id="chartCum-data">{{ d.cum_chart | tojson }}</script>
<div class="mb-section"> <div class="mb-section">
<div class="section-header"><h3>{{ t.planner_section_util_sensitivity }}</h3></div> <div class="section-header"><h3>{{ t.planner_section_util_sensitivity }}</h3></div>

View File

@@ -329,6 +329,7 @@
{{ slider('interestRate', t.sl_interest_rate, 0, 15, 0.1, s.interestRate, t.tip_interest_rate) }} {{ 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('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('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.')) }}
</div> </div>
</details> </details>
@@ -338,6 +339,8 @@
{{ slider('holdYears', t.sl_hold_years, 1, 20, 1, s.holdYears, t.tip_hold_years) }} {{ 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('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('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 45 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.')) }}
</div> </div>
</details> </details>
</div> </div>

View File

@@ -298,8 +298,11 @@ class TestCalcDefaultScenario:
month_rev = sum(m["totalRev"] for m in d["months"] if m["yr"] == y) month_rev = sum(m["totalRev"] for m in d["months"] if m["yr"] == y)
assert annual["revenue"] == approx(month_rev) assert annual["revenue"] == approx(month_rev)
def test_stab_ebitda_is_year3(self, d): def test_stab_ebitda_is_exit_year(self, d):
assert d["stabEbitda"] == d["annuals"][2]["ebitda"] # 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): def test_exit_value(self, d):
assert d["exitValue"] == approx(d["stabEbitda"] * 6) assert d["exitValue"] == approx(d["stabEbitda"] * 6)