340 lines
16 KiB
Python
340 lines
16 KiB
Python
"""§9.7 forecast-overlay для живого квартирография-рекомендатора (#982, 953-A).
|
||
|
||
Это ТОНКИЙ СОВЕТУЮЩИЙ мост между ЖИВЫМ `analytics_queries.recommend_mix` и
|
||
форсайт-движком §9.x (#980/#981). Своей §9.x-математики НЕ пересобирает — собирает
|
||
ответ из уже-смерженных сервисов и форматирует под schemas.recommend overlay-контракт.
|
||
Всё ДЕТЕРМИНИРОВАННО, БЕЗ LLM; своего SQL НЕТ.
|
||
|
||
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] — это ПРОКСИ относительной силы спроса, НЕ supply-based дефицит;
|
||
`balance_units=None`, confidence='low', обязательный warning что supply НЕ учтён.
|
||
НИКОГДА не фабрикуем предложение/конкурентов из воздуха.
|
||
|
||
ИМПОРТЫ §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()}
|
||
|
||
# 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": "эконом",
|
||
}
|
||
|
||
|
||
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)
|
||
|
||
|
||
# ── DB-оркестратор overlay — тонкий, graceful, live-safe ──────────────────────
|
||
|
||
|
||
def build_forecast_overlay(
|
||
db: Session,
|
||
*,
|
||
district: str | None,
|
||
cad_num: str | None,
|
||
horizon_months: int,
|
||
target_class: str | None,
|
||
market_buckets: list[dict[str, Any]] | None = None,
|
||
) -> 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 _overlay(horizon_months, "demand_supply", [], warnings)
|
||
|
||
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 _overlay(horizon_months, "demand_supply", ranked_segments, warnings)
|
||
|
||
|
||
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] — ПРОКСИ относительной силы спроса (НЕ supply-based дефицит).
|
||
balance_units=None, confidence='low', обязательный warning. НИКОГДА не
|
||
фабрикуем supply. Graceful → [].
|
||
"""
|
||
# Локальные импорты — избегаем 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 наблюдаемый темп (base_pace) + hold-last-rate для §9.4.
|
||
metrics = compute_market_metrics(db, district=district)
|
||
base_pace = metrics.unit_velocity
|
||
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 _overlay(horizon_months, "demand_only", [], warnings)
|
||
|
||
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.4 нормализация под будущий режим ставки (β внутри; rate_future None →
|
||
# деградирует к нейтрали внутри себя, передаём 0.0 как placeholder).
|
||
norm = compute_demand_normalization(
|
||
db, spec=spec, rate_future=rate_future if rate_future is not None else 0.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 = 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 _overlay(horizon_months, "demand_only", [], warnings)
|
||
|
||
# DESC по темпу; deficit_index = pace/max_pace ∈ (0,1] (ПРОКСИ, НЕ supply-based).
|
||
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=%.3f "
|
||
"(ADVISORY, supply НЕ учтён)",
|
||
district,
|
||
horizon_months,
|
||
len(ranked_segments),
|
||
base_pace,
|
||
)
|
||
return _overlay(horizon_months, "demand_only", ranked_segments, warnings)
|
||
|
||
|
||
def _overlay(
|
||
horizon_months: int,
|
||
mode: str,
|
||
ranked_segments: list[dict[str, Any]],
|
||
warnings: list[str],
|
||
) -> dict[str, Any]:
|
||
"""Собрать RecommendForecastOverlay-shaped dict. advisory ВСЕГДА True. PURE."""
|
||
return {
|
||
"horizon_months": horizon_months,
|
||
"mode": mode,
|
||
"advisory": True,
|
||
"ranked_segments": ranked_segments,
|
||
"warnings": warnings,
|
||
}
|