From 1ae8ae3ca0fa747dc2335995b22ca98588344a76 Mon Sep 17 00:00:00 2001
From: Light1YT
Date: Thu, 25 Jun 2026 13:25:25 +0500
Subject: [PATCH] feat(financial): levered (equity) IRR for the developer
financial model
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Walk the existing debt schedule to emit the equity holder's monthly cashflow
((1−LTC) share of outflows; residual inflow after debt service) and run the
same DCF IRR + roi-proxy fallback over it. Degenerate cases (LTC≥1, no equity
at risk) → 0.0 / proxy, never a fabricated return. Levered IRR можно выше или
НИЖЕ проектной (отрицательный леверидж, если проект не покрывает стоимость долга
15%) — показываем честно, со знаком (Unicode «−»).
Инвариант Σ equity_cf == net_profit_after_financing держится точно. Unlevered
NPV/IRR/PBP и Σ cashflow == net_profit НЕ затронуты (levered чисто additive).
Schema: additive levered_irr / levered_irr_is_proxy на FinancialModel
(api-types.ts регенерён точной CI-цепочкой openapi-typescript+prettier).
Frontend: строка «IRR на собственные средства» в finance-drawer кокпита +
concept-каскаде (drawer-only — не раздуваем above-the-fold KPI), proxy/negative
помечаются честно. Завершает паритет с Excel-эталоном «Авангардная 13» (IRR).
Deep-review ✅ (equity-cashflow математика независимо перепроверена, инвариант,
codegen byte-identical, blast radius contained).
Refs #1881
---
backend/app/schemas/concept.py | 11 ++
backend/app/services/generative/financial.py | 101 ++++++++++++---
.../services/generative/test_financial_dcf.py | 115 ++++++++++++++++--
.../concept/ConceptVariantsResult.tsx | 30 +++++
.../__tests__/ptica-adapt.honesty.test.ts | 39 ++++++
.../site-finder/ptica/ptica-adapt.ts | 37 ++++++
frontend/src/lib/api-types.ts | 14 +++
frontend/src/lib/concept-api.ts | 8 ++
frontend/src/lib/mocks/parcel-analyze.json | 2 +
frontend/src/types/site-finder.ts | 5 +
10 files changed, 332 insertions(+), 30 deletions(-)
diff --git a/backend/app/schemas/concept.py b/backend/app/schemas/concept.py
index 6fc67643..7c0b53cd 100644
--- a/backend/app/schemas/concept.py
+++ b/backend/app/schemas/concept.py
@@ -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) ───────────────────────────────────
# Цена продажи жилья, руб/кв.м, фактически использованная в выручке. Либо
diff --git a/backend/app/services/generative/financial.py b/backend/app/services/generative/financial.py
index 14f268c2..332bfd76 100644
--- a/backend/app/services/generative/financial.py
+++ b/backend/app/services/generative/financial.py
@@ -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
diff --git a/backend/tests/services/generative/test_financial_dcf.py b/backend/tests/services/generative/test_financial_dcf.py
index 67e8f5ff..c3d55040 100644
--- a/backend/tests/services/generative/test_financial_dcf.py
+++ b/backend/tests/services/generative/test_financial_dcf.py
@@ -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: окно продаж по рыночной абсорбции района ────────────────────────────
diff --git a/frontend/src/components/concept/ConceptVariantsResult.tsx b/frontend/src/components/concept/ConceptVariantsResult.tsx
index 239e7b5b..3d5b3b3e 100644
--- a/frontend/src/components/concept/ConceptVariantsResult.tsx
+++ b/frontend/src/components/concept/ConceptVariantsResult.tsx
@@ -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."
+ : "")
+ : ""}
@@ -463,6 +484,15 @@ function FinancialCascadeTable({ financial }: { financial: FinancialModel }) {
)}
emphasis
/>
+ {/* IRR на собственные средства (equity) при LTC 70 %. Может быть выше
+ проектной IRR (положительный леверидж) или ниже / отрицательной
+ (проект не покрывает стоимость долга) — знак показываем честно. */}
+
>
) : null}
diff --git a/frontend/src/components/site-finder/ptica/__tests__/ptica-adapt.honesty.test.ts b/frontend/src/components/site-finder/ptica/__tests__/ptica-adapt.honesty.test.ts
index 72839b0b..e7c3ce20 100644
--- a/frontend/src/components/site-finder/ptica/__tests__/ptica-adapt.honesty.test.ts
+++ b/frontend/src/components/site-finder/ptica/__tests__/ptica-adapt.honesty.test.ts
@@ -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", () => {
diff --git a/frontend/src/components/site-finder/ptica/ptica-adapt.ts b/frontend/src/components/site-finder/ptica/ptica-adapt.ts
index ba8d16df..5b526344 100644
--- a/frontend/src/components/site-finder/ptica/ptica-adapt.ts
+++ b/frontend/src/components/site-finder/ptica/ptica-adapt.ts
@@ -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, если проект не покрывает " +
+ "стоимость долга",
+ },
+ },
]
: [];
diff --git a/frontend/src/lib/api-types.ts b/frontend/src/lib/api-types.ts
index e12faade..df1fc61d 100644
--- a/frontend/src/lib/api-types.ts
+++ b/frontend/src/lib/api-types.ts
@@ -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;
/**
diff --git a/frontend/src/lib/concept-api.ts b/frontend/src/lib/concept-api.ts
index 98559fe3..1283f49d 100644
--- a/frontend/src/lib/concept-api.ts
+++ b/frontend/src/lib/concept-api.ts
@@ -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 =
diff --git a/frontend/src/lib/mocks/parcel-analyze.json b/frontend/src/lib/mocks/parcel-analyze.json
index 61b6c279..79855eff 100644
--- a/frontend/src/lib/mocks/parcel-analyze.json
+++ b/frontend/src/lib/mocks/parcel-analyze.json
@@ -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",
diff --git a/frontend/src/types/site-finder.ts b/frontend/src/types/site-finder.ts
index 642a8bac..cadf612b 100644
--- a/frontend/src/types/site-finder.ts
+++ b/frontend/src/types/site-finder.ts
@@ -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 =