gendesign/backend/app/services/generative/financial.py
Light1YT 4eeda46cfa
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m57s
CI / backend-tests (pull_request) Successful in 11m44s
fix(financial): парковка price/cost по классу (econom наземная, business Excel)
Финмодель брала ПЛОСКИЕ parking-константы для ВСЕХ классов: price 1.9М/cost 1.8М
(Excel «Авангардная 13» premium — подземная, near break-even). Но норма парковки
уже class-dependent (econom 0.8/comfort 1.0/business 1.5 м/м на квартиру). Econom
(дешёвое жильё) заряжался 1.8М/м/м ПОДЗЕМНОЙ застройки → огромный break-even капитал,
раздувающий пиковый долг/проценты. По РФ econom = наземная дешёвая парковка.

Фикс: _PARKING_PRICE_PER_SPOT / _PARKING_COST_PER_SPOT → dict[HousingClass]:
- econom 800k/350k (наземная), comfort 1.3М/1.0М (частично подземная),
  business 1.9М/1.8М (подземная, = Excel, unchanged).
- маржа/м/м: econom +450k, comfort +300k, business +100k.
- НОРМА парковки (_PARKING_PER_APARTMENT) НЕ тронута (возможно ПЗЗ-норматив).
- Константы помечены как TUNABLE ASSUMPTIONS (РФ-рынок), правятся в одном месте
  (паттерн _SALE_PRICE_PER_SQM / _CONSTRUCTION_COST_PER_SQM).

ВАЖНО (честно): парковка — ВТОРИЧНЫЙ драйвер. Улучшает econom в основном через
снижение peak-debt/процентов, НЕ переворачивает знак. ДОМИНИРУЮЩИЙ рычаг —
себестоимость СМР жилья к выручке (econom 72k на GFA vs выручка на sellable = 87%)
— отдельное продуктовое решение (не трогаю без согласования).

Инвариант Σ cashflow == net_profit сохранён (меняются только величины
revenue_parking/construction_parking в том же каскаде). Тесты: +6 (по классам,
маржа, инвариант econom+parking) + 3 существующих исправлены (хардкод comfort
1.9/1.8 → 1.3/1.0). tests/services/generative 90 passed. ruff+mypy(strict) чисто.
Схема не менялась.

Хозяйственное: untrack + gitignore backend/.coverage (артефакт блокировал checkout).

Refs #1881
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 16:58:21 +05:00

698 lines
42 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 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``), отдельный
от ставки дисконтирования (см. константы), а не выведенная из проекта величина.
* **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
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») — себестоимость
}
# ── Доли каскада затрат (источник — 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
# Длительность распродажи, мес (нормативный темп выбытия квартир). Используется,
# когда рыночная скорость абсорбции не передана (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 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
@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,
) -> _CashflowResult:
"""Разложить статический каскад в помесячный чистый cashflow по графику фаз.
Чистая функция. График — типовые нормативы (см. docstring модуля): земля (мес 0),
ПИР линейно, СМР+сети+услуги+резерв линейно по окну строительства, продажи линейно
по окну распродажи; маркетинг прорейтится на график продаж; НДС+налог на прибыль
прорейтятся на признание выручки. Выручка — полностью в месяц продажи (упрощение).
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)
# ── Затраты (отток, со знаком ) ───────────────────────────────────────────
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 _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.
Полный девелоперский каскад: выручка (жильё + паркинг) → каскад затрат (СМР +
ПИР + сети + услуги заказчика + резерв + маркетинг + земля) → БДР с НДС и
налогом на прибыль → чистая прибыль + 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).
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]
# ── Выручка (GDV) ──────────────────────────────────────────────────────────
revenue_residential = teap.residential_area_sqm * sale_price
revenue_parking = teap.parking_spaces * parking_price
revenue = revenue_residential + revenue_parking
# ── Затраты (каскад) ───────────────────────────────────────────────────────
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 # валовая (как было)
# НДС: УПРОЩЕНО — встроенный НДС в положительной марже (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
)
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,
)
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. 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,
# 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,
# 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"]