feat(finmodel): monthly DCF — real NPV/IRR/PBP, replace IRR-proxy (epic #1881 PR-3) #1884
11 changed files with 731 additions and 57 deletions
|
|
@ -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) ───────────────────────────────────
|
||||
# Цена продажи жилья, руб/кв.м, фактически использованная в выручке. Либо
|
||||
|
|
|
|||
|
|
@ -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>"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
257
backend/tests/services/generative/test_financial_dcf.py
Normal file
257
backend/tests/services/generative/test_financial_dcf.py
Normal 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
|
||||
|
|
@ -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: рыночная калибровка цены продажи жилья ───────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue