"""§9.7 forecast-overlay для живого квартирография-рекомендатора (#982, 953-A; #983, 953-B). Это ТОНКИЙ СОВЕТУЮЩИЙ мост между ЖИВЫМ `analytics_queries.recommend_mix` и форсайт-движком §9.x (#980/#981). Своей §9.x-математики НЕ пересобирает — собирает ответ из уже-смерженных сервисов и форматирует под schemas.recommend overlay-контракт. Всё ДЕТЕРМИНИРОВАННО, БЕЗ LLM; своего SQL НЕТ. #983 (953-B, EPIC 9) ADDITIVE-расширяет overlay поверх #982 (старые поля/поведение НЕ тронуты): §10.2 рекомендация класса (`class_reco`), §10.5 USP-из-дефицитов (`usp`), §10.4 советующий коммерческий сигнал (`commercial`, degraded-honest) и §16 структурная причина (`reason`) на КАЖДОМ ранжированном сегменте + на class_reco/usp. §16-причины ДЕРИВИРУЮТСЯ из ранкинга what_to_build (отвергнутые альтернативы = runner-up'ы / негатив-дефицит ячейки) — поэтому маршрут через rank_segments корректен и для §16. ADVISORY (КРИТИЧНО — честность, зеркало what_to_build.py): overlay наследует advisory-статус §9.x (движок не провалидирован до бэктеста #951). Поэтому `advisory` ВСЕГДА True, confidence ≤ 'medium' в demand_supply-режиме и 'low' в demand_only. Цифры — для explainability/прототипа, НЕ основание для инвест-решения. LIVE-ENDPOINT-SAFE: НИКОГДА не бросает — на любом тонком/сбойном входе возвращает пустой ranked_segments + warning (вызывающий в recommend_mix ещё и оборачивает всё в try/except, но мы и сами graceful). ДВА РЕЖИМА (выбор по наличию геометрии участка `cad_num`): • mode="demand_supply" (cad_num задан): полноценный §9.7-ранкер `what_to_build.rank_segments` — спрос (§9.4×§9.5) vs предложение (§9.3) + конкуренты участка → `deficit_index` ∈ [−1,+1] (>0 строить / <0 затоварка), `balance_units`, confidence ≤ 'medium'. DESC-порядок наследуется от ранкера. • mode="demand_only" (cad_num=None): БЕЗ геометрии участка supply/конкуренты НЕизмеримы → считаем ТОЛЬКО темп спроса (§9.2 × §9.4 × §9.5) per default room-bucket, ранжируем DESC по темпу. `deficit_index` здесь = pace/max_pace ∈ (0,1]; `balance_units=None`, confidence='low', обязательный warning что supply НЕ учтён. НИКОГДА не фабрикуем предложение/конкурентов из воздуха. #1593 (ИСПРАВЛЕНО): §9.2 base_pace теперь per-bucket через MarketMetrics.velocity_by_room (ед./мес из objective_lots per комнатность). Маппинг forecast_bucket → metric-ключи: _FORECAST_TO_METRIC_BUCKETS. "80+ м²" = сумма bucket'ов "4" + "5+" (оба крупные форматы). Если данных по конкретному bucket'у нет — fallback на агрегатный unit_velocity, что лучше чем фабрикация 0. §9.4 norm.coefficient по-прежнему без per-bucket фита (β фитится district×obj_class), поэтому он одинаков между bucket'ами и СОКРАЩАЕТСЯ в pace/max_pace — это честно задокументировано. ИМПОРТЫ §9.x — ЛОКАЛЬНЫЕ (внутри функций), чтобы избежать import-cycle: пакет `forecasting/__init__` тянет `affordability`, который импортит `analytics_queries._current_mortgage_rate`, а тот — часть живого стека. Top-level здесь — только `sales_series.SegmentSpec` (лёгкий, без обратных зависимостей) + stdlib/sqlalchemy. """ from __future__ import annotations import logging from typing import Any from sqlalchemy.orm import Session from app.services.forecasting.sales_series import SegmentSpec logger = logging.getLogger(__name__) # ── PURE bridge-таблицы (named, unit-тестируемы в обе стороны) ───────────────── # Forecast room-bucket (Source-B room_area-вокабуляр what_to_build) → live # bucket-id (ключ analytics_queries._BUCKET_PRETTY). ТОЧНЫЙ инверс _BUCKET_PRETTY; # 5 литералов ДУБЛИРУЕМ намеренно (избегаем import-цикла analytics_queries → # recommendation). Drift-guard в тестах сверяет это с inverse(_BUCKET_PRETTY). _FORECAST_TO_LIVE_BUCKET: dict[str, str] = { "Студии 15-30": "1-Студия", "1-к 30-45": "2-1-к", "2-к 45-60": "3-2-к", "3-к 60-80": "4-3-к", "80+ м²": "5-80+ м²", } # Инверс — live bucket-id → forecast room-bucket (для demand_only прохода по # дефолтной сетке room-bucket'ов §9.7 и для map_room_bucket_inverse). _LIVE_TO_FORECAST_BUCKET: dict[str, str] = {v: k for k, v in _FORECAST_TO_LIVE_BUCKET.items()} # #1593: forecast room-bucket → список ключей MarketMetrics.velocity_by_room. # Ключи velocity_by_room — вокабуляр _room_bucket() из market_metrics # ("студия","1","2","3","4","5+"). "80+ м²" накрывает 4-комнатные и 5+ # (большие квартиры), поэтому суммируем оба бакета. _FORECAST_TO_METRIC_BUCKETS: dict[str, list[str]] = { "Студии 15-30": ["студия"], "1-к 30-45": ["1"], "2-к 45-60": ["2"], "3-к 60-80": ["3"], "80+ м²": ["4", "5+"], } # Live target_class (вокабуляр schemas.recommend ClassLiteral, «человеческий» # регистр) → forecast obj_class (3 массовых класса §9.7 _DEFAULT_CLASSES). None и # незнакомое → None (вызывающий тогда отдаёт движку дефолтную сетку классов). _CLASS_TO_FORECAST: dict[str, str] = { "Comfort": "комфорт", "Comfort+": "комфорт", "Business": "бизнес", "Elite": "бизнес", "Economy": "эконом", } # ── #983 named-константы (§10/§16) ───────────────────────────────────────────── # §10.5 USP-из-дефицитов: сколько самых недообеспеченных сегментов превращаем в # USP-пункты «дефицит формата X — стройте его». 3 = верхушка сигнала, не «простыня». _USP_TOP_K: int = 3 # §10.4 коммерция: premise_kind, которым пробуем измерить нежилой сток (вокабуляр # objective_lots — «нежилое»). objective покрывает в основном жильё, поэтому обычно # выборка тонкая → degraded-honest путь (см. _commercial_signal). НЕ фабрикуем число. _COMMERCIAL_PREMISE_KIND: str = "нежилое" # §10.4: минимум нежилых лотов в выборке, ниже которого сигнал считаем недостаточным # (degraded-honest). Зеркало духа market_metrics порогов: мало лотов → ненадёжно. _COMMERCIAL_MIN_LOTS: int = 30 # §16 «что изменило бы индекс» — рычаги §9.x полными RU-предложениями (микрокопия: # п.п., ≥). Templated-константы (НЕ LLM): ставка §9.4/§9.6, скрытый запас §9.3 Layer2, # горизонт. Шаблон с {horizon} подставляет текущий горизонт вызова. _LEVER_RATE: str = ( "Рост ключевой ставки на ≥1 п.п. снижает нормализованный спрос (§9.4) → индекс падает." ) _LEVER_SUPPLY: str = ( "Выход скрытого запаса (Layer2, §9.3) в радиусе участка → предложение растёт → индекс падает." ) _LEVER_HORIZON_TMPL: str = ( "Сужение горизонта с {horizon} до 6 мес → меньше поглощённого спроса → индекс может вырасти." ) def map_room_bucket(forecast_bucket: str | None) -> str | None: """Forecast room-bucket → live bucket-id. PURE. Неизвестное/None → None.""" if forecast_bucket is None: return None return _FORECAST_TO_LIVE_BUCKET.get(forecast_bucket) def map_room_bucket_inverse(live_bucket: str | None) -> str | None: """Live bucket-id → forecast room-bucket (инверс map_room_bucket). PURE.""" if live_bucket is None: return None return _LIVE_TO_FORECAST_BUCKET.get(live_bucket) def map_class(target_class: str | None) -> str | None: """Live target_class → forecast obj_class. PURE. None/неизвестное → None. Comfort/Comfort+ сворачиваются в «комфорт», Business/Elite — в «бизнес», Economy — в «эконом» (3 массовых класса §9.7). None → None = «без фильтра класса», вызывающий отдаёт движку дефолтную сетку классов. """ if target_class is None: return None return _CLASS_TO_FORECAST.get(target_class) # ── #983 PURE-билдеры §10/§16 — без БД, полностью юнит-тестируемы ────────────── # Работают на live segment-dict'ах (shape _demand_*_overlay: bucket / obj_class / # deficit_index / balance_units / confidence). `all_ranked` — DESC-проекция ранкинга # what_to_build; из неё §16 деривирует отвергнутые альтернативы (runner-up'ы). def _segment_label(segment: dict[str, Any]) -> str: """Человекочитаемая метка ячейки для RU-микрокопии §16/§10. PURE. « (<класс>)» если класс задан, иначе «». bucket — уже живой pretty-id (например «3-2-к»); None-bucket → «формат» (не падаем на мусоре). """ bucket = segment.get("bucket") or "формат" obj_class = segment.get("obj_class") return f"{bucket} ({obj_class})" if obj_class else str(bucket) def _signed(value: float) -> str: """'+0.62' / '−0.18' — знак для RU-микрокопии индекса (минус — типографский). PURE.""" return f"+{value:.2f}" if value >= 0 else f"−{abs(value):.2f}" def _direction(value: float) -> str: """Направление драйвера для §16 ('+' рост сигнала / '−' давление вниз). PURE.""" return "+" if value >= 0 else "−" def _build_reason( segment: dict[str, Any], deficit_index: float, all_ranked: list[dict[str, Any]], *, horizon_months: int, demand_only: bool = False, demand: float | None = None, supply: float | None = None, ) -> dict[str, Any]: """§16 структурная причина одной ячейки (RecommendReason-shaped dict). PURE. Собирает why/drivers/rejected/what_would_change/confidence/advisory: • why — RU-предложение с deficit_index на горизонте (+ спрос/предложение если известны; в demand_only оговаривает, что предложение НЕ учтено). • drivers — deficit_index (всегда) + balance_units (если не None) с direction. • rejected — ДЕРИВИРУЕТСЯ из `all_ranked`: прочие ячейки (runner-up'ы + негатив-дефицит) — вот ПОЧЕМУ маршрут через rank_segments корректен для §16. Негативный дефицит помечаем «затоварка», прочее — «слабее сигнал». • what_would_change — templated рычаги §9.x (ставка/предложение/горизонт). `confidence` НАСЛЕДУЕТСЯ из самой ячейки (передаётся в segment). advisory=True. Args: segment: live segment-dict рассматриваемой ячейки (с confidence/bucket/класс). deficit_index: индекс ячейки (для why/drivers — отдельным аргументом, т.к. может быть уже округлён вызывающим). all_ranked: DESC-проекция всего ранкинга (для деривации rejected). horizon_months: горизонт прогноза (мес) — для why и horizon-рычага. demand_only: True → why оговаривает, что supply не учтён (demand_only-режим). demand: оценка спроса (ед.) если известна — иначе опускаем из why. supply: оценка предложения (ед.) если известна — иначе опускаем из why. Returns: RecommendReason-shaped dict. """ label = _segment_label(segment) why = ( f"Сегмент {label}: deficit_index {_signed(deficit_index)} на горизонте {horizon_months} мес" ) if demand is not None and supply is not None: why += f" (спрос ~{demand:.0f}, предложение ~{supply:.0f})" elif demand_only: why += " (прокси силы спроса; предложение участка НЕ учтено — нет геометрии)" why += "." drivers: list[dict[str, Any]] = [ { "factor": "deficit_index", "value": round(deficit_index, 2), "direction": _direction(deficit_index), } ] balance_units = segment.get("balance_units") if balance_units is not None: drivers.append( { "factor": "balance_units", "value": round(float(balance_units), 1), "direction": _direction(float(balance_units)), } ) rejected = _rejected_alternatives(segment, all_ranked) what_would_change = [ _LEVER_RATE, _LEVER_SUPPLY, _LEVER_HORIZON_TMPL.format(horizon=horizon_months), ] return { "why": why, "drivers": drivers, "rejected": rejected, "what_would_change": what_would_change, "confidence": segment.get("confidence", "low"), "advisory": True, } def _rejected_alternatives( chosen: dict[str, Any], all_ranked: list[dict[str, Any]] ) -> list[dict[str, Any]]: """§16 отвергнутые альтернативы из РАНКИНГА (всё, кроме выбранной ячейки). PURE. Деривируем из `all_ranked` (DESC): каждая ДРУГАЯ ячейка — отвергнутая альтернатива. Идентичность ячейки — по (bucket, obj_class) (live-ключ overlay). reason-ярлык: deficit_index < 0 → «затоварка», иначе «слабее сигнал». Сохраняем DESC-порядок ранкинга (детерминированно). Самоисключение по ключу — НЕ по object identity. """ chosen_key = (chosen.get("bucket"), chosen.get("obj_class")) rejected: list[dict[str, Any]] = [] for seg in all_ranked: if (seg.get("bucket"), seg.get("obj_class")) == chosen_key: continue di = seg.get("deficit_index") di_f = float(di) if di is not None else 0.0 rejected.append( { "alternative": _segment_label(seg), "deficit_index": round(di_f, 2), "reason": "затоварка" if di_f < 0 else "слабее сигнал", } ) return rejected def _recommend_class( all_ranked: list[dict[str, Any]], *, horizon_months: int, demand_only: bool = False ) -> dict[str, Any] | None: """§10.2 рекомендация класса — сильнейший агрегатный дефицит по классам. PURE. Агрегируем `deficit_index` per `obj_class` (среднее по его room-bucket'ам), выбираем класс с сильнейшим средним дефицитом. К рекомендации прикрепляем §16 `_build_reason` (как для синтетической class-level ячейки; rejected — прочие классы тем же механизмом). None если нет ранжированных данных или ни у одной ячейки нет obj_class (нечего агрегировать — НЕ фабрикуем класс). Детерминированно: tie-break по obj_class ASC (стабильно при равных средних). Args: all_ranked: DESC-проекция ранкинга (live segment-dict'ы). horizon_months: горизонт (для §16 reason). demand_only: проброс в reason (оговорка supply-excluded). Returns: {obj_class, mean_deficit_index, n_segments, reason} либо None. """ if not all_ranked: return None by_class: dict[str, list[float]] = {} for seg in all_ranked: obj_class = seg.get("obj_class") di = seg.get("deficit_index") if obj_class is None or di is None: continue by_class.setdefault(obj_class, []).append(float(di)) if not by_class: return None means = {cls: sum(vals) / len(vals) for cls, vals in by_class.items()} # Сильнейший средний дефицит; tie-break — obj_class ASC (детерминизм). best_class = max(means, key=lambda c: (means[c], _neg_str(c))) best_mean = means[best_class] # Синтетическая class-level ячейка для §16: confidence наследуем как лучший # (max) среди ячеек класса — class-сигнал не слабее своей сильнейшей ячейки. class_segments = [s for s in all_ranked if s.get("obj_class") == best_class] confidence = _best_confidence(s.get("confidence") for s in class_segments) synthetic = {"bucket": "класс", "obj_class": best_class, "confidence": confidence} # rejected — прочие КЛАССЫ (агрегатные ячейки), не room-bucket'ы. class_cells = [ {"bucket": "класс", "obj_class": cls, "deficit_index": m, "confidence": confidence} for cls, m in means.items() ] reason = _build_reason( synthetic, best_mean, class_cells, horizon_months=horizon_months, demand_only=demand_only, ) return { "obj_class": best_class, "mean_deficit_index": round(best_mean, 3), "n_segments": len(class_segments), "reason": reason, } def _usp_from_deficits( all_ranked: list[dict[str, Any]], *, horizon_months: int, top_k: int = _USP_TOP_K, demand_only: bool = False, ) -> list[dict[str, Any]]: """§10.5 USP-из-дефицитов — top-K самых недообеспеченных сегментов. PURE. `all_ranked` уже DESC по deficit_index → первые top_k = сильнейшая недообеспеченность. Каждый → USP-пункт «Дефицит формата X — стройте его» + §16 reason. Пустой вход → пустой список (НЕ фабрикуем USP). Берём ТОЛЬКО ячейки с genuine-дефицитом (deficit_index > 0): «стройте его» оправдан лишь для реально недонасыщенного формата. Затоварка (di ≤ 0) — не белое пятно, в USP не идёт; если в top_k все di ≤ 0 → []. Зеркало product_scoring._count_positive_usp (тот же gate di > 0). Args: all_ranked: DESC-проекция ранкинга (live segment-dict'ы). horizon_months: горизонт (для §16 reason). top_k: сколько верхних сегментов превратить в USP (по умолчанию _USP_TOP_K). demand_only: проброс в reason (оговорка supply-excluded). Returns: Список USP-пунктов {segment, obj_class, deficit_index, usp_text, reason}. """ usp: list[dict[str, Any]] = [] for seg in all_ranked[:top_k]: di = seg.get("deficit_index") if di is None or di <= 0: continue usp.append( { "segment": seg.get("bucket"), "obj_class": seg.get("obj_class"), "deficit_index": round(float(di), 3), "usp_text": f"Дефицит формата «{_segment_label(seg)}» — стройте его.", "reason": _build_reason( seg, float(di), all_ranked, horizon_months=horizon_months, demand_only=demand_only, ), } ) return usp def _neg_str(value: str) -> tuple[int, ...]: """Ключ для ASC-tie-break внутри max() (max берёт «больший» → инвертируем). PURE. max(..., key) выбирает максимум; чтобы при равном primary взять лексикографически МЕНЬШИЙ obj_class, отдаём покодовый кортеж с отрицанием (меньшая строка → больший ключ). Детерминированно, без локали. """ return tuple(-ord(ch) for ch in value) def _best_confidence(values: Any) -> str: """MAX confidence среди ячеек (class-сигнал не слабее сильнейшей ячейки). PURE. Зеркало _CONFIDENCE-vocab (low dict[str, Any]: """Собрать СОВЕТУЮЩИЙ §9.7 forecast-overlay для recommend_mix. LIVE-SAFE. ADVISORY (наследует advisory-статус §9.x, см. module docstring) — overlay исключительно explainability, НЕ основание для инвест-решения. НИКОГДА не бросает: на тонком/сбойном входе → пустой ranked_segments + warning. Режим выбирается по `cad_num`: • cad_num задан → mode="demand_supply": §9.7 rank_segments (спрос vs предложение + конкуренты участка), маппинг RankedSegment → segment-dict, DESC-порядок сохранён. • cad_num=None → mode="demand_only": ТОЛЬКО темп спроса (§9.2×§9.4×§9.5) per default room-bucket, DESC по темпу, deficit_index = pace/max_pace (ПРОКСИ, не supply-based), balance_units=None, confidence='low' + обязательный warning что supply/конкуренты не учтены. Args: db: SQLAlchemy sync Session. district: район участка (None → ЕКБ-wide). cad_num: кадастровый номер участка; None → demand_only режим. horizon_months: горизонт прогноза (мес) — продуктовый «на горизонте». target_class: живой класс недвижимости (ClassLiteral) или None. market_buckets: живые бакеты recommend_mix (пока для совместимости сигнатуры; demand_only ходит по дефолтной §9.7 room-сетке). Returns: RecommendForecastOverlay-shaped dict (horizon_months/mode/advisory/ ranked_segments/warnings). advisory ВСЕГДА True. """ if cad_num is not None: return _demand_supply_overlay( db, district=district, cad_num=cad_num, horizon_months=horizon_months, target_class=target_class, ) return _demand_only_overlay( db, district=district, horizon_months=horizon_months, target_class=target_class, ) def _demand_supply_overlay( db: Session, *, district: str | None, cad_num: str, horizon_months: int, target_class: str | None, ) -> dict[str, Any]: """mode="demand_supply": §9.7 rank_segments → overlay-dict. Graceful → [].""" # Локальный импорт — избегаем import-cycle (forecasting/__init__ → affordability # → analytics_queries). См. module docstring. from app.services.forecasting.what_to_build import rank_segments warnings: list[str] = [] mapped_class = map_class(target_class) # None класс → ОПУСКАЕМ kwarg classes, чтобы применилась движковая дефолтная # сетка §9.7 (rank_segments default = _DEFAULT_CLASSES); room_buckets всегда # опускаем → дефолтная §9.7 сетка форматов. Передать None нельзя — ранкер # итерирует по этим осям (None → TypeError). rank_kwargs: dict[str, Any] = {} if mapped_class is not None: rank_kwargs["classes"] = [mapped_class] try: ranking = rank_segments( db, district=district, cad_num=cad_num, horizon_months=horizon_months, **rank_kwargs, ) except ValueError as e: # Нет геометрии участка / невалидный вход — НЕ валим живой ответ. logger.info( "forecast-overlay demand_supply: rank_segments ValueError " "(district=%s cad_num=%s horizon=%d) → пустой ранкинг: %s", district, cad_num, horizon_months, e, ) warnings.append( "demand_supply: ранкинг недоступен (нет геометрии участка или тонкие " "данные) — сегменты не ранжированы." ) return _enrich_overlay( db, horizon_months=horizon_months, mode="demand_supply", ranked_segments=[], warnings=warnings, district=district, demand_only=False, ) ranked_segments: list[dict[str, Any]] = [] for seg in ranking.ranked: # уже DESC по deficit_index live_bucket = map_room_bucket(seg.segment.get("room_bucket")) if live_bucket is None: # Сегмент в неизвестном room-вокабуляре — отбрасываем (не отображаем мусор). continue ranked_segments.append( { "bucket": live_bucket, "obj_class": seg.segment.get("obj_class"), "deficit_index": seg.deficit_index, "balance_units": seg.balance_units, "confidence": seg.confidence, } ) if not ranked_segments: warnings.append( "demand_supply: все ячейки сетки тонкие (предложение неизмеримо) — " "сегменты не ранжированы." ) logger.info( "forecast-overlay demand_supply: district=%s cad_num=%s horizon=%d ranked=%d (ADVISORY)", district, cad_num, horizon_months, len(ranked_segments), ) return _enrich_overlay( db, horizon_months=horizon_months, mode="demand_supply", ranked_segments=ranked_segments, warnings=warnings, district=district, demand_only=False, ) def _demand_only_overlay( db: Session, *, district: str | None, horizon_months: int, target_class: str | None, ) -> dict[str, Any]: """mode="demand_only" (cad_num=None): ТОЛЬКО темп спроса per room-bucket. БЕЗ геометрии участка supply/конкуренты НЕизмеримы. Считаем темп спроса pace = §9.2 unit_velocity × §9.4 norm.coefficient × §9.5 macro.coefficient per дефолтный room-bucket, ранжируем DESC. deficit_index = pace/max_pace ∈ (0,1]. balance_units=None, confidence='low', обязательный warning. НИКОГДА не фабрикуем supply. Graceful → []. #1593 (ИСПРАВЛЕНО): §9.2 base_pace теперь per-bucket через MarketMetrics.velocity_by_room. §9.4 norm.coefficient одинаков между bucket'ами (β без room_bucket фита) и СОКРАЩАЕТСЯ в pace/max_pace — это задокументировано, не скрыто. Ранкинг теперь отражает реальные per-bucket темпы спроса из objective_lots, а не только §9.5 macro_coef. """ # Локальные импорты — избегаем import-cycle (см. module docstring). from app.services.forecasting.demand_normalization import compute_demand_normalization from app.services.forecasting.demand_supply_forecast import hold_last_rate from app.services.forecasting.macro_coefficient import compute_macro_coefficient from app.services.forecasting.macro_series import get_monthly_macro from app.services.site_finder.market_metrics import compute_market_metrics warnings: list[str] = [ "demand-only: без геометрии участка (cad_num) supply/конкуренты НЕ учтены" ] mapped_class = map_class(target_class) # Один раз на вызов: §9.2 наблюдаемый темп (aggregate + per-bucket) + hold-last-rate. metrics = compute_market_metrics(db, district=district) base_pace = metrics.unit_velocity # агрегатный fallback (используется если нет per-bucket) vel_by_room = metrics.velocity_by_room # #1593: per-bucket из objective_lots macro = get_monthly_macro(db) rate_future = hold_last_rate(macro, [horizon_months]).get(horizon_months) if base_pace is None: # Нет наблюдаемого темпа — ранжировать нечего (НЕ фабрикуем 0-сигнал). warnings.append( "demand-only: нет наблюдаемого темпа продаж (§9.2) — сегменты не ранжированы." ) return _enrich_overlay( db, horizon_months=horizon_months, mode="demand_only", ranked_segments=[], warnings=warnings, district=district, demand_only=True, ) paces: list[tuple[str, str | None, float]] = [] # (live_bucket, obj_class, pace) for live_bucket, forecast_bucket in _LIVE_TO_FORECAST_BUCKET.items(): spec = SegmentSpec( obj_class=mapped_class, room_bucket=forecast_bucket, district=district, ) # §9.2 #1593: per-bucket velocity из velocity_by_room (objective_lots # per комнатность). _FORECAST_TO_METRIC_BUCKETS даёт список metric-ключей # для данного forecast_bucket ("80+ м²" = "4"+"5+"). Суммируем ед./мес по # бакетам. Честный 0 при известных бакетах — НЕ fallback. Fallback на # агрегатный base_pace только когда velocity_by_room=None (пустая # выборка) или bucket неизвестен в _FORECAST_TO_METRIC_BUCKETS. metric_keys = _FORECAST_TO_METRIC_BUCKETS.get(forecast_bucket, []) if vel_by_room is not None and metric_keys: bucket_velocity: float = sum(vel_by_room.get(k, 0.0) for k in metric_keys) # Если нет продаж ни в одном из ключей — честный 0 (НЕ fallback к # aggregate: лучше показать реальный 0 чем маскировать его aggregate). bucket_base_pace: float = bucket_velocity else: # vel_by_room None (пустая выборка) или неизвестный forecast_bucket → # aggregate fallback сохраняет graceful поведение. bucket_base_pace = float(base_pace) # §9.4 нормализация под будущий режим ставки (β внутри). rate_future None # (hold_last_rate не дал ставку) → НЕ применяем §9.4: 0.0-placeholder дал бы # delta=−rate_window_avg → exp(β·delta) и клэмп к _NORM_MAX (макс. аплифт), # а НЕ нейтраль. Честная нейтраль при отсутствии будущей ставки = коэф. 1.0. if rate_future is not None: norm_coefficient = compute_demand_normalization( db, spec=spec, rate_future=rate_future ).coefficient else: norm_coefficient = 1.0 # §9.5 макро-коэффициент (ортогонален β); профиль — класс + room_bucket. profile: dict[str, Any] = {"room_bucket": forecast_bucket} if mapped_class is not None: profile["obj_class"] = mapped_class macro_coef = compute_macro_coefficient(db, segment_profile=profile) pace = bucket_base_pace * norm_coefficient * macro_coef.coefficient paces.append((live_bucket, mapped_class, pace)) max_pace = max((p for _, _, p in paces), default=0.0) if max_pace <= 0.0: warnings.append("demand-only: темп спроса по всем форматам ≤0 — сегменты не ранжированы.") return _enrich_overlay( db, horizon_months=horizon_months, mode="demand_only", ranked_segments=[], warnings=warnings, district=district, demand_only=True, ) # DESC по темпу; deficit_index = pace/max_pace ∈ (0,1]. # #1593: base_pace теперь per-bucket из velocity_by_room → ранкинг отражает # реальный спрос по комнатности. §9.4 norm одинаков между bucket'ами и # сокращается в pace/max_pace (β без per-bucket фита — честно задокументировано). paces.sort(key=lambda t: t[2], reverse=True) ranked_segments: list[dict[str, Any]] = [ { "bucket": live_bucket, "obj_class": obj_class, "deficit_index": pace / max_pace, "balance_units": None, # supply неизмеримо без cad_num — НЕ фабрикуем "confidence": "low", } for live_bucket, obj_class, pace in paces ] logger.info( "forecast-overlay demand_only: district=%s horizon=%d ranked=%d " "base_pace_agg=%.3f vel_by_room=%s (ADVISORY, supply НЕ учтён, #1593 per-bucket)", district, horizon_months, len(ranked_segments), base_pace, vel_by_room, ) return _enrich_overlay( db, horizon_months=horizon_months, mode="demand_only", ranked_segments=ranked_segments, warnings=warnings, district=district, demand_only=True, ) def _overlay( horizon_months: int, mode: str, ranked_segments: list[dict[str, Any]], warnings: list[str], *, class_reco: dict[str, Any] | None = None, usp: list[dict[str, Any]] | None = None, commercial: dict[str, Any] | None = None, ) -> dict[str, Any]: """Собрать RecommendForecastOverlay-shaped dict. advisory ВСЕГДА True. PURE. #982-поля (horizon_months/mode/advisory/ranked_segments/warnings) — без изменений. #983 ADDITIVE-поля (class_reco §10.2 / usp §10.5 / commercial §10.4) — опциональны, дефолты сохраняют #982-байт-в-байт-форму при пустом расширении (None/[]). """ return { "horizon_months": horizon_months, "mode": mode, "advisory": True, "ranked_segments": ranked_segments, "warnings": warnings, "class_reco": class_reco, "usp": usp if usp is not None else [], "commercial": commercial, } def _commercial_signal( db: Session, district: str | None, horizon_months: int ) -> dict[str, Any] | None: """§10.4 советующий коммерческий сигнал (темп распродажи нежилого) — degraded-honest. Пробует измерить нежилой сток через `compute_market_metrics(premise_kind= "нежилое")`. objective покрывает в основном жильё → выборка обычно тонкая. Тогда возвращаем degraded-honest {available: False, caveat, advisory} — НЕ фабрикуем число. Если данных достаточно (≥ _COMMERCIAL_MIN_LOTS лотов) → советующая оценка ТЕМПА РАСПРОДАЖИ нежилого: sell_through_pct = проданные ÷ (проданные+доступные)·100 (market_metrics §-определение) — прокси ликвидности/спроса на нежилое, НЕ доля нежилого в объёме застройки + §16-подобный reason. НИКОГДА не бросает: любой сбой движка/импорта → degraded-honest None-сигнал. Args: db: SQLAlchemy sync Session. district: район (None → ЕКБ-wide). horizon_months: горизонт (для контекста caveat/reason). Returns: Советующий dict {available, ...} либо None при полном отсутствии движка. """ # Локальный импорт — зеркало остального модуля (избегаем import-cycle). from app.services.site_finder.market_metrics import compute_market_metrics caveat = ( f"коммерция: нет достаточных данных (premise_kind={_COMMERCIAL_PREMISE_KIND}, " f"горизонт {horizon_months} мес)" ) try: metrics = compute_market_metrics( db, district=district, premise_kind=_COMMERCIAL_PREMISE_KIND ) n_lots = _as_int(getattr(metrics, "n_lots", None)) sell_through = _as_float(getattr(metrics, "sell_through_pct", None)) confidence = getattr(metrics, "confidence", "low") except Exception: # Движок не поддерживает коммерческий premise_kind / сбой — честный degrade. logger.exception( "forecast-overlay commercial: market_metrics failed (district=%s) → degraded", district, ) return {"available": False, "caveat": caveat, "advisory": True} # Тонкая выборка (objective ~ жильё) / неизмеримая доля → degraded-honest, без # фабрикации числа. n_lots None (нечисловой/сбой) тоже трактуем как недостаток. if n_lots is None or n_lots < _COMMERCIAL_MIN_LOTS or sell_through is None: logger.info( "forecast-overlay commercial: thin (district=%s n_lots=%s) → degraded (ADVISORY)", district, n_lots, ) return {"available": False, "caveat": caveat, "advisory": True} # Достаточно данных: советующая оценка ТЕМПА РАСПРОДАЖИ нежилого (прокси # ликвидности/спроса), НЕ доли нежилого в объёме застройки (#1635). sell_through_pct = round(sell_through, 1) confidence = confidence if confidence in ("high", "medium", "low") else "low" return { "available": True, "premise_kind": _COMMERCIAL_PREMISE_KIND, # #1635: ключ = ТЕМП РАСПРОДАЖИ нежилого (sell_through, прокси # ликвидности/спроса), НЕ доля нежилого в объёме застройки. "commercial_sell_through_pct": sell_through_pct, "n_lots": n_lots, "confidence": confidence, "reason": { "why": ( f"Коммерция (нежилое): темп распродажи ~{sell_through_pct}% по {n_lots} " f"лотам на горизонте {horizon_months} мес (прокси ликвидности/спроса на " f"нежилые помещения, НЕ доля нежилого в объёме застройки)." ), "drivers": [ {"factor": "sell_through_pct", "value": sell_through_pct, "direction": "+"}, {"factor": "n_lots", "value": n_lots, "direction": "+"}, ], "rejected": [], "what_would_change": [_LEVER_SUPPLY], "confidence": confidence, "advisory": True, }, "advisory": True, } def _as_int(value: Any) -> int | None: """Безопасно привести значение к int (None/нечисловое → None). Graceful. PURE. Защита §10.4 от мок/мусор-атрибутов (MagicMock < int бросил бы TypeError): любой нечисловой/bool/сбойный вход → None → degraded-honest путь, без падения. """ if isinstance(value, bool) or not isinstance(value, int | float): return None return int(value) def _as_float(value: Any) -> float | None: """Безопасно привести значение к float (None/нечисловое → None). Graceful. PURE.""" if isinstance(value, bool) or not isinstance(value, int | float): return None return float(value) def _enrich_overlay( db: Session, *, horizon_months: int, mode: str, ranked_segments: list[dict[str, Any]], warnings: list[str], district: str | None, demand_only: bool, ) -> dict[str, Any]: """#983 ADDITIVE-сборка: §16 reason на каждом сегменте + §10.2/§10.5/§10.4. Graceful. Мутирует `ranked_segments` IN-PLACE, добавляя `reason` (§16) к каждой ячейке (rejected деривируется из всего списка). Затем считает class_reco (§10.2), usp (§10.5) и commercial (§10.4) и собирает overlay через `_overlay`. Расширения §10.2/§10.5 только при НАЛИЧИИ ранжированных данных (иначе None/[]); commercial (§10.4) пробуем всегда (он независим от ранкинга, сам degraded-honest). Все #982-поля сохраняются _overlay'ем без изменений. Args: db: SQLAlchemy sync Session (для §10.4 коммерции). horizon_months: горизонт прогноза (мес). mode: "demand_supply" | "demand_only". ranked_segments: live segment-dict'ы (мутируются — добавляется reason). warnings: #982-варнинги (пробрасываются как есть). district: район (для §10.4). demand_only: True → reason'ы оговаривают, что supply не учтён. Returns: RecommendForecastOverlay-shaped dict с #983-расширениями. """ for seg in ranked_segments: di = seg.get("deficit_index") if di is None: continue seg["reason"] = _build_reason( seg, float(di), ranked_segments, horizon_months=horizon_months, demand_only=demand_only, ) class_reco = ( _recommend_class(ranked_segments, horizon_months=horizon_months, demand_only=demand_only) if ranked_segments else None ) usp = ( _usp_from_deficits(ranked_segments, horizon_months=horizon_months, demand_only=demand_only) if ranked_segments else [] ) commercial = _commercial_signal(db, district, horizon_months) return _overlay( horizon_months, mode, ranked_segments, warnings, class_reco=class_reco, usp=usp, commercial=commercial, )