gendesign/backend/tests/services/generative/test_financial_dcf.py
Light1YT d350d27325
All checks were successful
CI / frontend-tests (pull_request) Successful in 59s
CI / openapi-codegen-check (pull_request) Successful in 1m53s
CI / backend-tests (pull_request) Successful in 10m1s
CI / changes (pull_request) Successful in 7s
feat(finmodel): financing overlay (кредит 15%) поверх unlevered DCF (epic #1881 PR-5)
Финансирование как ДОПОЛНИТЕЛЬНЫЙ overlay поверх готового unlevered cashflow:
кредитная линия покрывает накопительный кассовый разрыв, проценты капитализируются
(compound), ставка _CREDIT_RATE_ANNUAL=15% (норматив, ОТДЕЛЬНО от ставки
дисконтирования — разная семантика: стоимость долга vs требуемая доходность).

Backend (financial.py):
- _compute_financing(operating_cf, annual_rate) — чистая (READ-ONLY вход, не
  мутирует): процент на остаток на начало месяца ДО движений → капитализация;
  draw=-cf при оттоке, repay=min(cf,debt) при притоке (долг не уходит в минус).
  Возвращает peak_debt_rub / total_interest_rub / ending_debt_rub.
- compute_financial: вызов после DCF, net_profit_after_financing = net − interest.
- _build_monthly_cashflow и unlevered NPV/IRR/PBP НЕ ТРОНУТЫ → инвариант
  Σ unlevered cashflow == net_profit сохранён (регресс-тест).
- FinancialModel +6 полей (financing_enabled/annual_rate_used/peak_debt_rub/
  total_interest_rub/net_profit_after_financing_rub/financing_is_simplified).

Frontend: ParcelFinancialEstimate +6 полей; adaptFinanceDrawer + concept-каскад
показывают пиковый долг/проценты/чистую прибыль после финанс. (когда enabled).

MVP: только агрегаты, БЕЗ levered IRR (хрупко на хардкод-100%-долге — отдельный
PR). HEAVY caveat: проценты — реальная стоимость (net после < net до); 100%
покрытие разрыва (нет equity/LTC); compound; эскроу не моделируется; unlevered
headline не затронут. financing_is_simplified=True флаг.

mypy strict clean, +9 backend + 3 frontend тестов (compound точное значение,
no-mutation, инвариант-регресс, монотонность, net_after<net), api-types
регенерён авторитетно, deps не тронуты (hand-roll).

Refs #1881
2026-06-24 01:12:47 +05:00

351 lines
17 KiB
Python
Raw Permalink 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,
_compute_financing,
_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
# ── FINANCING — OVERLAY (PR-5) ──────────────────────────────────────────────────
def test_financing_does_not_mutate_operating_cashflow() -> None:
# OVERLAY: _compute_financing читает unlevered поток READ-ONLY, НЕ мутирует.
cf = [-1000.0, -500.0, 0.0, 800.0, 900.0]
snapshot = list(cf)
_compute_financing(cf, 0.15)
assert cf == snapshot
def test_financing_draws_debt_on_cash_gap() -> None:
# Ранний отток / поздняя выручка → нужна кредитная линия → peak_debt>0, %>0.
cf = [-1000.0, -1000.0, 0.0, 1500.0, 1500.0]
res = _compute_financing(cf, 0.15)
assert res["peak_debt_rub"] > 0.0
assert res["total_interest_rub"] > 0.0
def test_financing_zero_when_no_cash_gap() -> None:
# Поток без минуса → финансирование не требуется → debt=0, interest=0.
cf = [100.0, 200.0, 300.0]
res = _compute_financing(cf, 0.15)
assert res["peak_debt_rub"] == 0.0
assert res["total_interest_rub"] == 0.0
assert res["ending_debt_rub"] == 0.0
def test_financing_interest_capitalises_compound() -> None:
# Долг 1000 в мес 0, дальше ноль → проценты КАПИТАЛИЗИРУЮТСЯ (начисляются на
# растущий остаток). За мес 1 и 2 суммарный % > 2× месячного % от 1000 (compound).
cf = [-1000.0, 0.0, 0.0]
res = _compute_financing(cf, 0.15)
monthly_rate = (1.0 + 0.15) ** (1.0 / 12.0) - 1.0
# Мес 0: % с нуля = 0, выборка 1000. Мес 1: % = 1000·r. Мес 2: % = (1000+1000·r)·r.
expected = 1000.0 * monthly_rate + (1000.0 + 1000.0 * monthly_rate) * monthly_rate
assert abs(res["total_interest_rub"] - round(expected, 2)) < 0.5
# Compound строго больше простого 2× месячного % от исходного тела долга.
assert res["total_interest_rub"] > 2.0 * 1000.0 * monthly_rate
def test_financing_interest_monotonic_in_rate() -> None:
# Монотонность: выше ставка → выше суммарные проценты при том же потоке.
cf = [-1000.0, -1000.0, 0.0, 1500.0, 1500.0]
low = _compute_financing(cf, 0.10)
high = _compute_financing(cf, 0.20)
assert high["total_interest_rub"] > low["total_interest_rub"]
assert high["peak_debt_rub"] >= low["peak_debt_rub"]
def test_net_profit_after_financing_equals_net_minus_interest() -> None:
# Тождество модели: net_after == net_profit total_interest (float-tol).
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"
)
expected = model.net_profit_rub - model.total_interest_rub
assert abs(model.net_profit_after_financing_rub - expected) < 0.01
def test_net_profit_after_financing_below_net_for_project_with_gap() -> None:
# Проект с кассовым разрывом: проценты режут прибыль → net_after < net.
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"
)
assert model.total_interest_rub > 0.0
assert model.net_profit_after_financing_rub < model.net_profit_rub
def test_financing_overlay_does_not_touch_unlevered_invariant() -> None:
# РЕГРЕСС: overlay не трогает unlevered cashflow → инвариант Σ cf == net_profit
# держится как до PR-5 (financing — поверх готового потока, не пересборка).
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)
assert abs(sum(cf) - model.net_profit_rub) < 1.0
def test_financing_enabled_on_realistic_parcel() -> None:
# Интеграция compute_financial: реалистичный участок → overlay посчитан.
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"
)
assert model.financing_enabled is True
assert model.financing_is_simplified is True
assert model.annual_rate_used == financial._CREDIT_RATE_ANNUAL
assert model.peak_debt_rub > 0.0