deficit_index pins to -1.0 for every ЕКБ segment (12mo demand flow vs multi-year supply stock → log-ratio clamps) → zero discriminating power, though the oversupply is partly real. Add MOI (gross competing supply / demand_per_mo), the real-estate absorption standard, as an additive non-saturating companion that DISCRIMINATES (Уралмаш 42mo … Чермет 109mo) where deficit cannot. deficit_index math kept exactly as-is (honest absolute: -1 = genuinely oversupplied); docstrings clarify -1 is common and MOI is the discriminating companion (no recalibration). _gross_supply extract-method (single source of truth; _project_supply behavior byte-identical, code-review-verified). Surface MOI in §22 future_market (passthrough) + exec_summary key_numbers/verdict. Guards: no demand → None, no supply → 0. Prod: MOI varies 42→109mo, deficit stays -1. Discrimination test pins MOI separating two segments both at deficit -1. Refs #952.
773 lines
48 KiB
Python
773 lines
48 KiB
Python
"""§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 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).
|
||
|
||
`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
|
||
|
||
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,
|
||
}
|
||
|
||
|
||
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 → деградация:
|
||
знак ratio−1 (без насыщающей шкалы). Монотонно неубывающее по 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 в арифметику спроса НЕ входят.
|
||
|
||
Один раз на вызов:
|
||
• 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 (АБСОЛЮТ) + 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) ────────────────────────────
|
||
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_regime_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
|
||
gross_supply = _gross_supply(fsp.open_units, hidden_release, future_online)
|
||
projected_supply = _project_supply(
|
||
fsp.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",
|
||
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,
|
||
)
|
||
|
||
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,
|
||
months_of_inventory=months_of_inventory,
|
||
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,
|
||
}
|