"""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) -> TEAP: return TEAP( built_area_sqm=100.0, total_floor_area_sqm=gfa, residential_area_sqm=residential, apartments_count=10, density=1.0, parking_spaces=parking, ) def _rebuild_cashflow( model: object, *, construction_months: int, 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 # ── Настоящий 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 # ── 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