"""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. НДС — ОСВОБОЖДЕНИЕ ЖИЛЬЯ, НДС ТОЛЬКО НА ПАРКИНГ (ст. 149 НК РФ) ------------------------------------------------------------ Реализация жилых помещений и услуги застройщика по ДДУ ОСВОБОЖДЕНЫ от НДС (пп.22 и пп.23.1 п.3 ст. 149 НК РФ) → выходной НДС на жильё = 0. Входной НДС по СМР при этом НЕвозмещаем (п.2 ст.170 НК РФ) и уже «сидит» в gross-ставках себестоимости (рыночные ориентиры ``_CONSTRUCTION_COST_PER_SQM`` даны С НДС) → чистая нагрузка по НДС на жилую часть ≈ 0. Машиноместа — НЕЖИЛЫЕ помещения: их реализация ОБЛАГАЕТСЯ НДС 20%, входной НДС вычитается. Поэтому НДС начисляется ТОЛЬКО на нежилой value-added паркинга (встроенный НДС в добавленной стоимости машиномест): vat = max(0, revenue_parking − construction_parking) × VAT_RATE / (1 + VAT_RATE) Если паркинг тоже окажется под ДДУ-льготой — оценка консервативна (чуть занижает прибыль завышенным НДС), что соответствует honest/conservative-политике модуля. Полную механику входного/выходного НДС с эскроу-спецификой не моделируем — жильё освобождено, входной НДС встроен в gross-СМР. IRR — REAL DCF, PROXY FALLBACK (PR-3) ------------------------------------- ``irr`` is now a **real** annualised IRR computed by bisection over the monthly cashflow (``irr_is_proxy=False``). For a degenerate cashflow with no sign change (e.g. an always-loss project — every month is an outflow, or every month inflow) bisection cannot bracket a root; we then fall back to the PR-1 annualised net-ROI proxy (``roi / _PROJECT_YEARS``, clamped ±1) and flag ``irr_is_proxy=True`` so the frontend shows the «оценочный» caveat. 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``), отдельный от ставки дисконтирования (см. константы), а не выведенная из проекта величина. * **LTC-покрытие разрыва** — кредитная линия покрывает ``_LOAN_TO_COST_RATIO`` (70 %) каждого ежемесячного оттока; оставшиеся 30 % (собственные средства) co-fund отток пропорционально и процентами не облагаются. Лимиты банка НЕ моделируются точно. * **Проценты капитализируются (compound)** — начисляются на остаток на начало месяца ДО движений и добавляются в тело долга. * **Эскроу-специфику не моделируем точно** — раскрытие средств с эскроу-счетов по этапам стройки, лимиты раскрытия, ставка по бридж/целевой части — всё это упрощено до единой капитализируемой ставки на кассовый разрыв. * **Levered (equity) IRR** считается по equity-потоку того же графика долга (``_FinancingResult.equity_cashflow``): отток = equity-доля (1−LTC) каждого кассового разрыва, приток = остаток притока после обслуживания долга. При положительном леверидже (доходность проекта > стоимости долга 15 %) levered IRR выше unlevered. Тот же proxy-fallback, что у unlevered (нет смены знака → roi-style proxy + ``levered_irr_is_proxy=True``). Вырожденные случаи (LTC>=1 — equity не под риском, финансирование выключено, нет equity-оттока) → ``levered_irr=0.0`` / ``levered_irr_is_proxy=True`` (не выдумываем доходность). debt_schedule в схему НЕ выносится. MVP-агрегаты: ``peak_debt_rub`` / ``total_interest_rub`` / ``net_profit_after_financing_rub`` (без изменений). * **Unlevered NPV / IRR / PBP НЕ затронуты** overlay'ем — это всё та же интринсивная доходность. График наследует неточность ``schedule_is_default``: финансирование считается по тому же дефолт-графику фаз, поэтому ``financing_is_simplified=True`` (honest-флаг для UI/PDF). This function stays **pure**: it does no DB lookup itself (the API layer does the lookup and passes the result in). Детерминированно, без LLM / внешних API / БД. """ from __future__ import annotations import logging import math from dataclasses import dataclass from typing import Literal from app.schemas.concept import TEAP, FinancialModel logger = logging.getLogger(__name__) HousingClass = Literal["econom", "comfort", "business"] # ── Цена продажи жилья, руб/кв.м (proxy рынка ЕКБ/региона, упрощённо) ────────── _SALE_PRICE_PER_SQM: dict[HousingClass, float] = { "econom": 110_000.0, "comfort": 145_000.0, "business": 210_000.0, } # ── Себестоимость СМР, руб/кв.м общей площади (выше класс -> дороже отделка/инж) ─ _CONSTRUCTION_COST_PER_SQM: dict[HousingClass, float] = { "econom": 72_000.0, "comfort": 88_000.0, "business": 120_000.0, } # ── Паркинг — ЦЕНА ПРОДАЖИ / СЕБЕСТОИМОСТЬ м/м по классу, руб ────────────────── # TUNABLE ASSUMPTIONS (РФ-рынок паркинга). Раньше эти константы были СКАЛЯРАМИ, # взятыми из Excel-эталона «Авангардная 13» (premium, ПОДЗЕМНЫЙ паркинг, near # break-even: 1.9М продажа / 1.8М себестоимость). Применять их ПЛОСКО ко всем # классам некорректно: эконом-жильё в РФ строит ДЕШЁВЫЙ НАЗЕМНЫЙ паркинг, а не # подземный — плоская подземная себестоимость 1.8М × 0.8 м/м на квартиру раздувала # near-break-even капитал → пиковый долг → проценты по финансированию (прод: Ж-5, # 212 м/м × 1.8М = 382М break-even капитал → ~120М процентов, душивших # net_after_financing). # # Поэтому цена/себестоимость теперь КЛАССОЗАВИСИМЫ (зеркалит паттерн # _SALE_PRICE_PER_SQM / _CONSTRUCTION_COST_PER_SQM). ВАЖНО: НОРМА паркинга # (_PARKING_PER_APARTMENT в teap.py: econom 0.8 / comfort 1.0 / business 1.5) НЕ # трогается — она может отражать нормы ПЗЗ (out of scope). # # business 1.9 / 1.8 — ПОДЗЕМНЫЙ, matches Excel «Авангардная 13» (без изменений). # econom 0.8 / 0.35 — НАЗЕМНЫЙ (surface): дешёвый СМР ~350k, продажа ~800k. # comfort 1.3 / 1.0 — ЧАСТИЧНО подземный (интерполяция econom↔business). # # Маржа на м/м: econom +450k, comfort +300k, business +100k (цена > себестоимости # во всех классах — паркинг не убыточен ни в одном классе). _PARKING_PRICE_PER_SPOT: dict[HousingClass, float] = { "econom": 800_000.0, # наземный паркинг — продажа "comfort": 1_300_000.0, # частично подземный — продажа "business": 1_900_000.0, # подземный (Excel «Авангардная 13») — продажа } _PARKING_COST_PER_SPOT: dict[HousingClass, float] = { "econom": 350_000.0, # наземный паркинг — себестоимость "comfort": 1_000_000.0, # частично подземный — себестоимость "business": 1_800_000.0, # подземный (Excel «Авангардная 13») — себестоимость } # ── Доли каскада затрат (источник — Excel-эталон + RU-рынок) ────────────────── # ПИР (проектирование + экспертиза + РД) как доля от СМР. # Excel: ПИР 147М при СМР 6123М ≈ 2.4% -> округлённо 2.5%. _PIR_PCT_OF_CONSTRUCTION: float = 0.025 # Сети / подключение по ТУ, руб/кв.м GFA. # Excel: ТУ 330М / 55042 кв.м ≈ 6000 руб/кв.м. _NETWORKS_COST_PER_SQM: float = 6_000.0 # Услуги заказчика / управление как доля от СМР. # Excel: 76.5М при СМР 6123М ≈ 1.25% -> округлённо 1.5%. _DEVELOPER_SERVICES_PCT: float = 0.015 # Резерв / непредвиденные как доля от (СМР + ПИР + сети). Excel: 3%. _CONTINGENCY_PCT: float = 0.03 # Реклама + риэлтор + маркетинг как доля от выручки. Excel: 7%. _MARKETING_REALTOR_PCT: float = 0.07 # ── Налоги ──────────────────────────────────────────────────────────────────── # НДС, ставка. РФ стандартная 20%. _VAT_RATE: float = 0.20 # Налог на прибыль организаций. С 01.01.2025 базовая ставка повышена 20% -> 25% # (ФЗ № 176-ФЗ от 12.07.2024). Используем актуальные 25%. _PROFIT_TAX_RATE: float = 0.25 # Условный горизонт проекта (лет) для аннуализации net-ROI в IRR-proxy (fallback). _PROJECT_YEARS: float = 3.0 # ── DCF: график фаз и продаж — ТИПОВЫЕ НОРМАТИВЫ (ДОПУЩЕНИЯ, не график проекта) ── # Эти константы — дефолтные допущения помесячной раскладки cashflow. Они ЧЕСТНО # помечены ``schedule_is_default=True`` в FinancialModel: NPV/IRR/PBP считаются DCF # по ЭТОМУ графику, точность зависит от реального графика проекта. Темп продаж — # дефолт-норматив, НЕ §22 velocity (отдельная интеграция позже). # Годовая ставка дисконтирования (Excel-эталон девелоперской модели). _DISCOUNT_RATE_ANNUAL: float = 0.15 # Ставка по кредиту/проектному финансированию (Excel-эталон). ОТДЕЛЬНА от # _DISCOUNT_RATE_ANNUAL: дисконт = требуемая доходность инвестора (NPV), кредитная # ставка = стоимость долга (проценты). Совпадение значения (0.15) — калибровка, # НЕ тождество; не сливать в одну константу. _CREDIT_RATE_ANNUAL: float = 0.15 # Доля банковского кредита от каждого ежемесячного оттока (LTC, Loan-to-Cost). # Собственные средства = (1 − LTC) co-fund отток пропорционально, без начисления %. # Норматив РФ МКД при проектном финансировании: 65–75 %. Используем 70 % (медиана). _LOAN_TO_COST_RATIO: float = 0.70 # Длительность ПИР (проектирование/экспертиза), мес. _PIR_DURATION_MONTHS: int = 6 # Длительность СМР по типу застройки, мес (точечная быстрее высотной). _CONSTRUCTION_DURATION_MONTHS: dict[str, int] = { "spot": 24, "mid_rise": 36, "high_rise": 48, } # Дефолт длительности СМР, если development_type неизвестен (среднеэтажная). _DEFAULT_CONSTRUCTION_MONTHS: int = _CONSTRUCTION_DURATION_MONTHS["mid_rise"] # Старт продаж — через N мес после начала СМР (нормативно — после котлована/этажей). _SALES_START_MONTH: int = 6 # Длительность распродажи, мес (нормативный темп выбытия квартир). Используется, # когда рыночная скорость абсорбции не передана (market_velocity_sqm_per_month=None). _SALES_DURATION_MONTHS: int = 30 # RANK 1: границы окна продаж, когда оно ДРАЙВИТСЯ рыночной скоростью абсорбции # (residential_area_sqm / market_velocity_sqm_per_month). Клампим вычисленную # длительность в [_SALES_DURATION_MIN, _SALES_DURATION_MAX], чтобы аномально # горячий/холодный радиус не давал нереалистичных 1-мес распродаж или 20-летних # хвостов. Эскроу-привязка (sales_end >= constr_end) применяется ПОВЕРХ клампа. _SALES_DURATION_MIN: int = 6 _SALES_DURATION_MAX: int = 120 # Первый взнос, доля (Excel: 80% при контракте, остаток при вводе/рассрочка). В MVP # DCF НЕ применяется — вся выручка признаётся в месяц продажи (см. docstring модуля). # Сохранён константой для будущей рассрочки (PR-4). _FIRST_PAYMENT_PCT: float = 0.80 # ── DCF helpers — hand-rolled, чистая математика (без numpy-financial / deps) ─── def _npv(monthly_rate: float, cf: list[float]) -> float: """NPV помесячного потока ``cf`` (t=0..N) при месячной ставке ``monthly_rate``. ``cf[t]`` — чистый денежный поток месяца t (приток +, отток −). t=0 не дисконтируется. """ return sum(c / (1.0 + monthly_rate) ** t for t, c in enumerate(cf)) def _irr_monthly( cf: list[float], lo: float = -0.9999, hi: float = 1.0, tol: float = 1e-7, max_iter: int = 200, ) -> float | None: """Месячный IRR помесячного потока ``cf`` методом бисекции. Возвращает месячную ставку r, при которой ``_npv(r, cf) == 0``, или ``None``, если на ``[lo, hi]`` нет смены знака NPV (вырожденный поток — всегда отток или всегда приток). Caller делает fallback на roi-proxy + ``irr_is_proxy=True``. Bracket-guard: если ``_npv(lo) * _npv(hi) > 0`` — корня в брекете нет → ``None``. NPV монотонно убывает по ставке для «нормального» девелоперского потока (ранний отток, поздний приток), поэтому бисекция сходится к единственному корню. """ # Вырожденный поток без денег (все нули) — NPV==0 при любой ставке, IRR неопределён. if all(abs(c) < tol for c in cf): return None f_lo = _npv(lo, cf) f_hi = _npv(hi, cf) # Краевое попадание в корень. if abs(f_lo) < tol: return lo if abs(f_hi) < tol: return hi # Нет смены знака на брекете → нет (единственного) корня здесь → None. if f_lo * f_hi > 0.0: return None for _ in range(max_iter): mid = (lo + hi) / 2.0 f_mid = _npv(mid, cf) if abs(f_mid) < tol: return mid if f_lo * f_mid < 0.0: hi = mid f_hi = f_mid else: lo = mid f_lo = f_mid return (lo + hi) / 2.0 def _payback_months(cf: list[float]) -> float | None: """Срок окупаемости (мес) по НЕдисконтированному накопительному потоку. Накапливаем cashflow; как только сумма пересекает 0, линейно интерполируем дробный месяц внутри переломного интервала. ``None`` — поток не окупается на горизонте (накопленный итог так и не достиг 0). """ cumulative = 0.0 for t, c in enumerate(cf): prev = cumulative cumulative += c # Month 0 — всегда land-отток (cf[0] < 0), поэтому prev<0 на первом переломе: # ветка t==0 ниже — теоретический corner (поток сразу неотрицателен), не реальный путь. if cumulative >= 0.0 and prev < 0.0: # Линейная интерполяция дроби месяца: prev + frac * c = 0. frac = -prev / c if c != 0.0 else 0.0 return float(t - 1) + frac if cumulative >= 0.0 and t == 0: # Окупается уже в нулевом месяце (поток сразу неотрицателен). return 0.0 return None @dataclass(frozen=True) class _FinancingResult: """Результат overlay'я финансирования: агрегаты долга + ПОМЕСЯЧНЫЙ equity-поток. Агрегаты (``peak_debt_rub`` / ``total_interest_rub`` / ``ending_debt_rub``) сохраняют прежний READ-ONLY-контракт PR-5 (значения не изменились). ``equity_cashflow`` — НОВЫЙ поток держателя equity по тому же графику долга (для levered IRR, отдельный PR): отток = equity-доля (1−ltc) каждого кассового разрыва (со знаком −), приток = остаток притока после обслуживания долга. Σ equity_cashflow == net_profit_after_financing (equity получает всю прибыль за вычетом процентов; банк — только тело + проценты). """ peak_debt_rub: float total_interest_rub: float ending_debt_rub: float equity_cashflow: list[float] def _compute_financing( operating_cf: list[float], annual_rate: float, ltc_ratio: float = 1.0, ) -> _FinancingResult: """OVERLAY финансирования поверх unlevered cashflow (PR-5). Чистая функция. ``operating_cf`` — готовый unlevered cashflow (READ-ONLY, НЕ мутируется). Кредитная линия покрывает ``ltc_ratio`` долю кассового разрыва; оставшийся (1 − ltc_ratio) — equity-взнос (без процентов). Проценты КАПИТАЛИЗИРУЮТСЯ в долг (compound) — упрощение под эскроу-логику РФ. Порядок внутри месяца: процент НАЧИСЛЯЕМ на остаток ДО draw/repay, затем выборка (cf<0) либо погашение притоком (cf>0, не уходя ниже 0). Если поток не уходит в минус → долг не выбирается → debt=0, interest=0 (корректно: финансирование не требуется). ВОЗВРАТ — :class:`_FinancingResult`: агрегаты (``peak_debt_rub`` — пиковая задолженность, ``total_interest_rub`` — суммарные капитализированные проценты, ``ending_debt_rub`` — остаток долга на конец) ПЛЮС ``equity_cashflow`` — помесячный поток держателя equity по тому же графику долга (для levered IRR, отдельный PR; см. :class:`_FinancingResult`). Агрегаты не изменились — контракт цел. """ monthly_rate = (1.0 + annual_rate) ** (1.0 / 12.0) - 1.0 debt = 0.0 peak_debt = 0.0 total_interest = 0.0 equity_cashflow: list[float] = [] for cf in operating_cf: interest = debt * monthly_rate # процент на остаток на начало месяца (до движений) debt += interest total_interest += interest if cf < 0: debt += -cf * ltc_ratio # банк покрывает ltc_ratio от оттока; equity-часть без % # Equity вносит (1−ltc) долю оттока (со знаком −, как и cf): свои деньги под риском. equity_cashflow.append(cf * (1.0 - ltc_ratio)) else: repay = min(cf, debt) # гасим долг притоком (не уходим в минус) debt -= repay # Остаток притока после обслуживания долга достаётся equity (приток, +). equity_cashflow.append(cf - repay) peak_debt = max(peak_debt, debt) return _FinancingResult( peak_debt_rub=round(peak_debt, 2), total_interest_rub=round(total_interest, 2), ending_debt_rub=round(debt, 2), equity_cashflow=equity_cashflow, ) def _spread_linear(total: float, start: int, duration: int, horizon: int) -> list[float]: """Разложить ``total`` равными долями по месяцам ``[start .. start+duration)``. Возвращает список длины ``horizon`` (по индексу = месяц). ``duration <= 0`` → единоразово в месяц ``start``. Месяцы вне горизонта молча обрезаются (страховка). """ out = [0.0] * horizon if total == 0.0: return out if duration <= 0: if 0 <= start < horizon: out[start] += total return out per = total / duration for m in range(start, start + duration): if 0 <= m < horizon: out[m] += per return out @dataclass(frozen=True) class _CashflowResult: """Результат раскладки cashflow: сам поток + реализованное окно продаж и флаг, был ли график построен по рыночной скорости абсорбции (RANK 1).""" cashflow: list[float] sales_duration_months_realized: int # фактическое окно sales_end − sales_start schedule_is_market: bool # True → окно драйвилось market_velocity, не дефолт-30 def _build_monthly_cashflow( *, revenue: float, construction: float, pir: float, networks: float, dev_services: float, contingency: float, marketing: float, land: float, vat: float, profit_tax: float, construction_duration_months: int, residential_area_sqm: float, market_velocity_sqm_per_month: float | None = None, ) -> _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 модуля). НДС — жильё освобождено (ст. 149 НК РФ), начисляется только на паркинг; офисов нет в 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" (concepts-путь через ``_lookup_market_price``), либо "objective_geo_radius" — этот лейбл приходит ТОЛЬКО из site-finder-моста (``synthesize_parcel_financial``), не из concepts. Игнорируется (форсится "class_norm"), если ``market_price_per_sqm is None``. development_type: "spot"/"mid_rise"/"high_rise" — задаёт длительность СМР в DCF-графике (``_CONSTRUCTION_DURATION_MONTHS``). ``None``/неизвестный → дефолт mid_rise (36 мес). График — типовое допущение (schedule_is_default). market_velocity_sqm_per_month: RANK 1 — темп абсорбции ОДНОГО типового конкурента района (м²/мес), из velocity.project_absorption_sqm_per_month. Драйвит окно продаж в DCF: ``ceil(residential_area_sqm / velocity)``, клампится в [_SALES_DURATION_MIN, _SALES_DURATION_MAX], затем эскроу-привязка ``sales_end >= constr_end``. ``None``/<=0/non-finite → дефолт-30-мес окно и ``schedule_is_default=True``. Это НЕ суммарная скорость района (новый проект — один продавец из многих), иначе срок занижается и NPV/IRR раздуваются. """ # Калибруем ТОЛЬКО цену продажи жилья. Если рыночной цены нет — честный fallback # на норматив класса, и source форсим в "class_norm" (не выдаём норму за рынок). if market_price_per_sqm is not None: sale_price = market_price_per_sqm price_is_calibrated = True resolved_price_source = price_source else: sale_price = _SALE_PRICE_PER_SQM[housing_class] price_is_calibrated = False resolved_price_source = "class_norm" construction_cost = _CONSTRUCTION_COST_PER_SQM[housing_class] # Паркинг — классозависимы цена/себестоимость (econom наземный, business # подземный); НОРМА м/м на квартиру задаётся в teap (не здесь). parking_price = _PARKING_PRICE_PER_SPOT[housing_class] parking_cost = _PARKING_COST_PER_SPOT[housing_class] # ── Выручка (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 # валовая (как было) # НДС: жилые помещения и услуги застройщика по ДДУ освобождены (пп.22 / пп.23.1 # п.3 ст. 149 НК РФ) → output-НДС на жильё = 0, а невозмещаемый входной НДС по СМР # уже сидит в gross-ставках себестоимости. НДС начисляется ТОЛЬКО на нежилую часть # (машиноместа — нежилые, их реализация облагается): встроенный НДС в value-added # паркинга. Если паркинг тоже окажется по ДДУ-льготе — оценка консервативна (чуть # занижает прибыль), что соответствует «honest/conservative» политике модуля. parking_value_added = revenue_parking - construction_parking vat = max(0.0, parking_value_added) * _VAT_RATE / (1.0 + _VAT_RATE) profit_before_tax = gross_margin - vat profit_tax = max(0.0, profit_before_tax) * _PROFIT_TAX_RATE net_profit = profit_before_tax - profit_tax # ── Метрики ──────────────────────────────────────────────────────────────── roi = net_profit / cost if cost > 0 else 0.0 # чистый ROI на затраты margin_pct = net_profit / revenue if revenue > 0 else 0.0 # чистая маржа на выручку # ── DCF: помесячный cashflow → NPV / IRR / PBP (PR-3) ────────────────────── construction_months = _CONSTRUCTION_DURATION_MONTHS.get( development_type or "", _DEFAULT_CONSTRUCTION_MONTHS ) cf_result = _build_monthly_cashflow( revenue=revenue, construction=construction, pir=pir, networks=networks, dev_services=dev_services, contingency=contingency, marketing=marketing, land=land, vat=vat, profit_tax=profit_tax, construction_duration_months=construction_months, residential_area_sqm=teap.residential_area_sqm, market_velocity_sqm_per_month=market_velocity_sqm_per_month, ) cashflow = cf_result.cashflow # Годовая ставка → месячная: (1+r_год)**(1/12) − 1. monthly_rate = (1.0 + _DISCOUNT_RATE_ANNUAL) ** (1.0 / 12.0) - 1.0 npv = _npv(monthly_rate, cashflow) payback = _payback_months(cashflow) monthly_irr = _irr_monthly(cashflow) if monthly_irr is not None: # Настоящий IRR: месячный → годовой эффективный. irr = (1.0 + monthly_irr) ** 12 - 1.0 irr_is_proxy = False else: # Вырожденный поток (нет смены знака NPV — всегда отток/приток). Fallback на # PR-1 proxy: аннуализированный net-ROI, клампинг ±1; флаг proxy. irr = max(-1.0, min(1.0, roi / _PROJECT_YEARS)) irr_is_proxy = True # ── Финансирование: OVERLAY поверх unlevered cashflow (PR-5) ─────────────── # Тот же массив cashflow, что построен выше (НЕ пересобираем — защита инварианта: # overlay читает unlevered поток и НЕ мутирует его). Проценты — реальная стоимость # долга, режут прибыль: net_after < net. financing = _compute_financing(cashflow, _CREDIT_RATE_ANNUAL, _LOAN_TO_COST_RATIO) net_profit_after_financing = net_profit - financing.total_interest_rub # ── Levered (equity) IRR — доходность на собственные средства ─────────────── # IRR equity-потока того же графика долга. equity_invested = Σ |отрицательных # equity_cf| (суммарный отток собственных средств). Вырожденные случаи (LTC>=1 — # equity не под риском, нет equity-оттока) → levered_irr=0.0 / proxy (не выдумываем # доходность). Иначе — тот же DCF-IRR + roi-proxy fallback, что у unlevered. equity_cf = financing.equity_cashflow equity_invested = sum(-c for c in equity_cf if c < 0.0) if equity_invested <= 0.0: # LTC>=1.0 (банк покрывает весь разрыв) ИЛИ нет кассового разрыва → equity не # рискует → доходность equity не определена. Honest: 0.0 + proxy-флаг. levered_irr = 0.0 levered_irr_is_proxy = True else: monthly_levered_irr = _irr_monthly(equity_cf) if monthly_levered_irr is not None: # Настоящий levered IRR: месячный → годовой эффективный (как unlevered). levered_irr = (1.0 + monthly_levered_irr) ** 12 - 1.0 levered_irr_is_proxy = False else: # Вырожденный equity-поток (нет смены знака). Fallback на roi-style proxy: # прибыль на equity после процентов / equity / горизонт, клампинг ±1 — точно # как unlevered делает irr=roi/_PROJECT_YEARS. levered_roi = net_profit_after_financing / equity_invested levered_irr = max(-1.0, min(1.0, levered_roi / _PROJECT_YEARS)) levered_irr_is_proxy = True model = FinancialModel( # legacy summary revenue_rub=round(revenue, 2), cost_rub=round(cost, 2), gross_margin_rub=round(gross_margin, 2), irr=round(irr, 4), # revenue breakdown revenue_residential_rub=round(revenue_residential, 2), revenue_parking_rub=round(revenue_parking, 2), # cost cascade construction_rub=round(construction, 2), pir_rub=round(pir, 2), networks_rub=round(networks, 2), developer_services_rub=round(dev_services, 2), contingency_rub=round(contingency, 2), marketing_rub=round(marketing, 2), land_rub=round(land, 2), # БДР / taxes vat_rub=round(vat, 2), profit_before_tax_rub=round(profit_before_tax, 2), profit_tax_rub=round(profit_tax, 2), net_profit_rub=round(net_profit, 2), # metrics roi=round(roi, 4), margin_pct=round(margin_pct, 4), irr_is_proxy=irr_is_proxy, # DCF (PR-3) npv_rub=round(npv, 2), payback_months=(round(payback, 1) if payback is not None else None), discount_rate_used=_DISCOUNT_RATE_ANNUAL, # RANK 1: schedule_is_default — honest-флаг. True, когда окно продаж — # дефолт-норматив 30 мес; False, когда оно построено по рыночной абсорбции. schedule_is_default=(not cf_result.schedule_is_market), # Реализованное окно продаж (sales_end − sales_start), мес — для UI/PDF. sales_duration_months=float(cf_result.sales_duration_months_realized), # financing overlay (PR-5) financing_enabled=True, annual_rate_used=_CREDIT_RATE_ANNUAL, peak_debt_rub=financing.peak_debt_rub, total_interest_rub=financing.total_interest_rub, net_profit_after_financing_rub=round(net_profit_after_financing, 2), financing_is_simplified=True, # levered (equity) IRR — доходность на собственные средства при LTC levered_irr=round(levered_irr, 4), levered_irr_is_proxy=levered_irr_is_proxy, # price calibration (PR-2) price_per_sqm_used=round(sale_price, 2), price_is_calibrated=price_is_calibrated, price_source=resolved_price_source, ) logger.info( "financial: revenue=%.0f cost=%.0f net=%.0f roi=%.3f npv=%.0f " "irr=%.3f%s pbp=%s price=%.0f/m2 source=%s " "peak_debt=%.0f total_interest=%.0f net_after_fin=%.0f levered_irr=%.3f%s", model.revenue_rub, model.cost_rub, model.net_profit_rub, model.roi, model.npv_rub, model.irr, " (proxy)" if model.irr_is_proxy else "", model.payback_months, model.price_per_sqm_used, model.price_source, model.peak_debt_rub, model.total_interest_rub, model.net_profit_after_financing_rub, model.levered_irr, " (proxy)" if model.levered_irr_is_proxy else "", ) return model __all__ = ["HousingClass", "compute_financial"]