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
257 lines
13 KiB
Python
257 lines
13 KiB
Python
"""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
|