gendesign/backend/tests/services/generative/test_financial_dcf.py
Light1YT d4068a255b
All checks were successful
CI / frontend-tests (pull_request) Successful in 57s
CI / backend-tests (pull_request) Successful in 9m54s
CI / changes (pull_request) Successful in 6s
CI / openapi-codegen-check (pull_request) Successful in 1m53s
feat(finmodel): monthly DCF — real NPV/IRR/PBP, replace IRR-proxy (epic #1881 PR-3)
Hand-rolled детерминированный DCF (без numpy-financial/новых deps): _npv /
_irr_monthly (bisection с bracket-guard) / _payback_months над помесячным
cashflow, построенным из статического каскада (PR-1) + калиброванной цены
(PR-2) по дефолтным нормативам фаз (ПИР 6мес → СМР по development_type
24/36/48 → распродажа, дисконт 15%).

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

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

Refs #1881
2026-06-23 21:52:04 +05:00

257 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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