gendesign/backend/app/services/forecasting/demand_supply_forecast.py
Light1YT 41804ed70e fix(forecast): посегментный+дедуплицированный индекс дефицита (#1959)
Корень «−1.00 везде» (эпик #1953): compute_demand_supply_forecast брал
district-wide unit_velocity (847.5/мес, ВСЕ классы/комнаты) как спрос и
весь district-сток (~63k доступных) как предложение для КАЖДОЙ ячейки
what_to_build → один и тот же ratio во всех ячейках → все deficit_index
прижаты к −1.0. Плюс objective_lots — append-per-snapshot (~2.9× инфляция
строк), что симметрично раздувало обе базы → даже сегментация без дедупа
осталась бы вырожденной.

Фикс (blast radius — ТОЛЬКО forecast/deficit calc; platform-wide dedup = #1964):
- market_metrics.compute_market_metrics: +obj_class/+room_bucket (+cache key).
  _STOCK_SQL и _SALES_WINDOW_SQL дедуплят до ПОСЛЕДНЕГО снапшота на физлот
  (DISTINCT ON project_name,corpus_name,section,floor,lot_number ORDER BY …
  snapshot_date DESC,id DESC), затем агрегируют. Class-фильтр (LOWER=LOWER,
  class lowercase) + room-bucket (Source-B room_area-вокабуляр, зеркало
  sales_series.room_area_bucket_of → what_to_build фильтрует без перевода).
  ROLLUP/GROUPING сохранён; confidence считается на дедуплицированных counts.
- demand_supply_forecast: base_pace и open-сток теперь ПОСЕГМЕНТНЫЕ
  (market_metrics(obj_class,room_bucket)). При заданном сегменте L2/L3
  (hidden/future) ИСКЛЮЧЕНЫ из баланса — они класс/формат-агностичны, иначе
  двоились бы по всем ячейкам. +_market_room_bucket VOCAB-мост (валидирующий
  pass-through Source-B меток; неизвестное → None = без фильтра, не тихий 0-rows).
- what_to_build/_DEFAULT_CLASSES и recommendation Economy-маппинг: «эконом»→
  «стандарт» (в objective_lots эконома НЕТ, стандарт=483k → раньше ячейка
  матчила 0 строк и молча выпадала).
- report_assembler honesty-guard: если ВСЯ сетка прижата к ±1.0
  (degenerate-fallback) — не эмитим «строить»/«избегать», показываем
  «недостаточно гранулярных данных для посегментного вывода».
- data/sql/173_objective_lots_physflat_idx.sql: partial index под DISTINCT ON
  (Index Only Scan + Unique, без Sort на 1.75M строк; idempotent, BEGIN/COMMIT).

Prod-verify (parcel 66:41:0205010:287, Железнодорожный, h=24): ячейки
ДИФФЕРЕНЦИРУЮТ (12 measured, 7 distinct) вместо all −1.0; MOI комфорт/студия
38.5 vs стандарт/студия 244.3 (точное совпадение с ожидаемым).

Тесты: регрессия «ячейки различаются (не all −1.0)» + vocab-translation +
honesty-guard + посегментное предложение. ruff clean; no :name::type.
2026-06-27 22:50:58 +05:00

906 lines
57 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.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 =
сильная затоварка «риск»). Это АБСОЛЮТНЫЙ сигнал: 1 = рынок
действительно затоварен (ЕКБ-новостройка широко затоварена при текущих
ставках, поэтому 1 встречается часто и это ЧЕСТНО). Дефицит-индекс
насыщается уже при двукратном перекосе, поэтому слабо ДИСКРИМИНИРУЕТ
между сильно-затоваренными сегментами (все липнут к 1).
• MONTHS-OF-INVENTORY (MOI) = ДИСКРИМИНИРУЮЩИЙ companion-метрик к дефициту
(стандарт рынка недвижимости): сколько МЕСЯЦЕВ нужно, чтобы поглотить
конкурирующее предложение при текущем нормализованном темпе спроса
(= competing_supply / demand_per_mo). Не насыщается → различает «30 мес»
от «100 мес» там, где дефицит-индекс у обоих = 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 при этом = +1.0 ТОЛЬКО если спрос > 0 (предложение исчерпано под спрос
= ПИК дефицита по семантике [1,+1], #980), иначе None (нет сигнала: и спрос ≤0).
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.regression import compute_rate_regime_sensitivity
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,
)
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"}
# #1959 VOCAB-мост (load-bearing): SegmentSpec.room_bucket приходит из what_to_build
# в Source-B room_area-вокабуляре ("Студии 15-30"/"1-к 30-45"/"2-к 45-60"/
# "3-к 60-80"/"80+ м²"). compute_market_metrics (после #1959) принимает ИМЕННО этот
# вокабуляр напрямую (его room CASE = зеркало sales_series.room_area_bucket_of). Эта
# white-list страхует от чужих/неизвестных меток: распознанные пробрасываем как есть,
# неизвестные (или None) → None = «без room-фильтра» (district-wide темп, но НЕ тихий
# 0-rows из-за опечатки в вокабуляре). Меняешь сетку room_buckets — обнови white-list.
_FORECAST_ROOM_BUCKETS: frozenset[str] = frozenset(
{
ROOM_AREA_BUCKET_STUDIO,
ROOM_AREA_BUCKET_1K,
ROOM_AREA_BUCKET_2K,
ROOM_AREA_BUCKET_3K,
ROOM_AREA_BUCKET_LARGE,
}
)
def _market_room_bucket(room_bucket: str | None) -> str | None:
"""Перевести SegmentSpec.room_bucket в room-фильтр compute_market_metrics. PURE.
Вокабуляр уже выровнен (#1959): market_metrics принимает Source-B room_area-метки
напрямую. Эта функция — VALIDATING pass-through: распознанная Source-B метка
возвращается КАК ЕСТЬ; None / неизвестная метка → None (= «без room-фильтра»,
district-wide темп). Без этой проверки опечатка/чужой вокабуляр дали бы тихий
0-rows фильтр → метрики None → ячейка молча выпадала бы из ранкинга. PURE.
Args:
room_bucket: room_bucket сегмента (ожидается Source-B вокабуляр) или None.
Returns:
Та же Source-B метка (если в белом списке) либо None.
"""
if room_bucket is None:
return None
if room_bucket in _FORECAST_ROOM_BUCKETS:
return room_bucket
logger.warning(
"demand_supply_forecast: unrecognized room_bucket %r — dropping room filter "
"(district-wide pace). Expected Source-B vocab %s.",
room_bucket,
sorted(_FORECAST_ROOM_BUCKETS),
)
return None
@dataclass(frozen=True)
class DemandSupplyForecast:
"""Per-(сегмент × горизонт) баланс СПРОС↔ПРЕДЛОЖЕНИЕ (ТЗ §9.8, считается на лету).
Все величины детерминированы. Любое числовое поле = None при недостатке данных
(НИКОГДА 0-как-заглушка). `advisory` ВСЕГДА True (движок не для production-решений
до бэктеста #951). `deficit_index` ∈ [1,+1] когда задан: +1 = сильный дефицит
(мало предложения под спрос — «хорошо» для девелопера), 1 = сильная затоварка
(предложения больше спроса — «риск»). Предложение исчерпано под спрос (supply≤0,
demand>0) → +1.0 (пик дефицита, #980, хотя balance_ratio тогда None); None только
при отсутствии сигнала (supply≤0 И demand≤0/None).
`deficit_index` — АБСОЛЮТНЫЙ сигнал (1 = реально затоварено; ЕКБ-новостройка
широко затоварена при текущих ставках → 1 встречается часто и это честно). Он
насыщается уже при двукратном перекосе, поэтому слабо ДИСКРИМИНИРУЕТ среди
сильно-затоваренных сегментов. `months_of_inventory` (MOI) — ДИСКРИМИНИРУЮЩИЙ
companion: сколько месяцев нужно поглотить ВАЛОВОЕ конкурирующее предложение при
текущем нормализованном темпе спроса (= gross_supply / demand_per_mo). Не
насыщается → различает «30 мес» от «100 мес» там, где дефицит у обоих = 1.
`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
# ── Баланс / индекс дефицита / months-of-inventory ─────────────────────────
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] (АБСОЛЮТ.)
months_of_inventory: float | None # ВАЛОВОЕ предложение / demand_per_mo (ДИСКРИМИН.)
# ── 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
# Шок-окно (PR2): True, если §9.5 macro_coefficient ИЛИ §9.6 rate_sensitivity
# сообщили `confounded` (ряд пересекает структурный разрыв 2024-07-01). Прокидываем
# в `as_dict()` → `report_assembler._confounded` → `compute_report_confidence` (#990)
# confounded factor: НИКОГДА не позволяет §15 объявить 'high'. Дефолт False (чистое
# окно). Без этого поля §15-фактор шок-окна (#1222) перманентно мёртв.
confounded: bool = False
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),
"months_of_inventory": _round_or_none(self.months_of_inventory, 1),
"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,
"confounded": self.confounded,
}
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 _gross_supply(
open_units: float, hidden_release_units: float, future_online_units: float
) -> float:
"""Валовое конкурирующее предложение = open + hidden_release + future_online. PURE.
Всё, что выйдет/доступно на горизонте, ДО вычета поглощённого спросом. Это
«total inventory» для учебной MOI (total-inventory / sales-rate) — берём
ИМЕННО валовое (а не net после absorption), т.к. MOI отвечает «сколько месяцев
рынок переваривает ВЕСЬ доступный сток при текущем темпе», что и есть стандарт.
"""
return open_units + hidden_release_units + future_online_units
def _project_supply(
open_units: float,
hidden_release_units: float,
future_online_units: float,
projected_demand_units: float | None,
) -> float:
"""Чистое конкурирующее предложение = валовое поглощённое спросом, clamp ≥0.
Валовое (см. `_gross_supply`) = 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 = _gross_supply(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.
АБСОЛЮТНЫЙ сигнал, НЕ относительный рейтинг: 1 = рынок реально затоварен
(ЕКБ-новостройка широко затоварена при текущих ставках → 1 встречается часто и
это честно). Насыщается уже при ДВУКРАТНОМ перекосе, поэтому слабо
ДИСКРИМИНИРУЕТ среди сильно-затоваренных сегментов (все липнут к 1) — для
различения пользуйтесь companion-метрикой months_of_inventory (gross-предложение
/ помесячный темп спроса), которая не насыщается. Намеренно НЕ перекалибруем
шкалу под ЕКБ: индекс честен как абсолют.
Лог-шкала (отношение мультипликативно — ×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 → деградация:
знак ratio1 (без насыщающей шкалы). Монотонно неубывающее по 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 _deficit_index_from_balance(
projected_demand_units: float | None,
projected_supply_units: float | None,
balance_ratio: float | None,
*,
saturation: float = _DEFICIT_RATIO_SATURATION,
) -> float | None:
"""deficit_index с явной обработкой исчерпанного предложения (#980). PURE.
Нормальный путь: supply > 0 → `_deficit_index(balance_ratio)` (лог-шкала).
Граничный случай СИЛЬНЕЙШЕГО дефицита (#980): спрос поглотил ВСЁ предложение
(effective `projected_supply_units` ≤ 0) ПРИ положительном спросе. balance_ratio
тогда честно None (спрос/0 неотличим от «нет данных» — оставляем None как есть),
НО индекс по документированной семантике (∈ [1,+1], +1 = сильный дефицит)
обязан быть +1.0, а НЕ None: исчерпанное под спрос предложение — это пик шкалы
дефицита, а не «нет сигнала». Раньше тут возвращался None — самый сильный
дефицит читался downstream как тонкие данные (market_fit падал к 0.5, ячейка
выпадала из what_to_build). Кап = +1.0 (саму ∞-кратность не выдумываем — клампим
к пределу шкалы, зеркало `_deficit_index` clamp-а).
«Нет сигнала вообще» (supply ≤ 0 И спрос ≤ 0 / None) → None: ни дефицита, ни
затоварки измерить нельзя (нечему конкурировать и нечему поглощать).
Args:
projected_demand_units: спроектированный спрос (None / ≤0 → нет дефицит-сигнала).
projected_supply_units: чистое конкурирующее предложение (≤0 → исчерпано).
balance_ratio: спрос/предложение (вход нормального пути; None при supply ≤0).
saturation: ratio, при котором индекс достигает ±1 (proxy в _deficit_index).
Returns:
Индекс в [1,+1] или None (нет измеримого сигнала).
"""
if projected_supply_units is not None and projected_supply_units <= 0:
# Предложение исчерпано: дефицит = +1 ТОЛЬКО при положительном спросе,
# иначе сигнала нет (None) — не путаем «пик дефицита» с «пустым рынком».
if projected_demand_units is not None and projected_demand_units > 0:
return 1.0
return None
return _deficit_index(balance_ratio, saturation=saturation)
def _months_of_inventory(
competing_supply: float | None, demand_per_mo: float | None
) -> float | None:
"""Months-of-inventory = валовое конкурирующее предложение / темп спроса (мес). PURE.
Стандартная метрика рынка недвижимости (total-inventory / monthly-sales-rate):
сколько МЕСЯЦЕВ нужно, чтобы поглотить competing_supply при текущем
нормализованном помесячном темпе спроса. ДИСКРИМИНИРУЕТ там, где deficit_index
насыщается (см. module docstring): два затоваренных сегмента с deficit 1 у обоих
различаются по MOI (30 мес vs 100 мес).
ВАЖНО — берём ВАЛОВОЕ (gross) предложение, НЕ net-после-absorption: MOI отвечает
«сколько рынок переваривает ВЕСЬ доступный сток», поэтому net тут занизил бы.
Gross-MOI читается чуть выше net-based прикидок — это ожидаемо и корректно.
Graceful (НИКОГДА не crash, НИКОГДА /0):
• demand_per_mo None ИЛИ ≤0 → None («нет данных о спросе» ≠ «∞ запас»;
бесконечную кратность не выдумываем — зеркало _balance_ratio).
• competing_supply None → None (нет данных о предложении).
• competing_supply 0 (и темп>0) → 0.0 (пусто = поглощается мгновенно — это
ОПРЕДЕЛЁННЫЙ сигнал «нет конкурирующего стока», не «нет данных»).
Args:
competing_supply: валовое конкурирующее предложение (ед.; None → None).
demand_per_mo: нормализованный помесячный темп спроса (ед./мес; None/≤0 → None).
Returns:
Месяцы инвентаря (≥0) или None.
"""
if competing_supply is None:
return None
if demand_per_mo is None or demand_per_mo <= 0:
return None
if competing_supply <= 0:
return 0.0
return competing_supply / demand_per_mo
def _demand_per_mo(projected_demand_units: float | None, horizon_months: int) -> float | None:
"""Восстановить помесячный темп спроса из спроектированного на горизонт. PURE.
projected_demand = demand_per_mo × h (линейно, см. _project_demand), поэтому
demand_per_mo = projected_demand / h. Это вход MOI (НЕ ×h). None / непозитивный
горизонт → None (нет осмысленного темпа). PURE.
Args:
projected_demand_units: спрос на горизонт (None → None).
horizon_months: горизонт проекции (мес; ≤0 → None).
Returns:
Помесячный темп спроса (ед./мес) или None.
"""
if projected_demand_units is None or horizon_months <= 0:
return None
return projected_demand_units / float(horizon_months)
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 в арифметику спроса НЕ входят.
#1959 ПОСЕГМЕНТНЫЙ баланс (фикс «1.00 везде»): и СПРОС, и ПРЕДЛОЖЕНИЕ теперь
сужаются под spec (obj_class/room_bucket), а не берутся district-wide:
• base_pace = compute_market_metrics(obj_class, room_bucket).unit_velocity —
дедуплицированный темп ИМЕННО сегмента (раньше один темп на все ячейки →
каждая what_to_build-ячейка делила одно и то же → дефицит = 1.0 везде).
• open-сток = market_metrics.n_available того же сегмента (дедуп). При заданном
сегменте L2/L3 (hidden/future) ИСКЛЮЧАЮТСЯ из баланса — они класс/формат-
агностичны, иначе один объём двоился бы по всем ячейкам (снова 1.0).
Один раз на вызов:
• 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
(для НЕзаданного сегмента); при заданном сегменте open = n_available,
hidden/future = 0. Чистое предложение = open + hidden_release + future
поглощённое спросом (clamp ≥0).
• balance / ratio / знаковый deficit_index (АБСОЛЮТ) + months_of_inventory
(ДИСКРИМИНИРУЮЩИЙ companion = gross-предложение / помесячный темп спроса).
• §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 None;
index = +1.0 если спрос > 0 (пик дефицита, #980), иначе 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) ────────────────────────────
# #1959: ПОСЕГМЕНТНЫЙ темп — фильтруем market_metrics по obj_class + room_bucket
# сегмента. Раньше брался district-wide unit_velocity (один темп на ВСЕ ячейки
# what_to_build → дефицит вырождался в 1.0 везде). room_bucket идёт из Source-B
# вокабуляра what_to_build ("Студии 15-30"/…); market_metrics теперь принимает
# его напрямую (room CASE = зеркало room_area_bucket_of) → переводчик-валидатор
# (см. _market_room_bucket) только страхует от неизвестных меток.
metrics = compute_market_metrics(
db,
district=district,
premise_kind=premise_kind,
obj_class=spec.obj_class,
room_bucket=_market_room_bucket(spec.room_bucket),
)
base_pace = metrics.unit_velocity
# ── Один раз: §9.5 макро-коэффициент (ортогонален β) ───────────────────────
macro_coef = compute_macro_coefficient(db, segment_profile=profile)
# ── Один раз: §9.6 чувствительность — ТОЛЬКО для explain-фразы (НЕ арифметика)
sensitivity = compute_rate_regime_sensitivity(db, spec=spec)
# Шок-окно (PR2): берём ИЗ §9.5/§9.6 ровно один раз и прокидываем во все per-
# горизонт DemandSupplyForecast — иначе #990 confounded-factor мёртв (#1222).
# MagicMock-стабы в тестах могут не задавать атрибут → `getattr(..., False)` +
# `bool(...)` для безопасности (Mock-attribute truthy исказил бы фактическое
# значение).
confounded = _series_confounded(macro_coef, sensitivity)
# #1959: сегмент «задан», если сужена хотя бы одна предметная ось (класс/формат).
# Тогда ПРЕДЛОЖЕНИЕ берём ПОСЕГМЕНТНОЕ — open = market_metrics.n_available
# (дедуплицированный сток ИМЕННО этого сегмента), а L2/L3 (hidden/future)
# ИСКЛЮЧАЕМ из баланса: они класс/формат-агностичны (нет room-оси) → один и тот
# же объём прибавился бы КАЖДОЙ ячейке → двойной счёт и снова вырождение к 1.0.
# Незаданный сегмент (оба None) → прежний district-wide путь (open из §9.3 view +
# hidden + future), обратная совместимость для агрегатных вызовов.
segmented = spec.obj_class is not None or spec.room_bucket is not None
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,
segment_open_units=metrics.n_available,
segmented=segmented,
macro_coef=macro_coef,
sensitivity_phrase=sensitivity.phrase,
confounded=confounded,
premise_kind=premise_kind,
)
)
return out
def _series_confounded(macro_coef: Any, sensitivity: Any) -> bool:
"""Окно §9.5 macro_coefficient ИЛИ §9.6 rate_sensitivity пересекает шок-период.
PR2-флаг приходит ИЗ под-сервисов готовым `.confounded: bool` (см.
`MacroCoefficient` / `RateSensitivity`). Берём ИЛИ-агрегат: достаточно одного
True, чтобы окно считалось confounded. `getattr` с дефолтом False для
устойчивости к стабам/будущим под-вариантам без поля. Используется в `as_dict()`
→ `report_assembler._confounded` → `compute_report_confidence` (#990, #1222).
"""
macro_flag = bool(getattr(macro_coef, "confounded", False) or False)
sens_flag = bool(getattr(sensitivity, "confounded", False) or False)
return macro_flag or sens_flag
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,
segment_open_units: int,
segmented: bool,
macro_coef: Any,
sensitivity_phrase: str | None,
confounded: bool,
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 читаем ВСЕГДА (нужен fsp.confidence для итоговой уверенности; для незаданного
# сегмента — ещё и open/hidden/future как раньше).
fsp = compute_future_supply_pressure(
db, district=district, horizon_months=horizon, premise_kind=premise_kind
)
if segmented:
# #1959: ПОСЕГМЕНТНОЕ предложение = открытый сток ИМЕННО этого сегмента
# (market_metrics.n_available, дедуплицированный). L2/L3 (hidden/future)
# класс/формат-агностичны (нет room-оси) → если прибавить их каждой ячейке,
# один и тот же объём двоится по всем сегментам → дефицит снова липнет к 1.0.
# Поэтому в посегментном балансе hidden/future = 0 (в dataclass их тоже не
# показываем, чтобы explain не вводил в заблуждение «этот сегмент имеет L2/L3»).
open_units = segment_open_units
hidden_release = 0.0
future_online = 0.0
else:
open_units = fsp.open_units
hidden_fraction = _hidden_release_fraction(horizon)
hidden_release = fsp.hidden_units * hidden_fraction
future_online = fsp.future_units_by_horizon
gross_supply = _gross_supply(open_units, hidden_release, future_online)
projected_supply = _project_supply(open_units, hidden_release, future_online, projected_demand)
# ── БАЛАНС / индекс дефицита / months-of-inventory ──────────────────────────
# balance_ratio честно None при исчерпанном предложении (спрос/0 неотличим от
# «нет данных»), НО deficit_index в этом случае = +1.0 — сильнейший дефицит, не
# «нет сигнала» (#980; см. _deficit_index_from_balance). deficit_index —
# АБСОЛЮТНЫЙ сигнал (часто 1 на затоваренном ЕКБ); months_of_inventory —
# ДИСКРИМИНИРУЮЩИЙ companion (gross-предложение / помесячный темп спроса).
balance_units = _balance(projected_demand, projected_supply)
balance_ratio = _balance_ratio(projected_demand, projected_supply)
deficit_index = _deficit_index_from_balance(projected_demand, projected_supply, balance_ratio)
demand_per_mo = _demand_per_mo(projected_demand, horizon)
months_of_inventory = _months_of_inventory(gross_supply, demand_per_mo)
# ── §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 moi=%s "
"confidence=%s confounded=%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),
_round_or_none(months_of_inventory, 1),
confidence,
confounded,
)
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=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,
months_of_inventory=months_of_inventory,
rate_future=rate_future,
rate_sensitivity_phrase=sensitivity_phrase,
future_competitors=future_competitors,
advisory=True,
confidence=confidence,
confounded=confounded,
)
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,
}