Merge pull request 'feat(finmodel): monthly DCF — real NPV/IRR/PBP, replace IRR-proxy (epic #1881 PR-3)' (#1884) from feat/finmodel-dcf into main
This commit is contained in:
commit
ae90f84c90
11 changed files with 731 additions and 57 deletions
|
|
@ -29,15 +29,24 @@ class TEAP(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class FinancialModel(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 —
|
Full static cost cascade + VAT + profit tax → net profit + ROI (PR-1). On top
|
||||||
timing/discounting lands in PR-3. VAT is a documented simplification (see
|
of that static P&L, PR-3 lays the cascade onto a **monthly cashflow schedule**
|
||||||
``compute_financial`` docstring), not exact НК РФ mechanics.
|
(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`` /
|
The legacy summary fields (``revenue_rub`` / ``cost_rub`` /
|
||||||
``gross_margin_rub`` / ``irr``) are kept for backward-compat; the new fields
|
``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) ───────────────────────────────────────
|
# ── Legacy summary (backward-compat) ───────────────────────────────────────
|
||||||
|
|
@ -68,7 +77,19 @@ class FinancialModel(BaseModel):
|
||||||
# ── Metrics ────────────────────────────────────────────────────────────────
|
# ── Metrics ────────────────────────────────────────────────────────────────
|
||||||
roi: float # net profit / total cost
|
roi: float # net profit / total cost
|
||||||
margin_pct: float # net profit / revenue
|
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) ───────────────────────────────────
|
# ── Price calibration (PR-2, эпик #1881) ───────────────────────────────────
|
||||||
# Цена продажи жилья, руб/кв.м, фактически использованная в выручке. Либо
|
# Цена продажи жилья, руб/кв.м, фактически использованная в выручке. Либо
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ import html
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
|
||||||
from app.schemas.concept import ConceptVariant
|
from app.schemas.concept import ConceptVariant, FinancialModel
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -47,7 +47,7 @@ thead th { background: #ececec; }
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_TITLE = "Концепции застройки — сводка вариантов"
|
_TITLE = "Концепции застройки — сводка вариантов"
|
||||||
_SUBTITLE = "Generative Design · Stage 1c · детерминированный расчёт ТЭП и финмодели"
|
_SUBTITLE = "Generative Design · Stage 1c · детерминированный расчёт ТЭП, финмодели и DCF"
|
||||||
|
|
||||||
|
|
||||||
def _fmt_int(value: float | int) -> str:
|
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:
|
def _financial_table(variants: Sequence[ConceptVariant]) -> str:
|
||||||
"""HTML-таблица финмодели (деньги в млн руб; полный каскад + БДР; IRR proxy)."""
|
"""HTML-таблица финмодели (деньги в млн руб; полный каскад + БДР + DCF)."""
|
||||||
headers = "".join(
|
headers = "".join(
|
||||||
f"<th>{html.escape(_strategy_label(v.strategy))}</th>" for v in variants
|
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]),
|
("Чистая прибыль, млн руб", [_fmt_money(v.financial.net_profit_rub) for v in variants]),
|
||||||
("ROI (на затраты)", [f"{v.financial.roi * 100:.1f}%" 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]),
|
("Чистая маржа (на выручку)", [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(
|
body = "".join(
|
||||||
"<tr><td class='row'>"
|
"<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 class='sub'>{html.escape(_SUBTITLE)}</p>"
|
||||||
f"<p>{_DASH} нет вариантов для отображения</p></body></html>"
|
f"<p>{_DASH} нет вариантов для отображения</p></body></html>"
|
||||||
)
|
)
|
||||||
|
disc_pct = f"{variants[0].financial.discount_rate_used * 100:.0f}%"
|
||||||
return (
|
return (
|
||||||
f"<html><head><meta charset='utf-8'><style>{_CSS}</style></head><body>"
|
f"<html><head><meta charset='utf-8'><style>{_CSS}</style></head><body>"
|
||||||
f"<h1>{html.escape(_TITLE)}</h1>"
|
f"<h1>{html.escape(_TITLE)}</h1>"
|
||||||
f"<p class='sub'>{html.escape(_SUBTITLE)}</p>"
|
f"<p class='sub'>{html.escape(_SUBTITLE)}</p>"
|
||||||
f"{_teap_table(variants)}"
|
f"{_teap_table(variants)}"
|
||||||
f"{_financial_table(variants)}"
|
f"{_financial_table(variants)}"
|
||||||
"<p class='sub'>Статический девелоперский расчёт (без дисконтирования / DCF). "
|
"<p class='sub'>NPV / IRR / PBP рассчитаны помесячным DCF по ТИПОВОМУ графику фаз "
|
||||||
"НДС — упрощённая оценка (встроенный НДС в положительной марже, не полная "
|
f"(ПИР 6 мес → СМР по типу застройки → распродажа 30 мес, дисконт {disc_pct} годовых). "
|
||||||
"механика входного/выходного НДС НК РФ). Налог на прибыль — 25% (с 2025). "
|
"График фаз и темп продаж — типовые допущения, НЕ график конкретного проекта; "
|
||||||
"IRR-proxy — аннуализированный чистый ROI без дисконтирования (не настоящий "
|
"точность метрик зависит от реального графика. Где IRR помечен «оценочный» — поток "
|
||||||
"IRR). Цены и себестоимость — рыночные ориентиры, не калиброванная модель. "
|
"вырожденный (нет смены знака), показан аннуализированный ROI вместо DCF-IRR. "
|
||||||
"Коммерческие/офисные площади не учитываются (нет в ТЭП).</p>"
|
"НДС — упрощённая оценка (встроенный НДС в положительной марже, не полная механика "
|
||||||
|
"входного/выходного НДС НК РФ). Налог на прибыль — 25% (с 2025). Цены и себестоимость "
|
||||||
|
"— рыночные ориентиры. Коммерческие/офисные площади не учитываются (нет в ТЭП).</p>"
|
||||||
"</body></html>"
|
"</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
|
We combine **our areas** (from real geometry → :class:`TEAP`) with the **cost
|
||||||
completeness** of a reference developer Excel model: a full static cost cascade,
|
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
|
VAT, profit tax → net profit + ROI (PR-1). On top of that *correct static* P&L,
|
||||||
DCF / discounting yet (that lands in PR-3); no new user inputs are required, only
|
PR-3 lays the cascade onto a **monthly cashflow schedule** and runs a real DCF —
|
||||||
norms + the existing TEAP.
|
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`):
|
Cascade (in :func:`compute_financial`):
|
||||||
|
|
||||||
|
|
@ -12,7 +50,7 @@ Cascade (in :func:`compute_financial`):
|
||||||
* **Cost cascade** = construction (residential GFA + parking) + ПИР (design) +
|
* **Cost cascade** = construction (residential GFA + parking) + ПИР (design) +
|
||||||
networks (ТУ) + developer services + contingency + marketing/realtor + land.
|
networks (ТУ) + developer services + contingency + marketing/realtor + land.
|
||||||
* **БДР / taxes** = gross margin − VAT − profit tax → net profit.
|
* **БДР / 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
|
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
|
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).
|
with proper output/input modelling when calibrating (PR-2/PR-3).
|
||||||
|
|
||||||
IRR — PROXY UNTIL DCF
|
IRR — REAL DCF, PROXY FALLBACK (PR-3)
|
||||||
---------------------
|
-------------------------------------
|
||||||
A real IRR needs a dated cashflow series (PR-3). We expose ``irr`` as an
|
``irr`` is now a **real** annualised IRR computed by bisection over the monthly
|
||||||
annualised net-ROI proxy (``roi / _PROJECT_YEARS``, clamped to ±1) and flag it via
|
cashflow (``irr_is_proxy=False``). For a degenerate cashflow with no sign change
|
||||||
``irr_is_proxy=True`` so the frontend can show the caveat.
|
(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
|
OFFICES / COMMERCIAL — SKIPPED
|
||||||
------------------------------
|
------------------------------
|
||||||
|
|
@ -108,9 +149,204 @@ _VAT_RATE: float = 0.20
|
||||||
# (ФЗ № 176-ФЗ от 12.07.2024). Используем актуальные 25%.
|
# (ФЗ № 176-ФЗ от 12.07.2024). Используем актуальные 25%.
|
||||||
_PROFIT_TAX_RATE: float = 0.25
|
_PROFIT_TAX_RATE: float = 0.25
|
||||||
|
|
||||||
# Условный горизонт проекта (лет) для аннуализации net-ROI в IRR-proxy.
|
# Условный горизонт проекта (лет) для аннуализации net-ROI в IRR-proxy (fallback).
|
||||||
_PROJECT_YEARS: float = 3.0
|
_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(
|
def compute_financial(
|
||||||
*,
|
*,
|
||||||
|
|
@ -119,13 +355,15 @@ def compute_financial(
|
||||||
land_cost_rub: float | None,
|
land_cost_rub: float | None,
|
||||||
market_price_per_sqm: float | None = None,
|
market_price_per_sqm: float | None = None,
|
||||||
price_source: str = "class_norm",
|
price_source: str = "class_norm",
|
||||||
|
development_type: str | None = None,
|
||||||
) -> FinancialModel:
|
) -> FinancialModel:
|
||||||
"""Свести ТЭП + класс + стоимость земли в полный статический :class:`FinancialModel`.
|
"""Свести ТЭП + класс + стоимость земли в полный :class:`FinancialModel` с DCF.
|
||||||
|
|
||||||
Полный девелоперский каскад: выручка (жильё + паркинг) → каскад затрат (СМР +
|
Полный девелоперский каскад: выручка (жильё + паркинг) → каскад затрат (СМР +
|
||||||
ПИР + сети + услуги заказчика + резерв + маркетинг + земля) → БДР с НДС и
|
ПИР + сети + услуги заказчика + резерв + маркетинг + земля) → БДР с НДС и
|
||||||
налогом на прибыль → чистая прибыль + ROI. НЕ DCF (PR-3); НДС — упрощение
|
налогом на прибыль → чистая прибыль + ROI. Поверх статики — помесячный DCF
|
||||||
(см. docstring модуля); офисов нет в TEAP — пропущены.
|
(NPV/IRR/PBP) по дефолт-графику фаз (см. docstring модуля). НДС — упрощение;
|
||||||
|
офисов нет в TEAP — пропущены.
|
||||||
|
|
||||||
Чистая функция: НЕ ходит в БД. Калибровку цены продажи жилья делает API-слой
|
Чистая функция: НЕ ходит в БД. Калибровку цены продажи жилья делает API-слой
|
||||||
(lookup рынка) и прокидывает сюда через ``market_price_per_sqm``.
|
(lookup рынка) и прокидывает сюда через ``market_price_per_sqm``.
|
||||||
|
|
@ -140,6 +378,9 @@ def compute_financial(
|
||||||
price_source: метка источника цены для honest-флага в UI/PDF — одно из
|
price_source: метка источника цены для honest-флага в UI/PDF — одно из
|
||||||
"objective_district_median" / "district_reference" / "class_norm".
|
"objective_district_median" / "district_reference" / "class_norm".
|
||||||
Игнорируется (форсится "class_norm"), если ``market_price_per_sqm is None``.
|
Игнорируется (форсится "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
|
# Калибруем ТОЛЬКО цену продажи жилья. Если рыночной цены нет — честный fallback
|
||||||
# на норматив класса, и source форсим в "class_norm" (не выдаём норму за рынок).
|
# на норматив класса, и source форсим в "class_norm" (не выдаём норму за рынок).
|
||||||
|
|
@ -183,10 +424,38 @@ def compute_financial(
|
||||||
roi = net_profit / cost if cost > 0 else 0.0 # чистый ROI на затраты
|
roi = net_profit / cost if cost > 0 else 0.0 # чистый ROI на затраты
|
||||||
margin_pct = net_profit / revenue if revenue > 0 else 0.0 # чистая маржа на выручку
|
margin_pct = net_profit / revenue if revenue > 0 else 0.0 # чистая маржа на выручку
|
||||||
|
|
||||||
# IRR-proxy: аннуализированный net-ROI. НЕ настоящий IRR (нет дат/дисконта —
|
# ── DCF: помесячный cashflow → NPV / IRR / PBP (PR-3) ──────────────────────
|
||||||
# PR-3). Клампинг ±1, чтобы поле было монотонным и читаемым; флаг proxy.
|
construction_months = _CONSTRUCTION_DURATION_MONTHS.get(
|
||||||
irr = roi / _PROJECT_YEARS
|
development_type or "", _DEFAULT_CONSTRUCTION_MONTHS
|
||||||
irr = max(-1.0, min(1.0, irr))
|
)
|
||||||
|
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(
|
model = FinancialModel(
|
||||||
# legacy summary
|
# legacy summary
|
||||||
|
|
@ -213,22 +482,28 @@ def compute_financial(
|
||||||
# metrics
|
# metrics
|
||||||
roi=round(roi, 4),
|
roi=round(roi, 4),
|
||||||
margin_pct=round(margin_pct, 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 calibration (PR-2)
|
||||||
price_per_sqm_used=round(sale_price, 2),
|
price_per_sqm_used=round(sale_price, 2),
|
||||||
price_is_calibrated=price_is_calibrated,
|
price_is_calibrated=price_is_calibrated,
|
||||||
price_source=resolved_price_source,
|
price_source=resolved_price_source,
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"financial: revenue=%.0f cost=%.0f gross=%.0f vat=%.0f tax=%.0f net=%.0f roi=%.3f "
|
"financial: revenue=%.0f cost=%.0f net=%.0f roi=%.3f npv=%.0f "
|
||||||
"price=%.0f/m2 source=%s",
|
"irr=%.3f%s pbp=%s price=%.0f/m2 source=%s",
|
||||||
model.revenue_rub,
|
model.revenue_rub,
|
||||||
model.cost_rub,
|
model.cost_rub,
|
||||||
model.gross_margin_rub,
|
|
||||||
model.vat_rub,
|
|
||||||
model.profit_tax_rub,
|
|
||||||
model.net_profit_rub,
|
model.net_profit_rub,
|
||||||
model.roi,
|
model.roi,
|
||||||
|
model.npv_rub,
|
||||||
|
model.irr,
|
||||||
|
" (proxy)" if model.irr_is_proxy else "",
|
||||||
|
model.payback_months,
|
||||||
model.price_per_sqm_used,
|
model.price_per_sqm_used,
|
||||||
model.price_source,
|
model.price_source,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -230,6 +230,8 @@ def place_strategy(
|
||||||
land_cost_rub=payload.land_cost_rub,
|
land_cost_rub=payload.land_cost_rub,
|
||||||
market_price_per_sqm=market_price_per_sqm,
|
market_price_per_sqm=market_price_per_sqm,
|
||||||
price_source=price_source,
|
price_source=price_source,
|
||||||
|
# development_type задаёт длительность СМР в DCF-графике финмодели (PR-3).
|
||||||
|
development_type=payload.development_type,
|
||||||
)
|
)
|
||||||
buildings_geojson = _footprints_to_geojson(parcel, footprints, floors, spec)
|
buildings_geojson = _footprints_to_geojson(parcel, footprints, floors, spec)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -162,6 +162,11 @@ def test_concepts_response_matches_contract_keys() -> None:
|
||||||
"roi",
|
"roi",
|
||||||
"margin_pct",
|
"margin_pct",
|
||||||
"irr_is_proxy",
|
"irr_is_proxy",
|
||||||
|
# DCF (PR-3)
|
||||||
|
"npv_rub",
|
||||||
|
"payback_months",
|
||||||
|
"discount_rate_used",
|
||||||
|
"schedule_is_default",
|
||||||
# price calibration (PR-2)
|
# price calibration (PR-2)
|
||||||
"price_per_sqm_used",
|
"price_per_sqm_used",
|
||||||
"price_is_calibrated",
|
"price_is_calibrated",
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,10 @@ def test_pdf_html_build_contains_tables() -> None:
|
||||||
html = pdf._build_html(variants)
|
html = pdf._build_html(variants)
|
||||||
assert "Технико-экономические показатели" in html
|
assert "Технико-экономические показатели" in html
|
||||||
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:
|
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.cost_rub, float)
|
||||||
assert isinstance(model.gross_margin_rub, float)
|
assert isinstance(model.gross_margin_rub, float)
|
||||||
assert isinstance(model.irr, 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: рыночная калибровка цены продажи жилья ───────────────────────────────
|
# ── PR-2: рыночная калибровка цены продажи жилья ───────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,12 @@ function formatPct(fraction: number): string {
|
||||||
return `${(fraction * 100).toFixed(1)}%`;
|
return `${(fraction * 100).toFixed(1)}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Срок окупаемости: «28.5 мес» или «не окупается» (null). */
|
||||||
|
function formatPayback(months: number | null): string {
|
||||||
|
if (months === null) return "не окупается";
|
||||||
|
return `${months.toFixed(1)} мес`;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Variant panel ─────────────────────────────────────────────────────────────
|
// ── Variant panel ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface PanelProps {
|
interface PanelProps {
|
||||||
|
|
@ -111,9 +117,11 @@ function VariantPanel({ parcel, variant }: PanelProps) {
|
||||||
color: "var(--fg-on-dark-muted)",
|
color: "var(--fg-on-dark-muted)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{STRATEGY_HINTS[variant.strategy]} IRR {formatPct(financial.irr)}
|
{STRATEGY_HINTS[variant.strategy]} NPV{" "}
|
||||||
{financial.irr_is_proxy ? " (оценочный, не DCF)" : ""} · плотность
|
{formatMoneyCompact(financial.npv_rub)} · IRR{" "}
|
||||||
(FAR){" "}
|
{formatPct(financial.irr)}
|
||||||
|
{financial.irr_is_proxy ? " (оценочный)" : ""} · окупаемость{" "}
|
||||||
|
{formatPayback(financial.payback_months)} · плотность (FAR){" "}
|
||||||
{teap.density.toLocaleString("ru-RU", {
|
{teap.density.toLocaleString("ru-RU", {
|
||||||
minimumFractionDigits: 2,
|
minimumFractionDigits: 2,
|
||||||
maximumFractionDigits: 2,
|
maximumFractionDigits: 2,
|
||||||
|
|
@ -184,7 +192,7 @@ function VariantPanel({ parcel, variant }: PanelProps) {
|
||||||
{/* Финмодель */}
|
{/* Финмодель */}
|
||||||
<Section
|
<Section
|
||||||
title="Финансовая модель"
|
title="Финансовая модель"
|
||||||
subtitle="Статический девелоперский расчёт: выручка, каскад затрат, НДС и налог на прибыль → чистая прибыль и ROI."
|
subtitle="Девелоперский расчёт: выручка, каскад затрат, НДС и налог на прибыль → чистая прибыль и ROI; поверх — помесячный DCF (NPV, IRR, окупаемость)."
|
||||||
right={<ConceptExportButtons variant={variant} />}
|
right={<ConceptExportButtons variant={variant} />}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|
@ -223,6 +231,33 @@ function VariantPanel({ parcel, variant }: PanelProps) {
|
||||||
positive: null,
|
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
|
<KpiCard
|
||||||
label="Цена продажи жилья"
|
label="Цена продажи жилья"
|
||||||
value={`${formatInt(financial.price_per_sqm_used)} ₽/м²`}
|
value={`${formatInt(financial.price_per_sqm_used)} ₽/м²`}
|
||||||
|
|
@ -254,14 +289,19 @@ function VariantPanel({ parcel, variant }: PanelProps) {
|
||||||
color: "var(--fg-tertiary)",
|
color: "var(--fg-tertiary)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Статический расчёт без дисконтирования (DCF — следующий этап). НДС
|
NPV / IRR / окупаемость рассчитаны помесячным DCF.{" "}
|
||||||
— упрощённая оценка (встроенный НДС в положительной марже, не
|
{financial.schedule_is_default
|
||||||
полная механика входного/выходного НДС НК РФ). Налог на прибыль —
|
? `График фаз и темп продаж — типовые допущения (ПИР 6 мес → СМР по типу застройки → распродажа 30 мес, дисконт ${formatPct(
|
||||||
25% (с 2025 года). IRR{" "}
|
financial.discount_rate_used,
|
||||||
|
)} годовых); метрики рассчитаны DCF по этому графику, точность зависит от графика конкретного проекта. `
|
||||||
|
: ""}
|
||||||
{financial.irr_is_proxy
|
{financial.irr_is_proxy
|
||||||
? "— оценочный (аннуализированный чистый ROI), не настоящий IRR."
|
? "IRR помечен как оценочный: денежный поток вырожденный (нет смены знака), показан аннуализированный ROI вместо DCF-IRR. "
|
||||||
: "."}{" "}
|
: ""}
|
||||||
Цена продажи жилья —{" "}
|
НДС — упрощённая оценка (встроенный НДС в положительной марже, не
|
||||||
|
полная механика входного/выходного НДС НК РФ); привязан к
|
||||||
|
признанию выручки. Налог на прибыль — 25% (с 2025 года). Цена
|
||||||
|
продажи жилья —{" "}
|
||||||
{financial.price_is_calibrated
|
{financial.price_is_calibrated
|
||||||
? `калибрована по рынку (${priceSourceCaption(financial)})`
|
? `калибрована по рынку (${priceSourceCaption(financial)})`
|
||||||
: "норматив класса (нет рыночных данных по участку)"}
|
: "норматив класса (нет рыночных данных по участку)"}
|
||||||
|
|
@ -384,6 +424,18 @@ function FinancialCascadeTable({ financial }: { financial: FinancialModel }) {
|
||||||
value={formatMoneyCompact(financial.net_profit_rub)}
|
value={formatMoneyCompact(financial.net_profit_rub)}
|
||||||
emphasis
|
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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3489,15 +3489,24 @@ export interface components {
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* FinancialModel
|
* 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 —
|
* Full static cost cascade + VAT + profit tax → net profit + ROI (PR-1). On top
|
||||||
* timing/discounting lands in PR-3. VAT is a documented simplification (see
|
* of that static P&L, PR-3 lays the cascade onto a **monthly cashflow schedule**
|
||||||
* ``compute_financial`` docstring), not exact НК РФ mechanics.
|
* (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`` /
|
* The legacy summary fields (``revenue_rub`` / ``cost_rub`` /
|
||||||
* ``gross_margin_rub`` / ``irr``) are kept for backward-compat; the new fields
|
* ``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: {
|
FinancialModel: {
|
||||||
/** Revenue Rub */
|
/** Revenue Rub */
|
||||||
|
|
@ -3543,6 +3552,23 @@ export interface components {
|
||||||
* @default true
|
* @default true
|
||||||
*/
|
*/
|
||||||
irr_is_proxy: boolean;
|
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 */
|
||||||
price_per_sqm_used: number;
|
price_per_sqm_used: number;
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,17 @@ export interface FinancialModel {
|
||||||
// Metrics
|
// Metrics
|
||||||
roi: number;
|
roi: number;
|
||||||
margin_pct: number;
|
margin_pct: number;
|
||||||
|
/** false → IRR настоящий (DCF); true → IRR — proxy (вырожденный поток). */
|
||||||
irr_is_proxy: boolean;
|
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 calibration (PR-2, эпик #1881)
|
||||||
/** Цена продажи жилья, ₽/м², фактически использованная в выручке. */
|
/** Цена продажи жилья, ₽/м², фактически использованная в выручке. */
|
||||||
price_per_sqm_used: number;
|
price_per_sqm_used: number;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue