Carve a documented office share out of GFA before residential (fixes prior overstatement where all non-parking GFA = residential). total_floor_area unchanged → СМР already covers office GFA (no double-count): office is pure additive revenue at +15% premium. Offices нежилое → VAT-able: vat base = parking_value_added + office_value_added (consistent with #1898). Σ invariant holds. Additive schema (office_area_sqm, revenue_office_rub) → api-types regen. Refs #1881
837 lines
45 KiB
Python
837 lines
45 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
|
||
|
||
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 = (2000−100) × 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| ≈ (1−ltc) × Σ оттоков 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-доля оттоков = (1−ltc) от каждого разрыва → Σ масштабируется линейно.
|
||
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
|