feat(financial): levered (equity) IRR — capstone финмодели #1881 #1900
11 changed files with 335 additions and 30 deletions
|
|
@ -44,6 +44,10 @@ class FinancialModel(BaseModel):
|
||||||
``irr_is_proxy=True``. VAT is a documented simplification (see docstring), not
|
``irr_is_proxy=True``. VAT is a documented simplification (see docstring), not
|
||||||
exact НК РФ mechanics.
|
exact НК РФ mechanics.
|
||||||
|
|
||||||
|
``levered_irr`` — IRR на собственные средства (equity cashflow при LTC): тот же
|
||||||
|
DCF-IRR + roi-proxy fallback, но по equity-потоку графика долга. При положительном
|
||||||
|
леверидже выше unlevered ``irr``; вырожденные случаи → 0.0 + ``levered_irr_is_proxy``.
|
||||||
|
|
||||||
The legacy summary fields (``revenue_rub`` / ``cost_rub`` /
|
The legacy summary fields (``revenue_rub`` / ``cost_rub`` /
|
||||||
``gross_margin_rub`` / ``irr``) are kept for backward-compat; the new fields
|
``gross_margin_rub`` / ``irr``) are kept for backward-compat; the new fields
|
||||||
expose the full cascade, the net P&L and the DCF metrics.
|
expose the full cascade, the net P&L and the DCF metrics.
|
||||||
|
|
@ -108,6 +112,13 @@ class FinancialModel(BaseModel):
|
||||||
net_profit_after_financing_rub: float = 0.0 # net_profit − total_interest
|
net_profit_after_financing_rub: float = 0.0 # net_profit − total_interest
|
||||||
# honest-флаг: 100% покрытие разрыва, compound %, эскроу не моделируется точно.
|
# honest-флаг: 100% покрытие разрыва, compound %, эскроу не моделируется точно.
|
||||||
financing_is_simplified: bool = True
|
financing_is_simplified: bool = True
|
||||||
|
# Levered (equity) IRR: доходность на собственные средства (equity cashflow при LTC).
|
||||||
|
# При положительном леверидже > unlevered ``irr``. Считается DCF-IRR'ом equity-потока
|
||||||
|
# того же графика долга, с тем же roi-proxy fallback'ом, что и unlevered IRR.
|
||||||
|
levered_irr: float = 0.0 # IRR на собственные средства (equity cashflow при LTC)
|
||||||
|
# honest-флаг: True → levered_irr — roi-proxy (вырожденный equity-поток / нет equity
|
||||||
|
# под риском при LTC>=1 / финансирование выключено), не настоящий DCF-IRR.
|
||||||
|
levered_irr_is_proxy: bool = True # True → levered_irr — roi-proxy, не DCF
|
||||||
|
|
||||||
# ── Price calibration (PR-2, эпик #1881) ───────────────────────────────────
|
# ── Price calibration (PR-2, эпик #1881) ───────────────────────────────────
|
||||||
# Цена продажи жилья, руб/кв.м, фактически использованная в выручке. Либо
|
# Цена продажи жилья, руб/кв.м, фактически использованная в выручке. Либо
|
||||||
|
|
|
||||||
|
|
@ -116,9 +116,16 @@ Unlevered (project) DCF — NPV / IRR / PBP — остаётся HEADLINE и Н
|
||||||
* **Эскроу-специфику не моделируем точно** — раскрытие средств с эскроу-счетов
|
* **Эскроу-специфику не моделируем точно** — раскрытие средств с эскроу-счетов
|
||||||
по этапам стройки, лимиты раскрытия, ставка по бридж/целевой части — всё это
|
по этапам стройки, лимиты раскрытия, ставка по бридж/целевой части — всё это
|
||||||
упрощено до единой капитализируемой ставки на кассовый разрыв.
|
упрощено до единой капитализируемой ставки на кассовый разрыв.
|
||||||
* **Levered IRR НЕ считается** (отдельный PR). MVP раскрывает только агрегаты:
|
* **Levered (equity) IRR** считается по equity-потоку того же графика долга
|
||||||
``peak_debt_rub`` / ``total_interest_rub`` / ``net_profit_after_financing_rub``;
|
(``_FinancingResult.equity_cashflow``): отток = equity-доля (1−LTC) каждого
|
||||||
debt_schedule в схему НЕ выносится.
|
кассового разрыва, приток = остаток притока после обслуживания долга. При
|
||||||
|
положительном леверидже (доходность проекта > стоимости долга 15 %) levered IRR
|
||||||
|
выше unlevered. Тот же proxy-fallback, что у unlevered (нет смены знака → roi-style
|
||||||
|
proxy + ``levered_irr_is_proxy=True``). Вырожденные случаи (LTC>=1 — equity не под
|
||||||
|
риском, финансирование выключено, нет equity-оттока) → ``levered_irr=0.0`` /
|
||||||
|
``levered_irr_is_proxy=True`` (не выдумываем доходность). debt_schedule в схему НЕ
|
||||||
|
выносится. MVP-агрегаты: ``peak_debt_rub`` / ``total_interest_rub`` /
|
||||||
|
``net_profit_after_financing_rub`` (без изменений).
|
||||||
* **Unlevered NPV / IRR / PBP НЕ затронуты** overlay'ем — это всё та же
|
* **Unlevered NPV / IRR / PBP НЕ затронуты** overlay'ем — это всё та же
|
||||||
интринсивная доходность. График наследует неточность ``schedule_is_default``:
|
интринсивная доходность. График наследует неточность ``schedule_is_default``:
|
||||||
финансирование считается по тому же дефолт-графику фаз, поэтому
|
финансирование считается по тому же дефолт-графику фаз, поэтому
|
||||||
|
|
@ -339,11 +346,29 @@ def _payback_months(cf: list[float]) -> float | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _FinancingResult:
|
||||||
|
"""Результат overlay'я финансирования: агрегаты долга + ПОМЕСЯЧНЫЙ equity-поток.
|
||||||
|
|
||||||
|
Агрегаты (``peak_debt_rub`` / ``total_interest_rub`` / ``ending_debt_rub``)
|
||||||
|
сохраняют прежний READ-ONLY-контракт PR-5 (значения не изменились). ``equity_cashflow``
|
||||||
|
— НОВЫЙ поток держателя equity по тому же графику долга (для levered IRR, отдельный PR):
|
||||||
|
отток = equity-доля (1−ltc) каждого кассового разрыва (со знаком −), приток =
|
||||||
|
остаток притока после обслуживания долга. Σ equity_cashflow == net_profit_after_financing
|
||||||
|
(equity получает всю прибыль за вычетом процентов; банк — только тело + проценты).
|
||||||
|
"""
|
||||||
|
|
||||||
|
peak_debt_rub: float
|
||||||
|
total_interest_rub: float
|
||||||
|
ending_debt_rub: float
|
||||||
|
equity_cashflow: list[float]
|
||||||
|
|
||||||
|
|
||||||
def _compute_financing(
|
def _compute_financing(
|
||||||
operating_cf: list[float],
|
operating_cf: list[float],
|
||||||
annual_rate: float,
|
annual_rate: float,
|
||||||
ltc_ratio: float = 1.0,
|
ltc_ratio: float = 1.0,
|
||||||
) -> dict[str, float]:
|
) -> _FinancingResult:
|
||||||
"""OVERLAY финансирования поверх unlevered cashflow (PR-5). Чистая функция.
|
"""OVERLAY финансирования поверх unlevered cashflow (PR-5). Чистая функция.
|
||||||
|
|
||||||
``operating_cf`` — готовый unlevered cashflow (READ-ONLY, НЕ мутируется).
|
``operating_cf`` — готовый unlevered cashflow (READ-ONLY, НЕ мутируется).
|
||||||
|
|
@ -356,28 +381,37 @@ def _compute_financing(
|
||||||
Если поток не уходит в минус → долг не выбирается → debt=0, interest=0
|
Если поток не уходит в минус → долг не выбирается → debt=0, interest=0
|
||||||
(корректно: финансирование не требуется).
|
(корректно: финансирование не требуется).
|
||||||
|
|
||||||
Возвращает агрегаты (без debt_schedule — только итоговые числа для MVP):
|
ВОЗВРАТ — :class:`_FinancingResult`: агрегаты (``peak_debt_rub`` — пиковая
|
||||||
``peak_debt_rub`` — пиковая задолженность, ``total_interest_rub`` — суммарные
|
задолженность, ``total_interest_rub`` — суммарные капитализированные проценты,
|
||||||
капитализированные проценты, ``ending_debt_rub`` — остаток долга на конец.
|
``ending_debt_rub`` — остаток долга на конец) ПЛЮС ``equity_cashflow`` —
|
||||||
|
помесячный поток держателя equity по тому же графику долга (для levered IRR,
|
||||||
|
отдельный PR; см. :class:`_FinancingResult`). Агрегаты не изменились — контракт цел.
|
||||||
"""
|
"""
|
||||||
monthly_rate = (1.0 + annual_rate) ** (1.0 / 12.0) - 1.0
|
monthly_rate = (1.0 + annual_rate) ** (1.0 / 12.0) - 1.0
|
||||||
debt = 0.0
|
debt = 0.0
|
||||||
peak_debt = 0.0
|
peak_debt = 0.0
|
||||||
total_interest = 0.0
|
total_interest = 0.0
|
||||||
|
equity_cashflow: list[float] = []
|
||||||
for cf in operating_cf:
|
for cf in operating_cf:
|
||||||
interest = debt * monthly_rate # процент на остаток на начало месяца (до движений)
|
interest = debt * monthly_rate # процент на остаток на начало месяца (до движений)
|
||||||
debt += interest
|
debt += interest
|
||||||
total_interest += interest
|
total_interest += interest
|
||||||
if cf < 0:
|
if cf < 0:
|
||||||
debt += -cf * ltc_ratio # банк покрывает ltc_ratio от оттока; equity-часть без %
|
debt += -cf * ltc_ratio # банк покрывает ltc_ratio от оттока; equity-часть без %
|
||||||
|
# Equity вносит (1−ltc) долю оттока (со знаком −, как и cf): свои деньги под риском.
|
||||||
|
equity_cashflow.append(cf * (1.0 - ltc_ratio))
|
||||||
else:
|
else:
|
||||||
debt -= min(cf, debt) # гасим долг притоком (не уходим в минус)
|
repay = min(cf, debt) # гасим долг притоком (не уходим в минус)
|
||||||
|
debt -= repay
|
||||||
|
# Остаток притока после обслуживания долга достаётся equity (приток, +).
|
||||||
|
equity_cashflow.append(cf - repay)
|
||||||
peak_debt = max(peak_debt, debt)
|
peak_debt = max(peak_debt, debt)
|
||||||
return {
|
return _FinancingResult(
|
||||||
"peak_debt_rub": round(peak_debt, 2),
|
peak_debt_rub=round(peak_debt, 2),
|
||||||
"total_interest_rub": round(total_interest, 2),
|
total_interest_rub=round(total_interest, 2),
|
||||||
"ending_debt_rub": round(debt, 2),
|
ending_debt_rub=round(debt, 2),
|
||||||
}
|
equity_cashflow=equity_cashflow,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _spread_linear(total: float, start: int, duration: int, horizon: int) -> list[float]:
|
def _spread_linear(total: float, start: int, duration: int, horizon: int) -> list[float]:
|
||||||
|
|
@ -646,9 +680,35 @@ def compute_financial(
|
||||||
# ── Финансирование: OVERLAY поверх unlevered cashflow (PR-5) ───────────────
|
# ── Финансирование: OVERLAY поверх unlevered cashflow (PR-5) ───────────────
|
||||||
# Тот же массив cashflow, что построен выше (НЕ пересобираем — защита инварианта:
|
# Тот же массив cashflow, что построен выше (НЕ пересобираем — защита инварианта:
|
||||||
# overlay читает unlevered поток и НЕ мутирует его). Проценты — реальная стоимость
|
# overlay читает unlevered поток и НЕ мутирует его). Проценты — реальная стоимость
|
||||||
# долга, режут прибыль: net_after < net. Levered IRR НЕ считаем (отдельный PR).
|
# долга, режут прибыль: net_after < net.
|
||||||
financing = _compute_financing(cashflow, _CREDIT_RATE_ANNUAL, _LOAN_TO_COST_RATIO)
|
financing = _compute_financing(cashflow, _CREDIT_RATE_ANNUAL, _LOAN_TO_COST_RATIO)
|
||||||
net_profit_after_financing = net_profit - financing["total_interest_rub"]
|
net_profit_after_financing = net_profit - financing.total_interest_rub
|
||||||
|
|
||||||
|
# ── Levered (equity) IRR — доходность на собственные средства ───────────────
|
||||||
|
# IRR equity-потока того же графика долга. equity_invested = Σ |отрицательных
|
||||||
|
# equity_cf| (суммарный отток собственных средств). Вырожденные случаи (LTC>=1 —
|
||||||
|
# equity не под риском, нет equity-оттока) → levered_irr=0.0 / proxy (не выдумываем
|
||||||
|
# доходность). Иначе — тот же DCF-IRR + roi-proxy fallback, что у unlevered.
|
||||||
|
equity_cf = financing.equity_cashflow
|
||||||
|
equity_invested = sum(-c for c in equity_cf if c < 0.0)
|
||||||
|
if equity_invested <= 0.0:
|
||||||
|
# LTC>=1.0 (банк покрывает весь разрыв) ИЛИ нет кассового разрыва → equity не
|
||||||
|
# рискует → доходность equity не определена. Honest: 0.0 + proxy-флаг.
|
||||||
|
levered_irr = 0.0
|
||||||
|
levered_irr_is_proxy = True
|
||||||
|
else:
|
||||||
|
monthly_levered_irr = _irr_monthly(equity_cf)
|
||||||
|
if monthly_levered_irr is not None:
|
||||||
|
# Настоящий levered IRR: месячный → годовой эффективный (как unlevered).
|
||||||
|
levered_irr = (1.0 + monthly_levered_irr) ** 12 - 1.0
|
||||||
|
levered_irr_is_proxy = False
|
||||||
|
else:
|
||||||
|
# Вырожденный equity-поток (нет смены знака). Fallback на roi-style proxy:
|
||||||
|
# прибыль на equity после процентов / equity / горизонт, клампинг ±1 — точно
|
||||||
|
# как unlevered делает irr=roi/_PROJECT_YEARS.
|
||||||
|
levered_roi = net_profit_after_financing / equity_invested
|
||||||
|
levered_irr = max(-1.0, min(1.0, levered_roi / _PROJECT_YEARS))
|
||||||
|
levered_irr_is_proxy = True
|
||||||
|
|
||||||
model = FinancialModel(
|
model = FinancialModel(
|
||||||
# legacy summary
|
# legacy summary
|
||||||
|
|
@ -688,10 +748,13 @@ def compute_financial(
|
||||||
# financing overlay (PR-5)
|
# financing overlay (PR-5)
|
||||||
financing_enabled=True,
|
financing_enabled=True,
|
||||||
annual_rate_used=_CREDIT_RATE_ANNUAL,
|
annual_rate_used=_CREDIT_RATE_ANNUAL,
|
||||||
peak_debt_rub=financing["peak_debt_rub"],
|
peak_debt_rub=financing.peak_debt_rub,
|
||||||
total_interest_rub=financing["total_interest_rub"],
|
total_interest_rub=financing.total_interest_rub,
|
||||||
net_profit_after_financing_rub=round(net_profit_after_financing, 2),
|
net_profit_after_financing_rub=round(net_profit_after_financing, 2),
|
||||||
financing_is_simplified=True,
|
financing_is_simplified=True,
|
||||||
|
# levered (equity) IRR — доходность на собственные средства при LTC
|
||||||
|
levered_irr=round(levered_irr, 4),
|
||||||
|
levered_irr_is_proxy=levered_irr_is_proxy,
|
||||||
# price calibration (PR-2)
|
# price calibration (PR-2)
|
||||||
price_per_sqm_used=round(sale_price, 2),
|
price_per_sqm_used=round(sale_price, 2),
|
||||||
price_is_calibrated=price_is_calibrated,
|
price_is_calibrated=price_is_calibrated,
|
||||||
|
|
@ -700,7 +763,7 @@ def compute_financial(
|
||||||
logger.info(
|
logger.info(
|
||||||
"financial: revenue=%.0f cost=%.0f net=%.0f roi=%.3f npv=%.0f "
|
"financial: revenue=%.0f cost=%.0f net=%.0f roi=%.3f npv=%.0f "
|
||||||
"irr=%.3f%s pbp=%s price=%.0f/m2 source=%s "
|
"irr=%.3f%s pbp=%s price=%.0f/m2 source=%s "
|
||||||
"peak_debt=%.0f total_interest=%.0f net_after_fin=%.0f",
|
"peak_debt=%.0f total_interest=%.0f net_after_fin=%.0f levered_irr=%.3f%s",
|
||||||
model.revenue_rub,
|
model.revenue_rub,
|
||||||
model.cost_rub,
|
model.cost_rub,
|
||||||
model.net_profit_rub,
|
model.net_profit_rub,
|
||||||
|
|
@ -714,6 +777,8 @@ def compute_financial(
|
||||||
model.peak_debt_rub,
|
model.peak_debt_rub,
|
||||||
model.total_interest_rub,
|
model.total_interest_rub,
|
||||||
model.net_profit_after_financing_rub,
|
model.net_profit_after_financing_rub,
|
||||||
|
model.levered_irr,
|
||||||
|
" (proxy)" if model.levered_irr_is_proxy else "",
|
||||||
)
|
)
|
||||||
return model
|
return model
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -175,6 +175,9 @@ def test_concepts_response_matches_contract_keys() -> None:
|
||||||
"total_interest_rub",
|
"total_interest_rub",
|
||||||
"net_profit_after_financing_rub",
|
"net_profit_after_financing_rub",
|
||||||
"financing_is_simplified",
|
"financing_is_simplified",
|
||||||
|
# levered (equity) IRR
|
||||||
|
"levered_irr",
|
||||||
|
"levered_irr_is_proxy",
|
||||||
# price calibration (PR-2)
|
# price calibration (PR-2)
|
||||||
"price_per_sqm_used",
|
"price_per_sqm_used",
|
||||||
"price_is_calibrated",
|
"price_is_calibrated",
|
||||||
|
|
|
||||||
|
|
@ -356,17 +356,17 @@ def test_financing_draws_debt_on_cash_gap() -> None:
|
||||||
# Ранний отток / поздняя выручка → нужна кредитная линия → peak_debt>0, %>0.
|
# Ранний отток / поздняя выручка → нужна кредитная линия → peak_debt>0, %>0.
|
||||||
cf = [-1000.0, -1000.0, 0.0, 1500.0, 1500.0]
|
cf = [-1000.0, -1000.0, 0.0, 1500.0, 1500.0]
|
||||||
res = _compute_financing(cf, 0.15)
|
res = _compute_financing(cf, 0.15)
|
||||||
assert res["peak_debt_rub"] > 0.0
|
assert res.peak_debt_rub > 0.0
|
||||||
assert res["total_interest_rub"] > 0.0
|
assert res.total_interest_rub > 0.0
|
||||||
|
|
||||||
|
|
||||||
def test_financing_zero_when_no_cash_gap() -> None:
|
def test_financing_zero_when_no_cash_gap() -> None:
|
||||||
# Поток без минуса → финансирование не требуется → debt=0, interest=0.
|
# Поток без минуса → финансирование не требуется → debt=0, interest=0.
|
||||||
cf = [100.0, 200.0, 300.0]
|
cf = [100.0, 200.0, 300.0]
|
||||||
res = _compute_financing(cf, 0.15)
|
res = _compute_financing(cf, 0.15)
|
||||||
assert res["peak_debt_rub"] == 0.0
|
assert res.peak_debt_rub == 0.0
|
||||||
assert res["total_interest_rub"] == 0.0
|
assert res.total_interest_rub == 0.0
|
||||||
assert res["ending_debt_rub"] == 0.0
|
assert res.ending_debt_rub == 0.0
|
||||||
|
|
||||||
|
|
||||||
def test_financing_interest_capitalises_compound() -> None:
|
def test_financing_interest_capitalises_compound() -> None:
|
||||||
|
|
@ -377,9 +377,9 @@ def test_financing_interest_capitalises_compound() -> None:
|
||||||
monthly_rate = (1.0 + 0.15) ** (1.0 / 12.0) - 1.0
|
monthly_rate = (1.0 + 0.15) ** (1.0 / 12.0) - 1.0
|
||||||
# Мес 0: % с нуля = 0, выборка 1000. Мес 1: % = 1000·r. Мес 2: % = (1000+1000·r)·r.
|
# Мес 0: % с нуля = 0, выборка 1000. Мес 1: % = 1000·r. Мес 2: % = (1000+1000·r)·r.
|
||||||
expected = 1000.0 * monthly_rate + (1000.0 + 1000.0 * monthly_rate) * monthly_rate
|
expected = 1000.0 * monthly_rate + (1000.0 + 1000.0 * monthly_rate) * monthly_rate
|
||||||
assert abs(res["total_interest_rub"] - round(expected, 2)) < 0.5
|
assert abs(res.total_interest_rub - round(expected, 2)) < 0.5
|
||||||
# Compound строго больше простого 2× месячного % от исходного тела долга.
|
# Compound строго больше простого 2× месячного % от исходного тела долга.
|
||||||
assert res["total_interest_rub"] > 2.0 * 1000.0 * monthly_rate
|
assert res.total_interest_rub > 2.0 * 1000.0 * monthly_rate
|
||||||
|
|
||||||
|
|
||||||
def test_financing_interest_monotonic_in_rate() -> None:
|
def test_financing_interest_monotonic_in_rate() -> None:
|
||||||
|
|
@ -387,8 +387,8 @@ def test_financing_interest_monotonic_in_rate() -> None:
|
||||||
cf = [-1000.0, -1000.0, 0.0, 1500.0, 1500.0]
|
cf = [-1000.0, -1000.0, 0.0, 1500.0, 1500.0]
|
||||||
low = _compute_financing(cf, 0.10)
|
low = _compute_financing(cf, 0.10)
|
||||||
high = _compute_financing(cf, 0.20)
|
high = _compute_financing(cf, 0.20)
|
||||||
assert high["total_interest_rub"] > low["total_interest_rub"]
|
assert high.total_interest_rub > low.total_interest_rub
|
||||||
assert high["peak_debt_rub"] >= low["peak_debt_rub"]
|
assert high.peak_debt_rub >= low.peak_debt_rub
|
||||||
|
|
||||||
|
|
||||||
def test_net_profit_after_financing_equals_net_minus_interest() -> None:
|
def test_net_profit_after_financing_equals_net_minus_interest() -> None:
|
||||||
|
|
@ -439,11 +439,11 @@ def test_financing_ltc_reduces_debt_vs_full_coverage() -> None:
|
||||||
cf = [-1000.0, -1000.0, -500.0, 0.0, 1500.0, 1500.0]
|
cf = [-1000.0, -1000.0, -500.0, 0.0, 1500.0, 1500.0]
|
||||||
full = _compute_financing(cf, 0.15, ltc_ratio=1.0)
|
full = _compute_financing(cf, 0.15, ltc_ratio=1.0)
|
||||||
ltc70 = _compute_financing(cf, 0.15, ltc_ratio=0.70)
|
ltc70 = _compute_financing(cf, 0.15, ltc_ratio=0.70)
|
||||||
assert ltc70["peak_debt_rub"] < full["peak_debt_rub"]
|
assert ltc70.peak_debt_rub < full.peak_debt_rub
|
||||||
assert ltc70["total_interest_rub"] < full["total_interest_rub"]
|
assert ltc70.total_interest_rub < full.total_interest_rub
|
||||||
# LTC 0.70 → debt = 70% от full для этого fixture (все оттоки ДО притоков,
|
# LTC 0.70 → debt = 70% от full для этого fixture (все оттоки ДО притоков,
|
||||||
# поэтому peak достигается до первого погашения и масштабируется линейно).
|
# поэтому peak достигается до первого погашения и масштабируется линейно).
|
||||||
assert abs(ltc70["peak_debt_rub"] / full["peak_debt_rub"] - 0.70) < 0.01
|
assert abs(ltc70.peak_debt_rub / full.peak_debt_rub - 0.70) < 0.01
|
||||||
|
|
||||||
|
|
||||||
def test_compute_financial_uses_ltc_ratio() -> None:
|
def test_compute_financial_uses_ltc_ratio() -> None:
|
||||||
|
|
@ -463,6 +463,97 @@ def test_compute_financial_uses_ltc_ratio() -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── LEVERED (EQUITY) IRR ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_equity_cashflow_shape_invested_matches_outflow_share() -> None:
|
||||||
|
# equity_invested = Σ |отрицательных equity_cf| ≈ (1−ltc) × Σ оттоков unlevered.
|
||||||
|
cf = [-1000.0, -1000.0, -500.0, 0.0, 1500.0, 1500.0]
|
||||||
|
ltc = 0.70
|
||||||
|
res = _compute_financing(cf, 0.15, ltc_ratio=ltc)
|
||||||
|
equity_cf = res.equity_cashflow
|
||||||
|
assert len(equity_cf) == len(cf)
|
||||||
|
equity_invested = sum(-c for c in equity_cf if c < 0.0)
|
||||||
|
total_outflow = sum(-c for c in cf if c < 0.0)
|
||||||
|
# Equity-доля оттоков = (1−ltc) от каждого разрыва → Σ масштабируется линейно.
|
||||||
|
assert equity_invested == pytest.approx((1.0 - ltc) * total_outflow, rel=1e-9)
|
||||||
|
|
||||||
|
|
||||||
|
def test_equity_cashflow_sum_equals_net_after_financing() -> None:
|
||||||
|
# Σ equity_cashflow == net_profit_after_financing: equity получает всю прибыль за
|
||||||
|
# вычетом процентов (банк — только тело долга + проценты). Sanity на equity-поток.
|
||||||
|
t = _teap(residential=10_000.0, gfa=11_000.0, parking=50)
|
||||||
|
model = financial.compute_financial(
|
||||||
|
teap=t, housing_class="comfort", land_cost_rub=200_000_000.0, development_type="mid_rise"
|
||||||
|
)
|
||||||
|
cf = _rebuild_cashflow(model, construction_months=36, residential_area_sqm=10_000.0)
|
||||||
|
res = _compute_financing(cf, financial._CREDIT_RATE_ANNUAL, financial._LOAN_TO_COST_RATIO)
|
||||||
|
assert abs(sum(res.equity_cashflow) - model.net_profit_after_financing_rub) < 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_levered_irr_exceeds_unlevered_on_profitable_project() -> None:
|
||||||
|
# Положительный леверидж: доходность проекта > стоимости долга 15% → equity-возврат
|
||||||
|
# выше проектного. levered_irr > irr на профитном leveraged-проекте.
|
||||||
|
t = _teap(residential=11_000.0, gfa=12_000.0, parking=80)
|
||||||
|
model = financial.compute_financial(
|
||||||
|
teap=t, housing_class="business", land_cost_rub=100_000_000.0, development_type="spot"
|
||||||
|
)
|
||||||
|
assert model.net_profit_rub > 0
|
||||||
|
assert model.irr_is_proxy is False
|
||||||
|
assert model.levered_irr_is_proxy is False
|
||||||
|
# Положительный леверидж → equity-IRR строго выше unlevered project-IRR.
|
||||||
|
assert model.levered_irr > model.irr
|
||||||
|
|
||||||
|
|
||||||
|
def test_levered_irr_proxy_on_degenerate_equity_flow() -> None:
|
||||||
|
# Вырожденный equity-поток (нет смены знака) → IRR не брекетится → proxy fallback.
|
||||||
|
# Equity-поток без смены знака: только оттоки (нет притока сверх обслуживания долга).
|
||||||
|
cf = [-1000.0, -1000.0, -1000.0] # всегда отток → equity_cf тоже всегда отток
|
||||||
|
equity_cf = _compute_financing(cf, 0.15, ltc_ratio=0.70).equity_cashflow
|
||||||
|
assert _irr_monthly(equity_cf) is None # вырожден → бисекция не брекетит
|
||||||
|
|
||||||
|
|
||||||
|
def test_levered_irr_zero_proxy_when_ltc_full() -> None:
|
||||||
|
# LTC=1.0 → банк покрывает весь разрыв → equity не под риском (equity_invested=0)
|
||||||
|
# → levered_irr=0.0 / proxy (не выдумываем доходность). Проверяем через прямой поток.
|
||||||
|
cf = [-1000.0, -1000.0, 0.0, 1500.0, 1500.0]
|
||||||
|
res = _compute_financing(cf, 0.15, ltc_ratio=1.0)
|
||||||
|
equity_invested = sum(-c for c in res.equity_cashflow if c < 0.0)
|
||||||
|
assert equity_invested == pytest.approx(0.0, abs=1e-9)
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_financial_levered_irr_fields_present() -> None:
|
||||||
|
# Интеграция: профитный участок → levered_irr посчитан, поля присутствуют/типизированы.
|
||||||
|
# business/spot — проектный IRR ВЫШЕ стоимости долга 15% → положительный леверидж
|
||||||
|
# (у marginal-проекта с проектным IRR < 15% леверидж отрицателен — это корректно,
|
||||||
|
# см. test_levered_irr_below_unlevered_on_marginal_project ниже).
|
||||||
|
t = _teap(residential=11_000.0, gfa=12_000.0, parking=80)
|
||||||
|
model = financial.compute_financial(
|
||||||
|
teap=t, housing_class="business", land_cost_rub=100_000_000.0, development_type="spot"
|
||||||
|
)
|
||||||
|
assert isinstance(model.levered_irr, float)
|
||||||
|
assert isinstance(model.levered_irr_is_proxy, bool)
|
||||||
|
# Профитный leveraged-проект (LTC=0.70, проектный IRR > 15%): levered IRR настоящий,
|
||||||
|
# выше unlevered (положительный финансовый рычаг).
|
||||||
|
assert model.levered_irr_is_proxy is False
|
||||||
|
assert model.levered_irr > model.irr
|
||||||
|
|
||||||
|
|
||||||
|
def test_levered_irr_below_unlevered_on_marginal_project() -> None:
|
||||||
|
# ОТРИЦАТЕЛЬНЫЙ леверидж — sanity на корректность знака: marginal comfort/mid_rise
|
||||||
|
# проект, где проектный IRR (~4.6%) НИЖЕ стоимости долга 15% → долг разрушает
|
||||||
|
# стоимость equity → levered_irr < irr. Модель НЕ выдумывает положительный рычаг,
|
||||||
|
# когда проект не отбивает стоимость кредита.
|
||||||
|
t = _teap(residential=10_000.0, gfa=11_000.0, parking=50)
|
||||||
|
model = financial.compute_financial(
|
||||||
|
teap=t, housing_class="comfort", land_cost_rub=200_000_000.0, development_type="mid_rise"
|
||||||
|
)
|
||||||
|
assert model.net_profit_rub > 0 # статически проект прибыльный
|
||||||
|
assert model.irr < model.annual_rate_used # но проектный IRR ниже стоимости долга
|
||||||
|
# Отрицательный рычаг: долг дороже доходности проекта → equity-возврат хуже проектного.
|
||||||
|
assert model.levered_irr < model.irr
|
||||||
|
|
||||||
|
|
||||||
# ── RANK 1: окно продаж по рыночной абсорбции района ────────────────────────────
|
# ── RANK 1: окно продаж по рыночной абсорбции района ────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,16 @@ function formatPct(fraction: number): string {
|
||||||
return `${(fraction * 100).toFixed(1)}%`;
|
return `${(fraction * 100).toFixed(1)}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Знаковый процент — для метрик, которые честно могут быть отрицательными
|
||||||
|
* (IRR на собственные средства при негативном леверидже). Отрицательное число
|
||||||
|
* печатается с Unicode-минусом «−» (U+2212), не ASCII «-» (ui-microcopy).
|
||||||
|
*/
|
||||||
|
function formatSignedPct(fraction: number): string {
|
||||||
|
const body = `${(Math.abs(fraction) * 100).toFixed(1)}%`;
|
||||||
|
return fraction < 0 ? `−${body}` : body;
|
||||||
|
}
|
||||||
|
|
||||||
/** Срок окупаемости: «28.5 мес» или «не окупается» (null). */
|
/** Срок окупаемости: «28.5 мес» или «не окупается» (null). */
|
||||||
function formatPayback(months: number | null): string {
|
function formatPayback(months: number | null): string {
|
||||||
if (months === null) return "не окупается";
|
if (months === null) return "не окупается";
|
||||||
|
|
@ -313,6 +323,17 @@ function VariantPanel({ parcel, variant }: PanelProps) {
|
||||||
"эскроу не моделируется точно. Проценты — реальная стоимость " +
|
"эскроу не моделируется точно. Проценты — реальная стоимость " +
|
||||||
"(чистая прибыль после финансирования ниже прибыли без него)."
|
"(чистая прибыль после финансирования ниже прибыли без него)."
|
||||||
: ""}
|
: ""}
|
||||||
|
{financial.financing_enabled
|
||||||
|
? " IRR на собственные средства — доходность на собственные " +
|
||||||
|
"средства инвестора при кредите LTC 70 %. Может быть выше " +
|
||||||
|
"проектной IRR при положительном леверидже или ниже неё " +
|
||||||
|
"(вплоть до отрицательной), если проект не покрывает стоимость " +
|
||||||
|
"долга." +
|
||||||
|
(financial.levered_irr_is_proxy
|
||||||
|
? " Помечена как оценочная: посчитана по вырожденному " +
|
||||||
|
"equity-потоку (proxy), а не DCF."
|
||||||
|
: "")
|
||||||
|
: ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
@ -463,6 +484,15 @@ function FinancialCascadeTable({ financial }: { financial: FinancialModel }) {
|
||||||
)}
|
)}
|
||||||
emphasis
|
emphasis
|
||||||
/>
|
/>
|
||||||
|
{/* IRR на собственные средства (equity) при LTC 70 %. Может быть выше
|
||||||
|
проектной IRR (положительный леверидж) или ниже / отрицательной
|
||||||
|
(проект не покрывает стоимость долга) — знак показываем честно. */}
|
||||||
|
<CascadeRow
|
||||||
|
label={`IRR на собственные средства (с учётом кредита, LTC 70%)${
|
||||||
|
financial.levered_irr_is_proxy ? ", оценочный" : ""
|
||||||
|
}`}
|
||||||
|
value={formatSignedPct(financial.levered_irr)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,9 @@ function financialFixture(): ParcelFinancialEstimate {
|
||||||
total_interest_rub: 320_000_000,
|
total_interest_rub: 320_000_000,
|
||||||
net_profit_after_financing_rub: 780_000_000,
|
net_profit_after_financing_rub: 780_000_000,
|
||||||
financing_is_simplified: true,
|
financing_is_simplified: true,
|
||||||
|
// Levered (equity) IRR — доходность на собственные средства при LTC 70 %.
|
||||||
|
levered_irr: 0.284,
|
||||||
|
levered_irr_is_proxy: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -228,6 +231,42 @@ describe("ptica-adapt finance (#1881 PR-4)", () => {
|
||||||
expect(keys).not.toContain("Пиковый долг");
|
expect(keys).not.toContain("Пиковый долг");
|
||||||
expect(keys).not.toContain("Проценты по кредиту");
|
expect(keys).not.toContain("Проценты по кредиту");
|
||||||
expect(keys).not.toContain("Чистая прибыль после финанс.");
|
expect(keys).not.toContain("Чистая прибыль после финанс.");
|
||||||
|
// Levered (equity) IRR is a financing-only row → absent when no financing.
|
||||||
|
expect(keys).not.toContain("IRR на собственные средства");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Levered (equity) IRR — доходность на собственные средства при LTC 70 %.
|
||||||
|
it("adaptFinanceDrawer: financing_enabled + DCF levered_irr → real row with LTC caption", () => {
|
||||||
|
const d = adaptFinanceDrawer(financialFixture()); // levered_irr_is_proxy: false
|
||||||
|
const row = d.rows.find((r) => r.k === "IRR на собственные средства");
|
||||||
|
expect(row?.field.isReal).toBe(true);
|
||||||
|
// 0.284 → «28,4 %» (ru запятая-разделитель).
|
||||||
|
expect(row?.field.value).toBe("28,4 %");
|
||||||
|
expect(row?.field.caption).toContain("LTC 70");
|
||||||
|
expect(row?.field.caption).toContain("проектной IRR");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adaptFinanceDrawer: levered_irr_is_proxy → row not real + «оценка (proxy)» caption", () => {
|
||||||
|
const d = adaptFinanceDrawer({
|
||||||
|
...financialFixture(),
|
||||||
|
levered_irr_is_proxy: true,
|
||||||
|
});
|
||||||
|
const row = d.rows.find((r) => r.k === "IRR на собственные средства");
|
||||||
|
// A proxy levered IRR must never read as a live DCF measurement.
|
||||||
|
expect(row?.field.isReal).toBe(false);
|
||||||
|
expect(row?.field.caption).toContain("proxy");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adaptFinanceDrawer: negative levered_irr → Unicode minus, never dressed positive", () => {
|
||||||
|
// Negative leverage (project return < cost of debt) is correct finance — the
|
||||||
|
// value is shown honestly with «−», not hidden or flipped to positive.
|
||||||
|
const d = adaptFinanceDrawer({
|
||||||
|
...financialFixture(),
|
||||||
|
levered_irr: -0.116,
|
||||||
|
});
|
||||||
|
const row = d.rows.find((r) => r.k === "IRR на собственные средства");
|
||||||
|
expect(row?.field.value).toBe("−11,6 %");
|
||||||
|
expect(row?.field.value.startsWith("−")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adaptFinanceDrawer: sales_duration_months shown with schedule source", () => {
|
it("adaptFinanceDrawer: sales_duration_months shown with schedule source", () => {
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,18 @@ function formatPctFromFraction(fraction: number, digits = 1): string {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Знаковый процент из доли — для метрик, которые честно могут быть
|
||||||
|
* отрицательными (levered/equity IRR при негативном леверидже). Отрицательное
|
||||||
|
* число печатается с Unicode-минусом «−» (U+2212), не ASCII «-», как требует
|
||||||
|
* ui-microcopy: «−11,6 %». Форматируем модуль и сами ставим «−», чтобы знак не
|
||||||
|
* зависел от ICU-сборки рантайма (Node даёт ASCII-hyphen).
|
||||||
|
*/
|
||||||
|
function formatSignedPctFromFraction(fraction: number, digits = 1): string {
|
||||||
|
const body = formatPctFromFraction(Math.abs(fraction), digits);
|
||||||
|
return fraction < 0 ? `−${body}` : body;
|
||||||
|
}
|
||||||
|
|
||||||
// #1881 PR-4: backend отдаёт inferred-токены латиницей (housing_class /
|
// #1881 PR-4: backend отдаёт inferred-токены латиницей (housing_class /
|
||||||
// development_type) — локализуем для финмодель-drawer, чтобы финдиректор не
|
// development_type) — локализуем для финмодель-drawer, чтобы финдиректор не
|
||||||
// видел "high_rise" в русском UI. Неизвестный токен → как есть (fallback).
|
// видел "high_rise" в русском UI. Неизвестный токен → как есть (fallback).
|
||||||
|
|
@ -1799,6 +1811,31 @@ export function adaptFinanceDrawer(
|
||||||
`${formatBln(financial.net_profit_after_financing_rub)} млрд ₽`,
|
`${formatBln(financial.net_profit_after_financing_rub)} млрд ₽`,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// IRR на собственные средства (equity) при кредитном плече LTC 70 %.
|
||||||
|
// Может быть ВЫШЕ проектной IRR (положительный леверидж) ИЛИ НИЖЕ /
|
||||||
|
// отрицательной (проект не покрывает стоимость долга) — это корректная
|
||||||
|
// финансовая картина, не прячем знак. isReal только когда финмодель
|
||||||
|
// посчитала equity-IRR по DCF; proxy/выкл. финансирование → не real.
|
||||||
|
k: "IRR на собственные средства",
|
||||||
|
field: financial.levered_irr_is_proxy
|
||||||
|
? {
|
||||||
|
value: `${formatSignedPctFromFraction(financial.levered_irr)} %`,
|
||||||
|
isReal: false,
|
||||||
|
caption:
|
||||||
|
"доходность на собственные средства при кредите LTC 70 % · " +
|
||||||
|
"оценка (proxy), не DCF · может быть ниже проектной IRR, если " +
|
||||||
|
"проект не покрывает стоимость долга",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
value: `${formatSignedPctFromFraction(financial.levered_irr)} %`,
|
||||||
|
isReal: true,
|
||||||
|
caption:
|
||||||
|
"доходность на собственные средства при кредите LTC 70 % · " +
|
||||||
|
"может быть ниже проектной IRR, если проект не покрывает " +
|
||||||
|
"стоимость долга",
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3529,6 +3529,10 @@ export interface components {
|
||||||
* ``irr_is_proxy=True``. VAT is a documented simplification (see docstring), not
|
* ``irr_is_proxy=True``. VAT is a documented simplification (see docstring), not
|
||||||
* exact НК РФ mechanics.
|
* exact НК РФ mechanics.
|
||||||
*
|
*
|
||||||
|
* ``levered_irr`` — IRR на собственные средства (equity cashflow при LTC): тот же
|
||||||
|
* DCF-IRR + roi-proxy fallback, но по equity-потоку графика долга. При положительном
|
||||||
|
* леверидже выше unlevered ``irr``; вырожденные случаи → 0.0 + ``levered_irr_is_proxy``.
|
||||||
|
*
|
||||||
* The legacy summary fields (``revenue_rub`` / ``cost_rub`` /
|
* The legacy summary fields (``revenue_rub`` / ``cost_rub`` /
|
||||||
* ``gross_margin_rub`` / ``irr``) are kept for backward-compat; the new fields
|
* ``gross_margin_rub`` / ``irr``) are kept for backward-compat; the new fields
|
||||||
* expose the full cascade, the net P&L and the DCF metrics.
|
* expose the full cascade, the net P&L and the DCF metrics.
|
||||||
|
|
@ -3626,6 +3630,16 @@ export interface components {
|
||||||
* @default true
|
* @default true
|
||||||
*/
|
*/
|
||||||
financing_is_simplified: boolean;
|
financing_is_simplified: boolean;
|
||||||
|
/**
|
||||||
|
* Levered Irr
|
||||||
|
* @default 0
|
||||||
|
*/
|
||||||
|
levered_irr: number;
|
||||||
|
/**
|
||||||
|
* Levered Irr Is Proxy
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
levered_irr_is_proxy: boolean;
|
||||||
/** Price Per Sqm Used */
|
/** Price Per Sqm Used */
|
||||||
price_per_sqm_used: number;
|
price_per_sqm_used: number;
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,14 @@ export interface FinancialModel {
|
||||||
* ставке-нормативу, проценты капитализируются, эскроу не моделируется точно.
|
* ставке-нормативу, проценты капитализируются, эскроу не моделируется точно.
|
||||||
*/
|
*/
|
||||||
financing_is_simplified: boolean;
|
financing_is_simplified: boolean;
|
||||||
|
/**
|
||||||
|
* IRR на собственные средства (equity) при кредитном плече LTC 70 %. При
|
||||||
|
* положительном леверидже > проектной (unlevered) `irr`; при отрицательном —
|
||||||
|
* ниже неё или отрицательная (проект не покрывает стоимость долга).
|
||||||
|
*/
|
||||||
|
levered_irr: number;
|
||||||
|
/** true → levered_irr — оценка (roi-proxy / вырожденный equity-поток), не DCF. */
|
||||||
|
levered_irr_is_proxy: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PriceSource =
|
export type PriceSource =
|
||||||
|
|
|
||||||
|
|
@ -170,6 +170,8 @@
|
||||||
"total_interest_rub": 224442467.76,
|
"total_interest_rub": 224442467.76,
|
||||||
"net_profit_after_financing_rub": 7180188.49,
|
"net_profit_after_financing_rub": 7180188.49,
|
||||||
"financing_is_simplified": true,
|
"financing_is_simplified": true,
|
||||||
|
"levered_irr": 0.1182,
|
||||||
|
"levered_irr_is_proxy": false,
|
||||||
"price_per_sqm_used": 165000.0,
|
"price_per_sqm_used": 165000.0,
|
||||||
"price_is_calibrated": true,
|
"price_is_calibrated": true,
|
||||||
"price_source": "objective_district_median",
|
"price_source": "objective_district_median",
|
||||||
|
|
|
||||||
|
|
@ -542,6 +542,11 @@ export interface ParcelFinancialEstimate {
|
||||||
total_interest_rub: number;
|
total_interest_rub: number;
|
||||||
net_profit_after_financing_rub: number;
|
net_profit_after_financing_rub: number;
|
||||||
financing_is_simplified: boolean;
|
financing_is_simplified: boolean;
|
||||||
|
// Levered (equity) IRR — доходность на собственные средства (equity cashflow при
|
||||||
|
// LTC). При положительном леверидже > unlevered irr. levered_irr_is_proxy=true →
|
||||||
|
// roi-proxy (вырожденный equity-поток / equity не под риском при LTC>=1), не DCF.
|
||||||
|
levered_irr: number;
|
||||||
|
levered_irr_is_proxy: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PoiCategory =
|
export type PoiCategory =
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue