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
|
||||
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`` /
|
||||
``gross_margin_rub`` / ``irr``) are kept for backward-compat; the new fields
|
||||
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
|
||||
# honest-флаг: 100% покрытие разрыва, compound %, эскроу не моделируется точно.
|
||||
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) ───────────────────────────────────
|
||||
# Цена продажи жилья, руб/кв.м, фактически использованная в выручке. Либо
|
||||
|
|
|
|||
|
|
@ -116,9 +116,16 @@ Unlevered (project) DCF — NPV / IRR / PBP — остаётся HEADLINE и Н
|
|||
* **Эскроу-специфику не моделируем точно** — раскрытие средств с эскроу-счетов
|
||||
по этапам стройки, лимиты раскрытия, ставка по бридж/целевой части — всё это
|
||||
упрощено до единой капитализируемой ставки на кассовый разрыв.
|
||||
* **Levered IRR НЕ считается** (отдельный PR). MVP раскрывает только агрегаты:
|
||||
``peak_debt_rub`` / ``total_interest_rub`` / ``net_profit_after_financing_rub``;
|
||||
debt_schedule в схему НЕ выносится.
|
||||
* **Levered (equity) IRR** считается по equity-потоку того же графика долга
|
||||
(``_FinancingResult.equity_cashflow``): отток = equity-доля (1−LTC) каждого
|
||||
кассового разрыва, приток = остаток притока после обслуживания долга. При
|
||||
положительном леверидже (доходность проекта > стоимости долга 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'ем — это всё та же
|
||||
интринсивная доходность. График наследует неточность ``schedule_is_default``:
|
||||
финансирование считается по тому же дефолт-графику фаз, поэтому
|
||||
|
|
@ -339,11 +346,29 @@ def _payback_months(cf: list[float]) -> float | 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(
|
||||
operating_cf: list[float],
|
||||
annual_rate: float,
|
||||
ltc_ratio: float = 1.0,
|
||||
) -> dict[str, float]:
|
||||
) -> _FinancingResult:
|
||||
"""OVERLAY финансирования поверх unlevered cashflow (PR-5). Чистая функция.
|
||||
|
||||
``operating_cf`` — готовый unlevered cashflow (READ-ONLY, НЕ мутируется).
|
||||
|
|
@ -356,28 +381,37 @@ def _compute_financing(
|
|||
Если поток не уходит в минус → долг не выбирается → debt=0, interest=0
|
||||
(корректно: финансирование не требуется).
|
||||
|
||||
Возвращает агрегаты (без debt_schedule — только итоговые числа для MVP):
|
||||
``peak_debt_rub`` — пиковая задолженность, ``total_interest_rub`` — суммарные
|
||||
капитализированные проценты, ``ending_debt_rub`` — остаток долга на конец.
|
||||
ВОЗВРАТ — :class:`_FinancingResult`: агрегаты (``peak_debt_rub`` — пиковая
|
||||
задолженность, ``total_interest_rub`` — суммарные капитализированные проценты,
|
||||
``ending_debt_rub`` — остаток долга на конец) ПЛЮС ``equity_cashflow`` —
|
||||
помесячный поток держателя equity по тому же графику долга (для levered IRR,
|
||||
отдельный PR; см. :class:`_FinancingResult`). Агрегаты не изменились — контракт цел.
|
||||
"""
|
||||
monthly_rate = (1.0 + annual_rate) ** (1.0 / 12.0) - 1.0
|
||||
debt = 0.0
|
||||
peak_debt = 0.0
|
||||
total_interest = 0.0
|
||||
equity_cashflow: list[float] = []
|
||||
for cf in operating_cf:
|
||||
interest = debt * monthly_rate # процент на остаток на начало месяца (до движений)
|
||||
debt += interest
|
||||
total_interest += interest
|
||||
if cf < 0:
|
||||
debt += -cf * ltc_ratio # банк покрывает ltc_ratio от оттока; equity-часть без %
|
||||
# Equity вносит (1−ltc) долю оттока (со знаком −, как и cf): свои деньги под риском.
|
||||
equity_cashflow.append(cf * (1.0 - ltc_ratio))
|
||||
else:
|
||||
debt -= min(cf, debt) # гасим долг притоком (не уходим в минус)
|
||||
repay = min(cf, debt) # гасим долг притоком (не уходим в минус)
|
||||
debt -= repay
|
||||
# Остаток притока после обслуживания долга достаётся equity (приток, +).
|
||||
equity_cashflow.append(cf - repay)
|
||||
peak_debt = max(peak_debt, debt)
|
||||
return {
|
||||
"peak_debt_rub": round(peak_debt, 2),
|
||||
"total_interest_rub": round(total_interest, 2),
|
||||
"ending_debt_rub": round(debt, 2),
|
||||
}
|
||||
return _FinancingResult(
|
||||
peak_debt_rub=round(peak_debt, 2),
|
||||
total_interest_rub=round(total_interest, 2),
|
||||
ending_debt_rub=round(debt, 2),
|
||||
equity_cashflow=equity_cashflow,
|
||||
)
|
||||
|
||||
|
||||
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) ───────────────
|
||||
# Тот же массив cashflow, что построен выше (НЕ пересобираем — защита инварианта:
|
||||
# overlay читает unlevered поток и НЕ мутирует его). Проценты — реальная стоимость
|
||||
# долга, режут прибыль: net_after < net. Levered IRR НЕ считаем (отдельный PR).
|
||||
# долга, режут прибыль: net_after < net.
|
||||
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(
|
||||
# legacy summary
|
||||
|
|
@ -688,10 +748,13 @@ def compute_financial(
|
|||
# financing overlay (PR-5)
|
||||
financing_enabled=True,
|
||||
annual_rate_used=_CREDIT_RATE_ANNUAL,
|
||||
peak_debt_rub=financing["peak_debt_rub"],
|
||||
total_interest_rub=financing["total_interest_rub"],
|
||||
peak_debt_rub=financing.peak_debt_rub,
|
||||
total_interest_rub=financing.total_interest_rub,
|
||||
net_profit_after_financing_rub=round(net_profit_after_financing, 2),
|
||||
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_per_sqm_used=round(sale_price, 2),
|
||||
price_is_calibrated=price_is_calibrated,
|
||||
|
|
@ -700,7 +763,7 @@ def compute_financial(
|
|||
logger.info(
|
||||
"financial: revenue=%.0f cost=%.0f net=%.0f roi=%.3f npv=%.0f "
|
||||
"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.cost_rub,
|
||||
model.net_profit_rub,
|
||||
|
|
@ -714,6 +777,8 @@ def compute_financial(
|
|||
model.peak_debt_rub,
|
||||
model.total_interest_rub,
|
||||
model.net_profit_after_financing_rub,
|
||||
model.levered_irr,
|
||||
" (proxy)" if model.levered_irr_is_proxy else "",
|
||||
)
|
||||
return model
|
||||
|
||||
|
|
|
|||
|
|
@ -175,6 +175,9 @@ def test_concepts_response_matches_contract_keys() -> None:
|
|||
"total_interest_rub",
|
||||
"net_profit_after_financing_rub",
|
||||
"financing_is_simplified",
|
||||
# levered (equity) IRR
|
||||
"levered_irr",
|
||||
"levered_irr_is_proxy",
|
||||
# price calibration (PR-2)
|
||||
"price_per_sqm_used",
|
||||
"price_is_calibrated",
|
||||
|
|
|
|||
|
|
@ -356,17 +356,17 @@ def test_financing_draws_debt_on_cash_gap() -> None:
|
|||
# Ранний отток / поздняя выручка → нужна кредитная линия → peak_debt>0, %>0.
|
||||
cf = [-1000.0, -1000.0, 0.0, 1500.0, 1500.0]
|
||||
res = _compute_financing(cf, 0.15)
|
||||
assert res["peak_debt_rub"] > 0.0
|
||||
assert res["total_interest_rub"] > 0.0
|
||||
assert res.peak_debt_rub > 0.0
|
||||
assert res.total_interest_rub > 0.0
|
||||
|
||||
|
||||
def test_financing_zero_when_no_cash_gap() -> None:
|
||||
# Поток без минуса → финансирование не требуется → debt=0, interest=0.
|
||||
cf = [100.0, 200.0, 300.0]
|
||||
res = _compute_financing(cf, 0.15)
|
||||
assert res["peak_debt_rub"] == 0.0
|
||||
assert res["total_interest_rub"] == 0.0
|
||||
assert res["ending_debt_rub"] == 0.0
|
||||
assert res.peak_debt_rub == 0.0
|
||||
assert res.total_interest_rub == 0.0
|
||||
assert res.ending_debt_rub == 0.0
|
||||
|
||||
|
||||
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
|
||||
# Мес 0: % с нуля = 0, выборка 1000. Мес 1: % = 1000·r. Мес 2: % = (1000+1000·r)·r.
|
||||
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× месячного % от исходного тела долга.
|
||||
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:
|
||||
|
|
@ -387,8 +387,8 @@ def test_financing_interest_monotonic_in_rate() -> None:
|
|||
cf = [-1000.0, -1000.0, 0.0, 1500.0, 1500.0]
|
||||
low = _compute_financing(cf, 0.10)
|
||||
high = _compute_financing(cf, 0.20)
|
||||
assert high["total_interest_rub"] > low["total_interest_rub"]
|
||||
assert high["peak_debt_rub"] >= low["peak_debt_rub"]
|
||||
assert high.total_interest_rub > low.total_interest_rub
|
||||
assert high.peak_debt_rub >= low.peak_debt_rub
|
||||
|
||||
|
||||
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]
|
||||
full = _compute_financing(cf, 0.15, ltc_ratio=1.0)
|
||||
ltc70 = _compute_financing(cf, 0.15, ltc_ratio=0.70)
|
||||
assert ltc70["peak_debt_rub"] < full["peak_debt_rub"]
|
||||
assert ltc70["total_interest_rub"] < full["total_interest_rub"]
|
||||
assert ltc70.peak_debt_rub < full.peak_debt_rub
|
||||
assert ltc70.total_interest_rub < full.total_interest_rub
|
||||
# LTC 0.70 → debt = 70% от full для этого fixture (все оттоки ДО притоков,
|
||||
# поэтому 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:
|
||||
|
|
@ -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: окно продаж по рыночной абсорбции района ────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -68,6 +68,16 @@ function formatPct(fraction: number): string {
|
|||
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). */
|
||||
function formatPayback(months: number | null): string {
|
||||
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>
|
||||
</div>
|
||||
</details>
|
||||
|
|
@ -463,6 +484,15 @@ function FinancialCascadeTable({ financial }: { financial: FinancialModel }) {
|
|||
)}
|
||||
emphasis
|
||||
/>
|
||||
{/* IRR на собственные средства (equity) при LTC 70 %. Может быть выше
|
||||
проектной IRR (положительный леверидж) или ниже / отрицательной
|
||||
(проект не покрывает стоимость долга) — знак показываем честно. */}
|
||||
<CascadeRow
|
||||
label={`IRR на собственные средства (с учётом кредита, LTC 70%)${
|
||||
financial.levered_irr_is_proxy ? ", оценочный" : ""
|
||||
}`}
|
||||
value={formatSignedPct(financial.levered_irr)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</tbody>
|
||||
|
|
|
|||
|
|
@ -140,6 +140,9 @@ function financialFixture(): ParcelFinancialEstimate {
|
|||
total_interest_rub: 320_000_000,
|
||||
net_profit_after_financing_rub: 780_000_000,
|
||||
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("Чистая прибыль после финанс.");
|
||||
// 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", () => {
|
||||
|
|
|
|||
|
|
@ -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 /
|
||||
// development_type) — локализуем для финмодель-drawer, чтобы финдиректор не
|
||||
// видел "high_rise" в русском UI. Неизвестный токен → как есть (fallback).
|
||||
|
|
@ -1799,6 +1811,31 @@ export function adaptFinanceDrawer(
|
|||
`${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
|
||||
* 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`` /
|
||||
* ``gross_margin_rub`` / ``irr``) are kept for backward-compat; the new fields
|
||||
* expose the full cascade, the net P&L and the DCF metrics.
|
||||
|
|
@ -3626,6 +3630,16 @@ export interface components {
|
|||
* @default true
|
||||
*/
|
||||
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: number;
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -107,6 +107,14 @@ export interface FinancialModel {
|
|||
* ставке-нормативу, проценты капитализируются, эскроу не моделируется точно.
|
||||
*/
|
||||
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 =
|
||||
|
|
|
|||
|
|
@ -170,6 +170,8 @@
|
|||
"total_interest_rub": 224442467.76,
|
||||
"net_profit_after_financing_rub": 7180188.49,
|
||||
"financing_is_simplified": true,
|
||||
"levered_irr": 0.1182,
|
||||
"levered_irr_is_proxy": false,
|
||||
"price_per_sqm_used": 165000.0,
|
||||
"price_is_calibrated": true,
|
||||
"price_source": "objective_district_median",
|
||||
|
|
|
|||
|
|
@ -542,6 +542,11 @@ export interface ParcelFinancialEstimate {
|
|||
total_interest_rub: number;
|
||||
net_profit_after_financing_rub: number;
|
||||
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 =
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue