diff --git a/backend/app/schemas/concept.py b/backend/app/schemas/concept.py index 31e3ed05..fd8044cd 100644 --- a/backend/app/schemas/concept.py +++ b/backend/app/schemas/concept.py @@ -29,15 +29,24 @@ class TEAP(BaseModel): class FinancialModel(BaseModel): - """Static developer P&L for a concept variant (PR-1, эпик #1881). + """Static developer P&L + monthly-DCF investment metrics (PR-1/PR-3, эпик #1881). - Full static cost cascade + VAT + profit tax → net profit + ROI. NOT a DCF — - timing/discounting lands in PR-3. VAT is a documented simplification (see - ``compute_financial`` docstring), not exact НК РФ mechanics. + Full static cost cascade + VAT + profit tax → net profit + ROI (PR-1). On top + of that static P&L, PR-3 lays the cascade onto a **monthly cashflow schedule** + (default phase norms — ПИР → СМР → продажи) and runs a real DCF: NPV, IRR + (bisection over the monthly cashflow) and *undiscounted* cumulative payback. + See ``compute_financial`` docstring for the schedule assumptions and the + налоги/рассрочка phasing choices. + + ``irr`` is a **real** annualised DCF IRR when ``irr_is_proxy is False``; for a + degenerate cashflow with no sign change (e.g. always-loss) the bisection cannot + bracket a root, so we fall back to the PR-1 annualised-ROI proxy and flag + ``irr_is_proxy=True``. VAT is a documented simplification (see docstring), not + exact НК РФ mechanics. The legacy summary fields (``revenue_rub`` / ``cost_rub`` / ``gross_margin_rub`` / ``irr``) are kept for backward-compat; the new fields - expose the full cascade and the net P&L. + expose the full cascade, the net P&L and the DCF metrics. """ # ── Legacy summary (backward-compat) ─────────────────────────────────────── @@ -68,7 +77,19 @@ class FinancialModel(BaseModel): # ── Metrics ──────────────────────────────────────────────────────────────── roi: float # net profit / total cost margin_pct: float # net profit / revenue - irr_is_proxy: bool = True # frontend caveat: not a real DCF IRR (см. PR-3) + irr_is_proxy: bool = True # frontend caveat: True → IRR is the ROI-proxy, not DCF + + # ── DCF / investment metrics (PR-3, эпик #1881) ──────────────────────────── + # NPV дисконтированного помесячного cashflow по ставке ``discount_rate_used``. + npv_rub: float = 0.0 + # Окупаемость (мес) по НЕдисконтированному накопительному cashflow, линейная + # интерполяция дробного месяца. ``None`` — проект не окупается на горизонте. + payback_months: float | None = None + # Годовая ставка дисконтирования, фактически применённая в NPV (норматив). + discount_rate_used: float = 0.0 + # График фаз/продаж — типовые нормативные ДОПУЩЕНИЯ (не график конкретного + # проекта). True → UI/PDF показывают caveat «график — типовое допущение». + schedule_is_default: bool = True # ── Price calibration (PR-2, эпик #1881) ─────────────────────────────────── # Цена продажи жилья, руб/кв.м, фактически использованная в выручке. Либо diff --git a/backend/app/services/generative/exporters/pdf.py b/backend/app/services/generative/exporters/pdf.py index 8c16f9a3..a6276e90 100644 --- a/backend/app/services/generative/exporters/pdf.py +++ b/backend/app/services/generative/exporters/pdf.py @@ -20,7 +20,7 @@ import html import logging from collections.abc import Sequence -from app.schemas.concept import ConceptVariant +from app.schemas.concept import ConceptVariant, FinancialModel logger = logging.getLogger(__name__) @@ -47,7 +47,7 @@ thead th { background: #ececec; } """ _TITLE = "Концепции застройки — сводка вариантов" -_SUBTITLE = "Generative Design · Stage 1c · детерминированный расчёт ТЭП и финмодели" +_SUBTITLE = "Generative Design · Stage 1c · детерминированный расчёт ТЭП, финмодели и DCF" def _fmt_int(value: float | int) -> str: @@ -92,8 +92,21 @@ def _teap_table(variants: Sequence[ConceptVariant]) -> str: ) +def _fmt_pbp(value: float | None) -> str: + """Срок окупаемости — мес (1 знак), либо «не окупается».""" + if value is None: + return "не окупается" + return f"{value:.1f} мес" + + +def _fmt_irr(financial: FinancialModel) -> str: + """IRR в % с пометкой «оценочный», если это proxy (вырожденный поток).""" + pct = f"{financial.irr * 100:.1f}%" + return f"{pct} (оценочный)" if financial.irr_is_proxy else pct + + def _financial_table(variants: Sequence[ConceptVariant]) -> str: - """HTML-таблица финмодели (деньги в млн руб; полный каскад + БДР; IRR proxy).""" + """HTML-таблица финмодели (деньги в млн руб; полный каскад + БДР + DCF).""" headers = "".join( f"{html.escape(_strategy_label(v.strategy))}" for v in variants ) @@ -128,7 +141,9 @@ def _financial_table(variants: Sequence[ConceptVariant]) -> str: ("Чистая прибыль, млн руб", [_fmt_money(v.financial.net_profit_rub) for v in variants]), ("ROI (на затраты)", [f"{v.financial.roi * 100:.1f}%" for v in variants]), ("Чистая маржа (на выручку)", [f"{v.financial.margin_pct * 100:.1f}%" for v in variants]), - ("IRR-proxy", [f"{v.financial.irr * 100:.1f}%" for v in variants]), + ("NPV (DCF), млн руб", [_fmt_money(v.financial.npv_rub) for v in variants]), + ("IRR (DCF, годовой)", [_fmt_irr(v.financial) for v in variants]), + ("Окупаемость (PBP)", [_fmt_pbp(v.financial.payback_months) for v in variants]), ] body = "".join( "" @@ -153,18 +168,21 @@ def _build_html(variants: Sequence[ConceptVariant]) -> str: f"

{html.escape(_SUBTITLE)}

" f"

{_DASH} нет вариантов для отображения

" ) + disc_pct = f"{variants[0].financial.discount_rate_used * 100:.0f}%" return ( f"" f"

{html.escape(_TITLE)}

" f"

{html.escape(_SUBTITLE)}

" f"{_teap_table(variants)}" f"{_financial_table(variants)}" - "

Статический девелоперский расчёт (без дисконтирования / DCF). " - "НДС — упрощённая оценка (встроенный НДС в положительной марже, не полная " - "механика входного/выходного НДС НК РФ). Налог на прибыль — 25% (с 2025). " - "IRR-proxy — аннуализированный чистый ROI без дисконтирования (не настоящий " - "IRR). Цены и себестоимость — рыночные ориентиры, не калиброванная модель. " - "Коммерческие/офисные площади не учитываются (нет в ТЭП).

" + "

NPV / IRR / PBP рассчитаны помесячным DCF по ТИПОВОМУ графику фаз " + f"(ПИР 6 мес → СМР по типу застройки → распродажа 30 мес, дисконт {disc_pct} годовых). " + "График фаз и темп продаж — типовые допущения, НЕ график конкретного проекта; " + "точность метрик зависит от реального графика. Где IRR помечен «оценочный» — поток " + "вырожденный (нет смены знака), показан аннуализированный ROI вместо DCF-IRR. " + "НДС — упрощённая оценка (встроенный НДС в положительной марже, не полная механика " + "входного/выходного НДС НК РФ). Налог на прибыль — 25% (с 2025). Цены и себестоимость " + "— рыночные ориентиры. Коммерческие/офисные площади не учитываются (нет в ТЭП).

" "" ) diff --git a/backend/app/services/generative/financial.py b/backend/app/services/generative/financial.py index fe3228fa..6ab52c01 100644 --- a/backend/app/services/generative/financial.py +++ b/backend/app/services/generative/financial.py @@ -1,10 +1,48 @@ -"""Generative Design — Stage 1c: static developer financial model (PR-1, эпик #1881). +"""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. This is a *correct static* P&L — there is no -DCF / discounting yet (that lands in PR-3); no new user inputs are required, only -norms + the existing TEAP. +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`): @@ -12,7 +50,7 @@ Cascade (in :func:`compute_financial`): * **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 an IRR *proxy*. +* **Metrics** = net ROI on cost, net margin on revenue, and DCF NPV/IRR/payback. VAT — DOCUMENTED SIMPLIFICATION ------------------------------- @@ -28,11 +66,14 @@ model the full input/output deduction chain — it is a stand-in that keeps the 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 — PROXY UNTIL DCF ---------------------- -A real IRR needs a dated cashflow series (PR-3). We expose ``irr`` as an -annualised net-ROI proxy (``roi / _PROJECT_YEARS``, clamped to ±1) and flag it via -``irr_is_proxy=True`` so the frontend can show the caveat. +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 ------------------------------ @@ -108,9 +149,204 @@ _VAT_RATE: float = 0.20 # (ФЗ № 176-ФЗ от 12.07.2024). Используем актуальные 25%. _PROFIT_TAX_RATE: float = 0.25 -# Условный горизонт проекта (лет) для аннуализации net-ROI в IRR-proxy. +# Условный горизонт проекта (лет) для аннуализации net-ROI в IRR-proxy (fallback). _PROJECT_YEARS: float = 3.0 +# ── DCF: график фаз и продаж — ТИПОВЫЕ НОРМАТИВЫ (ДОПУЩЕНИЯ, не график проекта) ── +# Эти константы — дефолтные допущения помесячной раскладки cashflow. Они ЧЕСТНО +# помечены ``schedule_is_default=True`` в FinancialModel: NPV/IRR/PBP считаются DCF +# по ЭТОМУ графику, точность зависит от реального графика проекта. Темп продаж — +# дефолт-норматив, НЕ §22 velocity (отдельная интеграция позже). + +# Годовая ставка дисконтирования (Excel-эталон девелоперской модели). +_DISCOUNT_RATE_ANNUAL: float = 0.15 +# Длительность ПИР (проектирование/экспертиза), мес. +_PIR_DURATION_MONTHS: int = 6 +# Длительность СМР по типу застройки, мес (точечная быстрее высотной). +_CONSTRUCTION_DURATION_MONTHS: dict[str, int] = { + "spot": 24, + "mid_rise": 36, + "high_rise": 48, +} +# Дефолт длительности СМР, если development_type неизвестен (среднеэтажная). +_DEFAULT_CONSTRUCTION_MONTHS: int = _CONSTRUCTION_DURATION_MONTHS["mid_rise"] +# Старт продаж — через N мес после начала СМР (нормативно — после котлована/этажей). +_SALES_START_MONTH: int = 6 +# Длительность распродажи, мес (нормативный темп выбытия квартир). +_SALES_DURATION_MONTHS: int = 30 +# Первый взнос, доля (Excel: 80% при контракте, остаток при вводе/рассрочка). В MVP +# DCF НЕ применяется — вся выручка признаётся в месяц продажи (см. docstring модуля). +# Сохранён константой для будущей рассрочки (PR-4). +_FIRST_PAYMENT_PCT: float = 0.80 + + +# ── DCF helpers — hand-rolled, чистая математика (без numpy-financial / deps) ─── + + +def _npv(monthly_rate: float, cf: list[float]) -> float: + """NPV помесячного потока ``cf`` (t=0..N) при месячной ставке ``monthly_rate``. + + ``cf[t]`` — чистый денежный поток месяца t (приток +, отток −). t=0 не дисконтируется. + """ + return sum(c / (1.0 + monthly_rate) ** t for t, c in enumerate(cf)) + + +def _irr_monthly( + cf: list[float], + lo: float = -0.9999, + hi: float = 1.0, + tol: float = 1e-7, + max_iter: int = 200, +) -> float | None: + """Месячный IRR помесячного потока ``cf`` методом бисекции. + + Возвращает месячную ставку r, при которой ``_npv(r, cf) == 0``, или ``None``, + если на ``[lo, hi]`` нет смены знака NPV (вырожденный поток — всегда отток или + всегда приток). Caller делает fallback на roi-proxy + ``irr_is_proxy=True``. + + Bracket-guard: если ``_npv(lo) * _npv(hi) > 0`` — корня в брекете нет → ``None``. + NPV монотонно убывает по ставке для «нормального» девелоперского потока (ранний + отток, поздний приток), поэтому бисекция сходится к единственному корню. + """ + # Вырожденный поток без денег (все нули) — NPV==0 при любой ставке, IRR неопределён. + if all(abs(c) < tol for c in cf): + return None + f_lo = _npv(lo, cf) + f_hi = _npv(hi, cf) + # Краевое попадание в корень. + if abs(f_lo) < tol: + return lo + if abs(f_hi) < tol: + return hi + # Нет смены знака на брекете → нет (единственного) корня здесь → None. + if f_lo * f_hi > 0.0: + return None + for _ in range(max_iter): + mid = (lo + hi) / 2.0 + f_mid = _npv(mid, cf) + if abs(f_mid) < tol: + return mid + if f_lo * f_mid < 0.0: + hi = mid + f_hi = f_mid + else: + lo = mid + f_lo = f_mid + return (lo + hi) / 2.0 + + +def _payback_months(cf: list[float]) -> float | None: + """Срок окупаемости (мес) по НЕдисконтированному накопительному потоку. + + Накапливаем cashflow; как только сумма пересекает 0, линейно интерполируем + дробный месяц внутри переломного интервала. ``None`` — поток не окупается на + горизонте (накопленный итог так и не достиг 0). + """ + cumulative = 0.0 + for t, c in enumerate(cf): + prev = cumulative + cumulative += c + # Month 0 — всегда land-отток (cf[0] < 0), поэтому prev<0 на первом переломе: + # ветка t==0 ниже — теоретический corner (поток сразу неотрицателен), не реальный путь. + if cumulative >= 0.0 and prev < 0.0: + # Линейная интерполяция дроби месяца: prev + frac * c = 0. + frac = -prev / c if c != 0.0 else 0.0 + return float(t - 1) + frac + if cumulative >= 0.0 and t == 0: + # Окупается уже в нулевом месяце (поток сразу неотрицателен). + return 0.0 + return None + + +def _spread_linear(total: float, start: int, duration: int, horizon: int) -> list[float]: + """Разложить ``total`` равными долями по месяцам ``[start .. start+duration)``. + + Возвращает список длины ``horizon`` (по индексу = месяц). ``duration <= 0`` → + единоразово в месяц ``start``. Месяцы вне горизонта молча обрезаются (страховка). + """ + out = [0.0] * horizon + if total == 0.0: + return out + if duration <= 0: + if 0 <= start < horizon: + out[start] += total + return out + per = total / duration + for m in range(start, start + duration): + if 0 <= m < horizon: + out[m] += per + return out + + +def _build_monthly_cashflow( + *, + revenue: float, + construction: float, + pir: float, + networks: float, + dev_services: float, + contingency: float, + marketing: float, + land: float, + vat: float, + profit_tax: float, + construction_duration_months: int, +) -> list[float]: + """Разложить статический каскад в помесячный чистый cashflow по дефолт-графику. + + Чистая функция. График — типовые нормативы (см. docstring модуля): земля (мес 0), + ПИР линейно, СМР+сети+услуги+резерв линейно по окну строительства, продажи линейно + по окну распродажи; маркетинг прорейтится на график продаж; НДС+налог на прибыль + прорейтятся на признание выручки. Выручка — полностью в месяц продажи (упрощение). + + ИНВАРИАНТ: ``sum(результат) == revenue − all_costs − vat − profit_tax == net_profit`` + (с точностью до float-округления). Гарантирует, что DCF не разошёлся со статикой. + """ + pir_end = _PIR_DURATION_MONTHS + constr_start = pir_end + constr_end = pir_end + construction_duration_months + # Продажи стартуют через _SALES_START_MONTH после начала СМР (нормативно, эскроу). + sales_start = constr_start + _SALES_START_MONTH + # Окончание продаж: минимум _SALES_DURATION_MONTHS, но НЕ раньше завершения стройки + # (РФ ДДУ/эскроу — продажи идут ВО ВРЕМЯ стройки и заканчиваются у/после ввода). + # Без этого high_rise (СМР до 54) распродавал бы всё на мес 42 — за 12 мес ДО ввода + # → хвост затрат ПОСЛЕ окончания продаж → двойная смена знака → IRR-бисекция не + # брекетит profitable-проект и молча падает в proxy. Привязка к constr_end это чинит. + sales_end = max(sales_start + _SALES_DURATION_MONTHS, constr_end) + sales_duration = sales_end - sales_start + # Горизонт = максимум всех фаз (+1 — месяц как индекс, длина = последний+1). + horizon = max(constr_end, sales_end, 1) + + # ── Затраты (отток, со знаком −) ─────────────────────────────────────────── + land_cf = _spread_linear(land, 0, 0, horizon) # земля — единоразово в мес 0 + pir_cf = _spread_linear(pir, 0, pir_end, horizon) # ПИР линейно [0..pir_end) + # СМР+сети+услуги+резерв линейно по окну строительства [pir_end .. constr_end). + constr_total = construction + networks + dev_services + contingency + constr_cf = _spread_linear(constr_total, pir_end, construction_duration_months, horizon) + + # ── Выручка (приток, +) и привязанные к ней маркетинг/налоги ──────────────── + # Темп продаж пересчитан на ФАКТИЧЕСКОЕ окно [sales_start, sales_end): выручка + # линейно по sales_duration (>= _SALES_DURATION_MONTHS, растянуто до ввода). + revenue_cf = _spread_linear(revenue, sales_start, sales_duration, horizon) + # Маркетинг + НДС + налог на прибыль прорейтятся на признание выручки: доля месяца + # = revenue_cf[m] / revenue. Так отток налога идёт за притоком, который его рождает, + # и Σ undiscounted cashflow == net_profit (инвариант). При revenue==0 — нули. + tax_marketing_total = marketing + vat + profit_tax + if revenue > 0.0: + prorate = [tax_marketing_total * (revenue_cf[m] / revenue) for m in range(horizon)] + else: + prorate = [0.0] * horizon + + cashflow = [ + revenue_cf[m] + - land_cf[m] + - pir_cf[m] + - constr_cf[m] + - prorate[m] + for m in range(horizon) + ] + return cashflow + def compute_financial( *, @@ -119,13 +355,15 @@ def compute_financial( land_cost_rub: float | None, market_price_per_sqm: float | None = None, price_source: str = "class_norm", + development_type: str | None = None, ) -> FinancialModel: - """Свести ТЭП + класс + стоимость земли в полный статический :class:`FinancialModel`. + """Свести ТЭП + класс + стоимость земли в полный :class:`FinancialModel` с DCF. Полный девелоперский каскад: выручка (жильё + паркинг) → каскад затрат (СМР + ПИР + сети + услуги заказчика + резерв + маркетинг + земля) → БДР с НДС и - налогом на прибыль → чистая прибыль + ROI. НЕ DCF (PR-3); НДС — упрощение - (см. docstring модуля); офисов нет в TEAP — пропущены. + налогом на прибыль → чистая прибыль + ROI. Поверх статики — помесячный DCF + (NPV/IRR/PBP) по дефолт-графику фаз (см. docstring модуля). НДС — упрощение; + офисов нет в TEAP — пропущены. Чистая функция: НЕ ходит в БД. Калибровку цены продажи жилья делает API-слой (lookup рынка) и прокидывает сюда через ``market_price_per_sqm``. @@ -140,6 +378,9 @@ def compute_financial( price_source: метка источника цены для honest-флага в UI/PDF — одно из "objective_district_median" / "district_reference" / "class_norm". Игнорируется (форсится "class_norm"), если ``market_price_per_sqm is None``. + development_type: "spot"/"mid_rise"/"high_rise" — задаёт длительность СМР в + DCF-графике (``_CONSTRUCTION_DURATION_MONTHS``). ``None``/неизвестный → + дефолт mid_rise (36 мес). График — типовое допущение (schedule_is_default). """ # Калибруем ТОЛЬКО цену продажи жилья. Если рыночной цены нет — честный fallback # на норматив класса, и source форсим в "class_norm" (не выдаём норму за рынок). @@ -183,10 +424,38 @@ def compute_financial( roi = net_profit / cost if cost > 0 else 0.0 # чистый ROI на затраты margin_pct = net_profit / revenue if revenue > 0 else 0.0 # чистая маржа на выручку - # IRR-proxy: аннуализированный net-ROI. НЕ настоящий IRR (нет дат/дисконта — - # PR-3). Клампинг ±1, чтобы поле было монотонным и читаемым; флаг proxy. - irr = roi / _PROJECT_YEARS - irr = max(-1.0, min(1.0, irr)) + # ── DCF: помесячный cashflow → NPV / IRR / PBP (PR-3) ────────────────────── + construction_months = _CONSTRUCTION_DURATION_MONTHS.get( + development_type or "", _DEFAULT_CONSTRUCTION_MONTHS + ) + cashflow = _build_monthly_cashflow( + revenue=revenue, + construction=construction, + pir=pir, + networks=networks, + dev_services=dev_services, + contingency=contingency, + marketing=marketing, + land=land, + vat=vat, + profit_tax=profit_tax, + construction_duration_months=construction_months, + ) + # Годовая ставка → месячная: (1+r_год)**(1/12) − 1. + monthly_rate = (1.0 + _DISCOUNT_RATE_ANNUAL) ** (1.0 / 12.0) - 1.0 + npv = _npv(monthly_rate, cashflow) + payback = _payback_months(cashflow) + + monthly_irr = _irr_monthly(cashflow) + if monthly_irr is not None: + # Настоящий IRR: месячный → годовой эффективный. + irr = (1.0 + monthly_irr) ** 12 - 1.0 + irr_is_proxy = False + else: + # Вырожденный поток (нет смены знака NPV — всегда отток/приток). Fallback на + # PR-1 proxy: аннуализированный net-ROI, клампинг ±1; флаг proxy. + irr = max(-1.0, min(1.0, roi / _PROJECT_YEARS)) + irr_is_proxy = True model = FinancialModel( # legacy summary @@ -213,22 +482,28 @@ def compute_financial( # metrics roi=round(roi, 4), margin_pct=round(margin_pct, 4), - irr_is_proxy=True, + irr_is_proxy=irr_is_proxy, + # DCF (PR-3) + npv_rub=round(npv, 2), + payback_months=(round(payback, 1) if payback is not None else None), + discount_rate_used=_DISCOUNT_RATE_ANNUAL, + schedule_is_default=True, # price calibration (PR-2) price_per_sqm_used=round(sale_price, 2), price_is_calibrated=price_is_calibrated, price_source=resolved_price_source, ) logger.info( - "financial: revenue=%.0f cost=%.0f gross=%.0f vat=%.0f tax=%.0f net=%.0f roi=%.3f " - "price=%.0f/m2 source=%s", + "financial: revenue=%.0f cost=%.0f net=%.0f roi=%.3f npv=%.0f " + "irr=%.3f%s pbp=%s price=%.0f/m2 source=%s", model.revenue_rub, model.cost_rub, - model.gross_margin_rub, - model.vat_rub, - model.profit_tax_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, ) diff --git a/backend/app/services/generative/placement.py b/backend/app/services/generative/placement.py index e66edc7b..a00bfe80 100644 --- a/backend/app/services/generative/placement.py +++ b/backend/app/services/generative/placement.py @@ -230,6 +230,8 @@ def place_strategy( land_cost_rub=payload.land_cost_rub, market_price_per_sqm=market_price_per_sqm, price_source=price_source, + # development_type задаёт длительность СМР в DCF-графике финмодели (PR-3). + development_type=payload.development_type, ) buildings_geojson = _footprints_to_geojson(parcel, footprints, floors, spec) diff --git a/backend/tests/services/generative/test_api_concepts.py b/backend/tests/services/generative/test_api_concepts.py index 9e22b941..bb179fe3 100644 --- a/backend/tests/services/generative/test_api_concepts.py +++ b/backend/tests/services/generative/test_api_concepts.py @@ -162,6 +162,11 @@ def test_concepts_response_matches_contract_keys() -> None: "roi", "margin_pct", "irr_is_proxy", + # DCF (PR-3) + "npv_rub", + "payback_months", + "discount_rate_used", + "schedule_is_default", # price calibration (PR-2) "price_per_sqm_used", "price_is_calibrated", diff --git a/backend/tests/services/generative/test_exporters.py b/backend/tests/services/generative/test_exporters.py index da30de60..5df30a3f 100644 --- a/backend/tests/services/generative/test_exporters.py +++ b/backend/tests/services/generative/test_exporters.py @@ -108,7 +108,10 @@ def test_pdf_html_build_contains_tables() -> None: html = pdf._build_html(variants) assert "Технико-экономические показатели" in html assert "Финансовая модель" in html - assert "IRR-proxy" in html + # PR-3: DCF-строки заменили IRR-proxy в таблице финмодели. + assert "NPV (DCF), млн руб" in html + assert "IRR (DCF, годовой)" in html + assert "Окупаемость (PBP)" in html def test_pdf_html_build_graceful_on_empty() -> None: diff --git a/backend/tests/services/generative/test_financial_dcf.py b/backend/tests/services/generative/test_financial_dcf.py new file mode 100644 index 00000000..c11c6bbc --- /dev/null +++ b/backend/tests/services/generative/test_financial_dcf.py @@ -0,0 +1,257 @@ +"""PR-3 tests — monthly DCF: NPV / IRR / payback helpers + the invariant. + +The DCF is hand-rolled deterministic math (no numpy-financial). These tests pin: + +* ``_npv`` / ``_irr_monthly`` / ``_payback_months`` against known textbook values; +* the **invariant** Σ undiscounted monthly cashflow ≈ static ``net_profit`` — the + DCF can never silently diverge from the static cascade; +* real IRR vs. ROI-proxy fallback (sign-change present vs. degenerate flow); +* NPV/IRR consistency (NPV>0 ⟺ IRR>discount rate). +""" + +from __future__ import annotations + +from itertools import pairwise + +from app.schemas.concept import TEAP +from app.services.generative import financial +from app.services.generative.financial import ( + _build_monthly_cashflow, + _irr_monthly, + _npv, + _payback_months, +) + + +def _teap(residential: float, gfa: float, parking: int = 10) -> TEAP: + return TEAP( + built_area_sqm=100.0, + total_floor_area_sqm=gfa, + residential_area_sqm=residential, + apartments_count=10, + density=1.0, + parking_spaces=parking, + ) + + +def _rebuild_cashflow(model: object, *, construction_months: int) -> list[float]: + """Пересобрать помесячный cashflow из полей модели — для инвариант-теста.""" + m = model # type: ignore[assignment] + return _build_monthly_cashflow( + revenue=m.revenue_rub, # type: ignore[attr-defined] + construction=m.construction_rub, # type: ignore[attr-defined] + pir=m.pir_rub, # type: ignore[attr-defined] + networks=m.networks_rub, # type: ignore[attr-defined] + dev_services=m.developer_services_rub, # type: ignore[attr-defined] + contingency=m.contingency_rub, # type: ignore[attr-defined] + marketing=m.marketing_rub, # type: ignore[attr-defined] + land=m.land_rub, # type: ignore[attr-defined] + vat=m.vat_rub, # type: ignore[attr-defined] + profit_tax=m.profit_tax_rub, # type: ignore[attr-defined] + construction_duration_months=construction_months, + ) + + +# ── DCF helpers — учебные значения ───────────────────────────────────────────── + + +def test_npv_textbook_zero_at_known_rate() -> None: + # cf=[-1000,0,0,1331], monthly IRR ровно 10% → NPV@0.10 == 0. + cf = [-1000.0, 0.0, 0.0, 1331.0] + assert abs(_npv(0.10, cf)) < 1e-6 + # NPV при ставке 0 = простая сумма потока. + assert _npv(0.0, cf) == 331.0 + + +def test_irr_monthly_textbook_ten_percent() -> None: + cf = [-1000.0, 0.0, 0.0, 1331.0] + r = _irr_monthly(cf) + assert r is not None + assert abs(r - 0.10) < 1e-5 + + +def test_irr_monthly_none_when_no_sign_change() -> None: + # Всегда отток → нет смены знака NPV → None (caller делает proxy fallback). + assert _irr_monthly([-100.0, -100.0, -100.0]) is None + # Всегда приток → тоже None. + assert _irr_monthly([100.0, 100.0, 100.0]) is None + + +def test_payback_months_linear_interpolation() -> None: + # cf=[-1000,0,0,1331]: накопит. -1000,-1000,-1000,+331 → перелом в мес 3, + # дробь = 1000/1331 от месяца → 2 + 0.751 ≈ 2.75. + pbp = _payback_months([-1000.0, 0.0, 0.0, 1331.0]) + assert pbp is not None + assert abs(pbp - (2.0 + 1000.0 / 1331.0)) < 1e-6 + + +def test_payback_months_none_when_never_recovers() -> None: + # Накопленный итог не достигает 0 → None. + assert _payback_months([-1000.0, 100.0, 100.0, 100.0]) is None + + +def test_payback_months_zero_when_immediately_positive() -> None: + assert _payback_months([0.0, 50.0, 50.0]) == 0.0 + + +# ── ИНВАРИАНТ (ключевой): Σ undiscounted cashflow ≈ net_profit ────────────────── + + +def test_invariant_cashflow_sum_equals_net_profit_profitable() -> None: + t = _teap(residential=10_000.0, gfa=11_000.0, parking=50) + model = financial.compute_financial( + teap=t, housing_class="comfort", land_cost_rub=200_000_000.0, development_type="mid_rise" + ) + cf = _rebuild_cashflow(model, construction_months=36) + # Σ недисконтированного потока == чистая прибыль каскада (±float-округление). + assert abs(sum(cf) - model.net_profit_rub) < 1.0 + + +def test_invariant_cashflow_sum_equals_net_profit_loss() -> None: + # Убыточный сценарий: инвариант тоже держится (net < 0, налоги = 0). + t = _teap(residential=1.0, gfa=10_000.0, parking=0) + model = financial.compute_financial( + teap=t, housing_class="business", land_cost_rub=None, development_type="high_rise" + ) + assert model.net_profit_rub < 0 + cf = _rebuild_cashflow(model, construction_months=48) + assert abs(sum(cf) - model.net_profit_rub) < 1.0 + + +def test_invariant_holds_for_all_development_types() -> None: + t = _teap(residential=8_000.0, gfa=9_000.0, parking=40) + months = {"spot": 24, "mid_rise": 36, "high_rise": 48} + for dev_type, dur in months.items(): + model = financial.compute_financial( + teap=t, housing_class="comfort", land_cost_rub=80_000_000.0, development_type=dev_type + ) + cf = _rebuild_cashflow(model, construction_months=dur) + assert abs(sum(cf) - model.net_profit_rub) < 1.0, dev_type + + +# ── Настоящий IRR vs proxy fallback ───────────────────────────────────────────── + + +def test_real_irr_on_normal_developer_flow() -> None: + # Нормальный поток: затраты рано, выручка поздно → смена знака есть → IRR настоящий. + t = _teap(residential=11_000.0, gfa=12_000.0, parking=80) + model = financial.compute_financial( + teap=t, housing_class="business", land_cost_rub=100_000_000.0, development_type="spot" + ) + assert model.irr_is_proxy is False + assert model.net_profit_rub > 0 + # Годовой IRR настоящий (а не зажатый proxy). + assert model.irr > 0.0 + + +def test_proxy_fallback_on_always_loss_flow() -> None: + # Всегда-убыток (выручка ниже затрат, нет месяца с притоком сверх оттока в начале): + # NPV без смены знака → IRR=None → proxy fallback, флаг proxy. + t = _teap(residential=1.0, gfa=20_000.0, parking=0) + model = financial.compute_financial( + teap=t, housing_class="business", land_cost_rub=500_000_000.0, development_type="mid_rise" + ) + assert model.gross_margin_rub < 0 + assert model.irr_is_proxy is True + assert -1.0 <= model.irr <= 1.0 + + +# ── NPV/IRR консистентность ───────────────────────────────────────────────────── + + +def test_npv_positive_when_irr_above_discount_rate() -> None: + t = _teap(residential=11_000.0, gfa=12_000.0, parking=80) + model = financial.compute_financial( + teap=t, housing_class="business", land_cost_rub=100_000_000.0, development_type="spot" + ) + assert model.irr_is_proxy is False + # Консистентность DCF: NPV>0 ⟺ IRR>ставка дисконтирования. + assert (model.npv_rub > 0.0) == (model.irr > model.discount_rate_used) + + +# ── Новые DCF-поля присутствуют и осмысленны ──────────────────────────────────── + + +def test_dcf_fields_present_and_typed() -> None: + t = _teap(residential=5_000.0, gfa=6_000.0, parking=20) + model = financial.compute_financial( + teap=t, housing_class="comfort", land_cost_rub=50_000_000.0, development_type="mid_rise" + ) + assert isinstance(model.npv_rub, float) + assert model.discount_rate_used == 0.15 + assert model.schedule_is_default is True + assert model.payback_months is None or isinstance(model.payback_months, float) + + +def _sign_changes(cf: list[float], *, tol: float = 1e-6) -> int: + """Число смен знака в потоке (нули пропускаем). Нормальный девелоперский поток + [--- +++] имеет ровно 1 смену; патологический [--- +++ ---] имеет 2 → IRR-бисекция + не брекетит.""" + signs = [1 if c > tol else -1 if c < -tol else 0 for c in cf] + nonzero = [s for s in signs if s != 0] + return sum(1 for a, b in pairwise(nonzero) if a != b) + + +def test_development_type_changes_construction_window_and_dcf() -> None: + # Та же экономика, разный development_type → разная длительность СМР → разные NPV/IRR. + # Статический net_profit идентичен (development_type на каскад не влияет), но DCF + # расходится из-за разной фазировки cashflow. + t = _teap(residential=10_000.0, gfa=11_000.0, parking=60) + spot = financial.compute_financial( + teap=t, housing_class="comfort", land_cost_rub=80_000_000.0, development_type="spot" + ) + high = financial.compute_financial( + teap=t, housing_class="comfort", land_cost_rub=80_000_000.0, development_type="high_rise" + ) + assert spot.net_profit_rub == high.net_profit_rub + assert spot.npv_rub != high.npv_rub + assert spot.irr != high.irr + + +def test_sales_never_end_before_construction_for_all_dev_types() -> None: + # ИНВАРИАНТ ГРАФИКА (регресс-тест бага): продажи не заканчиваются раньше завершения + # стройки → нет хвоста затрат после окончания продаж → ровно ОДНА смена знака в + # потоке (нормальный [--- +++]), а не патологические две ([--- +++ ---]). Двойная + # смена знака ломала IRR-бисекцию и роняла profitable-проект в proxy с инверсией NPV. + # Для high_rise (СМР=48 → constr_end=54, sales_start=30) старое окно [30..60) уже + # покрывало ввод; критичный тип — где sales_end < constr_end в старой логике. + t = _teap(residential=10_000.0, gfa=11_000.0, parking=60) + months = {"spot": 24, "mid_rise": 36, "high_rise": 48} + for dev_type, dur in months.items(): + model = financial.compute_financial( + teap=t, housing_class="comfort", land_cost_rub=80_000_000.0, development_type=dev_type + ) + assert model.net_profit_rub > 0, dev_type # сценарий profitable для всех типов + cf = _rebuild_cashflow(model, construction_months=dur) + # Ровно одна смена знака → бисекция брекетит → настоящий IRR, не proxy. + assert _sign_changes(cf) == 1, (dev_type, _sign_changes(cf)) + assert model.irr_is_proxy is False, dev_type + + +def test_profitable_high_rise_gets_real_irr_not_proxy() -> None: + # РЕГРЕСС именно на баг: profitable high_rise (СМР=48) раньше распродавался на мес 42 + # за 12 мес ДО ввода (54) → хвост затрат после продаж → двойная смена знака → + # IRR-бисекция не брекетит → молчаливый proxy + инверсия направления NPV. Привязка + # sales_end >= construction_end это устраняет: настоящий IRR. + t = _teap(residential=11_000.0, gfa=12_000.0, parking=80) + high = financial.compute_financial( + teap=t, housing_class="business", land_cost_rub=100_000_000.0, development_type="high_rise" + ) + assert high.net_profit_rub > 0 + assert high.irr_is_proxy is False + assert high.irr is not None + # Поток high_rise: ровно одна смена знака (нет патологического хвоста затрат). + cf = _rebuild_cashflow(high, construction_months=48) + assert _sign_changes(cf) == 1 + + +def test_unknown_development_type_defaults_to_mid_rise() -> None: + t = _teap(residential=10_000.0, gfa=11_000.0, parking=60) + explicit_mid = financial.compute_financial( + teap=t, housing_class="comfort", land_cost_rub=80_000_000.0, development_type="mid_rise" + ) + none_type = financial.compute_financial( + teap=t, housing_class="comfort", land_cost_rub=80_000_000.0, development_type=None + ) + assert none_type.npv_rub == explicit_mid.npv_rub + assert none_type.irr == explicit_mid.irr diff --git a/backend/tests/services/generative/test_teap_financial.py b/backend/tests/services/generative/test_teap_financial.py index 97921410..b64d8aaa 100644 --- a/backend/tests/services/generative/test_teap_financial.py +++ b/backend/tests/services/generative/test_teap_financial.py @@ -187,6 +187,11 @@ def test_financial_backward_compat_fields_present() -> None: assert isinstance(model.cost_rub, float) assert isinstance(model.gross_margin_rub, float) assert isinstance(model.irr, float) + # PR-3 DCF-поля additive — присутствуют с дефолтами. + assert isinstance(model.npv_rub, float) + assert model.discount_rate_used == 0.15 + assert model.schedule_is_default is True + assert model.payback_months is None or isinstance(model.payback_months, float) # ── PR-2: рыночная калибровка цены продажи жилья ─────────────────────────────── diff --git a/frontend/src/components/concept/ConceptVariantsResult.tsx b/frontend/src/components/concept/ConceptVariantsResult.tsx index 28428785..e328d99f 100644 --- a/frontend/src/components/concept/ConceptVariantsResult.tsx +++ b/frontend/src/components/concept/ConceptVariantsResult.tsx @@ -68,6 +68,12 @@ function formatPct(fraction: number): string { return `${(fraction * 100).toFixed(1)}%`; } +/** Срок окупаемости: «28.5 мес» или «не окупается» (null). */ +function formatPayback(months: number | null): string { + if (months === null) return "не окупается"; + return `${months.toFixed(1)} мес`; +} + // ── Variant panel ───────────────────────────────────────────────────────────── interface PanelProps { @@ -111,9 +117,11 @@ function VariantPanel({ parcel, variant }: PanelProps) { color: "var(--fg-on-dark-muted)", }} > - {STRATEGY_HINTS[variant.strategy]} IRR {formatPct(financial.irr)} - {financial.irr_is_proxy ? " (оценочный, не DCF)" : ""} · плотность - (FAR){" "} + {STRATEGY_HINTS[variant.strategy]} NPV{" "} + {formatMoneyCompact(financial.npv_rub)} · IRR{" "} + {formatPct(financial.irr)} + {financial.irr_is_proxy ? " (оценочный)" : ""} · окупаемость{" "} + {formatPayback(financial.payback_months)} · плотность (FAR){" "} {teap.density.toLocaleString("ru-RU", { minimumFractionDigits: 2, maximumFractionDigits: 2, @@ -184,7 +192,7 @@ function VariantPanel({ parcel, variant }: PanelProps) { {/* Финмодель */}
} >
+ 0 + ? true + : financial.npv_rub < 0 + ? false + : null, + }} + /> + financial.discount_rate_used + ? true + : false, + }} + /> - Статический расчёт без дисконтирования (DCF — следующий этап). НДС - — упрощённая оценка (встроенный НДС в положительной марже, не - полная механика входного/выходного НДС НК РФ). Налог на прибыль — - 25% (с 2025 года). IRR{" "} + NPV / IRR / окупаемость рассчитаны помесячным DCF.{" "} + {financial.schedule_is_default + ? `График фаз и темп продаж — типовые допущения (ПИР 6 мес → СМР по типу застройки → распродажа 30 мес, дисконт ${formatPct( + financial.discount_rate_used, + )} годовых); метрики рассчитаны DCF по этому графику, точность зависит от графика конкретного проекта. ` + : ""} {financial.irr_is_proxy - ? "— оценочный (аннуализированный чистый ROI), не настоящий IRR." - : "."}{" "} - Цена продажи жилья —{" "} + ? "IRR помечен как оценочный: денежный поток вырожденный (нет смены знака), показан аннуализированный ROI вместо DCF-IRR. " + : ""} + НДС — упрощённая оценка (встроенный НДС в положительной марже, не + полная механика входного/выходного НДС НК РФ); привязан к + признанию выручки. Налог на прибыль — 25% (с 2025 года). Цена + продажи жилья —{" "} {financial.price_is_calibrated ? `калибрована по рынку (${priceSourceCaption(financial)})` : "норматив класса (нет рыночных данных по участку)"} @@ -384,6 +424,18 @@ function FinancialCascadeTable({ financial }: { financial: FinancialModel }) { value={formatMoneyCompact(financial.net_profit_rub)} emphasis /> + + + ); diff --git a/frontend/src/lib/api-types.ts b/frontend/src/lib/api-types.ts index 03945257..ab4fcd69 100644 --- a/frontend/src/lib/api-types.ts +++ b/frontend/src/lib/api-types.ts @@ -3489,15 +3489,24 @@ export interface components { }; /** * FinancialModel - * @description Static developer P&L for a concept variant (PR-1, эпик #1881). + * @description Static developer P&L + monthly-DCF investment metrics (PR-1/PR-3, эпик #1881). * - * Full static cost cascade + VAT + profit tax → net profit + ROI. NOT a DCF — - * timing/discounting lands in PR-3. VAT is a documented simplification (see - * ``compute_financial`` docstring), not exact НК РФ mechanics. + * Full static cost cascade + VAT + profit tax → net profit + ROI (PR-1). On top + * of that static P&L, PR-3 lays the cascade onto a **monthly cashflow schedule** + * (default phase norms — ПИР → СМР → продажи) and runs a real DCF: NPV, IRR + * (bisection over the monthly cashflow) and *undiscounted* cumulative payback. + * See ``compute_financial`` docstring for the schedule assumptions and the + * налоги/рассрочка phasing choices. + * + * ``irr`` is a **real** annualised DCF IRR when ``irr_is_proxy is False``; for a + * degenerate cashflow with no sign change (e.g. always-loss) the bisection cannot + * bracket a root, so we fall back to the PR-1 annualised-ROI proxy and flag + * ``irr_is_proxy=True``. VAT is a documented simplification (see docstring), not + * exact НК РФ mechanics. * * The legacy summary fields (``revenue_rub`` / ``cost_rub`` / * ``gross_margin_rub`` / ``irr``) are kept for backward-compat; the new fields - * expose the full cascade and the net P&L. + * expose the full cascade, the net P&L and the DCF metrics. */ FinancialModel: { /** Revenue Rub */ @@ -3543,6 +3552,23 @@ export interface components { * @default true */ irr_is_proxy: boolean; + /** + * Npv Rub + * @default 0 + */ + npv_rub: number; + /** Payback Months */ + payback_months?: number | null; + /** + * Discount Rate Used + * @default 0 + */ + discount_rate_used: number; + /** + * Schedule Is Default + * @default true + */ + schedule_is_default: boolean; /** Price Per Sqm Used */ price_per_sqm_used: number; /** diff --git a/frontend/src/lib/concept-api.ts b/frontend/src/lib/concept-api.ts index 8bcd9a05..649ac02c 100644 --- a/frontend/src/lib/concept-api.ts +++ b/frontend/src/lib/concept-api.ts @@ -73,7 +73,17 @@ export interface FinancialModel { // Metrics roi: number; margin_pct: number; + /** false → IRR настоящий (DCF); true → IRR — proxy (вырожденный поток). */ irr_is_proxy: boolean; + // DCF / investment metrics (PR-3, эпик #1881) + /** NPV дисконтированного помесячного cashflow, ₽. */ + npv_rub: number; + /** Окупаемость, мес (линейная интерполяция); null — не окупается. */ + payback_months: number | null; + /** Годовая ставка дисконтирования, применённая в NPV. */ + discount_rate_used: number; + /** true — график фаз/продаж типовой (допущение), не график проекта. */ + schedule_is_default: boolean; // Price calibration (PR-2, эпик #1881) /** Цена продажи жилья, ₽/м², фактически использованная в выручке. */ price_per_sqm_used: number;