"""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 # ── РАЗДЕЛЬНАЯ ИНФЛЯЦИЯ ИНДЕКСОВ — 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)