gendesign/backend/app/services/generative/financial.py
bot-backend 66da75681f
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m45s
Deploy / build-worker (push) Successful in 2m44s
Deploy / deploy (push) Successful in 1m22s
feat(financial/dcf): dormant separate-index inflation capability (default OFF)
smr/sale_inflation_annual in _build_monthly_cashflow, gated by module constants =0.0 → byte-identical zero-diff (deep-review verified IEEE-754 identity + all 57 prior tests unchanged). Land not escalated; marketing/VAT/tax retimed on escalated revenue (total preserved). Capability wired+tested, OFF until calibrated indices + invariant reframe. No schema/codegen. Refs #1881
2026-06-25 12:12:02 +00:00

906 lines
61 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Generative Design — Stage 1c: developer financial model + monthly DCF (PR-1/PR-3).
We combine **our areas** (from real geometry → :class:`TEAP`) with the **cost
completeness** of a reference developer Excel model: a full static cost cascade,
VAT, profit tax → net profit + ROI (PR-1). On top of that *correct static* P&L,
PR-3 lays the cascade onto a **monthly cashflow schedule** and runs a real DCF —
NPV / IRR / payback — replacing the PR-1 IRR proxy. No new user inputs and no new
dependencies: the DCF is hand-rolled deterministic math (no numpy-financial) over
default phase norms + the existing TEAP cascade.
DCF — MONTHLY SCHEDULE (PR-3)
-----------------------------
The static cascade is spread over months using **default phase norms** (honestly
flagged ``schedule_is_default=True`` — these are typical assumptions, NOT a project
schedule):
* Land — month 0, lump sum.
* ПИР (design) — linear over ``_PIR_DURATION_MONTHS``.
* СМР + networks + dev-services + contingency — linear over the construction window
``[ПИР_end .. ПИР_end + construction_duration]`` (duration by ``development_type``).
* Sales (residential + parking) — linear over ``[sales_start .. sales_end)`` starting
``_SALES_START_MONTH`` after СМР begins; the window lasts at least
``_SALES_DURATION_MONTHS`` but never ends before construction completes (РФ
ДДУ/эскроу — sales run *during* construction and close at/after ввода). For tall
builds (long СМР) this stretches sales out, preventing an impossible early sell-out.
* Marketing — pro-rated to the sales curve (spent as units sell).
налоги — PHASING CHOICE (documented simplification)
НДС and profit tax are **pro-rated to revenue recognition**, not lumped at the
end: each month's tax = total_tax × (that month's revenue / total revenue). This
keeps the cashflow's tax outflow aligned with the inflow that triggers it, and —
crucially — keeps the **undiscounted** cashflow sum exactly equal to the static
``net_profit`` (INVARIANT, tested). Lumping at the end would distort IRR/PBP for
no modelling gain. If total revenue is 0 (degenerate), tax is 0 anyway.
рассрочка / первый взнос — SIMPLIFICATION (documented)
The Excel reference uses a 80% first payment + remainder on completion. For the
MVP DCF we recognise the **full sale price in the month of sale** (``_FIRST_PAYMENT_PCT``
is kept as a documented constant for a future рассрочка split in PR-4, but is NOT
applied here). This slightly *front-loads* cash vs. an installment plan — i.e. it
is the *optimistic* end of the timing band; the schedule caveat says so. Splitting
the remainder to a later month would only push IRR down, never break the invariant.
INVARIANT (tested): Σ undiscounted monthly cashflow ≈ static ``net_profit`` — the
DCF can never silently diverge from the static cascade.
Cascade (in :func:`compute_financial`):
* **Revenue (GDV)** = residential area × sale price + parking spaces × parking price
+ office area × office price (нежилое 1-го этажа — коммерция/офисы).
* **Cost cascade** = construction (residential + OFFICE GFA + parking) + ПИР (design)
+ networks (ТУ) + developer services + contingency + marketing/realtor + land.
* **БДР / taxes** = gross margin VAT profit tax → net profit.
* **Metrics** = net ROI on cost, net margin on revenue, and DCF NPV/IRR/payback.
OFFICES / COMMERCIAL — нежилое 1-го этажа (ADDITIVE ВЫРУЧКА, БЕЗ double-count СМР)
--------------------------------------------------------------------------------
TEAP теперь несёт ``office_area_sqm`` — нежилое (коммерция/офисы 1-го этажа),
ВЫРЕЗАННОЕ из GFA до расчёта жилой (см. ``_OFFICE_SHARE_OF_GFA`` в teap.py: жилая
ужимается ровно на эту площадь, total_floor_area_sqm НЕ меняется). Финмодель:
* **Выручка** ``revenue_office = office_area_sqm × _OFFICE_SALE_PRICE_PER_SQM[class]``
(умеренная премия к жилью; см. константу).
* **СМР нежилого — НЕ отдельная строка, double-count'а НЕТ.** Нежилая GFA осталась
в ``total_floor_area_sqm`` → ``construction_resid = total_floor_area_sqm × ставка
СМР`` УЖЕ её покрывает (как покрывала, когда эта площадь считалась жилой). Сдвиг
carve-out жилой ↔ нежилой меняет только классификацию площади и ВЫРУЧКУ, а СМР по
total_floor_area инвариантен. Поэтому нежилое = чисто additive ВЫРУЧКА.
НДС — ОСВОБОЖДЕНИЕ ЖИЛЬЯ, НДС НА ПАРКИНГ + НЕЖИЛОЕ (ст. 149 НК РФ)
------------------------------------------------------------
Реализация жилых помещений и услуги застройщика по ДДУ ОСВОБОЖДЕНЫ от НДС
(пп.22 и пп.23.1 п.3 ст. 149 НК РФ) → выходной НДС на жильё = 0. Входной НДС по
СМР при этом НЕвозмещаем (п.2 ст.170 НК РФ) и уже «сидит» в gross-ставках
себестоимости (рыночные ориентиры ``_CONSTRUCTION_COST_PER_SQM`` даны С НДС) →
чистая нагрузка по НДС на жилую часть ≈ 0.
Машиноместа И нежилое (коммерция/офисы) — НЕЖИЛЫЕ помещения: их реализация
ОБЛАГАЕТСЯ НДС 20%, входной НДС вычитается. Поэтому НДС начисляется на нежилой
value-added ОБОИХ нежилых частей (встроенный НДС в добавленной стоимости):
parking_value_added = max(0, revenue_parking construction_parking)
office_value_added = max(0, revenue_office office_construction)
где office_construction = office_area_sqm × _CONSTRUCTION_COST_PER_SQM[class]
(СМР-доля нежилого по той же ставке, что и весь СМР; та же доля УЖЕ сидит
в construction_rub — здесь она лишь ВОССТАНАВЛИВАЕТСЯ как база НДС, в
каскад затрат повторно НЕ добавляется → double-count'а нет).
vat = (parking_value_added + office_value_added) × VAT_RATE / (1 + VAT_RATE)
Если паркинг/нежилое окажутся под ДДУ-льготой — оценка консервативна (чуть занижает
прибыль завышенным НДС), что соответствует honest/conservative-политике модуля.
Полную механику входного/выходного НДС с эскроу-спецификой не моделируем —
жильё освобождено, входной НДС встроен в gross-СМР.
IRR — REAL DCF, PROXY FALLBACK (PR-3)
-------------------------------------
``irr`` is now a **real** annualised IRR computed by bisection over the monthly
cashflow (``irr_is_proxy=False``). For a degenerate cashflow with no sign change
(e.g. an always-loss project — every month is an outflow, or every month inflow)
bisection cannot bracket a root; we then fall back to the PR-1 annualised net-ROI
proxy (``roi / _PROJECT_YEARS``, clamped ±1) and flag ``irr_is_proxy=True`` so the
frontend shows the «оценочный» caveat.
PRICE CALIBRATION — PR-2
------------------------
The housing **sale price** can be calibrated to real market data: callers pass
``market_price_per_sqm`` (руб/кв.м) computed from Objective listings / district
reference at the API layer. When ``None`` we fall back to the per-class norm and
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 are also
per-class norms (см. ``_PARKING_PRICE_PER_SPOT`` / ``_PARKING_COST_PER_SPOT``).
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``), отдельный
от ставки дисконтирования (см. константы), а не выведенная из проекта величина.
* **LTC-покрытие разрыва** — кредитная линия покрывает ``_LOAN_TO_COST_RATIO``
(70 %) каждого ежемесячного оттока; оставшиеся 30 % (собственные средства)
co-fund отток пропорционально и процентами не облагаются. Лимиты банка НЕ
моделируются точно.
* **Проценты капитализируются (compound)** — начисляются на остаток на начало
месяца ДО движений и добавляются в тело долга.
* **Эскроу-специфику не моделируем точно** — раскрытие средств с эскроу-счетов
по этапам стройки, лимиты раскрытия, ставка по бридж/целевой части — всё это
упрощено до единой капитализируемой ставки на кассовый разрыв.
* **Levered (equity) IRR** считается по equity-потоку того же графика долга
(``_FinancingResult.equity_cashflow``): отток = equity-доля (1LTC) каждого
кассового разрыва, приток = остаток притока после обслуживания долга. При
положительном леверидже (доходность проекта > стоимости долга 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``:
финансирование считается по тому же дефолт-графику фаз, поэтому
``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 / БД.
"""
from __future__ import annotations
import logging
import math
from dataclasses import dataclass
from typing import Literal
from app.schemas.concept import TEAP, FinancialModel
logger = logging.getLogger(__name__)
HousingClass = Literal["econom", "comfort", "business"]
# ── Цена продажи жилья, руб/кв.м (proxy рынка ЕКБ/региона, упрощённо) ──────────
_SALE_PRICE_PER_SQM: dict[HousingClass, float] = {
"econom": 110_000.0,
"comfort": 145_000.0,
"business": 210_000.0,
}
# ── Себестоимость СМР, руб/кв.м общей площади (выше класс -> дороже отделка/инж) ─
_CONSTRUCTION_COST_PER_SQM: dict[HousingClass, float] = {
"econom": 72_000.0,
"comfort": 88_000.0,
"business": 120_000.0,
}
# ── Паркинг — ЦЕНА ПРОДАЖИ / СЕБЕСТОИМОСТЬ м/м по классу, руб ──────────────────
# TUNABLE ASSUMPTIONS (РФ-рынок паркинга). Раньше эти константы были СКАЛЯРАМИ,
# взятыми из Excel-эталона «Авангардная 13» (premium, ПОДЗЕМНЫЙ паркинг, near
# break-even: 1.9М продажа / 1.8М себестоимость). Применять их ПЛОСКО ко всем
# классам некорректно: эконом-жильё в РФ строит ДЕШЁВЫЙ НАЗЕМНЫЙ паркинг, а не
# подземный — плоская подземная себестоимость 1.8М × 0.8 м/м на квартиру раздувала
# near-break-even капитал → пиковый долг → проценты по финансированию (прод: Ж-5,
# 212 м/м × 1.8М = 382М break-even капитал → ~120М процентов, душивших
# net_after_financing).
#
# Поэтому цена/себестоимость теперь КЛАССОЗАВИСИМЫ (зеркалит паттерн
# _SALE_PRICE_PER_SQM / _CONSTRUCTION_COST_PER_SQM). ВАЖНО: НОРМА паркинга
# (_PARKING_PER_APARTMENT в teap.py: econom 0.8 / comfort 1.0 / business 1.5) НЕ
# трогается — она может отражать нормы ПЗЗ (out of scope).
#
# business 1.9 / 1.8 — ПОДЗЕМНЫЙ, matches Excel «Авангардная 13» (без изменений).
# econom 0.8 / 0.35 — НАЗЕМНЫЙ (surface): дешёвый СМР ~350k, продажа ~800k.
# comfort 1.3 / 1.0 — ЧАСТИЧНО подземный (интерполяция econom↔business).
#
# Маржа на м/м: econom +450k, comfort +300k, business +100k (цена > себестоимости
# во всех классах — паркинг не убыточен ни в одном классе).
_PARKING_PRICE_PER_SPOT: dict[HousingClass, float] = {
"econom": 800_000.0, # наземный паркинг — продажа
"comfort": 1_300_000.0, # частично подземный — продажа
"business": 1_900_000.0, # подземный (Excel «Авангардная 13») — продажа
}
_PARKING_COST_PER_SPOT: dict[HousingClass, float] = {
"econom": 350_000.0, # наземный паркинг — себестоимость
"comfort": 1_000_000.0, # частично подземный — себестоимость
"business": 1_800_000.0, # подземный (Excel «Авангардная 13») — себестоимость
}
# ── Цена ПРОДАЖИ нежилого (коммерция/офисы 1-го этажа), руб/кв.м по классу ─────
# НОРМАТИВ (РФ-рынок встроенно-пристроенной коммерции). Ликвидный стрит-ритейл 1-го
# этажа в городском МКД обычно продаётся С ПРЕМИЕЙ к жилью того же дома (лучшая
# проходимость/витрина, инвестиционный спрос на ГАБ — готовый арендный бизнес).
# Берём УМЕРЕННУЮ премию ~+15% к нормативу продажи жилья класса (консервативно — не
# задираем; в реальных проектах премия бывает выше, но зависит от локации/трафика):
# econom ~127k = 110k × 1.15 (доля нежилого = 0 → на выручку не влияет, но
# константа задана для полноты/будущих сценариев).
# comfort ~167k = 145k × 1.15
# business ~242k = 210k × 1.15
# СЕБЕСТОИМОСТЬ нежилого ОТДЕЛЬНО не вводим: нежилая GFA уже в total_floor_area_sqm →
# ``construction = total_floor_area × ставка СМР`` её УЖЕ покрывает (см. docstring
# раздел OFFICES). Поэтому нежилое — чисто additive ВЫРУЧКА; для базы НДС берём
# СМР-долю нежилого по той же ставке ``_CONSTRUCTION_COST_PER_SQM`` (см. ниже).
_OFFICE_SALE_PRICE_PER_SQM: dict[HousingClass, float] = {
"econom": 126_500.0, # 110_000 × 1.15
"comfort": 166_750.0, # 145_000 × 1.15
"business": 241_500.0, # 210_000 × 1.15
}
# ── Доли каскада затрат (источник — Excel-эталон + RU-рынок) ──────────────────
# ПИР (проектирование + экспертиза + РД) как доля от СМР.
# Excel: ПИР 147М при СМР 6123М ≈ 2.4% -> округлённо 2.5%.
_PIR_PCT_OF_CONSTRUCTION: float = 0.025
# Сети / подключение по ТУ, руб/кв.м GFA.
# Excel: ТУ 330М / 55042 кв.м ≈ 6000 руб/кв.м.
_NETWORKS_COST_PER_SQM: float = 6_000.0
# Услуги заказчика / управление как доля от СМР.
# Excel: 76.5М при СМР 6123М ≈ 1.25% -> округлённо 1.5%.
_DEVELOPER_SERVICES_PCT: float = 0.015
# Резерв / непредвиденные как доля от (СМР + ПИР + сети). Excel: 3%.
_CONTINGENCY_PCT: float = 0.03
# Реклама + риэлтор + маркетинг как доля от выручки. Excel: 7%.
_MARKETING_REALTOR_PCT: float = 0.07
# ── Налоги ────────────────────────────────────────────────────────────────────
# НДС, ставка. РФ стандартная 20%.
_VAT_RATE: float = 0.20
# Налог на прибыль организаций. С 01.01.2025 базовая ставка повышена 20% -> 25%
# (ФЗ № 176-ФЗ от 12.07.2024). Используем актуальные 25%.
_PROFIT_TAX_RATE: float = 0.25
# Условный горизонт проекта (лет) для аннуализации net-ROI в IRR-proxy (fallback).
_PROJECT_YEARS: float = 3.0
# ── DCF: график фаз и продаж — ТИПОВЫЕ НОРМАТИВЫ (ДОПУЩЕНИЯ, не график проекта) ──
# Эти константы — дефолтные допущения помесячной раскладки cashflow. Они ЧЕСТНО
# помечены ``schedule_is_default=True`` в FinancialModel: NPV/IRR/PBP считаются DCF
# по ЭТОМУ графику, точность зависит от реального графика проекта. Темп продаж —
# дефолт-норматив, НЕ §22 velocity (отдельная интеграция позже).
# Годовая ставка дисконтирования (Excel-эталон девелоперской модели).
_DISCOUNT_RATE_ANNUAL: float = 0.15
# Ставка по кредиту/проектному финансированию (Excel-эталон). ОТДЕЛЬНА от
# _DISCOUNT_RATE_ANNUAL: дисконт = требуемая доходность инвестора (NPV), кредитная
# ставка = стоимость долга (проценты). Совпадение значения (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
# Длительность СМР по типу застройки, мес (точечная быстрее высотной).
_CONSTRUCTION_DURATION_MONTHS: dict[str, int] = {
"spot": 24,
"mid_rise": 36,
"high_rise": 48,
}
# Дефолт длительности СМР, если development_type неизвестен (среднеэтажная).
_DEFAULT_CONSTRUCTION_MONTHS: int = _CONSTRUCTION_DURATION_MONTHS["mid_rise"]
# Старт продаж — через N мес после начала СМР (нормативно — после котлована/этажей).
_SALES_START_MONTH: int = 6
# Длительность распродажи, мес (нормативный темп выбытия квартир). Используется,
# когда рыночная скорость абсорбции не передана (market_velocity_sqm_per_month=None).
_SALES_DURATION_MONTHS: int = 30
# RANK 1: границы окна продаж, когда оно ДРАЙВИТСЯ рыночной скоростью абсорбции
# (residential_area_sqm / market_velocity_sqm_per_month). Клампим вычисленную
# длительность в [_SALES_DURATION_MIN, _SALES_DURATION_MAX], чтобы аномально
# горячий/холодный радиус не давал нереалистичных 1-мес распродаж или 20-летних
# хвостов. Эскроу-привязка (sales_end >= constr_end) применяется ПОВЕРХ клампа.
_SALES_DURATION_MIN: int = 6
_SALES_DURATION_MAX: int = 120
# Первый взнос, доля (Excel: 80% при контракте, остаток при вводе/рассрочка). В MVP
# DCF НЕ применяется — вся выручка признаётся в месяц продажи (см. docstring модуля).
# Сохранён константой для будущей рассрочки (PR-4).
_FIRST_PAYMENT_PCT: float = 0.80
# ── DCF: РАЗДЕЛЬНАЯ ИНФЛЯЦИЯ ИНДЕКСОВ — DORMANT CAPABILITY (default OFF) ──────────
# Excel-эталон применяет РАЗНУЮ годовую инфляцию к затратам СМР (индекс СМР Росстата
# ~7-8%/год) и к цене продажи (индекс цен ~6%/год) на многолетнем горизонте. Эта
# возможность ПРОВОДНА в `_build_monthly_cashflow` (см. `smr_inflation_annual` /
# `sale_inflation_annual`), но ДЕФОЛТ строго 0.0 — прод-поведение НЕ меняется.
#
# ПОЧЕМУ OFF ПО УМОЛЧАНИЮ (hard constraint):
# * Статический БДР (`compute_financial`) считается в деньгах СЕГОДНЯ (today-money);
# есть ТЕСТИРУЕМЫЙ инвариант `Σ undiscounted cashflow == net_profit`.
# * Раздельная инфляция делает НОМИНАЛЬНЫЕ помесячные потоки РАСХОДЯЩИМИСЯ с
# today-money `net_profit` → инвариант ломается при ставке > 0.
# * Поэтому включение требует (а) КАЛИБРОВАННЫХ индексов (СМР ~7-8%/год, цена
# ~6%/год — ориентиры Росстата) И (б) переосмысления headline-инварианта
# (номинальный DCF vs today-money БДР — это РАЗНЫЕ метрики). До этого решения
# ставки держим 0.0: capability разведена и протестирована, но выключена.
#
# При 0.0 месячный фактор `(1+0)**t == 1.0` для любого t → побайтово идентичный
# cashflow → существующий инвариант и все текущие тесты проходят без изменений.
_SMR_INFLATION_ANNUAL: float = 0.0
_SALE_INFLATION_ANNUAL: float = 0.0
# ── DCF helpers — hand-rolled, чистая математика (без numpy-financial / deps) ───
def _npv(monthly_rate: float, cf: list[float]) -> float:
"""NPV помесячного потока ``cf`` (t=0..N) при месячной ставке ``monthly_rate``.
``cf[t]`` — чистый денежный поток месяца t (приток +, отток ). t=0 не дисконтируется.
"""
return sum(c / (1.0 + monthly_rate) ** t for t, c in enumerate(cf))
def _irr_monthly(
cf: list[float],
lo: float = -0.9999,
hi: float = 1.0,
tol: float = 1e-7,
max_iter: int = 200,
) -> float | None:
"""Месячный IRR помесячного потока ``cf`` методом бисекции.
Возвращает месячную ставку r, при которой ``_npv(r, cf) == 0``, или ``None``,
если на ``[lo, hi]`` нет смены знака NPV (вырожденный поток — всегда отток или
всегда приток). Caller делает fallback на roi-proxy + ``irr_is_proxy=True``.
Bracket-guard: если ``_npv(lo) * _npv(hi) > 0`` — корня в брекете нет → ``None``.
NPV монотонно убывает по ставке для «нормального» девелоперского потока (ранний
отток, поздний приток), поэтому бисекция сходится к единственному корню.
"""
# Вырожденный поток без денег (все нули) — NPV==0 при любой ставке, IRR неопределён.
if all(abs(c) < tol for c in cf):
return None
f_lo = _npv(lo, cf)
f_hi = _npv(hi, cf)
# Краевое попадание в корень.
if abs(f_lo) < tol:
return lo
if abs(f_hi) < tol:
return hi
# Нет смены знака на брекете → нет (единственного) корня здесь → None.
if f_lo * f_hi > 0.0:
return None
for _ in range(max_iter):
mid = (lo + hi) / 2.0
f_mid = _npv(mid, cf)
if abs(f_mid) < tol:
return mid
if f_lo * f_mid < 0.0:
hi = mid
f_hi = f_mid
else:
lo = mid
f_lo = f_mid
return (lo + hi) / 2.0
def _payback_months(cf: list[float]) -> float | None:
"""Срок окупаемости (мес) по НЕдисконтированному накопительному потоку.
Накапливаем cashflow; как только сумма пересекает 0, линейно интерполируем
дробный месяц внутри переломного интервала. ``None`` — поток не окупается на
горизонте (накопленный итог так и не достиг 0).
"""
cumulative = 0.0
for t, c in enumerate(cf):
prev = cumulative
cumulative += c
# Month 0 — всегда land-отток (cf[0] < 0), поэтому prev<0 на первом переломе:
# ветка t==0 ниже — теоретический corner (поток сразу неотрицателен), не реальный путь.
if cumulative >= 0.0 and prev < 0.0:
# Линейная интерполяция дроби месяца: prev + frac * c = 0.
frac = -prev / c if c != 0.0 else 0.0
return float(t - 1) + frac
if cumulative >= 0.0 and t == 0:
# Окупается уже в нулевом месяце (поток сразу неотрицателен).
return 0.0
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-доля (1ltc) каждого кассового разрыва (со знаком ), приток =
остаток притока после обслуживания долга. Σ 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,
) -> _FinancingResult:
"""OVERLAY финансирования поверх unlevered cashflow (PR-5). Чистая функция.
``operating_cf`` — готовый unlevered cashflow (READ-ONLY, НЕ мутируется).
Кредитная линия покрывает ``ltc_ratio`` долю кассового разрыва; оставшийся
(1 ltc_ratio) — equity-взнос (без процентов). Проценты КАПИТАЛИЗИРУЮТСЯ
в долг (compound) — упрощение под эскроу-логику РФ. Порядок внутри месяца:
процент НАЧИСЛЯЕМ на остаток ДО draw/repay, затем выборка (cf<0) либо
погашение притоком (cf>0, не уходя ниже 0).
Если поток не уходит в минус → долг не выбирается → debt=0, interest=0
(корректно: финансирование не требуется).
ВОЗВРАТ — :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 вносит (1ltc) долю оттока (со знаком , как и cf): свои деньги под риском.
equity_cashflow.append(cf * (1.0 - ltc_ratio))
else:
repay = min(cf, debt) # гасим долг притоком (не уходим в минус)
debt -= repay
# Остаток притока после обслуживания долга достаётся equity (приток, +).
equity_cashflow.append(cf - repay)
peak_debt = max(peak_debt, debt)
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]:
"""Разложить ``total`` равными долями по месяцам ``[start .. start+duration)``.
Возвращает список длины ``horizon`` (по индексу = месяц). ``duration <= 0`` →
единоразово в месяц ``start``. Месяцы вне горизонта молча обрезаются (страховка).
"""
out = [0.0] * horizon
if total == 0.0:
return out
if duration <= 0:
if 0 <= start < horizon:
out[start] += total
return out
per = total / duration
for m in range(start, start + duration):
if 0 <= m < horizon:
out[m] += per
return out
@dataclass(frozen=True)
class _CashflowResult:
"""Результат раскладки cashflow: сам поток + реализованное окно продаж и флаг,
был ли график построен по рыночной скорости абсорбции (RANK 1)."""
cashflow: list[float]
sales_duration_months_realized: int # фактическое окно sales_end sales_start
schedule_is_market: bool # True → окно драйвилось market_velocity, не дефолт-30
def _build_monthly_cashflow(
*,
revenue: float,
construction: float,
pir: float,
networks: float,
dev_services: float,
contingency: float,
marketing: float,
land: float,
vat: float,
profit_tax: float,
construction_duration_months: int,
residential_area_sqm: float,
market_velocity_sqm_per_month: float | None = None,
smr_inflation_annual: float = 0.0,
sale_inflation_annual: float = 0.0,
) -> _CashflowResult:
"""Разложить статический каскад в помесячный чистый cashflow по графику фаз.
Чистая функция. График — типовые нормативы (см. docstring модуля): земля (мес 0),
ПИР линейно, СМР+сети+услуги+резерв линейно по окну строительства, продажи линейно
по окну распродажи; маркетинг прорейтится на график продаж; НДС+налог на прибыль
прорейтятся на признание выручки. Выручка — полностью в месяц продажи (упрощение).
РАЗДЕЛЬНАЯ ИНФЛЯЦИЯ ИНДЕКСОВ — DORMANT CAPABILITY (default OFF, см. модульные
константы `_SMR_INFLATION_ANNUAL` / `_SALE_INFLATION_ANNUAL`):
* ``smr_inflation_annual`` — годовая инфляция СМР-стороны затрат (индекс СМР):
эскалирует ПИР, СМР+сети+услуги+резерв. ЗЕМЛЯ НЕ эскалируется — это лумп-сумма
в мес 0 (платится сегодня, t=0 → фактор `(1+r)**0 == 1.0` и так бы дал нейтрал,
но земля исключена ЯВНО: участок покупается по сегодняшней цене, будущий индекс
СМР к ней неприменим).
* ``sale_inflation_annual`` — годовая инфляция цены продажи (индекс цен): эскалирует
приток выручки. Маркетинг/НДС/налог прорейтятся на УЖЕ ПРОЭСКАЛИРОВАННУЮ выручку
(доля = эскалированная revenue_cf[m] / Σ эскалированной выручки) → они едут на
той же номинальной выручке, что их рождает (консистентно).
* Месячный фактор: ``smr_m = (1+smr_inflation_annual)**(1/12) 1``, аналогично
``sale_m``; компонент месяца ``t`` (0-based) множится на ``(1+factor_m)**t``.
* ДЕФОЛТ 0.0 ⇒ ``(1+0)**t == 1.0`` для всех t ⇒ ПОБАЙТОВО идентичный cashflow ⇒
инвариант ниже и все текущие тесты держатся (safety-гарантия zero-diff).
ВНИМАНИЕ: при ставке > 0 номинальные потоки РАСХОДЯТСЯ с today-money ``net_profit``
→ инвариант ниже верен ТОЛЬКО при нулевой инфляции (today-money режим). Это
ожидаемо: номинальный DCF != today-money БДР (см. модульные константы).
RANK 1 — окно продаж по рыночной абсорбции: если передан конечный
``market_velocity_sqm_per_month > 0`` (темп выбытия ОДНОГО типового конкурента
района) и ``residential_area_sqm > 0``, базовое окно =
``ceil(residential_area_sqm / market_velocity_sqm_per_month)``, клампится в
``[_SALES_DURATION_MIN, _SALES_DURATION_MAX]`` (``schedule_is_market=True``).
Иначе — дефолт ``_SALES_DURATION_MONTHS`` (``schedule_is_market=False``). ПОВЕРХ
базового окна — та же эскроу-привязка ``sales_end >= constr_end`` (РФ ДДУ —
продажи заканчиваются у/после ввода). Перенос выручки во времени НЕ меняет сумму
потока, поэтому инвариант ниже держится при любом окне.
ИНВАРИАНТ: ``sum(cashflow) == revenue all_costs vat profit_tax == net_profit``
(с точностью до float-округления). Гарантирует, что DCF не разошёлся со статикой.
"""
pir_end = _PIR_DURATION_MONTHS
constr_start = pir_end
constr_end = pir_end + construction_duration_months
# Продажи стартуют через _SALES_START_MONTH после начала СМР (нормативно, эскроу).
sales_start = constr_start + _SALES_START_MONTH
# RANK 1: базовое окно продаж — по рыночной абсорбции, если она передана и валидна.
if (
market_velocity_sqm_per_month is not None
and math.isfinite(market_velocity_sqm_per_month)
and market_velocity_sqm_per_month > 0.0
and residential_area_sqm > 0.0
):
raw = math.ceil(residential_area_sqm / market_velocity_sqm_per_month)
base_duration = max(_SALES_DURATION_MIN, min(_SALES_DURATION_MAX, raw))
schedule_is_market = True
else:
base_duration = _SALES_DURATION_MONTHS # 30 — дефолт-норматив
schedule_is_market = False
# Окончание продаж: базовое окно, но НЕ раньше завершения стройки
# (РФ ДДУ/эскроу — продажи идут ВО ВРЕМЯ стройки и заканчиваются у/после ввода).
# Без этого high_rise (СМР до 54) распродавал бы всё на мес 42 — за 12 мес ДО ввода
# → хвост затрат ПОСЛЕ окончания продаж → двойная смена знака → IRR-бисекция не
# брекетит profitable-проект и молча падает в proxy. Привязка к constr_end это чинит.
sales_end = max(sales_start + base_duration, constr_end)
sales_duration = sales_end - sales_start
# Горизонт = максимум всех фаз (+1 — месяц как индекс, длина = последний+1).
horizon = max(constr_end, sales_end, 1)
# ── Месячные факторы инфляции (DORMANT: при ставке 0.0 фактор тождественно 1.0) ─
# smr_m / sale_m — месячные эквиваленты годовых ставок. (1+0)**(1/12)1 == 0.0 →
# все факторы (1+0)**t == 1.0 → побайтово идентичный cashflow при дефолте (zero-diff).
smr_m = (1.0 + smr_inflation_annual) ** (1.0 / 12.0) - 1.0
sale_m = (1.0 + sale_inflation_annual) ** (1.0 / 12.0) - 1.0
smr_factor = [(1.0 + smr_m) ** t for t in range(horizon)]
sale_factor = [(1.0 + sale_m) ** t for t in range(horizon)]
# ── Затраты (отток, со знаком ) ───────────────────────────────────────────
# ЗЕМЛЯ НЕ эскалируется: лумп-сумма в мес 0 (участок покупается по сегодняшней цене,
# индекс СМР к ней неприменим). При t=0 фактор и так == 1.0, но исключаем явно.
land_cf = _spread_linear(land, 0, 0, horizon) # земля — единоразово в мес 0
# ПИР и СМР+сети+услуги+резерв — СМР-сторона: эскалируются индексом СМР по месяцу t.
pir_cf = [c * smr_factor[m] for m, c in enumerate(_spread_linear(pir, 0, pir_end, horizon))]
# СМР+сети+услуги+резерв линейно по окну строительства [pir_end .. constr_end).
constr_total = construction + networks + dev_services + contingency
constr_cf = [
c * smr_factor[m]
for m, c in enumerate(
_spread_linear(constr_total, pir_end, construction_duration_months, horizon)
)
]
# ── Выручка (приток, +) и привязанные к ней маркетинг/налоги ────────────────
# Темп продаж пересчитан на ФАКТИЧЕСКОЕ окно [sales_start, sales_end): выручка
# линейно по sales_duration (>= _SALES_DURATION_MONTHS, растянуто до ввода).
# Выручка эскалируется индексом цены продажи по месяцу t (sale_factor).
revenue_cf = [
c * sale_factor[m]
for m, c in enumerate(_spread_linear(revenue, sales_start, sales_duration, horizon))
]
# Маркетинг + НДС + налог на прибыль прорейтятся на признание выручки. Прорейт идёт
# на УЖЕ ПРОЭСКАЛИРОВАННУЮ выручку: доля месяца = revenue_cf[m] / total_nominal_revenue
# → они едут на той же номинальной выручке, что их рождает (консистентно).
# ZERO-DIFF GUARANTEE: при выключенной инфляции продаж знаменатель = сырая ``revenue``
# (ровно как прежде, без зависимости от порядка float-суммирования) → формула
# ПОБАЙТОВО тождественна прежней → Σ undiscounted cashflow == net_profit (инвариант).
# При revenue==0 — нули.
tax_marketing_total = marketing + vat + profit_tax
total_nominal_revenue = revenue if sale_inflation_annual == 0.0 else sum(revenue_cf)
if total_nominal_revenue > 0.0:
prorate = [
tax_marketing_total * (revenue_cf[m] / total_nominal_revenue) for m in range(horizon)
]
else:
prorate = [0.0] * horizon
cashflow = [
revenue_cf[m] - land_cf[m] - pir_cf[m] - constr_cf[m] - prorate[m] for m in range(horizon)
]
return _CashflowResult(
cashflow=cashflow,
sales_duration_months_realized=sales_duration,
schedule_is_market=schedule_is_market,
)
def compute_financial(
*,
teap: TEAP,
housing_class: HousingClass,
land_cost_rub: float | None,
market_price_per_sqm: float | None = None,
price_source: str = "class_norm",
development_type: str | None = None,
market_velocity_sqm_per_month: float | None = None,
) -> FinancialModel:
"""Свести ТЭП + класс + стоимость земли в полный :class:`FinancialModel` с DCF.
Полный девелоперский каскад: выручка (жильё + паркинг + нежилое 1-го этажа) →
каскад затрат (СМР + ПИР + сети + услуги заказчика + резерв + маркетинг + земля) →
БДР с НДС и налогом на прибыль → чистая прибыль + ROI. Поверх статики — помесячный
DCF (NPV/IRR/PBP) по дефолт-графику фаз (см. docstring модуля). НДС — жильё
освобождено (ст. 149 НК РФ), начисляется на нежилые части (паркинг + коммерция/
офисы); СМР нежилого уже в total_floor_area → отдельной строки затрат нет
(см. раздел OFFICES в docstring модуля).
Чистая функция: НЕ ходит в БД. Калибровку цены продажи жилья делает API-слой
(lookup рынка) и прокидывает сюда через ``market_price_per_sqm``.
Args:
teap: Stage 1c ТЭП (residential_area_sqm, total_floor_area_sqm, parking_spaces).
housing_class: задаёт цену продажи (fallback) и себестоимость СМР.
land_cost_rub: стоимость участка (опционально); None -> 0 в затратах.
market_price_per_sqm: калиброванная рыночная цена продажи жилья, руб/кв.м.
``None`` → fallback на норматив класса ``_SALE_PRICE_PER_SQM``. Калибруем
ТОЛЬКО цену жилья; цена паркинга и себестоимость СМР остаются нормативом.
price_source: метка источника цены для honest-флага в UI/PDF — одно из
"objective_district_median" / "district_reference" / "class_norm" (concepts-путь
через ``_lookup_market_price``), либо "objective_geo_radius" — этот лейбл
приходит ТОЛЬКО из site-finder-моста (``synthesize_parcel_financial``), не из
concepts. Игнорируется (форсится "class_norm"), если ``market_price_per_sqm is None``.
development_type: "spot"/"mid_rise"/"high_rise" — задаёт длительность СМР в
DCF-графике (``_CONSTRUCTION_DURATION_MONTHS``). ``None``/неизвестный →
дефолт mid_rise (36 мес). График — типовое допущение (schedule_is_default).
market_velocity_sqm_per_month: RANK 1 — темп абсорбции ОДНОГО типового
конкурента района (м²/мес), из velocity.project_absorption_sqm_per_month.
Драйвит окно продаж в DCF: ``ceil(residential_area_sqm / velocity)``,
клампится в [_SALES_DURATION_MIN, _SALES_DURATION_MAX], затем эскроу-привязка
``sales_end >= constr_end``. ``None``/<=0/non-finite → дефолт-30-мес окно и
``schedule_is_default=True``. Это НЕ суммарная скорость района (новый проект —
один продавец из многих), иначе срок занижается и NPV/IRR раздуваются.
"""
# Калибруем ТОЛЬКО цену продажи жилья. Если рыночной цены нет — честный fallback
# на норматив класса, и source форсим в "class_norm" (не выдаём норму за рынок).
if market_price_per_sqm is not None:
sale_price = market_price_per_sqm
price_is_calibrated = True
resolved_price_source = price_source
else:
sale_price = _SALE_PRICE_PER_SQM[housing_class]
price_is_calibrated = False
resolved_price_source = "class_norm"
construction_cost = _CONSTRUCTION_COST_PER_SQM[housing_class]
# Паркинг — классозависимы цена/себестоимость (econom наземный, business
# подземный); НОРМА м/м на квартиру задаётся в teap (не здесь).
parking_price = _PARKING_PRICE_PER_SPOT[housing_class]
parking_cost = _PARKING_COST_PER_SPOT[housing_class]
# Нежилое (коммерция/офисы 1-го этажа) — цена продажи по классу (премия к жилью).
office_price = _OFFICE_SALE_PRICE_PER_SQM[housing_class]
# ── Выручка (GDV) ──────────────────────────────────────────────────────────
revenue_residential = teap.residential_area_sqm * sale_price
revenue_parking = teap.parking_spaces * parking_price
# Нежилое — чисто ADDITIVE выручка (СМР по нему уже в construction ниже).
revenue_office = teap.office_area_sqm * office_price
revenue = revenue_residential + revenue_parking + revenue_office
# ── Затраты (каскад) ───────────────────────────────────────────────────────
# ВАЖНО: ``total_floor_area_sqm`` — это ВСЯ GFA (жилая + нежилое 1-го этажа; см.
# teap: нежилое вырезано из ЖИЛОЙ, но осталось в total). Значит СМР по нежилой GFA
# УЖЕ покрыт здесь → отдельной строки затрат нежилого НЕ добавляем (no double-count).
construction_resid = teap.total_floor_area_sqm * construction_cost
construction_parking = teap.parking_spaces * parking_cost
construction = construction_resid + construction_parking
pir = construction * _PIR_PCT_OF_CONSTRUCTION
networks = teap.total_floor_area_sqm * _NETWORKS_COST_PER_SQM
dev_services = construction * _DEVELOPER_SERVICES_PCT
contingency = (construction + pir + networks) * _CONTINGENCY_PCT
marketing = revenue * _MARKETING_REALTOR_PCT
land = land_cost_rub if land_cost_rub is not None else 0.0
cost = construction + pir + networks + dev_services + contingency + marketing + land
# ── БДР / налоги → чистая прибыль ─────────────────────────────────────────
gross_margin = revenue - cost # валовая (как было)
# НДС: жилые помещения и услуги застройщика по ДДУ освобождены (пп.22 / пп.23.1
# п.3 ст. 149 НК РФ) → output-НДС на жильё = 0, а невозмещаемый входной НДС по СМР
# уже сидит в gross-ставках себестоимости. НДС начисляется на НЕЖИЛЫЕ части —
# машиноместа И коммерция/офисы 1-го этажа (их реализация облагается): встроенный
# НДС в их value-added. Если паркинг/нежилое тоже окажутся по ДДУ-льготе — оценка
# консервативна (чуть занижает прибыль), что соответствует «honest» политике модуля.
parking_value_added = revenue_parking - construction_parking
# СМР-доля нежилого по той же ставке СМР (она УЖЕ внутри construction_resid выше —
# здесь лишь восстанавливаем её как базу НДС, в каскад затрат повторно НЕ кладём).
office_construction = teap.office_area_sqm * construction_cost
office_value_added = revenue_office - office_construction
nonresidential_value_added = max(0.0, parking_value_added) + max(0.0, office_value_added)
vat = nonresidential_value_added * _VAT_RATE / (1.0 + _VAT_RATE)
profit_before_tax = gross_margin - vat
profit_tax = max(0.0, profit_before_tax) * _PROFIT_TAX_RATE
net_profit = profit_before_tax - profit_tax
# ── Метрики ────────────────────────────────────────────────────────────────
roi = net_profit / cost if cost > 0 else 0.0 # чистый ROI на затраты
margin_pct = net_profit / revenue if revenue > 0 else 0.0 # чистая маржа на выручку
# ── DCF: помесячный cashflow → NPV / IRR / PBP (PR-3) ──────────────────────
construction_months = _CONSTRUCTION_DURATION_MONTHS.get(
development_type or "", _DEFAULT_CONSTRUCTION_MONTHS
)
cf_result = _build_monthly_cashflow(
revenue=revenue,
construction=construction,
pir=pir,
networks=networks,
dev_services=dev_services,
contingency=contingency,
marketing=marketing,
land=land,
vat=vat,
profit_tax=profit_tax,
construction_duration_months=construction_months,
residential_area_sqm=teap.residential_area_sqm,
market_velocity_sqm_per_month=market_velocity_sqm_per_month,
# DORMANT capability: раздельная инфляция СМР/цены. Дефолт 0.0 → zero-diff
# (фактор (1+0)**t == 1.0). Включение требует калибровки индексов И
# переосмысления инварианта Σ cashflow == net_profit (см. модульные константы).
smr_inflation_annual=_SMR_INFLATION_ANNUAL,
sale_inflation_annual=_SALE_INFLATION_ANNUAL,
)
cashflow = cf_result.cashflow
# Годовая ставка → месячная: (1+r_год)**(1/12) 1.
monthly_rate = (1.0 + _DISCOUNT_RATE_ANNUAL) ** (1.0 / 12.0) - 1.0
npv = _npv(monthly_rate, cashflow)
payback = _payback_months(cashflow)
monthly_irr = _irr_monthly(cashflow)
if monthly_irr is not None:
# Настоящий IRR: месячный → годовой эффективный.
irr = (1.0 + monthly_irr) ** 12 - 1.0
irr_is_proxy = False
else:
# Вырожденный поток (нет смены знака NPV — всегда отток/приток). Fallback на
# PR-1 proxy: аннуализированный net-ROI, клампинг ±1; флаг proxy.
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.
financing = _compute_financing(cashflow, _CREDIT_RATE_ANNUAL, _LOAN_TO_COST_RATIO)
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
revenue_rub=round(revenue, 2),
cost_rub=round(cost, 2),
gross_margin_rub=round(gross_margin, 2),
irr=round(irr, 4),
# revenue breakdown
revenue_residential_rub=round(revenue_residential, 2),
revenue_parking_rub=round(revenue_parking, 2),
revenue_office_rub=round(revenue_office, 2),
# cost cascade
construction_rub=round(construction, 2),
pir_rub=round(pir, 2),
networks_rub=round(networks, 2),
developer_services_rub=round(dev_services, 2),
contingency_rub=round(contingency, 2),
marketing_rub=round(marketing, 2),
land_rub=round(land, 2),
# БДР / taxes
vat_rub=round(vat, 2),
profit_before_tax_rub=round(profit_before_tax, 2),
profit_tax_rub=round(profit_tax, 2),
net_profit_rub=round(net_profit, 2),
# metrics
roi=round(roi, 4),
margin_pct=round(margin_pct, 4),
irr_is_proxy=irr_is_proxy,
# DCF (PR-3)
npv_rub=round(npv, 2),
payback_months=(round(payback, 1) if payback is not None else None),
discount_rate_used=_DISCOUNT_RATE_ANNUAL,
# RANK 1: schedule_is_default — honest-флаг. True, когда окно продаж —
# дефолт-норматив 30 мес; False, когда оно построено по рыночной абсорбции.
schedule_is_default=(not cf_result.schedule_is_market),
# Реализованное окно продаж (sales_end sales_start), мес — для UI/PDF.
sales_duration_months=float(cf_result.sales_duration_months_realized),
# 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,
# 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,
price_source=resolved_price_source,
)
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 levered_irr=%.3f%s",
model.revenue_rub,
model.cost_rub,
model.net_profit_rub,
model.roi,
model.npv_rub,
model.irr,
" (proxy)" if model.irr_is_proxy else "",
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,
model.levered_irr,
" (proxy)" if model.levered_irr_is_proxy else "",
)
return model
__all__ = ["HousingClass", "compute_financial"]