From 7e42b34a46b52fbb564ca176f921590b9ef7832c Mon Sep 17 00:00:00 2001 From: Light1YT Date: Wed, 24 Jun 2026 21:37:17 +0500 Subject: [PATCH] =?UTF-8?q?feat(financial):=20LTC=2070%/equity=2030%=20?= =?UTF-8?q?=D0=BD=D0=BE=D1=80=D0=BC=D0=B0=D1=82=D0=B8=D0=B2=20=D0=9C=D0=9A?= =?UTF-8?q?=D0=94=20=D0=A0=D0=A4=20=D0=B2=20=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D0=B8=20=D1=84=D0=B8=D0=BD=D0=B0=D0=BD=D1=81=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Заменяет 100%-кредитное покрытие разрыва реалистичной LTC-моделью: банк покрывает 70 % каждого оттока, собственные средства — 30 % (без начисления процентов). Итог: total_interest снижается ≈ на 30 %, net_profit_after_financing_rub растёт соответственно. - financial.py: добавлен _LOAN_TO_COST_RATIO=0.70, _compute_financing принимает ltc_ratio (default 1.0 для BC), call site передаёт константу - ptica-adapt.ts: caveat «financing_is_simplified» уточнён (без англицизмов) - test_financial_dcf.py: 2 новых теста (ltc_reduces_debt, uses_ltc_ratio) - honesty.test.ts: assertion обновлён под новый текст caveat --- backend/app/services/generative/financial.py | 36 ++++++++++++------- .../services/generative/test_financial_dcf.py | 29 +++++++++++++++ .../__tests__/ptica-adapt.honesty.test.ts | 5 +-- .../site-finder/ptica/ptica-adapt.ts | 6 ++-- 4 files changed, 58 insertions(+), 18 deletions(-) diff --git a/backend/app/services/generative/financial.py b/backend/app/services/generative/financial.py index c25517cb..82a65566 100644 --- a/backend/app/services/generative/financial.py +++ b/backend/app/services/generative/financial.py @@ -102,17 +102,18 @@ Unlevered (project) DCF — NPV / IRR / PBP — остаётся HEADLINE и Н < net_profit`` для любого проекта с кассовым разрывом. * **Ставка ``annual_rate_used`` — норматив** (``_CREDIT_RATE_ANNUAL``), отдельный от ставки дисконтирования (см. константы), а не выведенная из проекта величина. -* **100% покрытие разрыва** — кредитная линия покрывает весь накопительный - кассовый разрыв; equity / LTC / лимиты банка НЕ моделируются (нет доли - собственных средств). +* **LTC-покрытие разрыва** — кредитная линия покрывает ``_LOAN_TO_COST_RATIO`` + (70 %) каждого ежемесячного оттока; оставшиеся 30 % (собственные средства) + co-fund отток пропорционально и процентами не облагаются. Лимиты банка НЕ + моделируются точно. * **Проценты капитализируются (compound)** — начисляются на остаток на начало месяца ДО движений и добавляются в тело долга. * **Эскроу-специфику не моделируем точно** — раскрытие средств с эскроу-счетов по этапам стройки, лимиты раскрытия, ставка по бридж/целевой части — всё это упрощено до единой капитализируемой ставки на кассовый разрыв. -* **Levered IRR НЕ считается** в этом PR (хрупко на хардкод-100%-долге — отдельный - PR). MVP раскрывает только агрегаты: ``peak_debt_rub`` / ``total_interest_rub`` - / ``net_profit_after_financing_rub``; debt_schedule в схему НЕ выносится. +* **Levered IRR НЕ считается** (отдельный PR). MVP раскрывает только агрегаты: + ``peak_debt_rub`` / ``total_interest_rub`` / ``net_profit_after_financing_rub``; + debt_schedule в схему НЕ выносится. * **Unlevered NPV / IRR / PBP НЕ затронуты** overlay'ем — это всё та же интринсивная доходность. График наследует неточность ``schedule_is_default``: финансирование считается по тому же дефолт-графику фаз, поэтому @@ -223,6 +224,10 @@ _DISCOUNT_RATE_ANNUAL: float = 0.15 # ставка = стоимость долга (проценты). Совпадение значения (0.15) — калибровка, # НЕ тождество; не сливать в одну константу. _CREDIT_RATE_ANNUAL: float = 0.15 +# Доля банковского кредита от каждого ежемесячного оттока (LTC, Loan-to-Cost). +# Собственные средства = (1 − LTC) co-fund отток пропорционально, без начисления %. +# Норматив РФ МКД при проектном финансировании: 65–75 %. Используем 70 % (медиана). +_LOAN_TO_COST_RATIO: float = 0.70 # Длительность ПИР (проектирование/экспертиза), мес. _PIR_DURATION_MONTHS: int = 6 # Длительность СМР по типу застройки, мес (точечная быстрее высотной). @@ -329,14 +334,19 @@ def _payback_months(cf: list[float]) -> float | None: return None -def _compute_financing(operating_cf: list[float], annual_rate: float) -> dict[str, float]: +def _compute_financing( + operating_cf: list[float], + annual_rate: float, + ltc_ratio: float = 1.0, +) -> dict[str, float]: """OVERLAY финансирования поверх unlevered cashflow (PR-5). Чистая функция. ``operating_cf`` — готовый unlevered cashflow (READ-ONLY, НЕ мутируется). - Кредитная линия покрывает накопительный кассовый разрыв на 100%; проценты - КАПИТАЛИЗИРУЮТСЯ в долг (compound) — упрощение под эскроу-логику РФ. Порядок - внутри месяца: процент НАЧИСЛЯЕМ на остаток на начало месяца ДО draw/repay, - затем выборка (cf<0) либо погашение притоком (cf>0, не уходя ниже 0). + Кредитная линия покрывает ``ltc_ratio`` долю кассового разрыва; оставшийся + (1 − ltc_ratio) — equity-взнос (без процентов). Проценты КАПИТАЛИЗИРУЮТСЯ + в долг (compound) — упрощение под эскроу-логику РФ. Порядок внутри месяца: + процент НАЧИСЛЯЕМ на остаток ДО draw/repay, затем выборка (cf<0) либо + погашение притоком (cf>0, не уходя ниже 0). Если поток не уходит в минус → долг не выбирается → debt=0, interest=0 (корректно: финансирование не требуется). @@ -354,7 +364,7 @@ def _compute_financing(operating_cf: list[float], annual_rate: float) -> dict[st debt += interest total_interest += interest if cf < 0: - debt += -cf # выборка на покрытие оттока + debt += -cf * ltc_ratio # банк покрывает ltc_ratio от оттока; equity-часть без % else: debt -= min(cf, debt) # гасим долг притоком (не уходим в минус) peak_debt = max(peak_debt, debt) @@ -624,7 +634,7 @@ def compute_financial( # Тот же массив cashflow, что построен выше (НЕ пересобираем — защита инварианта: # overlay читает unlevered поток и НЕ мутирует его). Проценты — реальная стоимость # долга, режут прибыль: net_after < net. Levered IRR НЕ считаем (отдельный PR). - financing = _compute_financing(cashflow, _CREDIT_RATE_ANNUAL) + financing = _compute_financing(cashflow, _CREDIT_RATE_ANNUAL, _LOAN_TO_COST_RATIO) net_profit_after_financing = net_profit - financing["total_interest_rub"] model = FinancialModel( diff --git a/backend/tests/services/generative/test_financial_dcf.py b/backend/tests/services/generative/test_financial_dcf.py index 90e5a9e6..2b5d630f 100644 --- a/backend/tests/services/generative/test_financial_dcf.py +++ b/backend/tests/services/generative/test_financial_dcf.py @@ -378,6 +378,35 @@ def test_financing_enabled_on_realistic_parcel() -> None: assert model.peak_debt_rub > 0.0 +def test_financing_ltc_reduces_debt_vs_full_coverage() -> None: + # LTC 0.70 → пиковый долг и проценты < чем при 100% покрытии (ltc=1.0). + 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"] + # LTC 0.70 → debt = 70% от full для этого fixture (все оттоки ДО притоков, + # поэтому peak достигается до первого погашения и масштабируется линейно). + assert abs(ltc70["peak_debt_rub"] / full["peak_debt_rub"] - 0.70) < 0.01 + + +def test_compute_financial_uses_ltc_ratio() -> None: + # compute_financial передаёт _LOAN_TO_COST_RATIO в _compute_financing: + # total_interest строго < чем при 100%-долге на том же cashflow. + 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" + ) + # Нет прямого доступа к «100%-версии», но LTC < 1.0 → peak_debt < revenue + # (у реалистичного МКД-проекта затраты ~ 70-80% выручки, значит + # LTC*затраты < затраты — пиковый долг не равен всем затратам). + assert 0.0 < model.peak_debt_rub < model.cost_rub + assert model.total_interest_rub > 0.0 + assert model.net_profit_after_financing_rub == pytest.approx( + model.net_profit_rub - model.total_interest_rub, abs=0.01 + ) + + # ── RANK 1: окно продаж по рыночной абсорбции района ──────────────────────────── 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 16ee44a1..72839b0b 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 @@ -212,9 +212,10 @@ describe("ptica-adapt finance (#1881 PR-4)", () => { expect(interest?.field.caption).toContain("годовых"); }); - it("adaptFinanceDrawer: financing_is_simplified → caveat appended to summary", () => { + it("adaptFinanceDrawer: financing_is_simplified → кредитная/собственные caveat appended to summary", () => { const d = adaptFinanceDrawer(financialFixture()); - expect(d.summary).toContain("упрощен"); + expect(d.summary).toContain("кредитная линия"); + expect(d.summary).toContain("собственные средства"); expect(d.summary).toContain("эскроу"); }); diff --git a/frontend/src/components/site-finder/ptica/ptica-adapt.ts b/frontend/src/components/site-finder/ptica/ptica-adapt.ts index e692a54e..faa57fd2 100644 --- a/frontend/src/components/site-finder/ptica/ptica-adapt.ts +++ b/frontend/src/components/site-finder/ptica/ptica-adapt.ts @@ -1788,9 +1788,9 @@ export function adaptFinanceDrawer( const summary = financial.financing_enabled && financial.financing_is_simplified - ? `${financial.caveat} Финансирование упрощено: весь кассовый разрыв ` + - "покрыт кредитом по ставке-нормативу, проценты капитализируются, эскроу " + - "не моделируется точно." + ? `${financial.caveat} Финансирование: 70 % — банк (кредитная линия, норматив МКД РФ), ` + + "30 % — собственные средства инвестора (без процентов). Проценты капитализируются, " + + "эскроу не моделируется точно." : financial.caveat; return { -- 2.45.3