"""PR-3 tests — monthly DCF: NPV / IRR / payback helpers + the invariant. The DCF is hand-rolled deterministic math (no numpy-financial). These tests pin: * ``_npv`` / ``_irr_monthly`` / ``_payback_months`` against known textbook values; * the **invariant** Σ undiscounted monthly cashflow ≈ static ``net_profit`` — the DCF can never silently diverge from the static cascade; * real IRR vs. ROI-proxy fallback (sign-change present vs. degenerate flow); * NPV/IRR consistency (NPV>0 ⟺ IRR>discount rate). """ from __future__ import annotations from itertools import pairwise from app.schemas.concept import TEAP from app.services.generative import financial from app.services.generative.financial import ( _build_monthly_cashflow, _irr_monthly, _npv, _payback_months, ) def _teap(residential: float, gfa: float, parking: int = 10) -> TEAP: return TEAP( built_area_sqm=100.0, total_floor_area_sqm=gfa, residential_area_sqm=residential, apartments_count=10, density=1.0, parking_spaces=parking, ) def _rebuild_cashflow(model: object, *, construction_months: int) -> list[float]: """Пересобрать помесячный cashflow из полей модели — для инвариант-теста.""" m = model # type: ignore[assignment] return _build_monthly_cashflow( revenue=m.revenue_rub, # type: ignore[attr-defined] construction=m.construction_rub, # type: ignore[attr-defined] pir=m.pir_rub, # type: ignore[attr-defined] networks=m.networks_rub, # type: ignore[attr-defined] dev_services=m.developer_services_rub, # type: ignore[attr-defined] contingency=m.contingency_rub, # type: ignore[attr-defined] marketing=m.marketing_rub, # type: ignore[attr-defined] land=m.land_rub, # type: ignore[attr-defined] vat=m.vat_rub, # type: ignore[attr-defined] profit_tax=m.profit_tax_rub, # type: ignore[attr-defined] construction_duration_months=construction_months, ) # ── DCF helpers — учебные значения ───────────────────────────────────────────── def test_npv_textbook_zero_at_known_rate() -> None: # cf=[-1000,0,0,1331], monthly IRR ровно 10% → NPV@0.10 == 0. cf = [-1000.0, 0.0, 0.0, 1331.0] assert abs(_npv(0.10, cf)) < 1e-6 # NPV при ставке 0 = простая сумма потока. assert _npv(0.0, cf) == 331.0 def test_irr_monthly_textbook_ten_percent() -> None: cf = [-1000.0, 0.0, 0.0, 1331.0] r = _irr_monthly(cf) assert r is not None assert abs(r - 0.10) < 1e-5 def test_irr_monthly_none_when_no_sign_change() -> None: # Всегда отток → нет смены знака NPV → None (caller делает proxy fallback). assert _irr_monthly([-100.0, -100.0, -100.0]) is None # Всегда приток → тоже None. assert _irr_monthly([100.0, 100.0, 100.0]) is None def test_payback_months_linear_interpolation() -> None: # cf=[-1000,0,0,1331]: накопит. -1000,-1000,-1000,+331 → перелом в мес 3, # дробь = 1000/1331 от месяца → 2 + 0.751 ≈ 2.75. pbp = _payback_months([-1000.0, 0.0, 0.0, 1331.0]) assert pbp is not None assert abs(pbp - (2.0 + 1000.0 / 1331.0)) < 1e-6 def test_payback_months_none_when_never_recovers() -> None: # Накопленный итог не достигает 0 → None. assert _payback_months([-1000.0, 100.0, 100.0, 100.0]) is None def test_payback_months_zero_when_immediately_positive() -> None: assert _payback_months([0.0, 50.0, 50.0]) == 0.0 # ── ИНВАРИАНТ (ключевой): Σ undiscounted cashflow ≈ net_profit ────────────────── def test_invariant_cashflow_sum_equals_net_profit_profitable() -> None: t = _teap(residential=10_000.0, gfa=11_000.0, parking=50) model = financial.compute_financial( teap=t, housing_class="comfort", land_cost_rub=200_000_000.0, development_type="mid_rise" ) cf = _rebuild_cashflow(model, construction_months=36) # Σ недисконтированного потока == чистая прибыль каскада (±float-округление). assert abs(sum(cf) - model.net_profit_rub) < 1.0 def test_invariant_cashflow_sum_equals_net_profit_loss() -> None: # Убыточный сценарий: инвариант тоже держится (net < 0, налоги = 0). t = _teap(residential=1.0, gfa=10_000.0, parking=0) model = financial.compute_financial( teap=t, housing_class="business", land_cost_rub=None, development_type="high_rise" ) assert model.net_profit_rub < 0 cf = _rebuild_cashflow(model, construction_months=48) assert abs(sum(cf) - model.net_profit_rub) < 1.0 def test_invariant_holds_for_all_development_types() -> None: t = _teap(residential=8_000.0, gfa=9_000.0, parking=40) months = {"spot": 24, "mid_rise": 36, "high_rise": 48} for dev_type, dur in months.items(): model = financial.compute_financial( teap=t, housing_class="comfort", land_cost_rub=80_000_000.0, development_type=dev_type ) cf = _rebuild_cashflow(model, construction_months=dur) assert abs(sum(cf) - model.net_profit_rub) < 1.0, dev_type # ── Настоящий IRR vs proxy fallback ───────────────────────────────────────────── def test_real_irr_on_normal_developer_flow() -> None: # Нормальный поток: затраты рано, выручка поздно → смена знака есть → IRR настоящий. t = _teap(residential=11_000.0, gfa=12_000.0, parking=80) model = financial.compute_financial( teap=t, housing_class="business", land_cost_rub=100_000_000.0, development_type="spot" ) assert model.irr_is_proxy is False assert model.net_profit_rub > 0 # Годовой IRR настоящий (а не зажатый proxy). assert model.irr > 0.0 def test_proxy_fallback_on_always_loss_flow() -> None: # Всегда-убыток (выручка ниже затрат, нет месяца с притоком сверх оттока в начале): # NPV без смены знака → IRR=None → proxy fallback, флаг proxy. t = _teap(residential=1.0, gfa=20_000.0, parking=0) model = financial.compute_financial( teap=t, housing_class="business", land_cost_rub=500_000_000.0, development_type="mid_rise" ) assert model.gross_margin_rub < 0 assert model.irr_is_proxy is True assert -1.0 <= model.irr <= 1.0 # ── NPV/IRR консистентность ───────────────────────────────────────────────────── def test_npv_positive_when_irr_above_discount_rate() -> None: t = _teap(residential=11_000.0, gfa=12_000.0, parking=80) model = financial.compute_financial( teap=t, housing_class="business", land_cost_rub=100_000_000.0, development_type="spot" ) assert model.irr_is_proxy is False # Консистентность DCF: NPV>0 ⟺ IRR>ставка дисконтирования. assert (model.npv_rub > 0.0) == (model.irr > model.discount_rate_used) # ── Новые DCF-поля присутствуют и осмысленны ──────────────────────────────────── def test_dcf_fields_present_and_typed() -> None: t = _teap(residential=5_000.0, gfa=6_000.0, parking=20) model = financial.compute_financial( teap=t, housing_class="comfort", land_cost_rub=50_000_000.0, development_type="mid_rise" ) assert isinstance(model.npv_rub, float) assert model.discount_rate_used == 0.15 assert model.schedule_is_default is True assert model.payback_months is None or isinstance(model.payback_months, float) def _sign_changes(cf: list[float], *, tol: float = 1e-6) -> int: """Число смен знака в потоке (нули пропускаем). Нормальный девелоперский поток [--- +++] имеет ровно 1 смену; патологический [--- +++ ---] имеет 2 → IRR-бисекция не брекетит.""" signs = [1 if c > tol else -1 if c < -tol else 0 for c in cf] nonzero = [s for s in signs if s != 0] return sum(1 for a, b in pairwise(nonzero) if a != b) def test_development_type_changes_construction_window_and_dcf() -> None: # Та же экономика, разный development_type → разная длительность СМР → разные NPV/IRR. # Статический net_profit идентичен (development_type на каскад не влияет), но DCF # расходится из-за разной фазировки cashflow. t = _teap(residential=10_000.0, gfa=11_000.0, parking=60) spot = financial.compute_financial( teap=t, housing_class="comfort", land_cost_rub=80_000_000.0, development_type="spot" ) high = financial.compute_financial( teap=t, housing_class="comfort", land_cost_rub=80_000_000.0, development_type="high_rise" ) assert spot.net_profit_rub == high.net_profit_rub assert spot.npv_rub != high.npv_rub assert spot.irr != high.irr def test_sales_never_end_before_construction_for_all_dev_types() -> None: # ИНВАРИАНТ ГРАФИКА (регресс-тест бага): продажи не заканчиваются раньше завершения # стройки → нет хвоста затрат после окончания продаж → ровно ОДНА смена знака в # потоке (нормальный [--- +++]), а не патологические две ([--- +++ ---]). Двойная # смена знака ломала IRR-бисекцию и роняла profitable-проект в proxy с инверсией NPV. # Для high_rise (СМР=48 → constr_end=54, sales_start=30) старое окно [30..60) уже # покрывало ввод; критичный тип — где sales_end < constr_end в старой логике. t = _teap(residential=10_000.0, gfa=11_000.0, parking=60) months = {"spot": 24, "mid_rise": 36, "high_rise": 48} for dev_type, dur in months.items(): model = financial.compute_financial( teap=t, housing_class="comfort", land_cost_rub=80_000_000.0, development_type=dev_type ) assert model.net_profit_rub > 0, dev_type # сценарий profitable для всех типов cf = _rebuild_cashflow(model, construction_months=dur) # Ровно одна смена знака → бисекция брекетит → настоящий IRR, не proxy. assert _sign_changes(cf) == 1, (dev_type, _sign_changes(cf)) assert model.irr_is_proxy is False, dev_type def test_profitable_high_rise_gets_real_irr_not_proxy() -> None: # РЕГРЕСС именно на баг: profitable high_rise (СМР=48) раньше распродавался на мес 42 # за 12 мес ДО ввода (54) → хвост затрат после продаж → двойная смена знака → # IRR-бисекция не брекетит → молчаливый proxy + инверсия направления NPV. Привязка # sales_end >= construction_end это устраняет: настоящий IRR. t = _teap(residential=11_000.0, gfa=12_000.0, parking=80) high = financial.compute_financial( teap=t, housing_class="business", land_cost_rub=100_000_000.0, development_type="high_rise" ) assert high.net_profit_rub > 0 assert high.irr_is_proxy is False assert high.irr is not None # Поток high_rise: ровно одна смена знака (нет патологического хвоста затрат). cf = _rebuild_cashflow(high, construction_months=48) assert _sign_changes(cf) == 1 def test_unknown_development_type_defaults_to_mid_rise() -> None: t = _teap(residential=10_000.0, gfa=11_000.0, parking=60) explicit_mid = financial.compute_financial( teap=t, housing_class="comfort", land_cost_rub=80_000_000.0, development_type="mid_rise" ) none_type = financial.compute_financial( teap=t, housing_class="comfort", land_cost_rub=80_000_000.0, development_type=None ) assert none_type.npv_rub == explicit_mid.npv_rub assert none_type.irr == explicit_mid.irr