gendesign/backend/app/services/forecasting/demand_supply_forecast.py
Light1YT 9ff80f3ac0
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / deploy (push) Successful in 1m7s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m39s
Deploy / build-worker (push) Successful in 2m39s
fix(forecasting): #980 strongest deficit→deficit_index +1.0; #981 MAI uses CBR key rate
REOPENED #980: when effective competing supply is exhausted under positive demand
(projected_supply<=0, demand>0), deficit_index now caps to +1.0 (peak of [-1,+1])
instead of None. balance_ratio stays None (demand/0 undefined), but the strongest
build signal no longer reads downstream as thin data (market_fit fell to 0.5,
what_to_build dropped the cell). No-signal (supply<=0 AND demand<=0) stays None.

REOPENED #981: MAI now uses CBR key rate (macro_indicator key_rate/rf via
get_monthly_macro) as the market borrowing-cost proxy (~16-21%) instead of the
subsidized weighted rate (~7.83%), per §7.9 DoD. rate_kind='key_rate_proxy'.
If key_rate absent → rate_kind='market_unavailable' (no silent subsidy fallback).
Income (#946) still missing → payment_to_income None, confidence low.

778 forecasting tests green. Refs #980 #981
2026-06-04 11:10:26 +05:00

666 lines
40 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 =
сильная затоварка «риск»).
• БУДУЩИЕ КОНКУРЕНТЫ (§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.rate_sensitivity import compute_rate_sensitivity
from app.services.forecasting.sales_series import 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"}
@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).
`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
# ── Баланс / индекс дефицита ───────────────────────────────────────────────
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]
# ── 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
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),
"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,
}
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 _project_supply(
open_units: float,
hidden_release_units: float,
future_online_units: float,
projected_demand_units: float | None,
) -> float:
"""Чистое конкурирующее предложение = валовое поглощённое спросом, clamp ≥0.
Валовое = 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 = 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.
Лог-шкала (отношение мультипликативно — ×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 _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 в арифметику спроса НЕ входят.
Один раз на вызов:
• 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;
hidden_release = hidden × _hidden_release_fraction(h); чистое предложение =
open + hidden_release + future поглощённое спросом (clamp ≥0).
• balance / ratio / знаковый deficit_index.
• §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) ────────────────────────────
metrics = compute_market_metrics(db, district=district, premise_kind=premise_kind)
base_pace = metrics.unit_velocity
# ── Один раз: §9.5 макро-коэффициент (ортогонален β) ───────────────────────
macro_coef = compute_macro_coefficient(db, segment_profile=profile)
# ── Один раз: §9.6 чувствительность — ТОЛЬКО для explain-фразы (НЕ арифметика)
sensitivity = compute_rate_sensitivity(db, spec=spec)
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,
macro_coef=macro_coef,
sensitivity_phrase=sensitivity.phrase,
premise_kind=premise_kind,
)
)
return out
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,
macro_coef: Any,
sensitivity_phrase: str | None,
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 = compute_future_supply_pressure(
db, district=district, horizon_months=horizon, premise_kind=premise_kind
)
hidden_fraction = _hidden_release_fraction(horizon)
hidden_release = fsp.hidden_units * hidden_fraction
future_online = fsp.future_units_by_horizon
projected_supply = _project_supply(
fsp.open_units, hidden_release, future_online, projected_demand
)
# ── БАЛАНС / индекс дефицита ────────────────────────────────────────────────
# balance_ratio честно None при исчерпанном предложении (спрос/0 неотличим от
# «нет данных»), НО deficit_index в этом случае = +1.0 — сильнейший дефицит, не
# «нет сигнала» (#980; см. _deficit_index_from_balance).
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)
# ── §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 confidence=%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),
confidence,
)
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=fsp.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,
rate_future=rate_future,
rate_sensitivity_phrase=sensitivity_phrase,
future_competitors=future_competitors,
advisory=True,
confidence=confidence,
)
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,
}