feat(finmodel): monthly DCF — real NPV/IRR/PBP, replace IRR-proxy (epic #1881 PR-3)
All checks were successful
CI / frontend-tests (pull_request) Successful in 57s
CI / backend-tests (pull_request) Successful in 9m54s
CI / changes (pull_request) Successful in 6s
CI / openapi-codegen-check (pull_request) Successful in 1m53s

Hand-rolled детерминированный DCF (без numpy-financial/новых deps): _npv /
_irr_monthly (bisection с bracket-guard) / _payback_months над помесячным
cashflow, построенным из статического каскада (PR-1) + калиброванной цены
(PR-2) по дефолтным нормативам фаз (ПИР 6мес → СМР по development_type
24/36/48 → распродажа, дисконт 15%).

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

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

Refs #1881
This commit is contained in:
Light1YT 2026-06-23 21:52:04 +05:00
parent 2276c2057b
commit d4068a255b
11 changed files with 731 additions and 57 deletions

View file

@ -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) ───────────────────────────────────
# Цена продажи жилья, руб/кв.м, фактически использованная в выручке. Либо

View file

@ -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"<th>{html.escape(_strategy_label(v.strategy))}</th>" 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(
"<tr><td class='row'>"
@ -153,18 +168,21 @@ def _build_html(variants: Sequence[ConceptVariant]) -> str:
f"<p class='sub'>{html.escape(_SUBTITLE)}</p>"
f"<p>{_DASH} нет вариантов для отображения</p></body></html>"
)
disc_pct = f"{variants[0].financial.discount_rate_used * 100:.0f}%"
return (
f"<html><head><meta charset='utf-8'><style>{_CSS}</style></head><body>"
f"<h1>{html.escape(_TITLE)}</h1>"
f"<p class='sub'>{html.escape(_SUBTITLE)}</p>"
f"{_teap_table(variants)}"
f"{_financial_table(variants)}"
"<p class='sub'>Статический девелоперский расчёт (без дисконтирования / DCF). "
"НДС — упрощённая оценка (встроенный НДС в положительной марже, не полная "
"механика входного/выходного НДС НК РФ). Налог на прибыль — 25% (с 2025). "
"IRR-proxy — аннуализированный чистый ROI без дисконтирования (не настоящий "
"IRR). Цены и себестоимость — рыночные ориентиры, не калиброванная модель. "
"Коммерческие/офисные площади не учитываются (нет в ТЭП).</p>"
"<p class='sub'>NPV / IRR / PBP рассчитаны помесячным DCF по ТИПОВОМУ графику фаз "
f"(ПИР 6 мес → СМР по типу застройки → распродажа 30 мес, дисконт {disc_pct} годовых). "
"График фаз и темп продаж — типовые допущения, НЕ график конкретного проекта; "
"точность метрик зависит от реального графика. Где IRR помечен «оценочный» — поток "
"вырожденный (нет смены знака), показан аннуализированный ROI вместо DCF-IRR. "
"НДС — упрощённая оценка (встроенный НДС в положительной марже, не полная механика "
"входного/выходного НДС НК РФ). Налог на прибыль — 25% (с 2025). Цены и себестоимость "
"— рыночные ориентиры. Коммерческие/офисные площади не учитываются (нет в ТЭП).</p>"
"</body></html>"
)

View file

@ -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,
)

View file

@ -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)

View file

@ -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",

View file

@ -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:

View file

@ -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

View file

@ -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: рыночная калибровка цены продажи жилья ───────────────────────────────

View file

@ -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) {
{/* Финмодель */}
<Section
title="Финансовая модель"
subtitle="Статический девелоперский расчёт: выручка, каскад затрат, НДС и налог на прибыль → чистая прибыль и ROI."
subtitle="Девелоперский расчёт: выручка, каскад затрат, НДС и налог на прибыль → чистая прибыль и ROI; поверх — помесячный DCF (NPV, IRR, окупаемость)."
right={<ConceptExportButtons variant={variant} />}
>
<div
@ -223,6 +231,33 @@ function VariantPanel({ parcel, variant }: PanelProps) {
positive: null,
}}
/>
<KpiCard
label="NPV (DCF)"
value={formatMoneyCompact(financial.npv_rub)}
delta={{
value: `Дисконт ${formatPct(financial.discount_rate_used)} годовых`,
positive:
financial.npv_rub > 0
? true
: financial.npv_rub < 0
? false
: null,
}}
/>
<KpiCard
label="IRR (DCF, годовой)"
value={formatPct(financial.irr)}
delta={{
value: financial.irr_is_proxy
? "Оценочный (вырожденный поток, не DCF)"
: `Окупаемость ${formatPayback(financial.payback_months)}`,
positive: financial.irr_is_proxy
? null
: financial.irr > financial.discount_rate_used
? true
: false,
}}
/>
<KpiCard
label="Цена продажи жилья"
value={`${formatInt(financial.price_per_sqm_used)} ₽/м²`}
@ -254,14 +289,19 @@ function VariantPanel({ parcel, variant }: PanelProps) {
color: "var(--fg-tertiary)",
}}
>
Статический расчёт без дисконтирования (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
/>
<CascadeRow
label={`NPV (DCF, дисконт ${formatPct(financial.discount_rate_used)})`}
value={formatMoneyCompact(financial.npv_rub)}
/>
<CascadeRow
label={`IRR (годовой)${financial.irr_is_proxy ? ", оценочный" : ""}`}
value={formatPct(financial.irr)}
/>
<CascadeRow
label="Окупаемость (PBP)"
value={formatPayback(financial.payback_months)}
/>
</tbody>
</table>
);

View file

@ -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;
/**

View file

@ -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;