gendesign/backend/tests/services/generative/test_teap_financial.py
Light1YT d4068a255b
All checks were successful
CI / frontend-tests (pull_request) Successful in 57s
CI / backend-tests (pull_request) Successful in 9m54s
CI / changes (pull_request) Successful in 6s
CI / openapi-codegen-check (pull_request) Successful in 1m53s
feat(finmodel): monthly DCF — real NPV/IRR/PBP, replace IRR-proxy (epic #1881 PR-3)
Hand-rolled детерминированный DCF (без numpy-financial/новых deps): _npv /
_irr_monthly (bisection с bracket-guard) / _payback_months над помесячным
cashflow, построенным из статического каскада (PR-1) + калиброванной цены
(PR-2) по дефолтным нормативам фаз (ПИР 6мес → СМР по development_type
24/36/48 → распродажа, дисконт 15%).

- Налоги прорейтятся на признание выручки → Σ недисконтированного cashflow ==
  net_profit (ИНВАРИАНТ, тест — гарантия что DCF не разошёлся со статикой).
- IRR настоящий когда есть смена знака; вырожденный поток → fallback на
  roi-proxy (irr_is_proxy=True). Честно помечено.
- График продаж привязан к завершению стройки: sales_end = max(sales_start +
  длительность, construction_end) — продажи не заканчиваются раньше ввода
  (РФ ДДУ/эскроу). Без этого high_rise распродавался за 12 мес ДО ввода →
  двойная смена знака → profitable-проект молча падал в proxy + инверсия NPV.
- FinancialModel +npv_rub/payback_months/discount_rate_used/schedule_is_default
  (additive). development_type проброшен через placement.
- UI/PDF: NPV/IRR/PBP + честный caveat «график — типовое допущение, точность
  зависит от него». api-types регенерён авторитетно.

mypy strict clean (generative.*), +15 тестов (DCF helpers на учебных значениях,
ИНВАРИАНТ, IRR real-vs-proxy обе ветки, PBP, sales≥construction для всех типов,
profitable high_rise → настоящий IRR). 48 passed.

Refs #1881
2026-06-23 21:52:04 +05:00

257 lines
11 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.

"""Stage 1c tests — ТЭП and financial model arithmetic.
Pure-arithmetic unit tests against known footprint geometry: GFA, residential area,
apartment count, FAR, parking, revenue/cost/margin and the IRR-proxy clamp.
"""
from __future__ import annotations
from shapely.geometry import box
from app.schemas.concept import TEAP
from app.services.generative import financial, teap
# Один прямоугольник 20 x 10 = 200 кв.м пятна.
_FOOTPRINT = box(0, 0, 20, 10)
def test_teap_basic_arithmetic() -> None:
result = teap.compute_teap(
footprints=[_FOOTPRINT],
floors=10,
site_area_sqm=1000.0,
housing_class="comfort",
)
assert result.built_area_sqm == 200.0
# GFA = пятно * этажность.
assert result.total_floor_area_sqm == 2000.0
# FAR = GFA / участок.
assert result.density == 2.0
# Жилая = GFA * efficiency(comfort=0.78).
assert result.residential_area_sqm == 1560.0
# Квартир = floor(жилая / avg(comfort=55)).
assert result.apartments_count == int(1560.0 // 55.0)
# Парковка = ceil(квартир * 1.0).
assert result.parking_spaces == result.apartments_count
def test_teap_class_changes_efficiency_and_lot() -> None:
econ = teap.compute_teap(
footprints=[_FOOTPRINT], floors=10, site_area_sqm=1000.0, housing_class="econom"
)
biz = teap.compute_teap(
footprints=[_FOOTPRINT], floors=10, site_area_sqm=1000.0, housing_class="business"
)
# Эконом эффективнее по площади -> больше жилой при той же GFA.
assert econ.residential_area_sqm > biz.residential_area_sqm
# Бизнес — крупнее лот -> меньше квартир.
assert biz.apartments_count < econ.apartments_count
# Бизнес — выше норма парковки на квартиру.
assert biz.parking_spaces / max(1, biz.apartments_count) >= 1.4
def test_teap_zero_site_area_no_division_error() -> None:
result = teap.compute_teap(
footprints=[_FOOTPRINT], floors=5, site_area_sqm=0.0, housing_class="comfort"
)
assert result.density == 0.0
def test_teap_empty_placement_is_zeroed() -> None:
result = teap.compute_teap(
footprints=[], floors=9, site_area_sqm=1000.0, housing_class="comfort"
)
assert result.built_area_sqm == 0.0
assert result.total_floor_area_sqm == 0.0
assert result.apartments_count == 0
assert result.parking_spaces == 0
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 test_financial_revenue_includes_parking() -> None:
t = _teap(residential=1000.0, gfa=1300.0, parking=10)
model = financial.compute_financial(
teap=t, housing_class="comfort", land_cost_rub=50_000_000.0
)
# revenue = жильё (1000 * 145_000) + паркинг (10 * 1_900_000).
assert model.revenue_residential_rub == 1000.0 * 145_000.0
assert model.revenue_parking_rub == 10 * 1_900_000.0
assert model.revenue_rub == model.revenue_residential_rub + model.revenue_parking_rub
def test_financial_cost_cascade_includes_all_lines() -> None:
t = _teap(residential=1000.0, gfa=1300.0, parking=10)
model = financial.compute_financial(
teap=t, housing_class="comfort", land_cost_rub=50_000_000.0
)
# СМР = GFA*СМР + паркинг*себест.
assert model.construction_rub == 1300.0 * 88_000.0 + 10 * 1_800_000.0
# Каждая статья каскада > 0 при ненулевых вводных.
assert model.pir_rub > 0
assert model.networks_rub > 0
assert model.developer_services_rub > 0
assert model.contingency_rub > 0
assert model.marketing_rub > 0
assert model.land_rub == 50_000_000.0
# Итог затрат = сумма всех статей.
expected_cost = (
model.construction_rub
+ model.pir_rub
+ model.networks_rub
+ model.developer_services_rub
+ model.contingency_rub
+ model.marketing_rub
+ model.land_rub
)
assert abs(model.cost_rub - expected_cost) < 1.0
assert model.gross_margin_rub == model.revenue_rub - model.cost_rub
def test_financial_net_profit_below_gross_margin_when_profitable() -> None:
# Прибыльный сценарий: налоги срезают валовую маржу -> net < gross.
t = _teap(residential=10_000.0, gfa=11_000.0, parking=50)
model = financial.compute_financial(teap=t, housing_class="comfort", land_cost_rub=None)
assert model.gross_margin_rub > 0
assert model.vat_rub > 0
assert model.profit_tax_rub > 0
assert model.net_profit_rub < model.profit_before_tax_rub < model.gross_margin_rub
# ROI и маржа% согласованы с чистой прибылью (поля округлены до 4 знаков).
assert model.roi == round(model.net_profit_rub / model.cost_rub, 4)
assert model.margin_pct == round(model.net_profit_rub / model.revenue_rub, 4)
def test_financial_no_profit_tax_on_loss() -> None:
# Убыток: налог на прибыль = 0, НДС = 0; net == pbt == gross_margin.
t = _teap(residential=1.0, gfa=10_000.0, parking=0)
model = financial.compute_financial(teap=t, housing_class="business", land_cost_rub=None)
assert model.gross_margin_rub < 0
assert model.profit_tax_rub == 0.0
assert model.vat_rub == 0.0
assert model.profit_before_tax_rub == model.gross_margin_rub
assert model.net_profit_rub == model.gross_margin_rub
def test_financial_parking_zero_yields_zero_parking_lines() -> None:
t = _teap(residential=1000.0, gfa=1300.0, parking=0)
model = financial.compute_financial(teap=t, housing_class="comfort", land_cost_rub=None)
assert model.revenue_parking_rub == 0.0
# СМР без паркинговой составляющей = ровно GFA*СМР.
assert model.construction_rub == 1300.0 * 88_000.0
def test_financial_land_cost_optional() -> None:
t = _teap(residential=1000.0, gfa=1300.0)
no_land = financial.compute_financial(teap=t, housing_class="comfort", land_cost_rub=None)
with_land = financial.compute_financial(
teap=t, housing_class="comfort", land_cost_rub=10_000_000.0
)
# Земля увеличивает затраты ровно на свою стоимость.
assert with_land.cost_rub - no_land.cost_rub == 10_000_000.0
assert with_land.land_rub == 10_000_000.0
assert no_land.land_rub == 0.0
def test_financial_irr_proxy_clamped_and_flagged() -> None:
# Огромная маржа -> irr-proxy зажат в [-1, 1]; флаг proxy выставлен.
t = _teap(residential=100_000.0, gfa=1.0, parking=0)
model = financial.compute_financial(teap=t, housing_class="business", land_cost_rub=None)
assert -1.0 <= model.irr <= 1.0
assert model.irr_is_proxy is True
def test_financial_zero_cost_no_division_error() -> None:
t = _teap(residential=0.0, gfa=0.0, parking=0)
model = financial.compute_financial(teap=t, housing_class="comfort", land_cost_rub=None)
assert model.cost_rub == 0.0
assert model.revenue_rub == 0.0
assert model.roi == 0.0
assert model.margin_pct == 0.0
assert model.irr == 0.0
def test_financial_backward_compat_fields_present() -> None:
t = _teap(residential=1000.0, gfa=1300.0)
model = financial.compute_financial(teap=t, housing_class="comfort", land_cost_rub=None)
# Старые summary-поля контракта остаются.
assert isinstance(model.revenue_rub, float)
assert isinstance(model.cost_rub, float)
assert isinstance(model.gross_margin_rub, float)
assert isinstance(model.irr, float)
# PR-3 DCF-поля additive — присутствуют с дефолтами.
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)
# ── PR-2: рыночная калибровка цены продажи жилья ───────────────────────────────
def test_financial_no_market_price_uses_class_norm() -> None:
# Без market_price_per_sqm — норматив класса comfort (145_000), флаг не калибровано.
t = _teap(residential=1000.0, gfa=1300.0)
model = financial.compute_financial(teap=t, housing_class="comfort", land_cost_rub=None)
assert model.price_per_sqm_used == 145_000.0
assert model.price_is_calibrated is False
assert model.price_source == "class_norm"
# Выручка жилья считается по нормативу.
assert model.revenue_residential_rub == 1000.0 * 145_000.0
def test_financial_market_price_overrides_class_norm() -> None:
# market_price_per_sqm передан → используется он, флаг калибровано, source проброшен.
t = _teap(residential=1000.0, gfa=1300.0)
model = financial.compute_financial(
teap=t,
housing_class="comfort",
land_cost_rub=None,
market_price_per_sqm=180_000.0,
price_source="objective_district_median",
)
assert model.price_per_sqm_used == 180_000.0
assert model.price_is_calibrated is True
assert model.price_source == "objective_district_median"
# Выручка жилья считается по рыночной цене, НЕ по нормативу класса.
assert model.revenue_residential_rub == 1000.0 * 180_000.0
def test_financial_market_price_does_not_calibrate_parking_or_smr() -> None:
# Калибруем ТОЛЬКО цену жилья: паркинг (1.9М) и себестоимость СМР (88_000) — норма.
t = _teap(residential=1000.0, gfa=1300.0, parking=10)
model = financial.compute_financial(
teap=t,
housing_class="comfort",
land_cost_rub=None,
market_price_per_sqm=200_000.0,
price_source="district_reference",
)
# Паркинг — норматив (10 * 1_900_000), не зависит от рыночной цены жилья.
assert model.revenue_parking_rub == 10 * 1_900_000.0
# СМР — норматив класса comfort (GFA*88_000 + паркинг*1_800_000).
assert model.construction_rub == 1300.0 * 88_000.0 + 10 * 1_800_000.0
def test_financial_none_market_price_forces_class_norm_source() -> None:
# Даже если source передан, при market_price_per_sqm=None он форсится в class_norm —
# нельзя выдавать норматив за рыночные данные.
t = _teap(residential=1000.0, gfa=1300.0)
model = financial.compute_financial(
teap=t,
housing_class="comfort",
land_cost_rub=None,
market_price_per_sqm=None,
price_source="objective_district_median",
)
assert model.price_is_calibrated is False
assert model.price_source == "class_norm"
assert model.price_per_sqm_used == 145_000.0