feat(financial): LTC 70%/equity 30% норматив МКД РФ в модели финансирования #1895

Merged
bot-backend merged 1 commit from feat/ltc-equity-financing into main 2026-06-24 16:49:01 +00:00
4 changed files with 58 additions and 18 deletions

View file

@ -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 отток пропорционально, без начисления %.
# Норматив РФ МКД при проектном финансировании: 6575 %. Используем 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(

View file

@ -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: окно продаж по рыночной абсорбции района ────────────────────────────

View file

@ -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("эскроу");
});

View file

@ -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 {