gendesign/backend/app/services/forecasting/what_to_build.py
Light1YT 8cd0620543
All checks were successful
CI / changes (pull_request) Successful in 8s
CI / changes (push) Successful in 9s
CI / frontend-tests (push) Successful in 1m43s
CI / frontend-tests (pull_request) Successful in 1m59s
CI / openapi-codegen-check (push) Successful in 2m43s
CI / openapi-codegen-check (pull_request) Successful in 1m55s
CI / backend-tests (push) Successful in 9m28s
CI / backend-tests (pull_request) Successful in 9m28s
feat(site-finder): продуктовые решения по фидбеку analyze (#1741 #1742 #1743 #1744 #1745)
После ролевой дискуссии (аналитик×комдир×собственник):
- #1741 секции в 2 группы «Участок»→«Стройка и рынок» (Section1→4→2→3→5→6), gate-баннер вынесен НАД группой (убран дубль из Section4Estimate), nav-группы
- #1742 объяснение выбора класса: фраза-причина + мини-таблица 3 классов по deficit_index («+1 острый дефицит, −1 затоварка»). POI «обеспеченность»/market-overstock relabel — N/A (в UI не выводятся)
- #1743 «Остатки и скорость»→«Как продаётся рынок рядом» + headline-вердикт + «Темп продаж»/«Будущее предложение конкурентов (24 мес)» (убран англицизм Velocity-score/Pipeline) + интерпретированные единицы
- #1744 горизонт: caption+tooltip «пересчёт ~10-30с» + статус «Пересчитываем…»; колонки прогноза «…, квартир» + caveat advisory
- #1745 фаза1: таблица «Прогноз по всем форматам» (студия/1-5к × индекс дефицита/прогноз спроса/сигнал); backend пробросил per-format projected_demand_units + signal. Слайдеры/рубли — фаза2 после бэктеста #951

Verify: tsc 0 ошибок; py_compile; what_to_build/recommendation/report/exporter тесты pass.

Closes #1741
Closes #1742
Closes #1743
Closes #1744
Closes #1745
2026-06-18 12:18:04 +05:00

365 lines
19 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 «что строить» — ранкер сегментной сетки по индексу дефицита (ТЗ §9.7).
#981 (952-B, Site Finder v2 / «GG-форсайт» ТЗ §9.7), EPIC 8 «что строить +
доступность». Это **СБОРОЧНЫЙ слой над #980**: прогоняет уже-смерженный
центральный движок `compute_demand_supply_forecast` (§9.8) по СЕТКЕ сегментов
(класс × room_bucket [× price_bucket]) для участка/горизонта и РАНЖИРУЕТ ячейки по
`deficit_index` (по убыванию = сила сигнала «строить»). Это продуктовый ответ §9.7
«строить студии? двушки? какой класс?». Своей §9.x-математики НЕ пересобирает —
вызывает #980 per-cell. Всё ДЕТЕРМИНИРОВАННО, БЕЗ LLM; своего SQL НЕТ.
ИНТЕРПРЕТАЦИЯ ИНДЕКСА (наследуется от #980 _deficit_index, см. его docstring):
• deficit_index > 0 → НЕДОобеспечен (спроса больше предложения) = СИГНАЛ СТРОИТЬ.
• deficit_index = 0 → баланс спрос↔предложение.
• deficit_index < 0 → ЗАТОВАРКА (предложения больше спроса) = ИЗБЕГАТЬ.
• deficit_index None → тонкие данные (предложение неизмеримо) — ячейка ОТБРАСЫВАЕТСЯ.
Ранжируем по убыванию: топ списка = самый сильный сигнал «строить именно это».
ADVISORY (КРИТИЧНО — честность): КАЖДАЯ ячейка наследует advisory-статус #980 и его
жёсткий потолок confidence ≤ 'medium' (движок не провалидирован до бэктеста #951).
Поэтому и весь ранкинг СОВЕТУЮЩИЙ: `generated_advisory` ВСЕГДА True. Цифры — для
explainability/прототипа, не основание для инвест-решения.
СЕТКА (сознательно НЕ взрываем до 1000 ячеек): основные оси — room_bucket × class
(дефолтная сетка из bucket-вокабуляра sales_series). price_bucket опционален
(по умолчанию один «без фильтра цены» проход — иначе ×4 ячеек ради оси, которую
#980 §9.5 учитывает грубо). Все оси — named-константы; вызывающий может сузить.
room_bucket source-семантика (см. sales_series.SegmentSpec): дефолтные ключи —
Source-B room_area-вокабуляр ('Студии 15-30' / '1-к 30-45' / …), т.к. это вывод
room_area_bucket_of. #980 пробрасывает room_bucket в §9.4/§9.5 profile; для агрегатов
класс/район Source-A ('студия'/'1'/'2'/…) — вызывающий может передать свой набор.
Graceful (дух demand_supply_forecast): пустая сетка / все ячейки None → ПУСТОЙ
ранкинг (не crash). Сбой #980 на отдельной ячейке → ячейка пропускается, остальные
ранжируются. Детерминированно (стабильная сортировка с tie-break по segment-ключу).
"""
from __future__ import annotations
import logging
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Any, Literal
from sqlalchemy.orm import Session
from app.services.forecasting.demand_supply_forecast import compute_demand_supply_forecast
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,
)
logger = logging.getLogger(__name__)
Confidence = Literal["high", "medium", "low"]
# ── Named-константы (дефолтная сетка) ─────────────────────────────────────────
# Дефолтные классы недвижимости (ось «класс»). ЕКБ-вокабуляр Объектива в
# «человеческом» регистре — SQL внутри §9.x нормализует регистр. 3 массовых класса
# (премиум опускаем из дефолта — тонкий сегмент, обычно low-данные; вызывающий
# может добавить). Держим явной константой для детерминизма сетки.
_DEFAULT_CLASSES: tuple[str, ...] = ("эконом", "комфорт", "бизнес")
# Дефолтные room-bucket'ы (ось «формат») — Source-B room_area-вокабуляр
# (вывод room_area_bucket_of). 5 форматов: студии / 1-к / 2-к / 3-к / 80+. Это
# продуктовая ось §9.7 «какой формат строить».
_DEFAULT_ROOM_BUCKETS: tuple[str, ...] = (
ROOM_AREA_BUCKET_STUDIO,
ROOM_AREA_BUCKET_1K,
ROOM_AREA_BUCKET_2K,
ROOM_AREA_BUCKET_3K,
ROOM_AREA_BUCKET_LARGE,
)
# Дефолтная ось цены: один проход БЕЗ price-фильтра (None). Сознательно не дробим
# по price_bucket в дефолте — иначе сетка ×4 ради оси, которую #980 §9.5 учитывает
# лишь грубо (price_tier в profile). Вызывающий может передать реальные bucket'ы.
_DEFAULT_PRICE_BUCKETS: tuple[str | None, ...] = (None,)
# Ранг уверенности для tie-break (выше = лучше). Зеркало demand_supply_forecast.
_CONFIDENCE_RANK: dict[Confidence, int] = {"low": 0, "medium": 1, "high": 2}
@dataclass(frozen=True)
class RankedSegment:
"""Одна ячейка сетки §9.7 с её сигналом дефицита (наследует #980 на горизонте).
`deficit_index` ∈ [1,+1]: >0 = строить (недообеспечен), <0 = избегать
(затоварка). Ячейки с deficit_index None (тонкие данные) в ранкинг НЕ попадают
(отброшены ранкером). `confidence` ≤ 'medium' (наследуется от #980 advisory-cap).
"""
segment: dict[str, str | None]
deficit_index: float # не None в ранкинге (None-ячейки отброшены)
balance_units: float | None # demand supply (>0 дефицит / <0 затоварка)
confidence: Confidence # ≤ 'medium' (#980 advisory-cap)
projected_demand_units: float | None = None # #1745: спрос на горизонт (квартир)
def as_dict(self) -> dict[str, Any]:
return {
"segment": dict(self.segment),
"deficit_index": _round_or_none(self.deficit_index, 3),
"balance_units": _round_or_none(self.balance_units, 1),
"projected_demand_units": _round_or_none(self.projected_demand_units, 1),
"confidence": self.confidence,
}
@dataclass(frozen=True)
class WhatToBuildRanking:
"""§9.7 ранжированный ответ «что строить» для участка/горизонта (ТЗ §9.7).
`ranked` отсортирован по `deficit_index` УБЫВАНИЕ (топ = сильнейший сигнал
«строить»); ячейки с deficit_index None отброшены (тонкие данные). Пустой
`ranked` = вся сетка тонкая / нет данных (graceful, не crash).
`generated_advisory` ВСЕГДА True — каждая ячейка наследует advisory-статус #980;
ранкинг СОВЕТУЮЩИЙ, не основание для инвест-решения.
"""
district: str | None
cad_num: str
horizon_months: int
ranked: list[RankedSegment] # DESC по deficit_index; None-ячейки отброшены
n_cells_scanned: int # сколько ячеек сетки прогнали (включая отброшенные)
n_cells_ranked: int # сколько попало в ранкинг (deficit_index не None)
generated_advisory: bool # ВСЕГДА True (наследует advisory #980)
def as_dict(self) -> dict[str, Any]:
return {
"district": self.district,
"cad_num": self.cad_num,
"horizon_months": self.horizon_months,
"ranked": [r.as_dict() for r in self.ranked],
"n_cells_scanned": self.n_cells_scanned,
"n_cells_ranked": self.n_cells_ranked,
"generated_advisory": self.generated_advisory,
}
def _round_or_none(value: float | None, digits: int) -> float | None:
return round(value, digits) if value is not None else None
# ──────────────────────────────────────────────────────────────────────────────
# Pure-логика — без БД, полностью юнит-тестируемо.
# ──────────────────────────────────────────────────────────────────────────────
def _rank_key(cell: RankedSegment) -> tuple[float, int, str]:
"""Ключ сортировки ячейки сетки для ранжирования §9.7. PURE.
Сортируем по (deficit_index DESC, confidence DESC, segment-ключ ASC):
1. deficit_index — primary (НЕГАТИВ для DESC через sorted без reverse-флага,
чтобы tie-break'и шли в естественном ASC-порядке): больше дефицит = выше.
2. confidence — tie-break: при равном индексе выше та ячейка, чьему сигналу
больше доверия (negate ранга для DESC).
3. segment-ключ — финальный СТАБИЛЬНЫЙ tie-break (детерминизм при равных
index+confidence): лексикографический по компактной строке сегмента.
Используется как `sorted(cells, key=_rank_key)` — БЕЗ reverse: негативы
primary/secondary дают убывание, а строковый ключ — возрастание. PURE.
Args:
cell: ячейка сетки (deficit_index не None — None-ячейки отброшены до сорта).
Returns:
Кортеж-ключ (deficit_index, confidence_rank, segment_key).
"""
confidence_rank = _CONFIDENCE_RANK.get(cell.confidence, 0)
return (-cell.deficit_index, -confidence_rank, _segment_key(cell.segment))
def _segment_key(segment: dict[str, str | None]) -> str:
"""Стабильная компактная строка сегмента для детерминированного tie-break. PURE.
Конкатенация осей в фиксированном порядке (class|room|district|price); None →
пустая строка. Не для отображения — только для устойчивой сортировки.
"""
return "|".join(
str(segment.get(axis) or "")
for axis in ("obj_class", "room_bucket", "district", "price_bucket")
)
def _build_grid(
*,
district: str | None,
classes: Sequence[str],
room_buckets: Sequence[str],
price_buckets: Sequence[str | None],
) -> list[SegmentSpec]:
"""Декартова сетка SegmentSpec из осей класс × room × price. PURE.
По одному SegmentSpec на комбинацию (class, room_bucket, price_bucket); district
проставляется во все (общий для участка). Порядок детерминирован (class-внешний,
room-средний, price-внутренний) — стабильный n_cells_scanned. PURE.
Args:
district: район участка (общий для всех ячеек).
classes: ось классов.
room_buckets: ось форматов (room_bucket).
price_buckets: ось цены (может содержать None = «без фильтра цены»).
Returns:
Список SegmentSpec — len = |classes|×|room_buckets|×|price_buckets|.
"""
grid: list[SegmentSpec] = []
for obj_class in classes:
for room_bucket in room_buckets:
for price_bucket in price_buckets:
grid.append(
SegmentSpec(
obj_class=obj_class,
room_bucket=room_bucket,
district=district,
price_bucket=price_bucket,
)
)
return grid
# ──────────────────────────────────────────────────────────────────────────────
# DB-оркестратор — тонкий, graceful. Pure-логика выше тестируется без него.
# ──────────────────────────────────────────────────────────────────────────────
def rank_segments(
db: Session,
*,
district: str | None,
cad_num: str,
horizon_months: int,
classes: Sequence[str] = _DEFAULT_CLASSES,
room_buckets: Sequence[str] = _DEFAULT_ROOM_BUCKETS,
price_buckets: Sequence[str | None] = _DEFAULT_PRICE_BUCKETS,
rate_path: dict[int, float] | None = None,
) -> WhatToBuildRanking:
"""§9.7 ранжировать сегменты по индексу дефицита для участка/горизонта.
ADVISORY (каждая ячейка наследует advisory-статус #980, см. module docstring) —
НЕ подключать в production-эндпоинт. СБОРКА над #980, НЕ пересчёт §9.x.
Прогоняет `compute_demand_supply_forecast` (#980) по сетке класс × room × price
(дефолт — _build_grid из named-констант). Для КАЖДОЙ ячейки берёт прогноз на
`horizon_months`, собирает (segment, deficit_index, balance_units, confidence),
ОТБРАСЫВАЕТ ячейки с deficit_index None (тонкие данные — предложение неизмеримо),
сортирует по `deficit_index` УБЫВАНИЕ (tie-break: confidence ↓, затем стабильный
segment-ключ). Топ списка = сильнейший сигнал «строить именно это».
Graceful: пустая сетка / все ячейки None → ПУСТОЙ ранкинг (не crash). Сбой #980
на ячейке → ячейка пропускается, остальные ранжируются. Детерминированно.
Args:
db: SQLAlchemy sync Session.
district: район участка (None → ЕКБ-wide).
cad_num: кадастровый номер участка — вход #980 (§9.7 конкуренты).
horizon_months: горизонт прогноза (мес) — ОДИН (продуктовый «на горизонте»).
classes: ось классов (по умолчанию _DEFAULT_CLASSES).
room_buckets: ось форматов (по умолчанию _DEFAULT_ROOM_BUCKETS, Source-B).
price_buckets: ось цены (по умолчанию _DEFAULT_PRICE_BUCKETS = [None]).
rate_path: сценарный {horizon: key_rate} для #980; None → hold_last_rate.
Returns:
WhatToBuildRanking — DESC-ранкинг (всегда; пустой при тонкой сетке).
"""
grid = _build_grid(
district=district,
classes=classes,
room_buckets=room_buckets,
price_buckets=price_buckets,
)
cells: list[RankedSegment] = []
for spec in grid:
cell = _forecast_cell(
db,
spec=spec,
district=district,
cad_num=cad_num,
horizon_months=horizon_months,
rate_path=rate_path,
)
if cell is not None:
cells.append(cell)
ranked = sorted(cells, key=_rank_key)
logger.info(
"what_to_build: district=%s cad_num=%s horizon=%d scanned=%d ranked=%d "
"top_segment=%s top_deficit=%s (ADVISORY)",
district,
cad_num,
horizon_months,
len(grid),
len(ranked),
ranked[0].segment if ranked else None,
_round_or_none(ranked[0].deficit_index, 3) if ranked else None,
)
return WhatToBuildRanking(
district=district,
cad_num=cad_num,
horizon_months=horizon_months,
ranked=ranked,
n_cells_scanned=len(grid),
n_cells_ranked=len(ranked),
generated_advisory=True,
)
def _forecast_cell(
db: Session,
*,
spec: SegmentSpec,
district: str | None,
cad_num: str,
horizon_months: int,
rate_path: dict[int, float] | None,
) -> RankedSegment | None:
"""Прогнать #980 для ОДНОЙ ячейки сетки → RankedSegment | None. Graceful.
Вызывает compute_demand_supply_forecast на единственном горизонте, берёт его
deficit_index/balance_units/confidence. None при: deficit_index None (тонкие
данные — ячейка вне ранкинга), пустом результате, ИЛИ сбое #980 (не валим всю
сетку — ячейка просто выпадает). Детерминированно.
Returns:
RankedSegment или None (отбрасывается выше по стеку).
"""
try:
forecasts = compute_demand_supply_forecast(
db,
spec=spec,
district=district,
cad_num=cad_num,
horizons=[horizon_months],
rate_path=rate_path,
)
except Exception:
logger.exception(
"what_to_build: forecast failed for cell segment=%s horizon=%d",
spec.as_dict(),
horizon_months,
)
return None
if not forecasts:
return None
forecast = forecasts[0]
if forecast.deficit_index is None:
# Тонкие данные (предложение неизмеримо) — ячейка вне ранкинга (НЕ 0-сигнал).
return None
return RankedSegment(
segment=forecast.segment,
deficit_index=forecast.deficit_index,
balance_units=forecast.balance_units,
confidence=forecast.confidence,
projected_demand_units=forecast.projected_demand_units,
)