gendesign/backend/tests/services/generative/test_financial_dcf.py
Light1YT 9e848cee43
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m47s
CI / backend-tests (pull_request) Successful in 11m0s
feat(financial/dcf): dormant separate-index inflation capability (default OFF)
Wire smr_inflation_annual / sale_inflation_annual into _build_monthly_cashflow
(monthly-compounded escalation of СМР costs vs sale revenue), gated by module
constants _SMR_INFLATION_ANNUAL / _SALE_INFLATION_ANNUAL = 0.0. At default the
factor (1+0)**t == 1.0 → byte-identical cashflow → existing Σ cashflow ==
net_profit invariant and all 57 prior tests unchanged (deep-review verified
IEEE-754 identity + proration-denominator guard). Land not escalated (t0 lump);
marketing/VAT/tax prorated on escalated revenue (total preserved, only retimed).

Capability разведено+протестировано, OFF until calibrated indices + a decision
to reframe the headline today-money invariant (nominal DCF vs real БДР). No
schema/API/codegen change. +10 tests (zero-diff, compounding, NPV monotonicity,
land-excluded, nominal-Σ-diverges-when-on).

Refs #1881
2026-06-25 16:53:32 +05:00

1034 lines
56 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
import pytest
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, office: float = 0.0) -> TEAP:
return TEAP(
built_area_sqm=100.0,
total_floor_area_sqm=gfa,
office_area_sqm=office,
residential_area_sqm=residential,
apartments_count=10,
density=1.0,
parking_spaces=parking,
)
def _rebuild_cashflow(
model: object,
*,
construction_months: int,
residential_area_sqm: float,
market_velocity_sqm_per_month: float | None = None,
) -> list[float]:
"""Пересобрать помесячный cashflow из полей модели — для инвариант-теста.
``residential_area_sqm`` + ``market_velocity_sqm_per_month`` должны совпадать с
тем, чем считалась модель, иначе окно продаж разойдётся (но инвариант суммы держится
при любом окне — перенос выручки во времени не меняет сумму потока).
"""
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,
residential_area_sqm=residential_area_sqm,
market_velocity_sqm_per_month=market_velocity_sqm_per_month,
).cashflow
# ── 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, residential_area_sqm=10_000.0)
# Σ недисконтированного потока == чистая прибыль каскада (±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, residential_area_sqm=1.0)
assert abs(sum(cf) - model.net_profit_rub) < 1.0
def test_invariant_cashflow_sum_equals_net_profit_econom_parking() -> None:
# Классозависимый паркинг (econom: цена 800k / себестоимость 350k) кормит тот же
# каскад → инвариант Σ cashflow == net_profit держится (магнитуды паркинга
# изменились, но cascade-структура та же).
t = _teap(residential=10_000.0, gfa=11_000.0, parking=50)
model = financial.compute_financial(
teap=t, housing_class="econom", land_cost_rub=150_000_000.0, development_type="mid_rise"
)
cf = _rebuild_cashflow(model, construction_months=36, residential_area_sqm=10_000.0)
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, residential_area_sqm=8_000.0)
assert abs(sum(cf) - model.net_profit_rub) < 1.0, dev_type
# ── НДС: освобождение жилья (ст.149 НК), НДС только на паркинг ───────────────────
def test_vat_zero_for_residential_only_project() -> None:
# Реализация жилья + услуги застройщика по ДДУ освобождены от НДС (пп.22/пп.23.1
# п.3 ст.149 НК РФ). Проект БЕЗ паркинга → НДС == 0 (раньше было margin×0.20/1.20).
# Profitable comfort-проект всё равно остаётся прибыльным.
t = _teap(residential=10_000.0, gfa=11_000.0, parking=0)
model = financial.compute_financial(
teap=t, housing_class="comfort", land_cost_rub=200_000_000.0, development_type="mid_rise"
)
# Освобождение явно: нет паркинга → нет облагаемого value-added → НДС ровно 0.
assert model.vat_rub == 0.0
assert model.revenue_parking_rub == 0.0
# Налогооблагаемая база до налога теперь == валовой марже (НДС не съедает её).
assert model.profit_before_tax_rub == pytest.approx(model.gross_margin_rub, abs=0.01)
# Проект прибыльный (освобождение НДС не делает его убыточным).
assert model.net_profit_rub > 0
def test_vat_only_on_parking_value_added() -> None:
# С паркингом → НДС > 0 и считается ТОЛЬКО на нежилой value-added машиномест:
# vat == max(0, revenue_parking construction_parking) × 0.20/1.20.
# Жилая часть (выручка/маржа жилья) в базу НДС НЕ входит (ст.149 НК РФ).
parking = 50
cls = "comfort"
t = _teap(residential=10_000.0, gfa=11_000.0, parking=parking)
model = financial.compute_financial(
teap=t, housing_class=cls, land_cost_rub=200_000_000.0, development_type="mid_rise"
)
# Ожидаемое из констант класса: revenue_parking construction_parking паркинга.
revenue_parking = parking * financial._PARKING_PRICE_PER_SPOT[cls]
construction_parking = parking * financial._PARKING_COST_PER_SPOT[cls]
parking_value_added = revenue_parking - construction_parking
expected_vat = max(0.0, parking_value_added) * financial._VAT_RATE / (1.0 + financial._VAT_RATE)
assert model.vat_rub > 0.0
assert model.vat_rub == pytest.approx(round(expected_vat, 2), abs=0.01)
# База НДС — именно нежилой value-added, а НЕ полная валовая маржа проекта.
full_margin_vat = (
max(0.0, model.gross_margin_rub) * financial._VAT_RATE / (1.0 + financial._VAT_RATE)
)
assert model.vat_rub < full_margin_vat # жильё освобождено → НДС много меньше margin×rate
def test_vat_invariant_holds_with_parking_vat() -> None:
# Инвариант Σ cashflow == net_profit держится с новым НДС (vat прокидывается в
# построитель cashflow без изменения формы — меняется только база НДС).
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.vat_rub > 0.0
cf = _rebuild_cashflow(model, construction_months=36, residential_area_sqm=10_000.0)
assert abs(sum(cf) - model.net_profit_rub) < 1.0
# ── НЕЖИЛОЕ (коммерция/офисы 1-го этажа) ────────────────────────────────────────
def test_office_revenue_zero_when_no_office_area() -> None:
# Нет нежилого (office=0, как у эконома/spot) → revenue_office_rub == 0, выручка =
# жильё + паркинг (без нежилого слагаемого).
t = _teap(residential=10_000.0, gfa=11_000.0, parking=50, office=0.0)
model = financial.compute_financial(
teap=t, housing_class="comfort", land_cost_rub=200_000_000.0, development_type="mid_rise"
)
assert model.revenue_office_rub == 0.0
assert model.revenue_rub == pytest.approx(
model.revenue_residential_rub + model.revenue_parking_rub, abs=0.01
)
def test_office_revenue_added_when_office_area_present() -> None:
# office > 0 → revenue_office_rub = office_area × цена нежилого класса; общая выручка
# = жильё + паркинг + нежилое.
cls = "comfort"
office = 550.0 # ~5% от GFA 11000
t = _teap(residential=9_450.0, gfa=11_000.0, parking=50, office=office)
model = financial.compute_financial(
teap=t, housing_class=cls, land_cost_rub=200_000_000.0, development_type="mid_rise"
)
expected_office = office * financial._OFFICE_SALE_PRICE_PER_SQM[cls]
assert model.revenue_office_rub == pytest.approx(round(expected_office, 2), abs=0.01)
assert model.revenue_office_rub > 0.0
assert model.revenue_rub == pytest.approx(
model.revenue_residential_rub + model.revenue_parking_rub + model.revenue_office_rub,
abs=0.01,
)
def test_office_construction_not_double_counted() -> None:
# СМР нежилого НЕ добавляется отдельной строкой: construction_rub зависит ТОЛЬКО от
# total_floor_area (+паркинг), а не от office_area. Два проекта с одинаковым GFA, но
# разной долей нежилого → одинаковый construction_rub (нежилое лишь меняет выручку).
no_office = _teap(residential=8_580.0, gfa=11_000.0, parking=50, office=0.0)
with_office = _teap(residential=8_151.0, gfa=11_000.0, parking=50, office=550.0)
m_no = financial.compute_financial(
teap=no_office, housing_class="comfort", land_cost_rub=None, development_type="mid_rise"
)
m_off = financial.compute_financial(
teap=with_office, housing_class="comfort", land_cost_rub=None, development_type="mid_rise"
)
# СМР идентичен (нежилое уже в total_floor_area → double-count'а нет).
assert m_off.construction_rub == m_no.construction_rub
# Но нежилое даёт дополнительную выручку.
assert m_off.revenue_office_rub > 0.0
assert m_no.revenue_office_rub == 0.0
def test_office_vat_includes_office_value_added() -> None:
# НДС с нежилым = НДС(паркинг value-added) + НДС(office value-added). office_value_added
# = revenue_office office_area × ставка СМР. Проверяем точную формулу.
cls = "comfort"
parking = 50
office = 550.0
t = _teap(residential=8_151.0, gfa=11_000.0, parking=parking, office=office)
model = financial.compute_financial(
teap=t, housing_class=cls, land_cost_rub=200_000_000.0, development_type="mid_rise"
)
revenue_parking = parking * financial._PARKING_PRICE_PER_SPOT[cls]
construction_parking = parking * financial._PARKING_COST_PER_SPOT[cls]
parking_va = max(0.0, revenue_parking - construction_parking)
revenue_office = office * financial._OFFICE_SALE_PRICE_PER_SQM[cls]
office_construction = office * financial._CONSTRUCTION_COST_PER_SQM[cls]
office_va = max(0.0, revenue_office - office_construction)
expected_vat = (parking_va + office_va) * financial._VAT_RATE / (1.0 + financial._VAT_RATE)
assert model.vat_rub == pytest.approx(round(expected_vat, 2), abs=0.01)
# НДС с нежилым строго БОЛЬШЕ, чем НДС только паркинга (нежилое добавляет базу).
parking_only_vat = parking_va * financial._VAT_RATE / (1.0 + financial._VAT_RATE)
assert model.vat_rub > parking_only_vat
def test_office_invariant_cashflow_sum_equals_net_profit() -> None:
# Инвариант Σ cashflow == net_profit держится с нежилым (office-выручка течёт по той
# же revenue → cashflow дорожке; НДС нежилого прокидывается в построитель потока).
t = _teap(residential=8_151.0, gfa=11_000.0, parking=50, office=550.0)
model = financial.compute_financial(
teap=t, housing_class="comfort", land_cost_rub=200_000_000.0, development_type="mid_rise"
)
assert model.revenue_office_rub > 0.0
cf = _rebuild_cashflow(model, construction_months=36, residential_area_sqm=8_151.0)
assert abs(sum(cf) - model.net_profit_rub) < 1.0
def test_office_carveout_shrinks_residential_via_compute_teap() -> None:
# РЕАЛИЗМ-ФИКС на уровне compute_teap: нежилое вырезается из GFA → жилая ужимается на
# office_area × efficiency, а total_floor_area не меняется. Comfort (share 0.05) vs
# econom (share 0.0) при той же геометрии: у comfort office>0, жилая ниже на office-вклад.
from shapely.geometry import box
from app.services.generative import teap as teap_mod
footprint = box(0, 0, 20, 10) # 200 кв.м пятна
comfort = teap_mod.compute_teap(
footprints=[footprint], floors=10, site_area_sqm=1000.0, housing_class="comfort"
)
# GFA 2000; office = 2000 × 0.05 = 100; residential = (2000100) × 0.78 = 1482.
assert comfort.total_floor_area_sqm == 2000.0
assert comfort.office_area_sqm == 100.0
assert comfort.residential_area_sqm == pytest.approx(1482.0, abs=0.1)
# econom: share 0 → office 0, residential по полной GFA × 0.82 = 1640 (unchanged).
econom = teap_mod.compute_teap(
footprints=[footprint], floors=10, site_area_sqm=1000.0, housing_class="econom"
)
assert econom.office_area_sqm == 0.0
assert econom.residential_area_sqm == pytest.approx(1640.0, abs=0.1)
# ── Настоящий 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, residential_area_sqm=10_000.0)
# Ровно одна смена знака → бисекция брекетит → настоящий 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, residential_area_sqm=11_000.0)
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, residential_area_sqm=10_000.0)
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
def test_financing_ltc_reduces_debt_vs_full_coverage() -> None:
# LTC 0.70 → пиковый долг и проценты < чем при 100% покрытии (ltc=1.0).
cf = [-1000.0, -1000.0, -500.0, 0.0, 1500.0, 1500.0]
full = _compute_financing(cf, 0.15, ltc_ratio=1.0)
ltc70 = _compute_financing(cf, 0.15, ltc_ratio=0.70)
assert ltc70.peak_debt_rub < full.peak_debt_rub
assert ltc70.total_interest_rub < full.total_interest_rub
# LTC 0.70 → debt = 70% от full для этого fixture (все оттоки ДО притоков,
# поэтому peak достигается до первого погашения и масштабируется линейно).
assert abs(ltc70.peak_debt_rub / full.peak_debt_rub - 0.70) < 0.01
def test_compute_financial_uses_ltc_ratio() -> None:
# compute_financial передаёт _LOAN_TO_COST_RATIO в _compute_financing:
# total_interest строго < чем при 100%-долге на том же cashflow.
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"
)
# Нет прямого доступа к «100%-версии», но LTC < 1.0 → peak_debt < revenue
# (у реалистичного МКД-проекта затраты ~ 70-80% выручки, значит
# LTC*затраты < затраты — пиковый долг не равен всем затратам).
assert 0.0 < model.peak_debt_rub < model.cost_rub
assert model.total_interest_rub > 0.0
assert model.net_profit_after_financing_rub == pytest.approx(
model.net_profit_rub - model.total_interest_rub, abs=0.01
)
# ── LEVERED (EQUITY) IRR ─────────────────────────────────────────────────────────
def test_equity_cashflow_shape_invested_matches_outflow_share() -> None:
# equity_invested = Σ |отрицательных equity_cf| ≈ (1ltc) × Σ оттоков unlevered.
cf = [-1000.0, -1000.0, -500.0, 0.0, 1500.0, 1500.0]
ltc = 0.70
res = _compute_financing(cf, 0.15, ltc_ratio=ltc)
equity_cf = res.equity_cashflow
assert len(equity_cf) == len(cf)
equity_invested = sum(-c for c in equity_cf if c < 0.0)
total_outflow = sum(-c for c in cf if c < 0.0)
# Equity-доля оттоков = (1ltc) от каждого разрыва → Σ масштабируется линейно.
assert equity_invested == pytest.approx((1.0 - ltc) * total_outflow, rel=1e-9)
def test_equity_cashflow_sum_equals_net_after_financing() -> None:
# Σ equity_cashflow == net_profit_after_financing: equity получает всю прибыль за
# вычетом процентов (банк — только тело долга + проценты). Sanity на equity-поток.
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, residential_area_sqm=10_000.0)
res = _compute_financing(cf, financial._CREDIT_RATE_ANNUAL, financial._LOAN_TO_COST_RATIO)
assert abs(sum(res.equity_cashflow) - model.net_profit_after_financing_rub) < 1.0
def test_levered_irr_exceeds_unlevered_on_profitable_project() -> None:
# Положительный леверидж: доходность проекта > стоимости долга 15% → equity-возврат
# выше проектного. levered_irr > irr на профитном leveraged-проекте.
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.net_profit_rub > 0
assert model.irr_is_proxy is False
assert model.levered_irr_is_proxy is False
# Положительный леверидж → equity-IRR строго выше unlevered project-IRR.
assert model.levered_irr > model.irr
def test_levered_irr_proxy_on_degenerate_equity_flow() -> None:
# Вырожденный equity-поток (нет смены знака) → IRR не брекетится → proxy fallback.
# Equity-поток без смены знака: только оттоки (нет притока сверх обслуживания долга).
cf = [-1000.0, -1000.0, -1000.0] # всегда отток → equity_cf тоже всегда отток
equity_cf = _compute_financing(cf, 0.15, ltc_ratio=0.70).equity_cashflow
assert _irr_monthly(equity_cf) is None # вырожден → бисекция не брекетит
def test_levered_irr_zero_proxy_when_ltc_full() -> None:
# LTC=1.0 → банк покрывает весь разрыв → equity не под риском (equity_invested=0)
# → levered_irr=0.0 / proxy (не выдумываем доходность). Проверяем через прямой поток.
cf = [-1000.0, -1000.0, 0.0, 1500.0, 1500.0]
res = _compute_financing(cf, 0.15, ltc_ratio=1.0)
equity_invested = sum(-c for c in res.equity_cashflow if c < 0.0)
assert equity_invested == pytest.approx(0.0, abs=1e-9)
def test_compute_financial_levered_irr_fields_present() -> None:
# Интеграция: профитный участок → levered_irr посчитан, поля присутствуют/типизированы.
# business/spot — проектный IRR ВЫШЕ стоимости долга 15% → положительный леверидж
# (у marginal-проекта с проектным IRR < 15% леверидж отрицателен — это корректно,
# см. test_levered_irr_below_unlevered_on_marginal_project ниже).
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 isinstance(model.levered_irr, float)
assert isinstance(model.levered_irr_is_proxy, bool)
# Профитный leveraged-проект (LTC=0.70, проектный IRR > 15%): levered IRR настоящий,
# выше unlevered (положительный финансовый рычаг).
assert model.levered_irr_is_proxy is False
assert model.levered_irr > model.irr
def test_levered_irr_below_unlevered_on_marginal_project() -> None:
# ОТРИЦАТЕЛЬНЫЙ леверидж — sanity на корректность знака: marginal comfort/mid_rise
# проект, где проектный IRR (~4.6%) НИЖЕ стоимости долга 15% → долг разрушает
# стоимость equity → levered_irr < irr. Модель НЕ выдумывает положительный рычаг,
# когда проект не отбивает стоимость кредита.
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.net_profit_rub > 0 # статически проект прибыльный
assert model.irr < model.annual_rate_used # но проектный IRR ниже стоимости долга
# Отрицательный рычаг: долг дороже доходности проекта → equity-возврат хуже проектного.
assert model.levered_irr < model.irr
# ── RANK 1: окно продаж по рыночной абсорбции района ────────────────────────────
def _build(
*,
residential: float,
velocity: float | None,
construction_months: int = 36,
) -> object:
"""Прямой вызов _build_monthly_cashflow с минимальным валидным каскадом."""
return _build_monthly_cashflow(
revenue=1_000_000_000.0,
construction=400_000_000.0,
pir=10_000_000.0,
networks=30_000_000.0,
dev_services=6_000_000.0,
contingency=13_000_000.0,
marketing=70_000_000.0,
land=100_000_000.0,
vat=50_000_000.0,
profit_tax=40_000_000.0,
construction_duration_months=construction_months,
residential_area_sqm=residential,
market_velocity_sqm_per_month=velocity,
)
def test_velocity_none_keeps_default_30mo_schedule() -> None:
# (a) velocity=None → дефолт-30-мес окно, schedule_is_default=True,
# Σ cashflow == net_profit (перенос выручки не меняет сумму).
t = _teap(residential=22_000.0, gfa=24_000.0, parking=60)
default_model = financial.compute_financial(
teap=t, housing_class="comfort", land_cost_rub=150_000_000.0, development_type="mid_rise"
)
assert default_model.schedule_is_default is True
# net_profit инвариантен сумме потока (велосити не трогали).
cf = _rebuild_cashflow(
default_model,
construction_months=36,
residential_area_sqm=22_000.0,
market_velocity_sqm_per_month=None,
)
assert abs(sum(cf) - default_model.net_profit_rub) < 1.0
# sales_duration_months выставлен и совпадает с дефолт-окном (с эскроу-привязкой).
assert default_model.sales_duration_months is not None
# Дефолт-окно для mid_rise: constr_end=42, sales_start=12 → max(12+30, 42)12 = 30.
assert default_model.sales_duration_months == pytest.approx(30.0)
def test_velocity_realistic_drives_market_schedule() -> None:
# (b) residential 22000 м², velocity 800 м²/мес → ceil(27.5)=28 мес окно.
t = _teap(residential=22_000.0, gfa=24_000.0, parking=60)
model = financial.compute_financial(
teap=t,
housing_class="comfort",
land_cost_rub=150_000_000.0,
development_type="mid_rise",
market_velocity_sqm_per_month=800.0,
)
assert model.schedule_is_default is False
assert model.sales_duration_months is not None
# mid_rise: constr_end=42, sales_start=12. base=28 → sales_end=max(40,42)=42 → 30.
# Эскроу-привязка вытягивает окно до ввода; market-флаг при этом остаётся.
assert model.sales_duration_months == pytest.approx(30.0)
# Инвариант держится при market-графике.
cf = _rebuild_cashflow(
model,
construction_months=36,
residential_area_sqm=22_000.0,
market_velocity_sqm_per_month=800.0,
)
assert abs(sum(cf) - model.net_profit_rub) < 1.0
def test_velocity_market_window_below_construction_for_short_build() -> None:
# (b') Тот же темп, но spot (короткая стройка) → market-окно 28 мес РЕАЛЬНО видно:
# constr_end=30, sales_start=12 → base=28 → sales_end=max(40,30)=40 → окно 28.
t = _teap(residential=22_000.0, gfa=24_000.0, parking=60)
model = financial.compute_financial(
teap=t,
housing_class="comfort",
land_cost_rub=150_000_000.0,
development_type="spot",
market_velocity_sqm_per_month=800.0,
)
assert model.schedule_is_default is False
assert model.sales_duration_months == pytest.approx(28.0)
# Регресс PR-3: даже когда market-окно КОРОЧЕ дефолта (эскроу не доминирует),
# cashflow обязан иметь РОВНО одну смену знака → IRR настоящий, не proxy.
assert model.irr_is_proxy is False
res = _build(residential=22_000.0, velocity=800.0, construction_months=24)
assert _sign_changes(res.cashflow) == 1 # type: ignore[attr-defined]
def test_velocity_high_clamps_to_min_and_respects_escrow() -> None:
# (c) очень высокая velocity → raw крошечный → клампится в _SALES_DURATION_MIN,
# sales_end НЕ раньше constr_end (эскроу), профитный проект → IRR не proxy.
t = _teap(residential=22_000.0, gfa=24_000.0, parking=60)
model = financial.compute_financial(
teap=t,
housing_class="comfort",
land_cost_rub=150_000_000.0,
development_type="mid_rise",
market_velocity_sqm_per_month=1_000_000.0, # абсурдно горячий рынок
)
assert model.schedule_is_default is False
assert model.net_profit_rub > 0
# Прямой каскад: base клампится к MIN=6, но эскроу вытягивает до constr_end.
res = _build(residential=22_000.0, velocity=1_000_000.0, construction_months=36)
# base=6 → sales_end=max(12+6, 42)=42 → realized=30 (эскроу > MIN).
assert res.schedule_is_market is True # type: ignore[attr-defined]
assert res.sales_duration_months_realized == 30 # type: ignore[attr-defined]
# IRR настоящий на профитном проекте (нет двойной смены знака).
assert model.irr_is_proxy is False
def test_velocity_high_clamps_to_min_visible_on_short_build() -> None:
# (c') high velocity на spot: base=MIN=6, constr_end=30 → sales_end=max(18,30)=30 → 18.
# MIN-кламп виден на коротком строительстве, где эскроу не доминирует над окном.
res = _build(residential=22_000.0, velocity=1_000_000.0, construction_months=24)
# constr_end=6+24=30, sales_start=12, base=6 → sales_end=max(18,30)=30 → 18.
assert res.sales_duration_months_realized == 18 # type: ignore[attr-defined]
assert res.schedule_is_market is True # type: ignore[attr-defined]
def test_velocity_low_clamps_to_max() -> None:
# (d) очень низкая velocity → raw огромный → клампится в _SALES_DURATION_MAX=120.
# residential 22000 / velocity 10 → raw=2200 → clamp 120.
res = _build(residential=22_000.0, velocity=10.0, construction_months=36)
# base=120 → sales_end=max(12+120, 42)=132 → realized=120.
assert res.sales_duration_months_realized == financial._SALES_DURATION_MAX # type: ignore[attr-defined]
assert res.schedule_is_market is True # type: ignore[attr-defined]
@pytest.mark.parametrize("bad_velocity", [0.0, -50.0, float("inf"), float("nan")])
def test_velocity_nonpositive_or_nonfinite_falls_back_to_default(bad_velocity: float) -> None:
# (e) velocity <= 0 или non-finite → как None → дефолт-график.
res = _build(residential=22_000.0, velocity=bad_velocity, construction_months=36)
assert res.schedule_is_market is False # type: ignore[attr-defined]
# Дефолт mid_rise окно: 30.
assert res.sales_duration_months_realized == 30 # type: ignore[attr-defined]
def test_velocity_zero_residential_falls_back_to_default() -> None:
# residential 0 → деление невозможно → дефолт-график.
res = _build(residential=0.0, velocity=800.0, construction_months=36)
assert res.schedule_is_market is False # type: ignore[attr-defined]
def test_velocity_invariant_holds_across_window_sizes() -> None:
# Перенос выручки во времени НЕ меняет сумму потока — инвариант при любом окне.
for vel in (None, 800.0, 50.0, 1_000_000.0):
res = _build(residential=22_000.0, velocity=vel, construction_months=36)
cf = res.cashflow # type: ignore[attr-defined]
# Σ = revenue costs vat tax (земля+ПИР+СМР+сети+услуги+резерв+маркетинг).
expected = (
1_000_000_000.0
- 400_000_000.0
- 10_000_000.0
- 30_000_000.0
- 6_000_000.0
- 13_000_000.0
- 70_000_000.0
- 100_000_000.0
- 50_000_000.0
- 40_000_000.0
)
assert abs(sum(cf) - expected) < 1.0, vel
# ── РАЗДЕЛЬНАЯ ИНФЛЯЦИЯ ИНДЕКСОВ — DORMANT CAPABILITY (default OFF) ───────────────
def _build_inflation(
*,
smr_inflation_annual: float = 0.0,
sale_inflation_annual: float = 0.0,
construction_months: int = 36,
revenue: float = 1_000_000_000.0,
construction: float = 400_000_000.0,
pir: float = 10_000_000.0,
networks: float = 30_000_000.0,
dev_services: float = 6_000_000.0,
contingency: float = 13_000_000.0,
marketing: float = 70_000_000.0,
land: float = 100_000_000.0,
vat: float = 50_000_000.0,
profit_tax: float = 40_000_000.0,
) -> object:
"""Прямой вызов _build_monthly_cashflow с явными ставками инфляции (capability)."""
return _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,
residential_area_sqm=22_000.0,
market_velocity_sqm_per_month=None,
smr_inflation_annual=smr_inflation_annual,
sale_inflation_annual=sale_inflation_annual,
)
def test_module_inflation_constants_default_off() -> None:
# SAFETY: модульные константы строго 0.0 (capability разведена, но выключена в проде).
assert financial._SMR_INFLATION_ANNUAL == 0.0
assert financial._SALE_INFLATION_ANNUAL == 0.0
def test_zero_diff_explicit_zero_vs_omitted_byte_identical() -> None:
# ZERO-DIFF (критично): дефолт без аргументов vs явные 0.0 → ПОБАЙТОВО идентичный
# cashflow. Пин: прод (константы 0.0) не изменился вообще.
omitted = _build(residential=22_000.0, velocity=None, construction_months=36)
explicit = _build_inflation(
smr_inflation_annual=0.0, sale_inflation_annual=0.0, construction_months=36
)
cf_omitted = omitted.cashflow # type: ignore[attr-defined]
cf_explicit = explicit.cashflow # type: ignore[attr-defined]
assert cf_explicit == cf_omitted # списки float идентичны элемент-в-элемент
def test_zero_diff_npv_irr_invariant_unchanged_at_default() -> None:
# ZERO-DIFF: NPV, IRR и инвариант Σ cashflow == net_profit идентичны при явных 0.0.
omitted = _build(residential=22_000.0, velocity=None, construction_months=48)
explicit = _build_inflation(
sale_inflation_annual=0.0, smr_inflation_annual=0.0, construction_months=48
)
cf_o = omitted.cashflow # type: ignore[attr-defined]
cf_e = explicit.cashflow # type: ignore[attr-defined]
monthly_rate = (1.0 + financial._DISCOUNT_RATE_ANNUAL) ** (1.0 / 12.0) - 1.0
assert _npv(monthly_rate, cf_e) == _npv(monthly_rate, cf_o)
assert _irr_monthly(cf_e) == _irr_monthly(cf_o)
# Инвариант (today-money): Σ == revenue все затраты vat tax.
expected = (
1_000_000_000.0
- 400_000_000.0
- 10_000_000.0
- 30_000_000.0
- 6_000_000.0
- 13_000_000.0
- 70_000_000.0
- 100_000_000.0
- 50_000_000.0
- 40_000_000.0
)
assert abs(sum(cf_e) - expected) < 1.0
def test_compute_financial_zero_diff_with_module_defaults() -> None:
# ZERO-DIFF на уровне compute_financial: пересборка cashflow с теми же ставками,
# что использует прод (константы 0.0), даёт инвариант Σ == net_profit (как было).
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, residential_area_sqm=10_000.0)
# _rebuild_cashflow не передаёт ставки → дефолт 0.0 → тот же поток, что построил прод.
assert abs(sum(cf) - model.net_profit_rub) < 1.0
def test_inflation_escalates_late_cost_by_monthly_compound_factor() -> None:
# COMPOUNDING (a): поздний СМР-отток = базовый × (1+smr_m)**t, где
# smr_m = (1.08)**(1/12) 1. Сравниваем месяц t для базового vs инфлированного
# потока → отношение == ровно фактор компаундинга месяца t.
base = _build_inflation(smr_inflation_annual=0.0, construction_months=36)
infl = _build_inflation(smr_inflation_annual=0.08, construction_months=36)
cf_base = base.cashflow # type: ignore[attr-defined]
cf_infl = infl.cashflow # type: ignore[attr-defined]
smr_m = (1.08) ** (1.0 / 12.0) - 1.0
# Возьмём месяц ВНУТРИ окна СМР, ДО старта продаж (чистый СМР-отток, без выручки):
# pir_end=6, constr_start=6, sales_start=6+6=12. Месяц 10 — чистый СМР+сети+резерв.
t = 10
# Затраты — отрицательные; отношение по модулю == фактор компаундинга.
factor = (1.0 + smr_m) ** t
assert cf_infl[t] == pytest.approx(cf_base[t] * factor, rel=1e-9)
# Sanity: фактор для t=10 близок к (1.08)**(10/12).
assert factor == pytest.approx(1.08 ** (10.0 / 12.0), rel=1e-9)
def test_inflation_revenue_escalates_by_sale_factor() -> None:
# COMPOUNDING: приток выручки месяца t = базовый × (1+sale_m)**t. Берём проект без
# затрат в окне продаж, чтобы изолировать выручку: месяц с чистым притоком.
base = _build_inflation(sale_inflation_annual=0.0, construction_months=24)
infl = _build_inflation(sale_inflation_annual=0.06, construction_months=24)
cf_base = base.cashflow # type: ignore[attr-defined]
cf_infl = infl.cashflow # type: ignore[attr-defined]
# Последний месяц горизонта — чистый приток (СМР завершён, продажи идут): на нём
# выручка эскалирована сильнее всего. Проверяем направление: инфл-приток > базового.
last = len(cf_base) - 1
assert cf_infl[last] > cf_base[last]
def test_nominal_cashflow_sum_diverges_from_net_profit_when_on() -> None:
# COMPOUNDING (b): при ставках > 0 Σ НОМИНАЛЬНОГО потока != today-money сумме.
# Это ОЖИДАЕМО (номинальный DCF vs today-money БДР — разные метрики), а не баг.
today_money_sum = (
1_000_000_000.0
- 400_000_000.0
- 10_000_000.0
- 30_000_000.0
- 6_000_000.0
- 13_000_000.0
- 70_000_000.0
- 100_000_000.0
- 50_000_000.0
- 40_000_000.0
)
infl = _build_inflation(smr_inflation_annual=0.08, sale_inflation_annual=0.06)
cf = infl.cashflow # type: ignore[attr-defined]
# Номинальная сумма ОТЛИЧАЕТСЯ от today-money (инвариант намеренно не держится on).
assert abs(sum(cf) - today_money_sum) > 1.0
def test_npv_lower_when_cost_inflation_applied_leverage_on_cost() -> None:
# COMPOUNDING (c): ИЗОЛИРОВАННЫЙ эффект СМР-инфляции — leverage на затраты.
# СМР-инфляция эскалирует ОТТОКИ (затраты), не трогая притоки → NPV строго НИЖЕ
# нулевого сценария. Это однозначное направление «leverage on cost»: затраты
# дорожают, выручка нет → ценность падает. Проверяем НАПРАВЛЕНИЕ, не магич. число.
#
# ПРИМЕЧАНИЕ (почему изолируем СМР, а не сравниваем smr8/sale6 vs zero): на этом
# фикстуре выручка (1.0B) много больше затрат, поэтому sale-инфляция 6% на крупном
# back-loaded притоке ПЕРЕВЕШИВАЕТ cost-инфляцию 8% — комбинированный NPV выходит ВЫШЕ
# нулевого. Это корректно (направление комбинации зависит от отношения выручка/затраты);
# «leverage on cost» — про ИЗОЛИРОВАННЫЙ cost-канал, который монотонно режет NPV.
monthly_rate = (1.0 + financial._DISCOUNT_RATE_ANNUAL) ** (1.0 / 12.0) - 1.0
zero = _build_inflation(smr_inflation_annual=0.0, sale_inflation_annual=0.0)
cost_only = _build_inflation(smr_inflation_annual=0.08, sale_inflation_annual=0.0)
npv_zero = _npv(monthly_rate, zero.cashflow) # type: ignore[attr-defined]
npv_cost = _npv(monthly_rate, cost_only.cashflow) # type: ignore[attr-defined]
assert npv_cost < npv_zero
# Зеркало: ИЗОЛИРОВАННАЯ sale-инфляция эскалирует притоки → NPV ВЫШЕ нулевого.
sale_only = _build_inflation(smr_inflation_annual=0.0, sale_inflation_annual=0.06)
npv_sale = _npv(monthly_rate, sale_only.cashflow) # type: ignore[attr-defined]
assert npv_sale > npv_zero
def test_npv_monotonic_decreasing_in_smr_inflation() -> None:
# MONOTONICITY: выше СМР-инфляция → ниже (или равный) NPV, всё прочее равно
# (sale_inflation фиксирована 0). Проверяем по сетке ставок.
monthly_rate = (1.0 + financial._DISCOUNT_RATE_ANNUAL) ** (1.0 / 12.0) - 1.0
rates = [0.0, 0.02, 0.05, 0.08, 0.12]
npvs = [
_npv(monthly_rate, _build_inflation(smr_inflation_annual=r).cashflow) # type: ignore[attr-defined]
for r in rates
]
for prev, nxt in pairwise(npvs):
assert nxt <= prev # NPV не растёт при росте СМР-инфляции
def test_land_not_escalated_by_smr_inflation() -> None:
# ЗЕМЛЯ НЕ эскалируется: лумп в мес 0. Меняем ТОЛЬКО землю при той же СМР-инфляции —
# cashflow[0] двигается ровно на дельту земли (без множителя инфляции).
a = _build_inflation(smr_inflation_annual=0.08, land=100_000_000.0)
b = _build_inflation(smr_inflation_annual=0.08, land=200_000_000.0)
cf_a = a.cashflow # type: ignore[attr-defined]
cf_b = b.cashflow # type: ignore[attr-defined]
# Месяц 0 — только земля (ПИР тоже в окне [0..6), но он одинаков в обоих вызовах).
# Разница cashflow[0] == разница земли × 1.0 (фактор t=0 == 1.0, земля не эскалирует).
assert cf_a[0] - cf_b[0] == pytest.approx(200_000_000.0 - 100_000_000.0, rel=1e-9)