feat(financial): DCF-график продаж по реальному темпу поглощения (rank 1)
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

Финмодель брала фиксированное окно продаж 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>
This commit is contained in:
Light1YT 2026-06-24 02:56:48 +05:00
parent cb7df4657a
commit b6b9aa9929
11 changed files with 409 additions and 21 deletions

View file

@ -2967,6 +2967,14 @@ def analyze_parcel(
_fin_cad_value = egrn_block.get("cadastral_value_rub")
if _fin_cad_value is None and parcel_meta is not None:
_fin_cad_value = parcel_meta.cad_cost
# RANK 1: окно продаж DCF драйвится реальной рыночной абсорбцией района —
# темпом выбытия ОДНОГО типового конкурента (project_absorption_sqm_per_month),
# НЕ суммарной скоростью района. velocity_data посчитан выше (≈line 2858), до
# этого моста — порядок не меняем. None (нет velocity / rosreestr-fallback) →
# дефолт-30-мес график (schedule_is_default).
_velocity_absorption = (
velocity_data["project_absorption_sqm_per_month"] if velocity_data else None
)
financial_estimate = synthesize_parcel_financial(
area_m2=_fin_area_m2,
nspd_zoning=nspd_dump_data["nspd_zoning"],
@ -2974,6 +2982,7 @@ def analyze_parcel(
district_price_median=district_price_block["district_price_per_m2_median"],
cadastral_value_rub=_fin_cad_value,
gate_verdict=gate_verdict,
velocity_sqm_per_month=_velocity_absorption,
)
except Exception as e:
logger.warning("financial_estimate bridge failed for %s: %s", cad_num, e)

View file

@ -88,8 +88,15 @@ class FinancialModel(BaseModel):
# Годовая ставка дисконтирования, фактически применённая в NPV (норматив).
discount_rate_used: float = 0.0
# График фаз/продаж — типовые нормативные ДОПУЩЕНИЯ (не график конкретного
# проекта). True → UI/PDF показывают caveat «график — типовое допущение».
# проекта). True → UI/PDF показывают caveat «график — типовое допущение». RANK 1:
# False, когда окно продаж построено по реальной рыночной скорости абсорбции
# района (а не дефолт-30-мес).
schedule_is_default: bool = True
# RANK 1: реализованное окно продаж (sales_end sales_start), мес — для UI/PDF.
# Драйвится рыночной абсорбцией района, когда ``schedule_is_default is False``;
# иначе дефолт-норматив (с эскроу-привязкой к завершению стройки). ``None`` —
# финмодель не строилась/окно неизвестно.
sales_duration_months: float | None = None
# ── Financing overlay (PR-5, эпик #1881) ───────────────────────────────────
# OVERLAY поверх unlevered cashflow: кредит покрывает кассовый разрыв, проценты

View file

@ -124,6 +124,8 @@ lookup and passes the result in). Детерминированно, без LLM /
from __future__ import annotations
import logging
import math
from dataclasses import dataclass
from typing import Literal
from app.schemas.concept import TEAP, FinancialModel
@ -205,8 +207,16 @@ _CONSTRUCTION_DURATION_MONTHS: dict[str, int] = {
_DEFAULT_CONSTRUCTION_MONTHS: int = _CONSTRUCTION_DURATION_MONTHS["mid_rise"]
# Старт продаж — через N мес после начала СМР (нормативно — после котлована/этажей).
_SALES_START_MONTH: int = 6
# Длительность распродажи, мес (нормативный темп выбытия квартир).
# Длительность распродажи, мес (нормативный темп выбытия квартир). Используется,
# когда рыночная скорость абсорбции не передана (market_velocity_sqm_per_month=None).
_SALES_DURATION_MONTHS: int = 30
# RANK 1: границы окна продаж, когда оно ДРАЙВИТСЯ рыночной скоростью абсорбции
# (residential_area_sqm / market_velocity_sqm_per_month). Клампим вычисленную
# длительность в [_SALES_DURATION_MIN, _SALES_DURATION_MAX], чтобы аномально
# горячий/холодный радиус не давал нереалистичных 1-мес распродаж или 20-летних
# хвостов. Эскроу-привязка (sales_end >= constr_end) применяется ПОВЕРХ клампа.
_SALES_DURATION_MIN: int = 6
_SALES_DURATION_MAX: int = 120
# Первый взнос, доля (Excel: 80% при контракте, остаток при вводе/рассрочка). В MVP
# DCF НЕ применяется — вся выручка признаётся в месяц продажи (см. docstring модуля).
# Сохранён константой для будущей рассрочки (PR-4).
@ -347,6 +357,16 @@ def _spread_linear(total: float, start: int, duration: int, horizon: int) -> lis
return out
@dataclass(frozen=True)
class _CashflowResult:
"""Результат раскладки cashflow: сам поток + реализованное окно продаж и флаг,
был ли график построен по рыночной скорости абсорбции (RANK 1)."""
cashflow: list[float]
sales_duration_months_realized: int # фактическое окно sales_end sales_start
schedule_is_market: bool # True → окно драйвилось market_velocity, не дефолт-30
def _build_monthly_cashflow(
*,
revenue: float,
@ -360,15 +380,27 @@ def _build_monthly_cashflow(
vat: float,
profit_tax: float,
construction_duration_months: int,
) -> list[float]:
"""Разложить статический каскад в помесячный чистый cashflow по дефолт-графику.
residential_area_sqm: float,
market_velocity_sqm_per_month: float | None = None,
) -> _CashflowResult:
"""Разложить статический каскад в помесячный чистый cashflow по графику фаз.
Чистая функция. График типовые нормативы (см. docstring модуля): земля (мес 0),
ПИР линейно, СМР+сети+услуги+резерв линейно по окну строительства, продажи линейно
по окну распродажи; маркетинг прорейтится на график продаж; НДС+налог на прибыль
прорейтятся на признание выручки. Выручка полностью в месяц продажи (упрощение).
ИНВАРИАНТ: ``sum(результат) == revenue all_costs vat profit_tax == net_profit``
RANK 1 окно продаж по рыночной абсорбции: если передан конечный
``market_velocity_sqm_per_month > 0`` (темп выбытия ОДНОГО типового конкурента
района) и ``residential_area_sqm > 0``, базовое окно =
``ceil(residential_area_sqm / market_velocity_sqm_per_month)``, клампится в
``[_SALES_DURATION_MIN, _SALES_DURATION_MAX]`` (``schedule_is_market=True``).
Иначе дефолт ``_SALES_DURATION_MONTHS`` (``schedule_is_market=False``). ПОВЕРХ
базового окна та же эскроу-привязка ``sales_end >= constr_end`` (РФ ДДУ
продажи заканчиваются у/после ввода). Перенос выручки во времени НЕ меняет сумму
потока, поэтому инвариант ниже держится при любом окне.
ИНВАРИАНТ: ``sum(cashflow) == revenue all_costs vat profit_tax == net_profit``
(с точностью до float-округления). Гарантирует, что DCF не разошёлся со статикой.
"""
pir_end = _PIR_DURATION_MONTHS
@ -376,12 +408,27 @@ def _build_monthly_cashflow(
constr_end = pir_end + construction_duration_months
# Продажи стартуют через _SALES_START_MONTH после начала СМР (нормативно, эскроу).
sales_start = constr_start + _SALES_START_MONTH
# Окончание продаж: минимум _SALES_DURATION_MONTHS, но НЕ раньше завершения стройки
# RANK 1: базовое окно продаж — по рыночной абсорбции, если она передана и валидна.
if (
market_velocity_sqm_per_month is not None
and math.isfinite(market_velocity_sqm_per_month)
and market_velocity_sqm_per_month > 0.0
and residential_area_sqm > 0.0
):
raw = math.ceil(residential_area_sqm / market_velocity_sqm_per_month)
base_duration = max(_SALES_DURATION_MIN, min(_SALES_DURATION_MAX, raw))
schedule_is_market = True
else:
base_duration = _SALES_DURATION_MONTHS # 30 — дефолт-норматив
schedule_is_market = False
# Окончание продаж: базовое окно, но НЕ раньше завершения стройки
# (РФ ДДУ/эскроу — продажи идут ВО ВРЕМЯ стройки и заканчиваются у/после ввода).
# Без этого high_rise (СМР до 54) распродавал бы всё на мес 42 — за 12 мес ДО ввода
# → хвост затрат ПОСЛЕ окончания продаж → двойная смена знака → IRR-бисекция не
# брекетит profitable-проект и молча падает в proxy. Привязка к constr_end это чинит.
sales_end = max(sales_start + _SALES_DURATION_MONTHS, constr_end)
sales_end = max(sales_start + base_duration, constr_end)
sales_duration = sales_end - sales_start
# Горизонт = максимум всех фаз (+1 — месяц как индекс, длина = последний+1).
horizon = max(constr_end, sales_end, 1)
@ -414,7 +461,11 @@ def _build_monthly_cashflow(
- prorate[m]
for m in range(horizon)
]
return cashflow
return _CashflowResult(
cashflow=cashflow,
sales_duration_months_realized=sales_duration,
schedule_is_market=schedule_is_market,
)
def compute_financial(
@ -425,6 +476,7 @@ def compute_financial(
market_price_per_sqm: float | None = None,
price_source: str = "class_norm",
development_type: str | None = None,
market_velocity_sqm_per_month: float | None = None,
) -> FinancialModel:
"""Свести ТЭП + класс + стоимость земли в полный :class:`FinancialModel` с DCF.
@ -450,6 +502,13 @@ def compute_financial(
development_type: "spot"/"mid_rise"/"high_rise" задаёт длительность СМР в
DCF-графике (``_CONSTRUCTION_DURATION_MONTHS``). ``None``/неизвестный
дефолт mid_rise (36 мес). График типовое допущение (schedule_is_default).
market_velocity_sqm_per_month: RANK 1 темп абсорбции ОДНОГО типового
конкурента района (м²/мес), из velocity.project_absorption_sqm_per_month.
Драйвит окно продаж в DCF: ``ceil(residential_area_sqm / velocity)``,
клампится в [_SALES_DURATION_MIN, _SALES_DURATION_MAX], затем эскроу-привязка
``sales_end >= constr_end``. ``None``/<=0/non-finite дефолт-30-мес окно и
``schedule_is_default=True``. Это НЕ суммарная скорость района (новый проект
один продавец из многих), иначе срок занижается и NPV/IRR раздуваются.
"""
# Калибруем ТОЛЬКО цену продажи жилья. Если рыночной цены нет — честный fallback
# на норматив класса, и source форсим в "class_norm" (не выдаём норму за рынок).
@ -497,7 +556,7 @@ def compute_financial(
construction_months = _CONSTRUCTION_DURATION_MONTHS.get(
development_type or "", _DEFAULT_CONSTRUCTION_MONTHS
)
cashflow = _build_monthly_cashflow(
cf_result = _build_monthly_cashflow(
revenue=revenue,
construction=construction,
pir=pir,
@ -509,7 +568,10 @@ def compute_financial(
vat=vat,
profit_tax=profit_tax,
construction_duration_months=construction_months,
residential_area_sqm=teap.residential_area_sqm,
market_velocity_sqm_per_month=market_velocity_sqm_per_month,
)
cashflow = cf_result.cashflow
# Годовая ставка → месячная: (1+r_год)**(1/12) 1.
monthly_rate = (1.0 + _DISCOUNT_RATE_ANNUAL) ** (1.0 / 12.0) - 1.0
npv = _npv(monthly_rate, cashflow)
@ -563,7 +625,11 @@ def compute_financial(
npv_rub=round(npv, 2),
payback_months=(round(payback, 1) if payback is not None else None),
discount_rate_used=_DISCOUNT_RATE_ANNUAL,
schedule_is_default=True,
# RANK 1: schedule_is_default — honest-флаг. True, когда окно продаж —
# дефолт-норматив 30 мес; False, когда оно построено по рыночной абсорбции.
schedule_is_default=(not cf_result.schedule_is_market),
# Реализованное окно продаж (sales_end sales_start), мес — для UI/PDF.
sales_duration_months=float(cf_result.sales_duration_months_realized),
# financing overlay (PR-5)
financing_enabled=True,
annual_rate_used=_CREDIT_RATE_ANNUAL,

View file

@ -166,6 +166,7 @@ def synthesize_parcel_financial(
district_price_median: float | None,
cadastral_value_rub: float | None,
gate_verdict: dict[str, Any] | None,
velocity_sqm_per_month: float | None = None,
) -> dict[str, Any] | None:
"""Мост buildability участка → ОРИЕНТИРОВОЧНАЯ финмодель (DCF) или ``None``.
@ -179,6 +180,10 @@ def synthesize_parcel_financial(
district_price_median: медиана цены по району, руб/кв.м (fallback к market).
cadastral_value_rub: кадастровая стоимость участка land_cost (НЕ рыночная).
gate_verdict: can-build-MKD вердикт гейт на расчёт.
velocity_sqm_per_month: RANK 1 темп абсорбции ОДНОГО типового конкурента
района (``velocity.project_absorption_sqm_per_month``), м²/мес. Прокидывается
в :func:`compute_financial` как ``market_velocity_sqm_per_month`` драйвит
окно продаж DCF. ``None`` дефолт-30-мес график (``schedule_is_default``).
Returns:
``dict`` = ``FinancialModel.model_dump()`` + bridge-метаданные
@ -237,11 +242,13 @@ def synthesize_parcel_financial(
market_price_per_sqm=price,
price_source=price_source,
development_type=development_type,
# RANK 1: окно продаж по рыночной абсорбции района (один типовой конкурент).
market_velocity_sqm_per_month=velocity_sqm_per_month,
)
logger.info(
"parcel financial bridge: area=%.0f far=%s floors=%s class=%s dev=%s "
"gfa=%.0f resid=%.0f apts=%d npv=%.0f irr=%.3f%s",
"gfa=%.0f resid=%.0f apts=%d npv=%.0f irr=%.3f%s sales_dur=%sm sched=%s",
area_m2,
max_far,
max_floors,
@ -253,6 +260,8 @@ def synthesize_parcel_financial(
fin.npv_rub,
fin.irr,
" (proxy)" if fin.irr_is_proxy else "",
fin.sales_duration_months,
"default" if fin.schedule_is_default else "market",
)
return {

View file

@ -95,6 +95,14 @@ class VelocityResult:
proxy_used: bool = False
# proxy_sqm_per_deal — допущение «м² на сделку» для fallback (45.0); None на objective/none.
proxy_sqm_per_deal: float | None = None
# RANK 1 (DCF velocity schedule): абсорбция ОДНОГО типового конкурента района,
# м²/мес = monthly_velocity_sqm / max(n_with_sales, 1). Это НЕ суммарная скорость
# района (monthly_velocity_sqm), а темп выбытия одного участника рынка — им и
# драйвится окно продаж нового проекта в финмодели (он один продавец из многих).
# None на rosreestr_fallback (квартальный count без per-project декомпозиции —
# per-project absorption там ill-defined и НЕ должен драйвить график) и на
# degenerate/zero путях.
project_absorption_sqm_per_month: float | None = None
def as_dict(self) -> dict[str, Any]:
return {
@ -112,6 +120,11 @@ class VelocityResult:
"objective_coverage_pct": self.objective_coverage_pct,
"proxy_used": self.proxy_used,
"proxy_sqm_per_deal": self.proxy_sqm_per_deal,
"project_absorption_sqm_per_month": (
round(self.project_absorption_sqm_per_month, 1)
if self.project_absorption_sqm_per_month is not None
else None
),
}
@ -345,6 +358,7 @@ def compute_velocity(
objective_coverage_pct=coverage_pct,
proxy_used=False,
proxy_sqm_per_deal=None,
project_absorption_sqm_per_month=None, # degenerate path — ill-defined
)
# ── Step 2b: разбивка по комнатности (room_bucket) ───────────────────────
@ -484,6 +498,7 @@ def compute_velocity(
objective_coverage_pct=coverage_pct,
proxy_used=False,
proxy_sqm_per_deal=None,
project_absorption_sqm_per_month=None, # degenerate path — ill-defined
)
# Среднемесячный объём = Σ(объём_i / месяцев_i) по активным конкурентам (#1354).
@ -506,6 +521,16 @@ def compute_velocity(
denominator = n_with_sales * ekb_median * 2.0 if n_with_sales > 0 else ekb_median * 2.0
velocity_score = min(1.0, max(0.0, monthly_velocity / denominator))
# RANK 1: абсорбция ОДНОГО типового конкурента = суммарная скорость района /
# число продающих ЖК. Драйвит окно продаж нового проекта в DCF (он один из
# многих продавцов, а НЕ поглощает весь рынок). Только на objective-пути, где
# есть per-project декомпозиция; иначе None.
project_absorption: float | None = (
monthly_velocity / max(n_with_sales, 1)
if n_with_sales >= 1 and monthly_velocity > 0.0
else None
)
# ── Step 4: confidence ───────────────────────────────────────────────────
mapped_conf: Literal["high", "medium", "low"]
if n_comps >= 10 and months_observed >= 5:
@ -550,6 +575,7 @@ def compute_velocity(
objective_coverage_pct=coverage_pct,
proxy_used=False,
proxy_sqm_per_deal=None,
project_absorption_sqm_per_month=project_absorption,
)
@ -650,6 +676,9 @@ def _compute_rosreestr_fallback(
objective_coverage_pct=objective_coverage_pct,
proxy_used=True, # объём = deal_count × avg_area_per_deal (фабрикуется)
proxy_sqm_per_deal=avg_area_per_deal,
# RANK 1: квартальный total deal-count БЕЗ per-project декомпозиции —
# per-project absorption ill-defined → None, НЕ драйвит DCF-график.
project_absorption_sqm_per_month=None,
)

View file

@ -167,6 +167,7 @@ def test_concepts_response_matches_contract_keys() -> None:
"payback_months",
"discount_rate_used",
"schedule_is_default",
"sales_duration_months",
# financing overlay (PR-5)
"financing_enabled",
"annual_rate_used",

View file

@ -13,6 +13,8 @@ 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 (
@ -35,8 +37,19 @@ def _teap(residential: float, gfa: float, parking: int = 10) -> TEAP:
)
def _rebuild_cashflow(model: object, *, construction_months: int) -> list[float]:
"""Пересобрать помесячный cashflow из полей модели — для инвариант-теста."""
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]
@ -50,7 +63,9 @@ def _rebuild_cashflow(model: object, *, construction_months: int) -> list[float]
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 — учебные значения ─────────────────────────────────────────────
@ -103,7 +118,7 @@ def test_invariant_cashflow_sum_equals_net_profit_profitable() -> None:
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)
cf = _rebuild_cashflow(model, construction_months=36, residential_area_sqm=10_000.0)
# Σ недисконтированного потока == чистая прибыль каскада (±float-округление).
assert abs(sum(cf) - model.net_profit_rub) < 1.0
@ -115,7 +130,7 @@ def test_invariant_cashflow_sum_equals_net_profit_loss() -> None:
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)
cf = _rebuild_cashflow(model, construction_months=48, residential_area_sqm=1.0)
assert abs(sum(cf) - model.net_profit_rub) < 1.0
@ -126,7 +141,7 @@ def test_invariant_holds_for_all_development_types() -> None:
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)
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
@ -223,7 +238,7 @@ def test_sales_never_end_before_construction_for_all_dev_types() -> None:
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)
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
@ -242,7 +257,7 @@ def test_profitable_high_rise_gets_real_irr_not_proxy() -> None:
assert high.irr_is_proxy is False
assert high.irr is not None
# Поток high_rise: ровно одна смена знака (нет патологического хвоста затрат).
cf = _rebuild_cashflow(high, construction_months=48)
cf = _rebuild_cashflow(high, construction_months=48, residential_area_sqm=11_000.0)
assert _sign_changes(cf) == 1
@ -335,7 +350,7 @@ def test_financing_overlay_does_not_touch_unlevered_invariant() -> None:
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)
cf = _rebuild_cashflow(model, construction_months=36, residential_area_sqm=10_000.0)
assert abs(sum(cf) - model.net_profit_rub) < 1.0
@ -349,3 +364,173 @@ def test_financing_enabled_on_realistic_parcel() -> None:
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

View file

@ -265,6 +265,9 @@ def test_as_dict_structure():
assert d["objective_coverage_pct"] == pytest.approx(66.7)
assert d["proxy_used"] is False
assert d["proxy_sqm_per_deal"] is None
# RANK 1: project_absorption_sqm_per_month присутствует (None по умолчанию).
assert "project_absorption_sqm_per_month" in d
assert d["project_absorption_sqm_per_month"] is None
def test_sample_competitors_top5():
@ -435,6 +438,78 @@ def test_rosreestr_fallback_marks_proxy_disclosure():
assert d["objective_coverage_pct"] == pytest.approx(25.0)
def test_objective_path_sets_project_absorption_per_competitor():
"""RANK 1: objective-путь выставляет project_absorption = monthly_velocity /
n_with_sales (темп ОДНОГО типового конкурента), а НЕ суммарную скорость района."""
n = 6
comp_rows = [_comp_row(i) for i in range(1, n + 1)]
# Каждый ЖК: 4500 м² за 6 мес = 750 м²/мес. 6 ЖК → суммарно 4500 м²/мес района.
sales_rows = [_sales_row(i, total_sqm=4500.0, months=6) for i in range(1, n + 1)]
db = _make_db(comp_rows=comp_rows, sales_rows=sales_rows)
with patch(
"app.services.site_finder.velocity._get_ekb_median",
return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH,
):
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
assert result is not None
# monthly_velocity_sqm = 6 × 750 = 4500 (суммарно по району).
assert result.monthly_velocity_sqm == pytest.approx(4500.0)
# project_absorption = 4500 / 6 = 750 (один типовой конкурент) — НЕ 4500.
assert result.project_absorption_sqm_per_month == pytest.approx(750.0)
assert result.as_dict()["project_absorption_sqm_per_month"] == pytest.approx(750.0)
def test_objective_path_project_absorption_none_when_no_sales():
"""RANK 1: если активных продаж нет (monthly_velocity=0) → absorption None."""
comp_rows = [_comp_row(1)]
sales_rows = [_sales_row(1, total_sqm=0.0, months=5)]
db = _make_db(comp_rows=comp_rows, sales_rows=sales_rows)
with patch(
"app.services.site_finder.velocity._get_ekb_median",
return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH,
):
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
assert result is not None
assert result.project_absorption_sqm_per_month is None
assert result.as_dict()["project_absorption_sqm_per_month"] is None
def test_rosreestr_fallback_project_absorption_is_none():
"""RANK 1: rosreestr_fallback — квартальный count БЕЗ per-project декомпозиции →
project_absorption None (per-project absorption там ill-defined, НЕ драйвит DCF)."""
row = MagicMock()
start_d = datetime.date.fromisoformat("2025-01-01")
end_d = datetime.date.fromisoformat("2025-06-01")
row.__getitem__ = lambda self, k: {
"total_deals": 60,
"period_start": start_d,
"period_end": end_d,
}[k]
result_mock = MagicMock()
result_mock.mappings.return_value.first.return_value = row
db = MagicMock()
db.execute.return_value = result_mock
result = _compute_rosreestr_fallback(
db=db,
cad_quarter="66:41:0702048",
months_window=6,
n_comps=8,
ekb_median=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH,
sample_competitors=[],
objective_coverage_pct=25.0,
)
assert result is not None
assert result.velocity_source == "rosreestr_fallback"
assert result.project_absorption_sqm_per_month is None
assert result.as_dict()["project_absorption_sqm_per_month"] is None
def test_sample_competitors_include_by_room_bucket():
"""sample_competitors каждого элемента содержит by_room_bucket."""
comp_rows = [_comp_row(1), _comp_row(2)]

View file

@ -3594,6 +3594,8 @@ export interface components {
* @default true
*/
schedule_is_default: boolean;
/** Sales Duration Months */
sales_duration_months?: number | null;
/**
* Financing Enabled
* @default false

View file

@ -162,7 +162,8 @@
"npv_rub": 3873055.47,
"payback_months": 42.0,
"discount_rate_used": 0.15,
"schedule_is_default": true,
"schedule_is_default": false,
"sales_duration_months": 50.0,
"financing_enabled": true,
"annual_rate_used": 0.15,
"peak_debt_rub": 690453598.74,

View file

@ -518,6 +518,10 @@ export interface ParcelFinancialEstimate {
housing_class_inferred: string;
development_type_inferred: string;
schedule_is_default: boolean;
// RANK 1 — реализованное окно продаж (мес), драйвится рыночной абсорбцией
// района когда schedule_is_default=false; иначе дефолт-норматив. null — окно
// неизвестно / финмодель не строилась.
sales_duration_months: number | null;
teap_synth: {
residential_area_sqm: number;
total_floor_area_sqm: number;