diff --git a/backend/app/schemas/concept.py b/backend/app/schemas/concept.py index fd8044cd..2689fc70 100644 --- a/backend/app/schemas/concept.py +++ b/backend/app/schemas/concept.py @@ -91,6 +91,17 @@ class FinancialModel(BaseModel): # проекта). True → UI/PDF показывают caveat «график — типовое допущение». schedule_is_default: bool = True + # ── Financing overlay (PR-5, эпик #1881) ─────────────────────────────────── + # OVERLAY поверх unlevered cashflow: кредит покрывает кассовый разрыв, проценты + # капитализируются (compound). Unlevered NPV/IRR (headline) НЕ затронуты. + financing_enabled: bool = False # True когда overlay посчитан + annual_rate_used: float = 0.0 # _CREDIT_RATE_ANNUAL (норматив, отдельно от дисконта) + peak_debt_rub: float = 0.0 # «задолженность по кредиту» Excel — пик долга + total_interest_rub: float = 0.0 # «поток по финдеятельности» Excel — Σ процентов + net_profit_after_financing_rub: float = 0.0 # net_profit − total_interest + # honest-флаг: 100% покрытие разрыва, compound %, эскроу не моделируется точно. + financing_is_simplified: bool = True + # ── Price calibration (PR-2, эпик #1881) ─────────────────────────────────── # Цена продажи жилья, руб/кв.м, фактически использованная в выручке. Либо # калиброванная по рынку (Objective / district reference), либо норматив класса. diff --git a/backend/app/services/generative/financial.py b/backend/app/services/generative/financial.py index 6ab52c01..e8eeddb8 100644 --- a/backend/app/services/generative/financial.py +++ b/backend/app/services/generative/financial.py @@ -89,6 +89,34 @@ flag the source honestly (``price_source="class_norm"``) so the UI/PDF never passes a class norm off as a market figure. Construction cost (СМР) is *not* calibrated in PR-2 — it stays a per-class norm. Parking price/cost also stay norms. +FINANCING — OVERLAY (PR-5) +-------------------------- +Unlevered (project) DCF — NPV / IRR / PBP — остаётся HEADLINE и НЕ меняется: +это интринсивная доходность проекта, инвариант Σ cashflow == net_profit +сохраняется. Финансирование (кредит/проектное финансирование) — это +ДОПОЛНИТЕЛЬНЫЙ overlay ПОВЕРХ готового unlevered cashflow; он его читает +(READ-ONLY) и НЕ пересобирает. + +* **Проценты — реальная стоимость долга**, поэтому ``net_profit_after_financing + < net_profit`` для любого проекта с кассовым разрывом. +* **Ставка ``annual_rate_used`` — норматив** (``_CREDIT_RATE_ANNUAL``), отдельный + от ставки дисконтирования (см. константы), а не выведенная из проекта величина. +* **100% покрытие разрыва** — кредитная линия покрывает весь накопительный + кассовый разрыв; equity / LTC / лимиты банка НЕ моделируются (нет доли + собственных средств). +* **Проценты капитализируются (compound)** — начисляются на остаток на начало + месяца ДО движений и добавляются в тело долга. +* **Эскроу-специфику не моделируем точно** — раскрытие средств с эскроу-счетов + по этапам стройки, лимиты раскрытия, ставка по бридж/целевой части — всё это + упрощено до единой капитализируемой ставки на кассовый разрыв. +* **Levered IRR НЕ считается** в этом PR (хрупко на хардкод-100%-долге — отдельный + PR). MVP раскрывает только агрегаты: ``peak_debt_rub`` / ``total_interest_rub`` + / ``net_profit_after_financing_rub``; debt_schedule в схему НЕ выносится. +* **Unlevered NPV / IRR / PBP НЕ затронуты** overlay'ем — это всё та же + интринсивная доходность. График наследует неточность ``schedule_is_default``: + финансирование считается по тому же дефолт-графику фаз, поэтому + ``financing_is_simplified=True`` (honest-флаг для UI/PDF). + This function stays **pure**: it does no DB lookup itself (the API layer does the lookup and passes the result in). Детерминированно, без LLM / внешних API / БД. """ @@ -160,6 +188,11 @@ _PROJECT_YEARS: float = 3.0 # Годовая ставка дисконтирования (Excel-эталон девелоперской модели). _DISCOUNT_RATE_ANNUAL: float = 0.15 +# Ставка по кредиту/проектному финансированию (Excel-эталон). ОТДЕЛЬНА от +# _DISCOUNT_RATE_ANNUAL: дисконт = требуемая доходность инвестора (NPV), кредитная +# ставка = стоимость долга (проценты). Совпадение значения (0.15) — калибровка, +# НЕ тождество; не сливать в одну константу. +_CREDIT_RATE_ANNUAL: float = 0.15 # Длительность ПИР (проектирование/экспертиза), мес. _PIR_DURATION_MONTHS: int = 6 # Длительность СМР по типу застройки, мес (точечная быстрее высотной). @@ -258,6 +291,42 @@ def _payback_months(cf: list[float]) -> 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 =