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>
This commit is contained in:
parent
cb7df4657a
commit
b6b9aa9929
11 changed files with 409 additions and 21 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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: кредит покрывает кассовый разрыв, проценты
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue