gendesign/backend/app/services/forecasting/recommendation.py
Light1YT 574ee43577 feat(forecasting): recommendation overlay module + schema/route (#982, 953-A)
Recreate the recommendation.py bridge module + RecommendMixInput
horizon_months/cad_num fields + RecommendForecast{Segment,Overlay} models +
endpoint passthrough + test_recommendation.py — completing the #982 opt-in
whose analytics_queries.recommend_mix wire-in landed in the prior commit.

build_forecast_overlay(db,*,district,cad_num,horizon_months,target_class,
market_buckets) matches the committed call. demand_supply via rank_segments
(cad_num) / demand_only pace-proxy (no cad_num, no fabricated supply, warning).
Pure room/class bridges drift-guarded vs _BUCKET_PRETTY; local §9.x imports
avoid the analytics_queries↔forecasting cycle. 55 tests green.
2026-06-03 12:40:03 +05:00

340 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""§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,
}