gendesign/backend/app/services/generative/financial.py
Light1YT d4068a255b
All checks were successful
CI / frontend-tests (pull_request) Successful in 57s
CI / backend-tests (pull_request) Successful in 9m54s
CI / changes (pull_request) Successful in 6s
CI / openapi-codegen-check (pull_request) Successful in 1m53s
feat(finmodel): monthly DCF — real NPV/IRR/PBP, replace IRR-proxy (epic #1881 PR-3)
Hand-rolled детерминированный DCF (без numpy-financial/новых deps): _npv /
_irr_monthly (bisection с bracket-guard) / _payback_months над помесячным
cashflow, построенным из статического каскада (PR-1) + калиброванной цены
(PR-2) по дефолтным нормативам фаз (ПИР 6мес → СМР по development_type
24/36/48 → распродажа, дисконт 15%).

- Налоги прорейтятся на признание выручки → Σ недисконтированного cashflow ==
  net_profit (ИНВАРИАНТ, тест — гарантия что DCF не разошёлся со статикой).
- IRR настоящий когда есть смена знака; вырожденный поток → fallback на
  roi-proxy (irr_is_proxy=True). Честно помечено.
- График продаж привязан к завершению стройки: sales_end = max(sales_start +
  длительность, construction_end) — продажи не заканчиваются раньше ввода
  (РФ ДДУ/эскроу). Без этого high_rise распродавался за 12 мес ДО ввода →
  двойная смена знака → profitable-проект молча падал в proxy + инверсия NPV.
- FinancialModel +npv_rub/payback_months/discount_rate_used/schedule_is_default
  (additive). development_type проброшен через placement.
- UI/PDF: NPV/IRR/PBP + честный caveat «график — типовое допущение, точность
  зависит от него». api-types регенерён авторитетно.

mypy strict clean (generative.*), +15 тестов (DCF helpers на учебных значениях,
ИНВАРИАНТ, IRR real-vs-proxy обе ветки, PBP, sales≥construction для всех типов,
profitable high_rise → настоящий IRR). 48 passed.

Refs #1881
2026-06-23 21:52:04 +05:00

513 lines
28 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.
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
# Длительность ПИР (проектирование/экспертиза), мес.
_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 _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
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,
# 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",
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,
)
return model
__all__ = ["HousingClass", "compute_financial"]