From d350d27325cbb636ef4282280cefa62d484b6a09 Mon Sep 17 00:00:00 2001
From: Light1YT
Date: Wed, 24 Jun 2026 01:12:47 +0500
Subject: [PATCH] =?UTF-8?q?feat(finmodel):=20financing=20overlay=20(=D0=BA?=
=?UTF-8?q?=D1=80=D0=B5=D0=B4=D0=B8=D1=82=2015%)=20=D0=BF=D0=BE=D0=B2?=
=?UTF-8?q?=D0=B5=D1=80=D1=85=20unlevered=20DCF=20(epic=20#1881=20PR-5)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Финансирование как ДОПОЛНИТЕЛЬНЫЙ overlay поверх готового unlevered cashflow:
кредитная линия покрывает накопительный кассовый разрыв, проценты капитализируются
(compound), ставка _CREDIT_RATE_ANNUAL=15% (норматив, ОТДЕЛЬНО от ставки
дисконтирования — разная семантика: стоимость долга vs требуемая доходность).
Backend (financial.py):
- _compute_financing(operating_cf, annual_rate) — чистая (READ-ONLY вход, не
мутирует): процент на остаток на начало месяца ДО движений → капитализация;
draw=-cf при оттоке, repay=min(cf,debt) при притоке (долг не уходит в минус).
Возвращает peak_debt_rub / total_interest_rub / ending_debt_rub.
- compute_financial: вызов после DCF, net_profit_after_financing = net − interest.
- _build_monthly_cashflow и unlevered NPV/IRR/PBP НЕ ТРОНУТЫ → инвариант
Σ unlevered cashflow == net_profit сохранён (регресс-тест).
- FinancialModel +6 полей (financing_enabled/annual_rate_used/peak_debt_rub/
total_interest_rub/net_profit_after_financing_rub/financing_is_simplified).
Frontend: ParcelFinancialEstimate +6 полей; adaptFinanceDrawer + concept-каскад
показывают пиковый долг/проценты/чистую прибыль после финанс. (когда enabled).
MVP: только агрегаты, БЕЗ levered IRR (хрупко на хардкод-100%-долге — отдельный
PR). HEAVY caveat: проценты — реальная стоимость (net после < net до); 100%
покрытие разрыва (нет equity/LTC); compound; эскроу не моделируется; unlevered
headline не затронут. financing_is_simplified=True флаг.
mypy strict clean, +9 backend + 3 frontend тестов (compound точное значение,
no-mutation, инвариант-регресс, монотонность, net_after float | None:
return None
+def _compute_financing(operating_cf: list[float], annual_rate: float) -> dict[str, float]:
+ """OVERLAY финансирования поверх unlevered cashflow (PR-5). Чистая функция.
+
+ ``operating_cf`` — готовый unlevered cashflow (READ-ONLY, НЕ мутируется).
+ Кредитная линия покрывает накопительный кассовый разрыв на 100%; проценты
+ КАПИТАЛИЗИРУЮТСЯ в долг (compound) — упрощение под эскроу-логику РФ. Порядок
+ внутри месяца: процент НАЧИСЛЯЕМ на остаток на начало месяца ДО draw/repay,
+ затем выборка (cf<0) либо погашение притоком (cf>0, не уходя ниже 0).
+
+ Если поток не уходит в минус → долг не выбирается → debt=0, interest=0
+ (корректно: финансирование не требуется).
+
+ Возвращает агрегаты (без debt_schedule — только итоговые числа для MVP):
+ ``peak_debt_rub`` — пиковая задолженность, ``total_interest_rub`` — суммарные
+ капитализированные проценты, ``ending_debt_rub`` — остаток долга на конец.
+ """
+ monthly_rate = (1.0 + annual_rate) ** (1.0 / 12.0) - 1.0
+ debt = 0.0
+ peak_debt = 0.0
+ total_interest = 0.0
+ for cf in operating_cf:
+ interest = debt * monthly_rate # процент на остаток на начало месяца (до движений)
+ debt += interest
+ total_interest += interest
+ if cf < 0:
+ debt += -cf # выборка на покрытие оттока
+ else:
+ debt -= min(cf, debt) # гасим долг притоком (не уходим в минус)
+ 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),
+ }
+
+
def _spread_linear(total: float, start: int, duration: int, horizon: int) -> list[float]:
"""Разложить ``total`` равными долями по месяцам ``[start .. start+duration)``.
@@ -457,6 +526,13 @@ def compute_financial(
irr = max(-1.0, min(1.0, roi / _PROJECT_YEARS))
irr_is_proxy = True
+ # ── Финансирование: OVERLAY поверх unlevered cashflow (PR-5) ───────────────
+ # Тот же массив cashflow, что построен выше (НЕ пересобираем — защита инварианта:
+ # overlay читает unlevered поток и НЕ мутирует его). Проценты — реальная стоимость
+ # долга, режут прибыль: net_after < net. Levered IRR НЕ считаем (отдельный PR).
+ financing = _compute_financing(cashflow, _CREDIT_RATE_ANNUAL)
+ net_profit_after_financing = net_profit - financing["total_interest_rub"]
+
model = FinancialModel(
# legacy summary
revenue_rub=round(revenue, 2),
@@ -488,6 +564,13 @@ def compute_financial(
payback_months=(round(payback, 1) if payback is not None else None),
discount_rate_used=_DISCOUNT_RATE_ANNUAL,
schedule_is_default=True,
+ # 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"],
+ net_profit_after_financing_rub=round(net_profit_after_financing, 2),
+ financing_is_simplified=True,
# price calibration (PR-2)
price_per_sqm_used=round(sale_price, 2),
price_is_calibrated=price_is_calibrated,
@@ -495,7 +578,8 @@ 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",
+ "irr=%.3f%s pbp=%s price=%.0f/m2 source=%s "
+ "peak_debt=%.0f total_interest=%.0f net_after_fin=%.0f",
model.revenue_rub,
model.cost_rub,
model.net_profit_rub,
@@ -506,6 +590,9 @@ def compute_financial(
model.payback_months,
model.price_per_sqm_used,
model.price_source,
+ model.peak_debt_rub,
+ model.total_interest_rub,
+ model.net_profit_after_financing_rub,
)
return model
diff --git a/backend/tests/services/generative/test_api_concepts.py b/backend/tests/services/generative/test_api_concepts.py
index bb179fe3..4aebb4bb 100644
--- a/backend/tests/services/generative/test_api_concepts.py
+++ b/backend/tests/services/generative/test_api_concepts.py
@@ -167,6 +167,13 @@ def test_concepts_response_matches_contract_keys() -> None:
"payback_months",
"discount_rate_used",
"schedule_is_default",
+ # financing overlay (PR-5)
+ "financing_enabled",
+ "annual_rate_used",
+ "peak_debt_rub",
+ "total_interest_rub",
+ "net_profit_after_financing_rub",
+ "financing_is_simplified",
# price calibration (PR-2)
"price_per_sqm_used",
"price_is_calibrated",
diff --git a/backend/tests/services/generative/test_financial_dcf.py b/backend/tests/services/generative/test_financial_dcf.py
index c11c6bbc..b1d662bf 100644
--- a/backend/tests/services/generative/test_financial_dcf.py
+++ b/backend/tests/services/generative/test_financial_dcf.py
@@ -17,6 +17,7 @@ from app.schemas.concept import TEAP
from app.services.generative import financial
from app.services.generative.financial import (
_build_monthly_cashflow,
+ _compute_financing,
_irr_monthly,
_npv,
_payback_months,
@@ -255,3 +256,96 @@ def test_unknown_development_type_defaults_to_mid_rise() -> None:
)
assert none_type.npv_rub == explicit_mid.npv_rub
assert none_type.irr == explicit_mid.irr
+
+
+# ── FINANCING — OVERLAY (PR-5) ──────────────────────────────────────────────────
+
+
+def test_financing_does_not_mutate_operating_cashflow() -> None:
+ # OVERLAY: _compute_financing читает unlevered поток READ-ONLY, НЕ мутирует.
+ cf = [-1000.0, -500.0, 0.0, 800.0, 900.0]
+ snapshot = list(cf)
+ _compute_financing(cf, 0.15)
+ assert cf == snapshot
+
+
+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
+
+
+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
+
+
+def test_financing_interest_capitalises_compound() -> None:
+ # Долг 1000 в мес 0, дальше ноль → проценты КАПИТАЛИЗИРУЮТСЯ (начисляются на
+ # растущий остаток). За мес 1 и 2 суммарный % > 2× месячного % от 1000 (compound).
+ cf = [-1000.0, 0.0, 0.0]
+ res = _compute_financing(cf, 0.15)
+ 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
+ # Compound строго больше простого 2× месячного % от исходного тела долга.
+ assert res["total_interest_rub"] > 2.0 * 1000.0 * monthly_rate
+
+
+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"]
+
+
+def test_net_profit_after_financing_equals_net_minus_interest() -> None:
+ # Тождество модели: net_after == net_profit − total_interest (float-tol).
+ 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"
+ )
+ expected = model.net_profit_rub - model.total_interest_rub
+ assert abs(model.net_profit_after_financing_rub - expected) < 0.01
+
+
+def test_net_profit_after_financing_below_net_for_project_with_gap() -> None:
+ # Проект с кассовым разрывом: проценты режут прибыль → net_after < net.
+ 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.total_interest_rub > 0.0
+ assert model.net_profit_after_financing_rub < model.net_profit_rub
+
+
+def test_financing_overlay_does_not_touch_unlevered_invariant() -> None:
+ # РЕГРЕСС: overlay не трогает unlevered cashflow → инвариант Σ cf == net_profit
+ # держится как до PR-5 (financing — поверх готового потока, не пересборка).
+ 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)
+ assert abs(sum(cf) - model.net_profit_rub) < 1.0
+
+
+def test_financing_enabled_on_realistic_parcel() -> None:
+ # Интеграция compute_financial: реалистичный участок → overlay посчитан.
+ 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.financing_enabled is True
+ assert model.financing_is_simplified is True
+ assert model.annual_rate_used == financial._CREDIT_RATE_ANNUAL
+ assert model.peak_debt_rub > 0.0
diff --git a/frontend/src/components/concept/ConceptVariantsResult.tsx b/frontend/src/components/concept/ConceptVariantsResult.tsx
index e328d99f..eacb9ebd 100644
--- a/frontend/src/components/concept/ConceptVariantsResult.tsx
+++ b/frontend/src/components/concept/ConceptVariantsResult.tsx
@@ -307,6 +307,12 @@ function VariantPanel({ parcel, variant }: PanelProps) {
: "норматив класса (нет рыночных данных по участку)"}
; себестоимость СМР и цена паркинга — нормативные ориентиры.
Коммерческие и офисные площади не учитываются.
+ {financial.financing_enabled && financial.financing_is_simplified
+ ? " Финансирование упрощено: весь кассовый разрыв покрыт " +
+ "кредитом по ставке-нормативу, проценты капитализируются, " +
+ "эскроу не моделируется точно. Проценты — реальная стоимость " +
+ "(чистая прибыль после финансирования ниже прибыли без него)."
+ : ""}
@@ -436,6 +442,29 @@ function FinancialCascadeTable({ financial }: { financial: FinancialModel }) {
label="Окупаемость (PBP)"
value={formatPayback(financial.payback_months)}
/>
+ {/* #1881 PR-5 — финансирование (кредит). Показываем только когда движок
+ смоделировал финансирование (financing_enabled). */}
+ {financial.financing_enabled ? (
+ <>
+
+
+
+ >
+ ) : 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 eb6b2092..d383686c 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
@@ -129,6 +129,13 @@ function financialFixture(): ParcelFinancialEstimate {
apartments_count: 520,
},
caveat: "Расчёт по максимальной застройке НСПД, а не реальной концепции.",
+ // #1881 PR-5 — финансирование (кредит).
+ financing_enabled: true,
+ annual_rate_used: 0.18,
+ peak_debt_rub: 1_800_000_000,
+ total_interest_rub: 320_000_000,
+ net_profit_after_financing_rub: 780_000_000,
+ financing_is_simplified: true,
};
}
@@ -182,9 +189,39 @@ describe("ptica-adapt finance (#1881 PR-4)", () => {
const fin = financialFixture();
const d = adaptFinanceDrawer(fin);
expect(d.rows.every((r) => r.field.isReal === true)).toBe(true);
- expect(d.summary).toBe(fin.caveat);
const keys = d.rows.map((r) => r.k);
expect(keys).toContain("IRR");
expect(keys).toContain("ТЭП · квартир");
});
+
+ // #1881 PR-5 — финансирование (кредит)
+ it("adaptFinanceDrawer: financing_enabled → debt/interest/net-after rows + caption", () => {
+ const fin = financialFixture(); // financing_enabled: true
+ const d = adaptFinanceDrawer(fin);
+ const keys = d.rows.map((r) => r.k);
+ expect(keys).toContain("Пиковый долг");
+ expect(keys).toContain("Проценты по кредиту");
+ expect(keys).toContain("Чистая прибыль после финанс.");
+ const interest = d.rows.find((r) => r.k === "Проценты по кредиту");
+ expect(interest?.field.isReal).toBe(true);
+ expect(interest?.field.caption).toContain("18");
+ expect(interest?.field.caption).toContain("годовых");
+ });
+
+ it("adaptFinanceDrawer: financing_is_simplified → caveat appended to summary", () => {
+ const d = adaptFinanceDrawer(financialFixture());
+ expect(d.summary).toContain("упрощен");
+ expect(d.summary).toContain("эскроу");
+ });
+
+ it("adaptFinanceDrawer: financing_enabled=false → no financing rows", () => {
+ const d = adaptFinanceDrawer({
+ ...financialFixture(),
+ financing_enabled: false,
+ });
+ const keys = d.rows.map((r) => r.k);
+ expect(keys).not.toContain("Пиковый долг");
+ expect(keys).not.toContain("Проценты по кредиту");
+ expect(keys).not.toContain("Чистая прибыль после финанс.");
+ });
});
diff --git a/frontend/src/components/site-finder/ptica/ptica-adapt.ts b/frontend/src/components/site-finder/ptica/ptica-adapt.ts
index db0ed7a7..19acd17f 100644
--- a/frontend/src/components/site-finder/ptica/ptica-adapt.ts
+++ b/frontend/src/components/site-finder/ptica/ptica-adapt.ts
@@ -1725,9 +1725,46 @@ export function adaptFinanceDrawer(
)} мес)`
: "не окупается в горизонте модели";
+ // #1881 PR-5 — финансирование (кредит). Показываем строки только когда
+ // financing_enabled; проценты — реальная стоимость (чистая прибыль после
+ // финансирования < чистой прибыли до). financing_is_simplified → честный
+ // caveat в summary, чтобы финдиректор не принял упрощение за точную модель.
+ const financingRows: DrawerKvRow[] = financial.financing_enabled
+ ? [
+ {
+ k: "Пиковый долг",
+ field: real(`${formatBln(financial.peak_debt_rub)} млрд ₽`),
+ },
+ {
+ k: "Проценты по кредиту",
+ field: {
+ value: `${formatBln(financial.total_interest_rub)} млрд ₽`,
+ isReal: true,
+ caption: `ставка ${formatPctFromFraction(
+ financial.annual_rate_used,
+ )} % годовых`,
+ },
+ },
+ {
+ k: "Чистая прибыль после финанс.",
+ field: real(
+ `${formatBln(financial.net_profit_after_financing_rub)} млрд ₽`,
+ ),
+ },
+ ]
+ : [];
+
+ const summary =
+ financial.financing_enabled && financial.financing_is_simplified
+ ? `${financial.caveat} Финансирование упрощено: весь кассовый разрыв ` +
+ "покрыт кредитом по ставке-нормативу, проценты капитализируются, эскроу " +
+ "не моделируется точно."
+ : financial.caveat;
+
return {
- summary: financial.caveat,
+ summary,
rows: [
+ ...financingRows,
{ k: "GDV (выручка)", field: real(`${formatBln(financial.revenue_rub)} млрд ₽`) },
{
k: "Совокупные затраты",
diff --git a/frontend/src/lib/api-types.ts b/frontend/src/lib/api-types.ts
index ab4fcd69..ba4931ac 100644
--- a/frontend/src/lib/api-types.ts
+++ b/frontend/src/lib/api-types.ts
@@ -3569,6 +3569,36 @@ export interface components {
* @default true
*/
schedule_is_default: boolean;
+ /**
+ * Financing Enabled
+ * @default false
+ */
+ financing_enabled: boolean;
+ /**
+ * Annual Rate Used
+ * @default 0
+ */
+ annual_rate_used: number;
+ /**
+ * Peak Debt Rub
+ * @default 0
+ */
+ peak_debt_rub: number;
+ /**
+ * Total Interest Rub
+ * @default 0
+ */
+ total_interest_rub: number;
+ /**
+ * Net Profit After Financing Rub
+ * @default 0
+ */
+ net_profit_after_financing_rub: number;
+ /**
+ * Financing Is Simplified
+ * @default true
+ */
+ financing_is_simplified: 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 649ac02c..98559fe3 100644
--- a/frontend/src/lib/concept-api.ts
+++ b/frontend/src/lib/concept-api.ts
@@ -91,6 +91,22 @@ export interface FinancialModel {
price_is_calibrated: boolean;
/** "objective_district_median" | "district_reference" | "class_norm". */
price_source: PriceSource;
+ // Financing / кредит (PR-5, эпик #1881)
+ /** false → финансирование не моделировалось (строки кредита не показываем). */
+ financing_enabled: boolean;
+ /** Годовая ставка кредита, доля (0.15 = 15% годовых). */
+ annual_rate_used: number;
+ /** Пиковый долг по телу кредита, ₽. */
+ peak_debt_rub: number;
+ /** Суммарные проценты по кредиту за проект, ₽. */
+ total_interest_rub: number;
+ /** Чистая прибыль за вычетом процентов по кредиту, ₽. */
+ net_profit_after_financing_rub: number;
+ /**
+ * true — упрощённая модель: весь кассовый разрыв покрыт кредитом по
+ * ставке-нормативу, проценты капитализируются, эскроу не моделируется точно.
+ */
+ financing_is_simplified: boolean;
}
export type PriceSource =
diff --git a/frontend/src/types/site-finder.ts b/frontend/src/types/site-finder.ts
index 6a219336..2103fd9c 100644
--- a/frontend/src/types/site-finder.ts
+++ b/frontend/src/types/site-finder.ts
@@ -525,6 +525,19 @@ export interface ParcelFinancialEstimate {
apartments_count: number;
};
caveat: string;
+ // #1881 PR-5 — финансирование (кредит) в финмодели. financing_enabled=false →
+ // финансирование не моделировалось (UI не показывает строки кредита).
+ // annual_rate_used — годовая ставка кредита (доля 0..1). peak_debt_rub —
+ // пиковый долг по телу кредита, ₽. total_interest_rub — суммарные проценты, ₽.
+ // net_profit_after_financing_rub — чистая прибыль за вычетом процентов, ₽.
+ // financing_is_simplified=true → упрощённая модель (100% покрытие кассового
+ // разрыва, проценты капитализируются, эскроу не моделируется точно) — caveat.
+ financing_enabled: boolean;
+ annual_rate_used: number;
+ peak_debt_rub: number;
+ total_interest_rub: number;
+ net_profit_after_financing_rub: number;
+ financing_is_simplified: boolean;
}
export type PoiCategory =