gendesign/backend/app/services/generative/financial.py
Light1YT d350d27325
All checks were successful
CI / frontend-tests (pull_request) Successful in 59s
CI / openapi-codegen-check (pull_request) Successful in 1m53s
CI / backend-tests (pull_request) Successful in 10m1s
CI / changes (pull_request) Successful in 7s
feat(finmodel): financing overlay (кредит 15%) поверх unlevered DCF (epic #1881 PR-5)
Финансирование как ДОПОЛНИТЕЛЬНЫЙ 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<net), api-types
регенерён авторитетно, deps не тронуты (hand-roll).

Refs #1881
2026-06-24 01:12:47 +05:00

600 lines
34 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.
* **Cost cascade** = construction (residential 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.
VAT — DOCUMENTED SIMPLIFICATION
-------------------------------
Exact НК РФ VAT mechanics (output VAT on flats vs. input-VAT deductions on
construction/materials, эскроу specifics, residential-sale exemptions) are out of
PR-1 scope. We use a conservative, honest approximation on the project's
*value-added* (gross margin):
vat = max(0, gross_margin) × VAT_RATE / (1 + VAT_RATE)
i.e. the embedded VAT inside the positive margin. This deliberately *does not*
model the full input/output deduction chain — it is a stand-in that keeps the net
P&L plausible and conservative. A loss-making project pays no VAT here. Replace
with proper output/input modelling when calibrating (PR-2/PR-3).
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.
OFFICES / COMMERCIAL — SKIPPED
------------------------------
TEAP carries no office/commercial area (only residential + parking), so commercial
revenue/cost is intentionally **not** modelled here. Add when TEAP gains the field.
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 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 / БД.
"""
from __future__ import annotations
import logging
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,
}
# ── Паркинг (Excel-эталон девелоперской модели) ───────────────────────────────
# Цена продажи / себестоимость одного машиноместа, руб.
_PARKING_PRICE_PER_SPOT: float = 1_900_000.0 # Excel: цена продажи м/м
_PARKING_COST_PER_SPOT: float = 1_800_000.0 # Excel: себестоимость м/м
# ── Доли каскада затрат (источник — 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
# Длительность ПИР (проектирование/экспертиза), мес.
_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
# Длительность распродажи, мес (нормативный темп выбытия квартир).
_SALES_DURATION_MONTHS: int = 30
# Первый взнос, доля (Excel: 80% при контракте, остаток при вводе/рассрочка). В MVP
# DCF НЕ применяется — вся выручка признаётся в месяц продажи (см. docstring модуля).
# Сохранён константой для будущей рассрочки (PR-4).
_FIRST_PAYMENT_PCT: float = 0.80
# ── 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
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)``.
Возвращает список длины ``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
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,
) -> list[float]:
"""Разложить статический каскад в помесячный чистый cashflow по дефолт-графику.
Чистая функция. График — типовые нормативы (см. docstring модуля): земля (мес 0),
ПИР линейно, СМР+сети+услуги+резерв линейно по окну строительства, продажи линейно
по окну распродажи; маркетинг прорейтится на график продаж; НДС+налог на прибыль
прорейтятся на признание выручки. Выручка — полностью в месяц продажи (упрощение).
ИНВАРИАНТ: ``sum(результат) == 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
# Окончание продаж: минимум _SALES_DURATION_MONTHS, но НЕ раньше завершения стройки
# (РФ ДДУ/эскроу — продажи идут ВО ВРЕМЯ стройки и заканчиваются у/после ввода).
# Без этого high_rise (СМР до 54) распродавал бы всё на мес 42 — за 12 мес ДО ввода
# → хвост затрат ПОСЛЕ окончания продаж → двойная смена знака → IRR-бисекция не
# брекетит profitable-проект и молча падает в proxy. Привязка к constr_end это чинит.
sales_end = max(sales_start + _SALES_DURATION_MONTHS, constr_end)
sales_duration = sales_end - sales_start
# Горизонт = максимум всех фаз (+1 — месяц как индекс, длина = последний+1).
horizon = max(constr_end, sales_end, 1)
# ── Затраты (отток, со знаком ) ───────────────────────────────────────────
land_cf = _spread_linear(land, 0, 0, horizon) # земля — единоразово в мес 0
pir_cf = _spread_linear(pir, 0, pir_end, horizon) # ПИР линейно [0..pir_end)
# СМР+сети+услуги+резерв линейно по окну строительства [pir_end .. constr_end).
constr_total = construction + networks + dev_services + contingency
constr_cf = _spread_linear(constr_total, pir_end, construction_duration_months, horizon)
# ── Выручка (приток, +) и привязанные к ней маркетинг/налоги ────────────────
# Темп продаж пересчитан на ФАКТИЧЕСКОЕ окно [sales_start, sales_end): выручка
# линейно по sales_duration (>= _SALES_DURATION_MONTHS, растянуто до ввода).
revenue_cf = _spread_linear(revenue, sales_start, sales_duration, horizon)
# Маркетинг + НДС + налог на прибыль прорейтятся на признание выручки: доля месяца
# = revenue_cf[m] / revenue. Так отток налога идёт за притоком, который его рождает,
# и Σ undiscounted cashflow == net_profit (инвариант). При revenue==0 — нули.
tax_marketing_total = marketing + vat + profit_tax
if revenue > 0.0:
prorate = [tax_marketing_total * (revenue_cf[m] / 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 cashflow
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,
) -> FinancialModel:
"""Свести ТЭП + класс + стоимость земли в полный :class:`FinancialModel` с DCF.
Полный девелоперский каскад: выручка (жильё + паркинг) → каскад затрат (СМР +
ПИР + сети + услуги заказчика + резерв + маркетинг + земля) → БДР с НДС и
налогом на прибыль → чистая прибыль + ROI. Поверх статики — помесячный DCF
(NPV/IRR/PBP) по дефолт-графику фаз (см. docstring модуля). НДС — упрощение;
офисов нет в TEAP — пропущены.
Чистая функция: НЕ ходит в БД. Калибровку цены продажи жилья делает 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".
Игнорируется (форсится "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).
"""
# Калибруем ТОЛЬКО цену продажи жилья. Если рыночной цены нет — честный 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]
# ── Выручка (GDV) ──────────────────────────────────────────────────────────
revenue_residential = teap.residential_area_sqm * sale_price
revenue_parking = teap.parking_spaces * _PARKING_PRICE_PER_SPOT
revenue = revenue_residential + revenue_parking
# ── Затраты (каскад) ───────────────────────────────────────────────────────
construction_resid = teap.total_floor_area_sqm * construction_cost
construction_parking = teap.parking_spaces * _PARKING_COST_PER_SPOT
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 # валовая (как было)
# НДС: УПРОЩЕНО — встроенный НДС в положительной марже (value-added),
# не полная механика входного/выходного НДС НК РФ (см. docstring модуля).
vat = max(0.0, gross_margin) * _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
)
cashflow = _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,
)
# Годовая ставка → месячная: (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. 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),
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),
# 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,
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,
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",
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,
)
return model
__all__ = ["HousingClass", "compute_financial"]