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 =