Финмодель брала ПЛОСКИЕ 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>
698 lines
42 KiB
Python
698 lines
42 KiB
Python
"""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"]
|