diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index f9a1aa02..8e329fe5 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -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) diff --git a/backend/app/schemas/concept.py b/backend/app/schemas/concept.py index 2689fc70..6fc67643 100644 --- a/backend/app/schemas/concept.py +++ b/backend/app/schemas/concept.py @@ -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: кредит покрывает кассовый разрыв, проценты diff --git a/backend/app/services/generative/financial.py b/backend/app/services/generative/financial.py index e8eeddb8..a15fbccc 100644 --- a/backend/app/services/generative/financial.py +++ b/backend/app/services/generative/financial.py @@ -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, diff --git a/backend/app/services/site_finder/parcel_financial.py b/backend/app/services/site_finder/parcel_financial.py index 80e2b773..239036de 100644 --- a/backend/app/services/site_finder/parcel_financial.py +++ b/backend/app/services/site_finder/parcel_financial.py @@ -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 { diff --git a/backend/app/services/site_finder/velocity.py b/backend/app/services/site_finder/velocity.py index 44621e3f..31c1acf1 100644 --- a/backend/app/services/site_finder/velocity.py +++ b/backend/app/services/site_finder/velocity.py @@ -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, ) diff --git a/backend/tests/services/generative/test_api_concepts.py b/backend/tests/services/generative/test_api_concepts.py index 4aebb4bb..dac5aef2 100644 --- a/backend/tests/services/generative/test_api_concepts.py +++ b/backend/tests/services/generative/test_api_concepts.py @@ -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", diff --git a/backend/tests/services/generative/test_financial_dcf.py b/backend/tests/services/generative/test_financial_dcf.py index b1d662bf..dd5a803d 100644 --- a/backend/tests/services/generative/test_financial_dcf.py +++ b/backend/tests/services/generative/test_financial_dcf.py @@ -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 diff --git a/backend/tests/test_velocity.py b/backend/tests/test_velocity.py index c9334771..c93b06c8 100644 --- a/backend/tests/test_velocity.py +++ b/backend/tests/test_velocity.py @@ -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)] diff --git a/frontend/src/lib/api-types.ts b/frontend/src/lib/api-types.ts index 79fa819e..e12faade 100644 --- a/frontend/src/lib/api-types.ts +++ b/frontend/src/lib/api-types.ts @@ -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 diff --git a/frontend/src/lib/mocks/parcel-analyze.json b/frontend/src/lib/mocks/parcel-analyze.json index c2474568..61b6c279 100644 --- a/frontend/src/lib/mocks/parcel-analyze.json +++ b/frontend/src/lib/mocks/parcel-analyze.json @@ -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, diff --git a/frontend/src/types/site-finder.ts b/frontend/src/types/site-finder.ts index 2103fd9c..642a8bac 100644 --- a/frontend/src/types/site-finder.ts +++ b/frontend/src/types/site-finder.ts @@ -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;