feat(financial): DCF-график продаж по реальному темпу поглощения (rank 1, #1881) #1890

Merged
bot-backend merged 1 commit from feat/dcf-velocity-schedule into main 2026-06-23 22:08:09 +00:00
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") _fin_cad_value = egrn_block.get("cadastral_value_rub")
if _fin_cad_value is None and parcel_meta is not None: if _fin_cad_value is None and parcel_meta is not None:
_fin_cad_value = parcel_meta.cad_cost _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( financial_estimate = synthesize_parcel_financial(
area_m2=_fin_area_m2, area_m2=_fin_area_m2,
nspd_zoning=nspd_dump_data["nspd_zoning"], 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"], district_price_median=district_price_block["district_price_per_m2_median"],
cadastral_value_rub=_fin_cad_value, cadastral_value_rub=_fin_cad_value,
gate_verdict=gate_verdict, gate_verdict=gate_verdict,
velocity_sqm_per_month=_velocity_absorption,
) )
except Exception as e: except Exception as e:
logger.warning("financial_estimate bridge failed for %s: %s", cad_num, e) logger.warning("financial_estimate bridge failed for %s: %s", cad_num, e)

View file

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

View file

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

View file

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

View file

@ -95,6 +95,14 @@ class VelocityResult:
proxy_used: bool = False proxy_used: bool = False
# proxy_sqm_per_deal — допущение «м² на сделку» для fallback (45.0); None на objective/none. # proxy_sqm_per_deal — допущение «м² на сделку» для fallback (45.0); None на objective/none.
proxy_sqm_per_deal: float | None = 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]: def as_dict(self) -> dict[str, Any]:
return { return {
@ -112,6 +120,11 @@ class VelocityResult:
"objective_coverage_pct": self.objective_coverage_pct, "objective_coverage_pct": self.objective_coverage_pct,
"proxy_used": self.proxy_used, "proxy_used": self.proxy_used,
"proxy_sqm_per_deal": self.proxy_sqm_per_deal, "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, objective_coverage_pct=coverage_pct,
proxy_used=False, proxy_used=False,
proxy_sqm_per_deal=None, proxy_sqm_per_deal=None,
project_absorption_sqm_per_month=None, # degenerate path — ill-defined
) )
# ── Step 2b: разбивка по комнатности (room_bucket) ─────────────────────── # ── Step 2b: разбивка по комнатности (room_bucket) ───────────────────────
@ -484,6 +498,7 @@ def compute_velocity(
objective_coverage_pct=coverage_pct, objective_coverage_pct=coverage_pct,
proxy_used=False, proxy_used=False,
proxy_sqm_per_deal=None, proxy_sqm_per_deal=None,
project_absorption_sqm_per_month=None, # degenerate path — ill-defined
) )
# Среднемесячный объём = Σ(объём_i / месяцев_i) по активным конкурентам (#1354). # Среднемесячный объём = Σ(объём_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 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)) 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 ─────────────────────────────────────────────────── # ── Step 4: confidence ───────────────────────────────────────────────────
mapped_conf: Literal["high", "medium", "low"] mapped_conf: Literal["high", "medium", "low"]
if n_comps >= 10 and months_observed >= 5: if n_comps >= 10 and months_observed >= 5:
@ -550,6 +575,7 @@ def compute_velocity(
objective_coverage_pct=coverage_pct, objective_coverage_pct=coverage_pct,
proxy_used=False, proxy_used=False,
proxy_sqm_per_deal=None, 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, objective_coverage_pct=objective_coverage_pct,
proxy_used=True, # объём = deal_count × avg_area_per_deal (фабрикуется) proxy_used=True, # объём = deal_count × avg_area_per_deal (фабрикуется)
proxy_sqm_per_deal=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", "payback_months",
"discount_rate_used", "discount_rate_used",
"schedule_is_default", "schedule_is_default",
"sales_duration_months",
# financing overlay (PR-5) # financing overlay (PR-5)
"financing_enabled", "financing_enabled",
"annual_rate_used", "annual_rate_used",

View file

@ -13,6 +13,8 @@ from __future__ import annotations
from itertools import pairwise from itertools import pairwise
import pytest
from app.schemas.concept import TEAP from app.schemas.concept import TEAP
from app.services.generative import financial from app.services.generative import financial
from app.services.generative.financial import ( 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]: def _rebuild_cashflow(
"""Пересобрать помесячный 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] m = model # type: ignore[assignment]
return _build_monthly_cashflow( return _build_monthly_cashflow(
revenue=m.revenue_rub, # type: ignore[attr-defined] 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] vat=m.vat_rub, # type: ignore[attr-defined]
profit_tax=m.profit_tax_rub, # type: ignore[attr-defined] profit_tax=m.profit_tax_rub, # type: ignore[attr-defined]
construction_duration_months=construction_months, construction_duration_months=construction_months,
) residential_area_sqm=residential_area_sqm,
market_velocity_sqm_per_month=market_velocity_sqm_per_month,
).cashflow
# ── DCF helpers — учебные значения ───────────────────────────────────────────── # ── DCF helpers — учебные значения ─────────────────────────────────────────────
@ -103,7 +118,7 @@ def test_invariant_cashflow_sum_equals_net_profit_profitable() -> None:
model = financial.compute_financial( model = financial.compute_financial(
teap=t, housing_class="comfort", land_cost_rub=200_000_000.0, development_type="mid_rise" 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-округление). # Σ недисконтированного потока == чистая прибыль каскада (±float-округление).
assert abs(sum(cf) - model.net_profit_rub) < 1.0 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" teap=t, housing_class="business", land_cost_rub=None, development_type="high_rise"
) )
assert model.net_profit_rub < 0 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 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( model = financial.compute_financial(
teap=t, housing_class="comfort", land_cost_rub=80_000_000.0, development_type=dev_type 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 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 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 для всех типов 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. # Ровно одна смена знака → бисекция брекетит → настоящий IRR, не proxy.
assert _sign_changes(cf) == 1, (dev_type, _sign_changes(cf)) assert _sign_changes(cf) == 1, (dev_type, _sign_changes(cf))
assert model.irr_is_proxy is False, dev_type 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_proxy is False
assert high.irr is not None assert high.irr is not None
# Поток high_rise: ровно одна смена знака (нет патологического хвоста затрат). # Поток 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 assert _sign_changes(cf) == 1
@ -335,7 +350,7 @@ def test_financing_overlay_does_not_touch_unlevered_invariant() -> None:
model = financial.compute_financial( model = financial.compute_financial(
teap=t, housing_class="comfort", land_cost_rub=200_000_000.0, development_type="mid_rise" 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 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.financing_is_simplified is True
assert model.annual_rate_used == financial._CREDIT_RATE_ANNUAL assert model.annual_rate_used == financial._CREDIT_RATE_ANNUAL
assert model.peak_debt_rub > 0.0 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["objective_coverage_pct"] == pytest.approx(66.7)
assert d["proxy_used"] is False assert d["proxy_used"] is False
assert d["proxy_sqm_per_deal"] is None 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(): 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) 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(): def test_sample_competitors_include_by_room_bucket():
"""sample_competitors каждого элемента содержит by_room_bucket.""" """sample_competitors каждого элемента содержит by_room_bucket."""
comp_rows = [_comp_row(1), _comp_row(2)] comp_rows = [_comp_row(1), _comp_row(2)]

View file

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

View file

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

View file

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