Финмодель брала ПЛОСКИЕ parking-константы для ВСЕХ классов: price 1.9М/cost 1.8М (Excel «Авангардная 13» premium — подземная, near break-even). Но норма парковки уже class-dependent (econom 0.8/comfort 1.0/business 1.5 м/м на квартиру). Econom (дешёвое жильё) заряжался 1.8М/м/м ПОДЗЕМНОЙ застройки → огромный break-even капитал, раздувающий пиковый долг/проценты. По РФ econom = наземная дешёвая парковка. Фикс: _PARKING_PRICE_PER_SPOT / _PARKING_COST_PER_SPOT → dict[HousingClass]: - econom 800k/350k (наземная), comfort 1.3М/1.0М (частично подземная), business 1.9М/1.8М (подземная, = Excel, unchanged). - маржа/м/м: econom +450k, comfort +300k, business +100k. - НОРМА парковки (_PARKING_PER_APARTMENT) НЕ тронута (возможно ПЗЗ-норматив). - Константы помечены как TUNABLE ASSUMPTIONS (РФ-рынок), правятся в одном месте (паттерн _SALE_PRICE_PER_SQM / _CONSTRUCTION_COST_PER_SQM). ВАЖНО (честно): парковка — ВТОРИЧНЫЙ драйвер. Улучшает econom в основном через снижение peak-debt/процентов, НЕ переворачивает знак. ДОМИНИРУЮЩИЙ рычаг — себестоимость СМР жилья к выручке (econom 72k на GFA vs выручка на sellable = 87%) — отдельное продуктовое решение (не трогаю без согласования). Инвариант Σ cashflow == net_profit сохранён (меняются только величины revenue_parking/construction_parking в том же каскаде). Тесты: +6 (по классам, маржа, инвариант econom+parking) + 3 существующих исправлены (хардкод comfort 1.9/1.8 → 1.3/1.0). tests/services/generative 90 passed. ruff+mypy(strict) чисто. Схема не менялась. Хозяйственное: untrack + gitignore backend/.coverage (артефакт блокировал checkout). Refs #1881 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
548 lines
27 KiB
Python
548 lines
27 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) -> 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
|