"""§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 = сильная затоварка «риск»). • БУДУЩИЕ КОНКУРЕНТЫ (§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=None, 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.rate_sensitivity import compute_rate_sensitivity from app.services.forecasting.sales_series import 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"} @dataclass(frozen=True) class DemandSupplyForecast: """Per-(сегмент × горизонт) баланс СПРОС↔ПРЕДЛОЖЕНИЕ (ТЗ §9.8, считается на лету). Все величины детерминированы. Любое числовое поле = None при недостатке данных (НИКОГДА 0-как-заглушка). `advisory` ВСЕГДА True (движок не для production-решений до бэктеста #951). `deficit_index` ∈ [−1,+1] когда задан: +1 = сильный дефицит (мало предложения под спрос — «хорошо» для девелопера), −1 = сильная затоварка (предложения больше спроса — «риск»); None при неизмеримом предложении. `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 # ── Баланс / индекс дефицита ─────────────────────────────────────────────── 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] # ── 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 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), "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, } 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 _project_supply( open_units: float, hidden_release_units: float, future_online_units: float, projected_demand_units: float | None, ) -> float: """Чистое конкурирующее предложение = валовое − поглощённое спросом, clamp ≥0. Валовое = 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 = 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. Лог-шкала (отношение мультипликативно — ×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 _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 в арифметику спроса НЕ входят. Один раз на вызов: • 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; hidden_release = hidden × _hidden_release_fraction(h); чистое предложение = open + hidden_release + future − поглощённое спросом (clamp ≥0). • balance / ratio / знаковый deficit_index. • §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/index 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) ──────────────────────────── metrics = compute_market_metrics(db, district=district, premise_kind=premise_kind) base_pace = metrics.unit_velocity # ── Один раз: §9.5 макро-коэффициент (ортогонален β) ─────────────────────── macro_coef = compute_macro_coefficient(db, segment_profile=profile) # ── Один раз: §9.6 чувствительность — ТОЛЬКО для explain-фразы (НЕ арифметика) sensitivity = compute_rate_sensitivity(db, spec=spec) 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, macro_coef=macro_coef, sensitivity_phrase=sensitivity.phrase, premise_kind=premise_kind, ) ) return out 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, macro_coef: Any, sensitivity_phrase: str | None, 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 = compute_future_supply_pressure( db, district=district, horizon_months=horizon, premise_kind=premise_kind ) hidden_fraction = _hidden_release_fraction(horizon) hidden_release = fsp.hidden_units * hidden_fraction future_online = fsp.future_units_by_horizon projected_supply = _project_supply( fsp.open_units, hidden_release, future_online, projected_demand ) # ── БАЛАНС / индекс дефицита ──────────────────────────────────────────────── balance_units = _balance(projected_demand, projected_supply) balance_ratio = _balance_ratio(projected_demand, projected_supply) deficit_index = _deficit_index(balance_ratio) # ── §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 confidence=%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), confidence, ) 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=fsp.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, rate_future=rate_future, rate_sensitivity_phrase=sensitivity_phrase, future_competitors=future_competitors, advisory=True, confidence=confidence, ) 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, }