gendesign/backend/tests/services/generative/test_financial_dcf.py
Light1YT b6b9aa9929
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Successful in 1m10s
CI / openapi-codegen-check (pull_request) Successful in 2m3s
CI / backend-tests (pull_request) Successful in 10m10s
feat(financial): DCF-график продаж по реальному темпу поглощения (rank 1)
Финмодель брала фиксированное окно продаж 30 мес независимо от рынка
(schedule_is_default всегда true). Теперь окно считается из ЛОКАЛЬНОГО темпа
поглощения, который уже вычисляется в том же /analyze, но финмодель его
игнорировала.

Корректность абсорбции (ключевое): velocity.monthly_velocity_sqm — это СУММА
поглощения ВСЕГО конкурентного набора в радиусе (м²/мес), НЕ темп одного
проекта. Поэтому per-project absorption = monthly_velocity / max(n_with_sales,1)
(темп одного типичного локального продавца) — иначе модель считала бы, что новый
проект забирает весь рыночный темп (дико оптимистично). Поле
project_absorption_sqm_per_month добавлено в VelocityResult (objective-путь);
rosreestr-fallback и вырожденные пути → None (поквартальный count без
по-проектной декомпозиции не может задавать график).

financial.py: окно = clamp(ceil(residential/velocity), MIN=6, MAX=120) при
конечной velocity>0; иначе дефолт 30. Эскроу-инвариант сохранён:
sales_end=max(sales_start+base, constr_end). Инвариант Σ cashflow == net_profit
держится (перенос выручки во времени не меняет сумму). schedule_is_default
флипается в false когда график рыночный; новое поле sales_duration_months
(реализованное окно) для UI/PDF.

Wiring: parcels.py → synthesize_parcel_financial(velocity_sqm_per_month) →
compute_financial(market_velocity_sqm_per_month). Generative §1c путь пока
передаёт None (out of scope, follow-up).

Тесты: +13 (None→дефолт+инвариант; рыночная velocity; клампы MIN/MAX; эскроу;
non-finite→fallback; rosreestr→None; инвариант по размерам окна; регресс PR-3 —
ровно одна смена знака на коротком окне). Полный бэкенд: 3414 passed, 0 failed.
ruff+mypy(strict financial.py) чисто. api-types перегенерены.

Code-review: 2× approve, 0 majors (adversarial correctness-lens подтвердил
семантику абсорбции, инвариант, не-proxy IRR, клампы, rosreestr-None).

Refs #1881

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 02:56:48 +05:00

536 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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_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