"""§9.4×§9.5 спрос vs §9.3 предложение — центральный прогнозный движок (ТЗ §9.8). #952 (Site Finder v2 / «GG-форсайт» ТЗ §9.8), EPIC 8 «Центральный прогнозный движок». Это **СБОРОЧНЫЙ слой**: он НЕ пересобирает §9.x-математику, а синтезирует уже-смерженные сервисы в per-(сегмент × горизонт) баланс СПРОС↔ПРЕДЛОЖЕНИЕ: • СПРОС = наблюдаемый темп (§9.2 unit_velocity) × нормализация под режим ставки (§9.4 compute_demand_normalization) × макро-режим (§9.5 compute_macro_coefficient), спроектированный линейно на горизонт. • ПРЕДЛОЖЕНИЕ = открытый сток + фазированный скрытый запас + будущий слой в горизонте − поглощённое спросом (§9.3 compute_future_supply_pressure). • БАЛАНС / индекс дефицита = знаковое насыщающее преобразование отношения спрос/предложение в [−1,+1] (+1 = сильный дефицит «хорошо» / −1 = сильная затоварка «риск»). Это АБСОЛЮТНЫЙ сигнал: −1 = рынок действительно затоварен (ЕКБ-новостройка широко затоварена при текущих ставках, поэтому −1 встречается часто и это ЧЕСТНО). Дефицит-индекс насыщается уже при двукратном перекосе, поэтому слабо ДИСКРИМИНИРУЕТ между сильно-затоваренными сегментами (все липнут к −1). • MONTHS-OF-INVENTORY (MOI) = ДИСКРИМИНИРУЮЩИЙ companion-метрик к дефициту (стандарт рынка недвижимости): сколько МЕСЯЦЕВ нужно, чтобы поглотить конкурирующее предложение при текущем нормализованном темпе спроса (= competing_supply / demand_per_mo). Не насыщается → различает «30 мес» от «100 мес» там, где дефицит-индекс у обоих = −1. • БУДУЩИЕ КОНКУРЕНТЫ (§9.7) — top-N по relevance_weight на горизонт. Всё ДЕТЕРМИНИРОВАННО, БЕЗ LLM (чистая арифметика + reuse §9.x; своего SQL НЕТ). ADVISORY-СТАТУС: движок СОВЕТУЮЩИЙ и НЕ подключён ни к одному production-эндпоинту (как §9.4/§9.5/§9.6, все advisory до бэктеста #951 / валидации #951). `advisory` поле ВСЕГДА True; итоговый confidence ЖЁСТКО ограничен сверху 'medium' (синтез не надёжнее непровалидированных компонентов). Цифры — для explainability/прототипа. ────────────────────────────────────────────────────────────────────────────── ⚠️ ГЛАВНОЕ ПРАВИЛО КОРРЕКТНОСТИ — β УЧТЁН РОВНО ОДИН РАЗ: • §9.4 `compute_demand_normalization` УЖЕ внутри вызывает §9.6 `compute_rate_sensitivity` и применяет β как exp(β·Δrate). Поэтому СПРОС проходит через `compute_demand_normalization` ТОЛЬКО — мы НИГДЕ не домножаем `rate_sensitivity.beta`/`x_pct` в число спроса (это было бы двойным учётом β). • `compute_rate_sensitivity` здесь вызывается ИСКЛЮЧИТЕЛЬНО ради explainability — чтобы вынести `.phrase` (и связанные `x_pct`/`y_lag_months`) в вывод. НИКАКОЙ арифметики над её β/x_pct в расчёте спроса. • §9.5 `macro_coefficient` ОРТОГОНАЛЕН β: он про каналы issuance/overdue/ mortgage-rate (макро-режим), а НЕ про эластичность к Δ ключевой ставки. Поэтому `§9.4 × §9.5` — это два РАЗНЫХ множителя, НЕ двойной учёт. ГОРИЗОНТ-ПРОЕКЦИЯ ЛИНЕЙНА (документируем): projected_demand = demand_per_mo × h, БЕЗ компаундинга. Это сознательно: помесячный темп уже нормализован под будущий режим (§9.4) и макро (§9.5); накручивать сверху сложный процент роста на непровалидированном движке = ложная точность. Линейная проекция интерпретируема («столько ед. поглотит рынок за h мес при текущем нормализованном темпе») и зеркалит дисциплину линейного clamp future_supply._saturating_index. Graceful-on-thin-data (дух future_supply / market_metrics): любой тонкий вход → соответствующее поле None (НИКОГДА 0-как-заглушка), НИКОГДА не crash, НИКОГДА деления на ноль. supply ≤ 0 / None → balance_ratio=None (∞-кратность не выдумываем); deficit_index при этом = +1.0 ТОЛЬКО если спрос > 0 (предложение исчерпано под спрос = ПИК дефицита по семантике [−1,+1], #980), иначе None (нет сигнала: и спрос ≤0). confidence занижается выше по стеку. Детерминированно. """ from __future__ import annotations import logging import math from collections.abc import Sequence from dataclasses import dataclass from typing import Any, Literal from sqlalchemy.orm import Session from app.schemas.parcel import CompetitorsRequest from app.services.forecasting.demand_normalization import compute_demand_normalization from app.services.forecasting.macro_coefficient import compute_macro_coefficient from app.services.forecasting.macro_series import MonthlyMacro, get_monthly_macro from app.services.forecasting.regression import compute_rate_regime_sensitivity from app.services.forecasting.sales_series import ( ROOM_AREA_BUCKET_1K, ROOM_AREA_BUCKET_2K, ROOM_AREA_BUCKET_3K, ROOM_AREA_BUCKET_LARGE, ROOM_AREA_BUCKET_STUDIO, SegmentSpec, ) from app.services.site_finder.competitors import get_competitors from app.services.site_finder.future_supply import compute_future_supply_pressure from app.services.site_finder.market_metrics import compute_market_metrics logger = logging.getLogger(__name__) Confidence = Literal["high", "medium", "low"] # ── Named-константы ─────────────────────────────────────────────────────────── # Горизонты прогноза по умолчанию (мес). 6/12/18/24 — зеркало §9.x горизонт-сетки # (competitors.horizon_months / future_supply.horizon_months): полгода…2 года — # окно, в котором перспективный объект реально выходит на рынок и конкурирует. _DEFAULT_HORIZONS: tuple[int, ...] = (6, 12, 18, 24) # Фазированный выход скрытого запаса (Layer2 ПД) — за сколько месяцев он полностью # «созревает» в конкурирующее предложение. 18 мес ≈ типовой цикл от запаса ПД до # вывода корпуса на продажу в ЕКБ. Доля выхода = clamp(h / _HIDDEN_RELEASE_MONTHS, # 0, 1): на горизонте 6 мес вышла ~⅓ скрытого, 12 мес ~⅔, ≥18 мес — весь. Так # скрытый слой давит ПОСТЕПЕННО (не весь сразу на ближнем горизонте и не игнор — # в духе future_supply._horizon_weight, но непрерывно по доле, а не по дате). _HIDDEN_RELEASE_MONTHS: float = 18.0 # Насыщение индекса дефицита: при balance_ratio = _DEFICIT_RATIO_SATURATION (спрос # вдвое выше предложения) индекс достигает +1 (сильный дефицит); при обратном # (предложение вдвое выше спроса, ratio = 1/2) → −1 (сильная затоварка). Знаковое # насыщающее преобразование log-отношения (симметрично вокруг ratio=1 → 0): берём # log(ratio)/log(saturation), clamp в [−1,+1]. 2.0 = «двукратный перекос — уже # экстремум» (зеркало духа future_supply._PRESSURE_SATURATION линейного clamp, но # здесь лог-шкала, т.к. отношение мультипликативно: ×2 и ÷2 симметричны). Tunable. _DEFICIT_RATIO_SATURATION: float = 2.0 # Сколько будущих конкурентов (§9.7) выносим в вывод (top-N по relevance_weight). _TOP_COMPETITORS: int = 5 # Радиус поиска конкурентов (км) для §9.7 — дефолт CompetitorsRequest (1 км = # «прямые соседи по локации»). Держим явной константой для детерминизма вызова. _COMPETITORS_RADIUS_KM: float = 1.0 # Жёсткий потолок итогового confidence: движок advisory до бэктеста #951, поэтому # даже при всех 'high'-компонентах синтез не объявляем надёжнее 'medium'. _CONFIDENCE_CAP: Confidence = "medium" # Порядок уверенности для MIN-агрегации (хуже = ниже). Зеркало future_supply. _CONFIDENCE_RANK: dict[Confidence, int] = {"low": 0, "medium": 1, "high": 2} _RANK_TO_CONFIDENCE: dict[int, Confidence] = {0: "low", 1: "medium", 2: "high"} # #1959 VOCAB-мост (load-bearing): SegmentSpec.room_bucket приходит из what_to_build # в Source-B room_area-вокабуляре ("Студии 15-30"/"1-к 30-45"/"2-к 45-60"/ # "3-к 60-80"/"80+ м²"). compute_market_metrics (после #1959) принимает ИМЕННО этот # вокабуляр напрямую (его room CASE = зеркало sales_series.room_area_bucket_of). Эта # white-list страхует от чужих/неизвестных меток: распознанные пробрасываем как есть, # неизвестные (или None) → None = «без room-фильтра» (district-wide темп, но НЕ тихий # 0-rows из-за опечатки в вокабуляре). Меняешь сетку room_buckets — обнови white-list. _FORECAST_ROOM_BUCKETS: frozenset[str] = frozenset( { ROOM_AREA_BUCKET_STUDIO, ROOM_AREA_BUCKET_1K, ROOM_AREA_BUCKET_2K, ROOM_AREA_BUCKET_3K, ROOM_AREA_BUCKET_LARGE, } ) def _market_room_bucket(room_bucket: str | None) -> str | None: """Перевести SegmentSpec.room_bucket в room-фильтр compute_market_metrics. PURE. Вокабуляр уже выровнен (#1959): market_metrics принимает Source-B room_area-метки напрямую. Эта функция — VALIDATING pass-through: распознанная Source-B метка возвращается КАК ЕСТЬ; None / неизвестная метка → None (= «без room-фильтра», district-wide темп). Без этой проверки опечатка/чужой вокабуляр дали бы тихий 0-rows фильтр → метрики None → ячейка молча выпадала бы из ранкинга. PURE. Args: room_bucket: room_bucket сегмента (ожидается Source-B вокабуляр) или None. Returns: Та же Source-B метка (если в белом списке) либо None. """ if room_bucket is None: return None if room_bucket in _FORECAST_ROOM_BUCKETS: return room_bucket logger.warning( "demand_supply_forecast: unrecognized room_bucket %r — dropping room filter " "(district-wide pace). Expected Source-B vocab %s.", room_bucket, sorted(_FORECAST_ROOM_BUCKETS), ) return None @dataclass(frozen=True) class DemandSupplyForecast: """Per-(сегмент × горизонт) баланс СПРОС↔ПРЕДЛОЖЕНИЕ (ТЗ §9.8, считается на лету). Все величины детерминированы. Любое числовое поле = None при недостатке данных (НИКОГДА 0-как-заглушка). `advisory` ВСЕГДА True (движок не для production-решений до бэктеста #951). `deficit_index` ∈ [−1,+1] когда задан: +1 = сильный дефицит (мало предложения под спрос — «хорошо» для девелопера), −1 = сильная затоварка (предложения больше спроса — «риск»). Предложение исчерпано под спрос (supply≤0, demand>0) → +1.0 (пик дефицита, #980, хотя balance_ratio тогда None); None только при отсутствии сигнала (supply≤0 И demand≤0/None). `deficit_index` — АБСОЛЮТНЫЙ сигнал (−1 = реально затоварено; ЕКБ-новостройка широко затоварена при текущих ставках → −1 встречается часто и это честно). Он насыщается уже при двукратном перекосе, поэтому слабо ДИСКРИМИНИРУЕТ среди сильно-затоваренных сегментов. `months_of_inventory` (MOI) — ДИСКРИМИНИРУЮЩИЙ companion: сколько месяцев нужно поглотить ВАЛОВОЕ конкурирующее предложение при текущем нормализованном темпе спроса (= gross_supply / demand_per_mo). Не насыщается → различает «30 мес» от «100 мес» там, где дефицит у обоих = −1. `rate_sensitivity_phrase` — ТОЛЬКО explainability (β НЕ участвует в арифметике спроса дважды — он уже учтён внутри demand_norm_coefficient §9.4). """ # ── Контекст ────────────────────────────────────────────────────────────── segment: dict[str, str | None] horizon_months: int # ── Спрос (§9.2 темп × §9.4 норм × §9.5 макро, линейно на горизонт) ──────── base_pace_units_per_mo: float | None # §9.2 observed unit_velocity (ед./мес) demand_norm_coefficient: float | None # §9.4 множитель (β внутри — учтён 1 раз) macro_coefficient: float | None # §9.5 множитель (ортогонален β) projected_demand_units: float | None # base_pace × §9.4 × §9.5 × h (линейно) # ── Предложение (§9.3 слои, фазированный hidden, за вычетом поглощённого) ─── open_units: int # Σ Layer1 (в продаже) — контекст hidden_release_units: float # Σ Layer2 × фаза выхода на горизонте future_online_units: float # Σ Layer3, взвешенный по попаданию в горизонт projected_supply_units: float # open + hidden_release + future − absorbed, ≥0 # ── Баланс / индекс дефицита / months-of-inventory ───────────────────────── balance_units: float | None # demand − supply (>0 дефицит / <0 затоварка) balance_ratio: float | None # demand / supply (None если supply ≤ 0) deficit_index: float | None # знаковое насыщение balance_ratio → [−1,+1] (АБСОЛЮТ.) months_of_inventory: float | None # ВАЛОВОЕ предложение / demand_per_mo (ДИСКРИМИН.) # ── Explainability ───────────────────────────────────────────────────────── rate_future: float | None # ставка сценария на горизонте (вход §9.4) rate_sensitivity_phrase: str | None # §9.6 фраза (НЕ арифметика — explain-only) future_competitors: list[dict[str, Any]] # §9.7 top-N по relevance_weight advisory: bool # ВСЕГДА True (движок не для production-решений) confidence: Confidence # MIN(компоненты), жёстко ≤ _CONFIDENCE_CAP # Шок-окно (PR2): True, если §9.5 macro_coefficient ИЛИ §9.6 rate_sensitivity # сообщили `confounded` (ряд пересекает структурный разрыв 2024-07-01). Прокидываем # в `as_dict()` → `report_assembler._confounded` → `compute_report_confidence` (#990) # confounded factor: НИКОГДА не позволяет §15 объявить 'high'. Дефолт False (чистое # окно). Без этого поля §15-фактор шок-окна (#1222) перманентно мёртв. confounded: bool = False def as_dict(self) -> dict[str, Any]: return { "segment": dict(self.segment), "horizon_months": self.horizon_months, "base_pace_units_per_mo": _round_or_none(self.base_pace_units_per_mo, 2), "demand_norm_coefficient": _round_or_none(self.demand_norm_coefficient, 4), "macro_coefficient": _round_or_none(self.macro_coefficient, 4), "projected_demand_units": _round_or_none(self.projected_demand_units, 1), "open_units": self.open_units, "hidden_release_units": _round_or_none(self.hidden_release_units, 1), "future_online_units": _round_or_none(self.future_online_units, 1), "projected_supply_units": _round_or_none(self.projected_supply_units, 1), "balance_units": _round_or_none(self.balance_units, 1), "balance_ratio": _round_or_none(self.balance_ratio, 3), "deficit_index": _round_or_none(self.deficit_index, 3), "months_of_inventory": _round_or_none(self.months_of_inventory, 1), "rate_future": _round_or_none(self.rate_future, 2), "rate_sensitivity_phrase": self.rate_sensitivity_phrase, "future_competitors": list(self.future_competitors), "advisory": self.advisory, "confidence": self.confidence, "confounded": self.confounded, } def _round_or_none(value: float | None, digits: int) -> float | None: return round(value, digits) if value is not None else None # ────────────────────────────────────────────────────────────────────────────── # Pure-арифметика — без БД, полностью юнит-тестируемо. # Каждая функция graceful: тонкий/нулевой вход → None/нейтраль (не crash, не /0). # ────────────────────────────────────────────────────────────────────────────── def _hidden_release_fraction( horizon_months: int, *, release_months: float = _HIDDEN_RELEASE_MONTHS ) -> float: """Доля скрытого запаса (Layer2), вышедшая в предложение к горизонту ∈ [0,1]. clamp(h / release_months, 0, 1): фазированный выход скрытого ПД в конкурирующее предложение. На горизонте 6 мес из 18-месячного цикла вышла треть, 12 → две трети, ≥18 → весь. Так скрытый слой давит ПОСТЕПЕННО (не весь сразу). PURE. Args: horizon_months: горизонт прогноза (мес); ≤0 → 0.0 (ничего не вышло). release_months: полный цикл созревания скрытого запаса (по умолчанию _HIDDEN_RELEASE_MONTHS); ≤0 → деградация: любой h>0 → 1.0 (весь сразу). Returns: Доля в [0,1]. """ if horizon_months <= 0: return 0.0 if release_months <= 0: return 1.0 return max(0.0, min(1.0, horizon_months / release_months)) def _project_demand( base_pace: float | None, norm_coefficient: float | None, macro_coefficient: float | None, horizon_months: int, ) -> float | None: """Спроектированный спрос = base_pace × §9.4 × §9.5 × h (ЛИНЕЙНО, без компаунда). base_pace None → None (НЕ 0: «нет данных о рынке» ≠ «продажи 0»). Коэффициенты None трактуем как 1.0 (нейтраль — компонент не скорректировал темп, но темп известен). β учтён РОВНО ОДИН РАЗ внутри norm_coefficient (§9.4) — здесь его больше НЕ домножаем. Линейность сознательна (см. module docstring). PURE. Args: base_pace: §9.2 наблюдаемый темп (ед./мес); None = нет выборки. norm_coefficient: §9.4 множитель нормализации (β уже внутри); None → 1.0. macro_coefficient: §9.5 макро-множитель (ортогонален β); None → 1.0. horizon_months: горизонт проекции (мес); ≤0 → 0.0 спроса. Returns: Спроектированные ед. спроса или None (нет base_pace). """ if base_pace is None: return None if horizon_months <= 0: return 0.0 norm = norm_coefficient if norm_coefficient is not None else 1.0 macro = macro_coefficient if macro_coefficient is not None else 1.0 demand_per_mo = base_pace * norm * macro return demand_per_mo * float(horizon_months) def _gross_supply( open_units: float, hidden_release_units: float, future_online_units: float ) -> float: """Валовое конкурирующее предложение = open + hidden_release + future_online. PURE. Всё, что выйдет/доступно на горизонте, ДО вычета поглощённого спросом. Это «total inventory» для учебной MOI (total-inventory / sales-rate) — берём ИМЕННО валовое (а не net после absorption), т.к. MOI отвечает «сколько месяцев рынок переваривает ВЕСЬ доступный сток при текущем темпе», что и есть стандарт. """ return open_units + hidden_release_units + future_online_units def _project_supply( open_units: float, hidden_release_units: float, future_online_units: float, projected_demand_units: float | None, ) -> float: """Чистое конкурирующее предложение = валовое − поглощённое спросом, clamp ≥0. Валовое (см. `_gross_supply`) = open + hidden_release + future_online (всё, что выйдет/доступно на горизонте). Поглощённое (absorbed) = min(валовое, projected_demand) — рынок «съест» столько, сколько есть спроса, но не больше доступного объёма. Чистое = валовое − absorbed (остаток, который РЕАЛЬНО конкурирует с нашим объектом). Спрос None трактуем как 0 поглощения (нет данных о спросе → ничего не вычитаем → консервативно показываем всё валовое как конкурирующее). clamp ≥0. PURE. Args: open_units: Σ Layer1 (в продаже). hidden_release_units: Σ Layer2 × фаза выхода на горизонте. future_online_units: Σ Layer3, взвешенный по горизонту. projected_demand_units: спроектированный спрос (None → 0 поглощения). Returns: Чистое конкурирующее предложение (≥0). """ gross = _gross_supply(open_units, hidden_release_units, future_online_units) demand = projected_demand_units if projected_demand_units is not None else 0.0 absorbed = min(gross, max(0.0, demand)) return max(0.0, gross - absorbed) def _balance( projected_demand_units: float | None, projected_supply_units: float | None ) -> float | None: """balance_units = спрос − предложение (>0 дефицит / <0 затоварка). PURE. Любой вход None → None (нечего сравнивать). >0 = спроса больше, чем конкурирующего предложения (дефицит — «хорошо» для девелопера); <0 = затоварка. """ if projected_demand_units is None or projected_supply_units is None: return None return projected_demand_units - projected_supply_units def _balance_ratio( projected_demand_units: float | None, projected_supply_units: float | None ) -> float | None: """balance_ratio = спрос / предложение. PURE. supply ≤ 0 / None ИЛИ demand None → None (НЕ ∞: «предложение исчерпано» не отличить от «нет данных», поэтому честно None, а не выдуманный бесконечный дефицит — индекс тогда тоже None, confidence занижается выше по стеку). """ if projected_demand_units is None or projected_supply_units is None: return None if projected_supply_units <= 0: return None return projected_demand_units / projected_supply_units def _deficit_index( balance_ratio: float | None, *, saturation: float = _DEFICIT_RATIO_SATURATION ) -> float | None: """Знаковое насыщение balance_ratio → индекс дефицита ∈ [−1,+1]. PURE. АБСОЛЮТНЫЙ сигнал, НЕ относительный рейтинг: −1 = рынок реально затоварен (ЕКБ-новостройка широко затоварена при текущих ставках → −1 встречается часто и это честно). Насыщается уже при ДВУКРАТНОМ перекосе, поэтому слабо ДИСКРИМИНИРУЕТ среди сильно-затоваренных сегментов (все липнут к −1) — для различения пользуйтесь companion-метрикой months_of_inventory (gross-предложение / помесячный темп спроса), которая не насыщается. Намеренно НЕ перекалибруем шкалу под ЕКБ: индекс честен как абсолют. Лог-шкала (отношение мультипликативно — ×2 и ÷2 симметричны вокруг 1.0): index = clamp(log(ratio) / log(saturation), −1, +1). • ratio = 1.0 (спрос = предложение) → log(1)=0 → index 0 (баланс). • ratio = saturation (спрос вдвое > предложения) → +1 (сильный дефицит). • ratio = 1/saturation (предложение вдвое > спроса) → −1 (сильная затоварка). None / непозитивный ratio → None (неизмеримо). saturation ≤1 → деградация: знак ratio−1 (без насыщающей шкалы). Монотонно неубывающее по ratio. PURE. Args: balance_ratio: спрос/предложение (None → None). saturation: ratio, при котором индекс достигает ±1 (по умолч. _DEFICIT_RATIO_SATURATION). Returns: Индекс в [−1,+1] или None. """ if balance_ratio is None or balance_ratio <= 0: return None if saturation <= 1.0: # Деградация без падения: нет осмысленной шкалы → только знак перекоса. if balance_ratio > 1.0: return 1.0 if balance_ratio < 1.0: return -1.0 return 0.0 raw = math.log(balance_ratio) / math.log(saturation) return max(-1.0, min(1.0, raw)) def _deficit_index_from_balance( projected_demand_units: float | None, projected_supply_units: float | None, balance_ratio: float | None, *, saturation: float = _DEFICIT_RATIO_SATURATION, ) -> float | None: """deficit_index с явной обработкой исчерпанного предложения (#980). PURE. Нормальный путь: supply > 0 → `_deficit_index(balance_ratio)` (лог-шкала). Граничный случай СИЛЬНЕЙШЕГО дефицита (#980): спрос поглотил ВСЁ предложение (effective `projected_supply_units` ≤ 0) ПРИ положительном спросе. balance_ratio тогда честно None (спрос/0 неотличим от «нет данных» — оставляем None как есть), НО индекс по документированной семантике (∈ [−1,+1], +1 = сильный дефицит) обязан быть +1.0, а НЕ None: исчерпанное под спрос предложение — это пик шкалы дефицита, а не «нет сигнала». Раньше тут возвращался None — самый сильный дефицит читался downstream как тонкие данные (market_fit падал к 0.5, ячейка выпадала из what_to_build). Кап = +1.0 (саму ∞-кратность не выдумываем — клампим к пределу шкалы, зеркало `_deficit_index` clamp-а). «Нет сигнала вообще» (supply ≤ 0 И спрос ≤ 0 / None) → None: ни дефицита, ни затоварки измерить нельзя (нечему конкурировать и нечему поглощать). Args: projected_demand_units: спроектированный спрос (None / ≤0 → нет дефицит-сигнала). projected_supply_units: чистое конкурирующее предложение (≤0 → исчерпано). balance_ratio: спрос/предложение (вход нормального пути; None при supply ≤0). saturation: ratio, при котором индекс достигает ±1 (proxy в _deficit_index). Returns: Индекс в [−1,+1] или None (нет измеримого сигнала). """ if projected_supply_units is not None and projected_supply_units <= 0: # Предложение исчерпано: дефицит = +1 ТОЛЬКО при положительном спросе, # иначе сигнала нет (None) — не путаем «пик дефицита» с «пустым рынком». if projected_demand_units is not None and projected_demand_units > 0: return 1.0 return None return _deficit_index(balance_ratio, saturation=saturation) def _months_of_inventory( competing_supply: float | None, demand_per_mo: float | None ) -> float | None: """Months-of-inventory = валовое конкурирующее предложение / темп спроса (мес). PURE. Стандартная метрика рынка недвижимости (total-inventory / monthly-sales-rate): сколько МЕСЯЦЕВ нужно, чтобы поглотить competing_supply при текущем нормализованном помесячном темпе спроса. ДИСКРИМИНИРУЕТ там, где deficit_index насыщается (см. module docstring): два затоваренных сегмента с deficit −1 у обоих различаются по MOI (30 мес vs 100 мес). ВАЖНО — берём ВАЛОВОЕ (gross) предложение, НЕ net-после-absorption: MOI отвечает «сколько рынок переваривает ВЕСЬ доступный сток», поэтому net тут занизил бы. Gross-MOI читается чуть выше net-based прикидок — это ожидаемо и корректно. Graceful (НИКОГДА не crash, НИКОГДА /0): • demand_per_mo None ИЛИ ≤0 → None («нет данных о спросе» ≠ «∞ запас»; бесконечную кратность не выдумываем — зеркало _balance_ratio). • competing_supply None → None (нет данных о предложении). • competing_supply 0 (и темп>0) → 0.0 (пусто = поглощается мгновенно — это ОПРЕДЕЛЁННЫЙ сигнал «нет конкурирующего стока», не «нет данных»). Args: competing_supply: валовое конкурирующее предложение (ед.; None → None). demand_per_mo: нормализованный помесячный темп спроса (ед./мес; None/≤0 → None). Returns: Месяцы инвентаря (≥0) или None. """ if competing_supply is None: return None if demand_per_mo is None or demand_per_mo <= 0: return None if competing_supply <= 0: return 0.0 return competing_supply / demand_per_mo def _demand_per_mo(projected_demand_units: float | None, horizon_months: int) -> float | None: """Восстановить помесячный темп спроса из спроектированного на горизонт. PURE. projected_demand = demand_per_mo × h (линейно, см. _project_demand), поэтому demand_per_mo = projected_demand / h. Это вход MOI (НЕ ×h). None / непозитивный горизонт → None (нет осмысленного темпа). PURE. Args: projected_demand_units: спрос на горизонт (None → None). horizon_months: горизонт проекции (мес; ≤0 → None). Returns: Помесячный темп спроса (ед./мес) или None. """ if projected_demand_units is None or horizon_months <= 0: return None return projected_demand_units / float(horizon_months) def _min_confidence(values: Sequence[Confidence | None]) -> Confidence: """Итоговая уверенность = MIN компонентных (худшая тянет вниз). Зеркало vocab. Тонкий любой компонент (market_metrics / §9.4 / §9.5 / future_supply) честно роняет общий confidence. None в списке игнорируем (компонент без сигнала). Пустой/весь-None вход → 'low'. Только whitelisted 'high|medium|low'. PURE. """ ranks = [_CONFIDENCE_RANK[v] for v in values if v is not None] if not ranks: return "low" return _RANK_TO_CONFIDENCE[min(ranks)] def _cap_confidence(confidence: Confidence, *, cap: Confidence = _CONFIDENCE_CAP) -> Confidence: """Ограничить confidence сверху (advisory-движок не надёжнее cap). PURE. Берём ранг-минимум(confidence, cap): high+cap-medium → medium; low → low. """ capped_rank = min(_CONFIDENCE_RANK[confidence], _CONFIDENCE_RANK[cap]) return _RANK_TO_CONFIDENCE[capped_rank] def hold_last_rate(macro: list[MonthlyMacro], horizons: Sequence[int]) -> dict[int, float | None]: """Дефолтный rate-path: последняя известная key_rate, удержанная плоско. PURE. Сценарий «ставка не меняется»: берём самую свежую НЕпустую key_rate из макро- ряда и присваиваем её КАЖДОМУ горизонту. Нет ни одной точки key_rate → None на всех горизонтах (graceful: §9.4 тогда деградирует к нейтрали внутри себя). Args: macro: monthly макро-ряд (§9.5/§9.6 PR2 get_monthly_macro). horizons: горизонты (мес), под которые строим path. Returns: {horizon: rate}; rate = последняя key_rate (или None) на каждом горизонте. """ last_rate: float | None = None for m in macro: # ряд ASC по month → последняя непустая = самая свежая if m.key_rate is not None: last_rate = m.key_rate return {h: last_rate for h in horizons} # ────────────────────────────────────────────────────────────────────────────── # DB-оркестратор — тонкий, graceful. Pure-логика выше тестируется без него. # ────────────────────────────────────────────────────────────────────────────── def compute_demand_supply_forecast( db: Session, *, spec: SegmentSpec, district: str | None, cad_num: str, horizons: Sequence[int] = _DEFAULT_HORIZONS, rate_path: dict[int, float] | None = None, premise_kind: str = "квартира", ) -> list[DemandSupplyForecast]: """Собрать per-горизонт прогноз баланса СПРОС↔ПРЕДЛОЖЕНИЕ (ТЗ §9.8) для сегмента. ADVISORY (все §9.x-компоненты advisory до бэктеста #951) — НЕ подключать в production-эндпоинт. СБОРКА, НЕ пересчёт §9.x: вызывает уже-смерженные сервисы. ⚠️ β УЧТЁН РОВНО ОДИН РАЗ: спрос идёт через §9.4 compute_demand_normalization (она сама применяет β внутри); §9.6 compute_rate_sensitivity вызывается ТОЛЬКО ради explainability-фразы — её β/x_pct в арифметику спроса НЕ входят. #1959 ПОСЕГМЕНТНЫЙ баланс (фикс «−1.00 везде»): и СПРОС, и ПРЕДЛОЖЕНИЕ теперь сужаются под spec (obj_class/room_bucket), а не берутся district-wide: • base_pace = compute_market_metrics(obj_class, room_bucket).unit_velocity — дедуплицированный темп ИМЕННО сегмента (раньше один темп на все ячейки → каждая what_to_build-ячейка делила одно и то же → дефицит = −1.0 везде). • open-сток = market_metrics.n_available того же сегмента (дедуп). При заданном сегменте L2/L3 (hidden/future) ИСКЛЮЧАЮТСЯ из баланса — они класс/формат- агностичны, иначе один объём двоился бы по всем ячейкам (снова −1.0). Один раз на вызов: • macro = get_monthly_macro(db) — для дефолтного rate-path (hold_last_rate). • base_pace = compute_market_metrics(...).unit_velocity (§9.2 ед./мес). • §9.5 compute_macro_coefficient (макро-режим, ортогонален β). • §9.6 compute_rate_sensitivity (ТОЛЬКО фраза для explain). На каждый горизонт h: • rate_future = rate_path[h] (или hold_last_rate). • §9.4 compute_demand_normalization(rate_future=rate_future) → norm (β внутри). • projected_demand = base_pace × norm × §9.5 × h (линейно, без компаунда). • §9.3 compute_future_supply_pressure(horizon_months=h) → open/hidden/future (для НЕзаданного сегмента); при заданном сегменте open = n_available, hidden/future = 0. Чистое предложение = open + hidden_release + future − поглощённое спросом (clamp ≥0). • balance / ratio / знаковый deficit_index (АБСОЛЮТ) + months_of_inventory (ДИСКРИМИНИРУЮЩИЙ companion = gross-предложение / помесячный темп спроса). • §9.7 future_competitors = top-N get_competitors(horizon_months=h). • confidence = MIN(market, §9.4, §9.5, future_supply) ≤ 'medium'. Graceful: любой тонкий вход → поле None, НЕ crash. supply ≤0/None → ratio None; index = +1.0 если спрос > 0 (пик дефицита, #980), иначе None. Конкуренты недоступны (нет геометрии cad_num) → []. Детерминированно. Args: db: SQLAlchemy sync Session. spec: целевой сегмент рынка (любой subset осей). district: район для §9.3 supply + §9.2 metrics (None → ЕКБ-wide). cad_num: кадастровый номер участка — вход для §9.7 конкурентов. horizons: горизонты прогноза (мес; по умолчанию _DEFAULT_HORIZONS). rate_path: сценарный {horizon: key_rate}; None → hold_last_rate (плоско). premise_kind: тип помещения (по умолчанию 'квартира'). Returns: Список DemandSupplyForecast — по одному на горизонт (всегда; тонко → None-поля). """ horizon_list = list(horizons) segment = spec.as_dict() profile = _segment_profile(spec) # ── Один раз на вызов: макро-ряд + дефолтный rate-path ───────────────────── macro = get_monthly_macro(db) effective_rate_path = ( dict(rate_path) if rate_path is not None else hold_last_rate(macro, horizon_list) ) # ── Один раз: §9.2 наблюдаемый темп (base_pace) ──────────────────────────── # #1959: ПОСЕГМЕНТНЫЙ темп — фильтруем market_metrics по obj_class + room_bucket # сегмента. Раньше брался district-wide unit_velocity (один темп на ВСЕ ячейки # what_to_build → дефицит вырождался в −1.0 везде). room_bucket идёт из Source-B # вокабуляра what_to_build ("Студии 15-30"/…); market_metrics теперь принимает # его напрямую (room CASE = зеркало room_area_bucket_of) → переводчик-валидатор # (см. _market_room_bucket) только страхует от неизвестных меток. metrics = compute_market_metrics( db, district=district, premise_kind=premise_kind, obj_class=spec.obj_class, room_bucket=_market_room_bucket(spec.room_bucket), ) base_pace = metrics.unit_velocity # ── Один раз: §9.5 макро-коэффициент (ортогонален β) ─────────────────────── macro_coef = compute_macro_coefficient(db, segment_profile=profile) # ── Один раз: §9.6 чувствительность — ТОЛЬКО для explain-фразы (НЕ арифметика) sensitivity = compute_rate_regime_sensitivity(db, spec=spec) # Шок-окно (PR2): берём ИЗ §9.5/§9.6 ровно один раз и прокидываем во все per- # горизонт DemandSupplyForecast — иначе #990 confounded-factor мёртв (#1222). # MagicMock-стабы в тестах могут не задавать атрибут → `getattr(..., False)` + # `bool(...)` для безопасности (Mock-attribute truthy исказил бы фактическое # значение). confounded = _series_confounded(macro_coef, sensitivity) # #1959: сегмент «задан», если сужена хотя бы одна предметная ось (класс/формат). # Тогда ПРЕДЛОЖЕНИЕ берём ПОСЕГМЕНТНОЕ — open = market_metrics.n_available # (дедуплицированный сток ИМЕННО этого сегмента), а L2/L3 (hidden/future) # ИСКЛЮЧАЕМ из баланса: они класс/формат-агностичны (нет room-оси) → один и тот # же объём прибавился бы КАЖДОЙ ячейке → двойной счёт и снова вырождение к −1.0. # Незаданный сегмент (оба None) → прежний district-wide путь (open из §9.3 view + # hidden + future), обратная совместимость для агрегатных вызовов. segmented = spec.obj_class is not None or spec.room_bucket is not None out: list[DemandSupplyForecast] = [] for h in horizon_list: out.append( _forecast_for_horizon( db, spec=spec, segment=segment, district=district, cad_num=cad_num, horizon=h, rate_future=effective_rate_path.get(h), base_pace=base_pace, market_confidence=metrics.confidence, segment_open_units=metrics.n_available, segmented=segmented, macro_coef=macro_coef, sensitivity_phrase=sensitivity.phrase, confounded=confounded, premise_kind=premise_kind, ) ) return out def _series_confounded(macro_coef: Any, sensitivity: Any) -> bool: """Окно §9.5 macro_coefficient ИЛИ §9.6 rate_sensitivity пересекает шок-период. PR2-флаг приходит ИЗ под-сервисов готовым `.confounded: bool` (см. `MacroCoefficient` / `RateSensitivity`). Берём ИЛИ-агрегат: достаточно одного True, чтобы окно считалось confounded. `getattr` с дефолтом False для устойчивости к стабам/будущим под-вариантам без поля. Используется в `as_dict()` → `report_assembler._confounded` → `compute_report_confidence` (#990, #1222). """ macro_flag = bool(getattr(macro_coef, "confounded", False) or False) sens_flag = bool(getattr(sensitivity, "confounded", False) or False) return macro_flag or sens_flag def _forecast_for_horizon( db: Session, *, spec: SegmentSpec, segment: dict[str, str | None], district: str | None, cad_num: str, horizon: int, rate_future: float | None, base_pace: float | None, market_confidence: Confidence, segment_open_units: int, segmented: bool, macro_coef: Any, sensitivity_phrase: str | None, confounded: bool, premise_kind: str, ) -> DemandSupplyForecast: """Собрать прогноз для ОДНОГО горизонта (тонкий — pure-логика выше). Graceful.""" # ── СПРОС: §9.4 (β внутри — ОДИН раз) → линейная проекция × §9.5 ─────────── # rate_future None → §9.4 деградирует к нейтрали внутри себя (передаём 0.0 как # placeholder ставки, но при норм-coef из low-conf β результат всё равно 1.0). norm = compute_demand_normalization( db, spec=spec, rate_future=rate_future if rate_future is not None else 0.0 ) norm_coefficient = norm.coefficient if rate_future is not None else None macro_coefficient = macro_coef.coefficient projected_demand = _project_demand(base_pace, norm_coefficient, macro_coefficient, horizon) # ── ПРЕДЛОЖЕНИЕ: §9.3 слои → фазированный hidden → чистое (− поглощённое) ─── # fsp читаем ВСЕГДА (нужен fsp.confidence для итоговой уверенности; для незаданного # сегмента — ещё и open/hidden/future как раньше). fsp = compute_future_supply_pressure( db, district=district, horizon_months=horizon, premise_kind=premise_kind ) if segmented: # #1959: ПОСЕГМЕНТНОЕ предложение = открытый сток ИМЕННО этого сегмента # (market_metrics.n_available, дедуплицированный). L2/L3 (hidden/future) # класс/формат-агностичны (нет room-оси) → если прибавить их каждой ячейке, # один и тот же объём двоится по всем сегментам → дефицит снова липнет к −1.0. # Поэтому в посегментном балансе hidden/future = 0 (в dataclass их тоже не # показываем, чтобы explain не вводил в заблуждение «этот сегмент имеет L2/L3»). open_units = segment_open_units hidden_release = 0.0 future_online = 0.0 else: open_units = fsp.open_units hidden_fraction = _hidden_release_fraction(horizon) hidden_release = fsp.hidden_units * hidden_fraction future_online = fsp.future_units_by_horizon gross_supply = _gross_supply(open_units, hidden_release, future_online) projected_supply = _project_supply(open_units, hidden_release, future_online, projected_demand) # ── БАЛАНС / индекс дефицита / months-of-inventory ────────────────────────── # balance_ratio честно None при исчерпанном предложении (спрос/0 неотличим от # «нет данных»), НО deficit_index в этом случае = +1.0 — сильнейший дефицит, не # «нет сигнала» (#980; см. _deficit_index_from_balance). deficit_index — # АБСОЛЮТНЫЙ сигнал (часто −1 на затоваренном ЕКБ); months_of_inventory — # ДИСКРИМИНИРУЮЩИЙ companion (gross-предложение / помесячный темп спроса). balance_units = _balance(projected_demand, projected_supply) balance_ratio = _balance_ratio(projected_demand, projected_supply) deficit_index = _deficit_index_from_balance(projected_demand, projected_supply, balance_ratio) demand_per_mo = _demand_per_mo(projected_demand, horizon) months_of_inventory = _months_of_inventory(gross_supply, demand_per_mo) # ── §9.7 будущие конкуренты (top-N по relevance_weight) ──────────────────── future_competitors = _future_competitors(db, cad_num=cad_num, horizon=horizon) # ── confidence = MIN(компоненты) ≤ cap ───────────────────────────────────── confidence = _cap_confidence( _min_confidence([market_confidence, norm.confidence, macro_coef.confidence, fsp.confidence]) ) logger.info( "demand_supply_forecast: segment=%s h=%d base_pace=%s norm=%s macro=%s " "demand=%s supply=%.1f balance=%s ratio=%s deficit_index=%s moi=%s " "confidence=%s confounded=%s", segment, horizon, _round_or_none(base_pace, 2), _round_or_none(norm_coefficient, 4), _round_or_none(macro_coefficient, 4), _round_or_none(projected_demand, 1), projected_supply, _round_or_none(balance_units, 1), _round_or_none(balance_ratio, 3), _round_or_none(deficit_index, 3), _round_or_none(months_of_inventory, 1), confidence, confounded, ) return DemandSupplyForecast( segment=segment, horizon_months=horizon, base_pace_units_per_mo=base_pace, demand_norm_coefficient=norm_coefficient, macro_coefficient=macro_coefficient, projected_demand_units=projected_demand, open_units=open_units, hidden_release_units=hidden_release, future_online_units=future_online, projected_supply_units=projected_supply, balance_units=balance_units, balance_ratio=balance_ratio, deficit_index=deficit_index, months_of_inventory=months_of_inventory, rate_future=rate_future, rate_sensitivity_phrase=sensitivity_phrase, future_competitors=future_competitors, advisory=True, confidence=confidence, confounded=confounded, ) def _segment_profile(spec: SegmentSpec) -> dict[str, Any]: """Спроецировать SegmentSpec на профиль для §9.5 segment_steepness. PURE. §9.5 segment_steepness читает obj_class / price_tier / room_bucket. Маппим оси spec на ожидаемые ключи (price_bucket → price_tier). None-оси не кладём (segment_steepness терпит отсутствие — нейтральная крутизна). """ profile: dict[str, Any] = {} if spec.obj_class is not None: profile["obj_class"] = spec.obj_class if spec.room_bucket is not None: profile["room_bucket"] = spec.room_bucket if spec.price_bucket is not None: profile["price_tier"] = spec.price_bucket return profile def _future_competitors(db: Session, *, cad_num: str, horizon: int) -> list[dict[str, Any]]: """§9.7 top-N будущих конкурентов по relevance_weight на горизонт. Graceful → []. get_competitors сам horizon-aware (stage_at_horizon). Сортирует по relevance_weight DESC, поэтому берём первые _TOP_COMPETITORS. Нет геометрии участка (ValueError) / сбой → [] (не валим прогноз — конкуренты опциональны). """ try: response = get_competitors( db, cad_num, CompetitorsRequest(horizon_months=horizon, radius_km=_COMPETITORS_RADIUS_KM), ) except Exception: logger.exception( "demand_supply_forecast: competitors lookup failed (cad_num=%s horizon=%d)", cad_num, horizon, ) return [] return [_competitor_to_dict(c) for c in response.competitors[:_TOP_COMPETITORS]] def _competitor_to_dict(competitor: Any) -> dict[str, Any]: """Свести Competitor в компактный explain-словарь (релевантные §9.7 поля). PURE-ish.""" return { "obj_id": competitor.obj_id, "comm_name": competitor.comm_name, "obj_class": competitor.obj_class, "distance_m": competitor.distance_m, "flats_total": competitor.flats_total, "velocity_per_month": competitor.velocity_per_month, "relevance_weight": competitor.relevance_weight, }