feat(forecasting): demand-supply forecast engine (#980, 952-A) #1012
3 changed files with 1497 additions and 0 deletions
|
|
@ -11,6 +11,8 @@
|
|||
• rate_sensitivity (#951d) — §9.6 чувствительность продаж к key_rate (CORE, ADVISORY).
|
||||
• macro_coefficient (#951e) — §9.5 макро-коэффициент (композитный множитель, ADVISORY).
|
||||
• demand_normalization (#951f) — §9.4 нормализация спроса под смену режима ставки (ADVISORY).
|
||||
• demand_supply_forecast (#952a) — §9.8 центральный движок: спрос (§9.4×§9.5) vs
|
||||
предложение (§9.3) по горизонтам → баланс/индекс дефицита (СБОРКА, ADVISORY).
|
||||
|
||||
Источники данных:
|
||||
• макро — таблица macro_indicator через reader site_finder/macro.py (reuse).
|
||||
|
|
@ -24,6 +26,11 @@ from app.services.forecasting.demand_normalization import (
|
|||
compute_demand_normalization,
|
||||
normalization_factor,
|
||||
)
|
||||
from app.services.forecasting.demand_supply_forecast import (
|
||||
DemandSupplyForecast,
|
||||
compute_demand_supply_forecast,
|
||||
hold_last_rate,
|
||||
)
|
||||
from app.services.forecasting.macro_coefficient import (
|
||||
MacroCoefficient,
|
||||
assemble_coefficient,
|
||||
|
|
@ -61,6 +68,7 @@ from app.services.forecasting.sales_series import (
|
|||
|
||||
__all__ = [
|
||||
"DemandNormalization",
|
||||
"DemandSupplyForecast",
|
||||
"MacroCoefficient",
|
||||
"MonthlyMacro",
|
||||
"RateSensitivity",
|
||||
|
|
@ -71,6 +79,7 @@ __all__ = [
|
|||
"build_sales_series",
|
||||
"classify_regime",
|
||||
"compute_demand_normalization",
|
||||
"compute_demand_supply_forecast",
|
||||
"compute_macro_coefficient",
|
||||
"compute_rate_sensitivity",
|
||||
"f_issuance",
|
||||
|
|
@ -79,6 +88,7 @@ __all__ = [
|
|||
"f_rate",
|
||||
"fill_month_grid",
|
||||
"get_monthly_macro",
|
||||
"hold_last_rate",
|
||||
"is_confounded_window",
|
||||
"log_diff",
|
||||
"macro_at_lag",
|
||||
|
|
|
|||
616
backend/app/services/forecasting/demand_supply_forecast.py
Normal file
616
backend/app/services/forecasting/demand_supply_forecast.py
Normal file
|
|
@ -0,0 +1,616 @@
|
|||
"""§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=None,
|
||||
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 = сильная затоварка
|
||||
(предложения больше спроса — «риск»); 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 → деградация:
|
||||
знак 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 _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/index
|
||||
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_units = _balance(projected_demand, projected_supply)
|
||||
balance_ratio = _balance_ratio(projected_demand, projected_supply)
|
||||
deficit_index = _deficit_index(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,
|
||||
}
|
||||
|
|
@ -0,0 +1,871 @@
|
|||
"""Unit-тесты §9.8 центрального движка СПРОС↔ПРЕДЛОЖЕНИЕ (#952a, ADVISORY).
|
||||
|
||||
Чистые тесты — БЕЗ живой БД (чистая математика + мок всех §9.x compute_*):
|
||||
• pure-арифметика: _hidden_release_fraction (6→⅓ / 12→⅔ / 18→1.0 / clamp),
|
||||
_project_demand (pace×§9.4×§9.5×h ЛИНЕЙНО; None pace → None; None-коэф → 1.0),
|
||||
_project_supply (absorbed-clamp ≥0; None спрос → 0 поглощения),
|
||||
_balance / _balance_ratio (знак + None при supply≤0),
|
||||
_deficit_index (знаковое насыщение + clamp [−1,+1] + None), _min_confidence,
|
||||
_cap_confidence (≤ medium), hold_last_rate (последняя key_rate плоско).
|
||||
• compute_demand_supply_forecast через MagicMock-сессию + @patch всех reused
|
||||
сервисов (зеркало test_future_supply): спрос = pace×§9.4×§9.5×h ТОЧНО; β НЕ
|
||||
учтён дважды (rate_sensitivity.beta/x_pct не влияет на projected_demand);
|
||||
confidence = MIN ≤ medium; advisory ВСЕГДА True; один результат на горизонт;
|
||||
graceful empty → None-поля; конкуренты-сбой → [].
|
||||
|
||||
Детерминированно, без LLM. Мокаем все §9.x compute_* + db (нет живой БД).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
import datetime as dt
|
||||
from typing import Any, ClassVar
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.forecasting.demand_supply_forecast import (
|
||||
_CONFIDENCE_CAP,
|
||||
_DEFICIT_RATIO_SATURATION,
|
||||
_HIDDEN_RELEASE_MONTHS,
|
||||
_TOP_COMPETITORS,
|
||||
DemandSupplyForecast,
|
||||
_balance,
|
||||
_balance_ratio,
|
||||
_cap_confidence,
|
||||
_deficit_index,
|
||||
_hidden_release_fraction,
|
||||
_min_confidence,
|
||||
_project_demand,
|
||||
_project_supply,
|
||||
_round_or_none,
|
||||
_segment_profile,
|
||||
compute_demand_supply_forecast,
|
||||
hold_last_rate,
|
||||
)
|
||||
from app.services.forecasting.macro_series import MonthlyMacro
|
||||
from app.services.forecasting.sales_series import SegmentSpec
|
||||
|
||||
# Пути патча reused-сервисов (импортированы в модуль demand_supply_forecast).
|
||||
_MACRO = "app.services.forecasting.demand_supply_forecast.get_monthly_macro"
|
||||
_METRICS = "app.services.forecasting.demand_supply_forecast.compute_market_metrics"
|
||||
_NORM = "app.services.forecasting.demand_supply_forecast.compute_demand_normalization"
|
||||
_MACRO_COEF = "app.services.forecasting.demand_supply_forecast.compute_macro_coefficient"
|
||||
_SENS = "app.services.forecasting.demand_supply_forecast.compute_rate_sensitivity"
|
||||
_SUPPLY = "app.services.forecasting.demand_supply_forecast.compute_future_supply_pressure"
|
||||
_COMPETITORS = "app.services.forecasting.demand_supply_forecast.get_competitors"
|
||||
|
||||
_ALLOWED_CONFIDENCE = {"high", "medium", "low"}
|
||||
|
||||
|
||||
# ── pure: _hidden_release_fraction ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestHiddenReleaseFraction:
|
||||
def test_six_months_is_third(self) -> None:
|
||||
# 6 / 18 = 0.333… (≈⅓ скрытого запаса вышло к 6 мес).
|
||||
assert _hidden_release_fraction(6) == pytest.approx(1.0 / 3.0)
|
||||
|
||||
def test_twelve_months_is_two_thirds(self) -> None:
|
||||
assert _hidden_release_fraction(12) == pytest.approx(2.0 / 3.0)
|
||||
|
||||
def test_eighteen_months_is_full(self) -> None:
|
||||
# ровно цикл созревания → весь скрытый запас вышел (1.0).
|
||||
assert _hidden_release_fraction(18) == pytest.approx(1.0)
|
||||
|
||||
def test_beyond_cycle_clamps_to_one(self) -> None:
|
||||
assert _hidden_release_fraction(24) == 1.0
|
||||
assert _hidden_release_fraction(36) == 1.0
|
||||
|
||||
def test_zero_or_negative_horizon_is_zero(self) -> None:
|
||||
assert _hidden_release_fraction(0) == 0.0
|
||||
assert _hidden_release_fraction(-5) == 0.0
|
||||
|
||||
def test_uses_named_constant(self) -> None:
|
||||
# На горизонте = _HIDDEN_RELEASE_MONTHS → ровно 1.0 (привязка к константе).
|
||||
assert _hidden_release_fraction(int(_HIDDEN_RELEASE_MONTHS)) == pytest.approx(1.0)
|
||||
|
||||
def test_degraded_zero_release_months(self) -> None:
|
||||
# release_months ≤0 → деградация: любой h>0 → весь сразу (1.0), не /0.
|
||||
assert _hidden_release_fraction(6, release_months=0.0) == 1.0
|
||||
assert _hidden_release_fraction(0, release_months=0.0) == 0.0
|
||||
|
||||
|
||||
# ── pure: _project_demand (ЛИНЕЙНО, β внутри §9.4 — один раз) ──────────────────
|
||||
|
||||
|
||||
class TestProjectDemand:
|
||||
def test_linear_product(self) -> None:
|
||||
# 10 ед/мес × 0.8 (§9.4) × 1.1 (§9.5) × 12 мес = 105.6 (ЛИНЕЙНО, без компаунда).
|
||||
assert _project_demand(10.0, 0.8, 1.1, 12) == pytest.approx(10.0 * 0.8 * 1.1 * 12)
|
||||
|
||||
def test_linear_not_compounded(self) -> None:
|
||||
# Двойной горизонт → ровно двойной спрос (линейность, НЕ (1+r)^h).
|
||||
d12 = _project_demand(5.0, 0.9, 1.0, 12)
|
||||
d24 = _project_demand(5.0, 0.9, 1.0, 24)
|
||||
assert d12 is not None and d24 is not None
|
||||
assert d24 == pytest.approx(2.0 * d12)
|
||||
|
||||
def test_none_pace_returns_none(self) -> None:
|
||||
# нет наблюдаемого темпа → спрос None (НЕ 0: «нет данных» ≠ «продажи 0»).
|
||||
assert _project_demand(None, 0.8, 1.1, 12) is None
|
||||
|
||||
def test_none_coefficients_treated_as_neutral(self) -> None:
|
||||
# коэф None → 1.0 (компонент не скорректировал, но темп известен).
|
||||
assert _project_demand(10.0, None, None, 12) == pytest.approx(120.0)
|
||||
assert _project_demand(10.0, 0.5, None, 12) == pytest.approx(60.0)
|
||||
|
||||
def test_zero_horizon_zero_demand(self) -> None:
|
||||
assert _project_demand(10.0, 0.8, 1.1, 0) == 0.0
|
||||
|
||||
def test_zero_pace_is_zero_not_none(self) -> None:
|
||||
# темп измерим и равен 0 → спрос 0 (валидно, НЕ None).
|
||||
assert _project_demand(0.0, 0.8, 1.1, 12) == 0.0
|
||||
|
||||
|
||||
# ── pure: _project_supply (absorbed-clamp ≥0) ─────────────────────────────────
|
||||
|
||||
|
||||
class TestProjectSupply:
|
||||
def test_net_supply_after_absorption(self) -> None:
|
||||
# валовое = 100+50+30 = 180; спрос 80 → absorbed 80 → чистое 100.
|
||||
assert _project_supply(100.0, 50.0, 30.0, 80.0) == pytest.approx(100.0)
|
||||
|
||||
def test_demand_exceeds_supply_clamps_to_zero(self) -> None:
|
||||
# спрос 500 > валовое 180 → absorbed = всё 180 → чистое 0 (clamp ≥0, не отриц).
|
||||
assert _project_supply(100.0, 50.0, 30.0, 500.0) == 0.0
|
||||
|
||||
def test_none_demand_no_absorption(self) -> None:
|
||||
# спрос None → 0 поглощения → всё валовое конкурирует (консервативно).
|
||||
assert _project_supply(100.0, 50.0, 30.0, None) == pytest.approx(180.0)
|
||||
|
||||
def test_zero_demand_no_absorption(self) -> None:
|
||||
assert _project_supply(100.0, 50.0, 30.0, 0.0) == pytest.approx(180.0)
|
||||
|
||||
def test_negative_demand_treated_as_zero(self) -> None:
|
||||
# артефактно-отрицательный спрос не «добавляет» предложение (max(0, demand)).
|
||||
assert _project_supply(100.0, 0.0, 0.0, -50.0) == pytest.approx(100.0)
|
||||
|
||||
def test_empty_supply_is_zero(self) -> None:
|
||||
assert _project_supply(0.0, 0.0, 0.0, 80.0) == 0.0
|
||||
|
||||
def test_result_never_negative(self) -> None:
|
||||
for demand in [0.0, 10.0, 50.0, 100.0, 250.0, 1000.0]:
|
||||
assert _project_supply(40.0, 30.0, 20.0, demand) >= 0.0
|
||||
|
||||
|
||||
# ── pure: _balance ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBalance:
|
||||
def test_deficit_positive(self) -> None:
|
||||
# спрос 200 > предложение 120 → +80 (дефицит — «хорошо»).
|
||||
assert _balance(200.0, 120.0) == pytest.approx(80.0)
|
||||
|
||||
def test_oversupply_negative(self) -> None:
|
||||
# спрос 80 < предложение 120 → −40 (затоварка — «риск»).
|
||||
assert _balance(80.0, 120.0) == pytest.approx(-40.0)
|
||||
|
||||
def test_none_demand_returns_none(self) -> None:
|
||||
assert _balance(None, 120.0) is None
|
||||
|
||||
def test_none_supply_returns_none(self) -> None:
|
||||
assert _balance(200.0, None) is None
|
||||
|
||||
|
||||
# ── pure: _balance_ratio (None при supply ≤0) ─────────────────────────────────
|
||||
|
||||
|
||||
class TestBalanceRatio:
|
||||
def test_basic_ratio(self) -> None:
|
||||
assert _balance_ratio(200.0, 100.0) == pytest.approx(2.0)
|
||||
|
||||
def test_zero_supply_returns_none(self) -> None:
|
||||
# предложение исчерпано → None (НЕ ∞: не отличить от «нет данных»).
|
||||
assert _balance_ratio(200.0, 0.0) is None
|
||||
|
||||
def test_negative_supply_returns_none(self) -> None:
|
||||
assert _balance_ratio(200.0, -5.0) is None
|
||||
|
||||
def test_none_demand_returns_none(self) -> None:
|
||||
assert _balance_ratio(None, 100.0) is None
|
||||
|
||||
def test_none_supply_returns_none(self) -> None:
|
||||
assert _balance_ratio(200.0, None) is None
|
||||
|
||||
|
||||
# ── pure: _deficit_index (знаковое насыщение [−1,+1]) ─────────────────────────
|
||||
|
||||
|
||||
class TestDeficitIndex:
|
||||
def test_balance_is_zero(self) -> None:
|
||||
# спрос = предложение (ratio 1.0) → индекс 0 (баланс).
|
||||
assert _deficit_index(1.0) == pytest.approx(0.0)
|
||||
|
||||
def test_saturation_ratio_is_plus_one(self) -> None:
|
||||
# ratio = saturation (спрос вдвое > предложения) → +1 (сильный дефицит).
|
||||
assert _deficit_index(_DEFICIT_RATIO_SATURATION) == pytest.approx(1.0)
|
||||
|
||||
def test_inverse_saturation_is_minus_one(self) -> None:
|
||||
# ratio = 1/saturation (предложение вдвое > спроса) → −1 (сильная затоварка).
|
||||
assert _deficit_index(1.0 / _DEFICIT_RATIO_SATURATION) == pytest.approx(-1.0)
|
||||
|
||||
def test_beyond_saturation_clamps_to_plus_one(self) -> None:
|
||||
assert _deficit_index(_DEFICIT_RATIO_SATURATION * 5.0) == 1.0
|
||||
|
||||
def test_far_below_clamps_to_minus_one(self) -> None:
|
||||
assert _deficit_index(1.0 / (_DEFICIT_RATIO_SATURATION * 5.0)) == -1.0
|
||||
|
||||
def test_symmetric_around_balance(self) -> None:
|
||||
# ×r и ÷r симметричны вокруг 0 (лог-шкала отношения).
|
||||
r = 1.5
|
||||
assert _deficit_index(r) == pytest.approx(-_deficit_index(1.0 / r))
|
||||
|
||||
def test_none_ratio_returns_none(self) -> None:
|
||||
assert _deficit_index(None) is None
|
||||
|
||||
def test_nonpositive_ratio_returns_none(self) -> None:
|
||||
assert _deficit_index(0.0) is None
|
||||
assert _deficit_index(-1.0) is None
|
||||
|
||||
def test_monotonic_non_decreasing(self) -> None:
|
||||
prev = -2.0
|
||||
for ratio in [0.1, 0.25, 0.5, 0.75, 1.0, 1.5, 2.0, 4.0, 10.0]:
|
||||
idx = _deficit_index(ratio)
|
||||
assert idx is not None
|
||||
assert -1.0 <= idx <= 1.0
|
||||
assert idx >= prev
|
||||
prev = idx
|
||||
|
||||
def test_degraded_saturation_le_one(self) -> None:
|
||||
# saturation ≤1 → деградация: только знак перекоса (не /0 в log).
|
||||
assert _deficit_index(2.0, saturation=1.0) == 1.0
|
||||
assert _deficit_index(0.5, saturation=1.0) == -1.0
|
||||
assert _deficit_index(1.0, saturation=1.0) == 0.0
|
||||
|
||||
|
||||
# ── pure: _min_confidence / _cap_confidence ───────────────────────────────────
|
||||
|
||||
|
||||
class TestMinConfidence:
|
||||
def test_all_high(self) -> None:
|
||||
assert _min_confidence(["high", "high"]) == "high"
|
||||
|
||||
def test_low_drags_down(self) -> None:
|
||||
assert _min_confidence(["high", "medium", "low"]) == "low"
|
||||
|
||||
def test_medium_floor(self) -> None:
|
||||
assert _min_confidence(["high", "medium"]) == "medium"
|
||||
|
||||
def test_none_ignored(self) -> None:
|
||||
assert _min_confidence(["high", None, "medium"]) == "medium"
|
||||
|
||||
def test_all_none_low(self) -> None:
|
||||
assert _min_confidence([None, None]) == "low"
|
||||
|
||||
def test_empty_low(self) -> None:
|
||||
assert _min_confidence([]) == "low"
|
||||
|
||||
|
||||
class TestCapConfidence:
|
||||
def test_high_capped_to_medium(self) -> None:
|
||||
# advisory-движок: даже all-high → не выше medium.
|
||||
assert _cap_confidence("high") == "medium"
|
||||
assert _CONFIDENCE_CAP == "medium"
|
||||
|
||||
def test_medium_stays_medium(self) -> None:
|
||||
assert _cap_confidence("medium") == "medium"
|
||||
|
||||
def test_low_stays_low(self) -> None:
|
||||
assert _cap_confidence("low") == "low"
|
||||
|
||||
def test_custom_cap(self) -> None:
|
||||
assert _cap_confidence("high", cap="low") == "low"
|
||||
|
||||
|
||||
# ── pure: hold_last_rate ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _macro_pt(month: dt.date, key_rate: float | None) -> MonthlyMacro:
|
||||
return MonthlyMacro(
|
||||
month=month,
|
||||
key_rate=key_rate,
|
||||
mortgage_rate_weighted=None,
|
||||
mortgage_issued_count=None,
|
||||
mortgage_issued_volume=None,
|
||||
mortgage_debt=None,
|
||||
mortgage_overdue=None,
|
||||
)
|
||||
|
||||
|
||||
class TestHoldLastRate:
|
||||
def test_holds_latest_known_rate_flat(self) -> None:
|
||||
macro = [
|
||||
_macro_pt(dt.date(2025, 1, 1), 16.0),
|
||||
_macro_pt(dt.date(2025, 2, 1), 18.0),
|
||||
_macro_pt(dt.date(2025, 3, 1), 21.0),
|
||||
]
|
||||
path = hold_last_rate(macro, [6, 12, 24])
|
||||
assert path == {6: 21.0, 12: 21.0, 24: 21.0}
|
||||
|
||||
def test_ignores_trailing_none(self) -> None:
|
||||
# последняя НЕпустая key_rate (трейлинг None игнорируем).
|
||||
macro = [
|
||||
_macro_pt(dt.date(2025, 1, 1), 16.0),
|
||||
_macro_pt(dt.date(2025, 2, 1), None),
|
||||
]
|
||||
assert hold_last_rate(macro, [12]) == {12: 16.0}
|
||||
|
||||
def test_all_none_yields_none(self) -> None:
|
||||
macro = [_macro_pt(dt.date(2025, 1, 1), None)]
|
||||
assert hold_last_rate(macro, [6, 12]) == {6: None, 12: None}
|
||||
|
||||
def test_empty_macro_yields_none(self) -> None:
|
||||
assert hold_last_rate([], [6, 12]) == {6: None, 12: None}
|
||||
|
||||
|
||||
# ── pure: _segment_profile ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSegmentProfile:
|
||||
def test_maps_axes(self) -> None:
|
||||
spec = SegmentSpec(obj_class="бизнес", room_bucket="3", price_bucket="премиум")
|
||||
prof = _segment_profile(spec)
|
||||
assert prof["obj_class"] == "бизнес"
|
||||
assert prof["room_bucket"] == "3"
|
||||
assert prof["price_tier"] == "премиум" # price_bucket → price_tier
|
||||
|
||||
def test_omits_none_axes(self) -> None:
|
||||
prof = _segment_profile(SegmentSpec())
|
||||
assert prof == {}
|
||||
|
||||
|
||||
# ── pure: _round_or_none ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRoundOrNone:
|
||||
def test_rounds(self) -> None:
|
||||
assert _round_or_none(1.23456, 3) == 1.235
|
||||
|
||||
def test_none_passthrough(self) -> None:
|
||||
assert _round_or_none(None, 3) is None
|
||||
|
||||
|
||||
# ── DemandSupplyForecast.as_dict ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_forecast(**over: object) -> DemandSupplyForecast:
|
||||
base: dict[str, object] = {
|
||||
"segment": {
|
||||
"obj_class": "комфорт",
|
||||
"room_bucket": None,
|
||||
"district": "X",
|
||||
"price_bucket": None,
|
||||
},
|
||||
"horizon_months": 12,
|
||||
"base_pace_units_per_mo": 10.0,
|
||||
"demand_norm_coefficient": 0.8,
|
||||
"macro_coefficient": 1.1,
|
||||
"projected_demand_units": 105.6,
|
||||
"open_units": 300,
|
||||
"hidden_release_units": 133.33,
|
||||
"future_online_units": 40.0,
|
||||
"projected_supply_units": 367.73,
|
||||
"balance_units": -262.13,
|
||||
"balance_ratio": 0.287,
|
||||
"deficit_index": -0.9,
|
||||
"rate_future": 21.0,
|
||||
"rate_sensitivity_phrase": "при росте ставки …",
|
||||
"future_competitors": [{"obj_id": 1, "relevance_weight": 0.7}],
|
||||
"advisory": True,
|
||||
"confidence": "medium",
|
||||
}
|
||||
base.update(over)
|
||||
return DemandSupplyForecast(**base) # type: ignore[arg-type]
|
||||
|
||||
|
||||
class TestAsDict:
|
||||
def test_all_fields_rounded(self) -> None:
|
||||
d = _make_forecast().as_dict()
|
||||
assert d["base_pace_units_per_mo"] == 10.0
|
||||
assert d["demand_norm_coefficient"] == 0.8
|
||||
assert d["macro_coefficient"] == 1.1
|
||||
assert d["projected_demand_units"] == 105.6
|
||||
assert d["projected_supply_units"] == 367.7
|
||||
assert d["deficit_index"] == -0.9
|
||||
assert d["advisory"] is True
|
||||
assert d["confidence"] in _ALLOWED_CONFIDENCE
|
||||
|
||||
def test_none_fields_survive(self) -> None:
|
||||
d = _make_forecast(
|
||||
base_pace_units_per_mo=None,
|
||||
projected_demand_units=None,
|
||||
balance_units=None,
|
||||
balance_ratio=None,
|
||||
deficit_index=None,
|
||||
).as_dict()
|
||||
assert d["base_pace_units_per_mo"] is None
|
||||
assert d["projected_demand_units"] is None
|
||||
assert d["balance_ratio"] is None
|
||||
assert d["deficit_index"] is None
|
||||
|
||||
def test_competitors_passthrough(self) -> None:
|
||||
d = _make_forecast().as_dict()
|
||||
assert d["future_competitors"] == [{"obj_id": 1, "relevance_weight": 0.7}]
|
||||
|
||||
|
||||
# ── orchestrator helpers (стабы §9.x compute_*) ───────────────────────────────
|
||||
|
||||
|
||||
def _metrics_stub(*, unit_velocity: float | None = 10.0, confidence: str = "high") -> MagicMock:
|
||||
m = MagicMock()
|
||||
m.unit_velocity = unit_velocity
|
||||
m.confidence = confidence
|
||||
return m
|
||||
|
||||
|
||||
def _norm_stub(*, coefficient: float = 0.8, confidence: str = "high") -> MagicMock:
|
||||
m = MagicMock()
|
||||
m.coefficient = coefficient
|
||||
m.confidence = confidence
|
||||
return m
|
||||
|
||||
|
||||
def _macro_coef_stub(*, coefficient: float = 1.1, confidence: str = "high") -> MagicMock:
|
||||
m = MagicMock()
|
||||
m.coefficient = coefficient
|
||||
m.confidence = confidence
|
||||
return m
|
||||
|
||||
|
||||
def _sens_stub(
|
||||
*, beta: float = -0.5, x_pct: float = -40.0, phrase: str = "при росте ставки …"
|
||||
) -> MagicMock:
|
||||
"""Стаб §9.6: несёт DISTINCTIVE beta/x_pct — они НЕ должны влиять на спрос."""
|
||||
m = MagicMock()
|
||||
m.beta = beta
|
||||
m.x_pct = x_pct
|
||||
m.phrase = phrase
|
||||
return m
|
||||
|
||||
|
||||
def _supply_stub(
|
||||
*,
|
||||
open_units: int = 300,
|
||||
hidden_units: int = 200,
|
||||
future_units_by_horizon: float = 40.0,
|
||||
confidence: str = "high",
|
||||
) -> MagicMock:
|
||||
m = MagicMock()
|
||||
m.open_units = open_units
|
||||
m.hidden_units = hidden_units
|
||||
m.future_units_by_horizon = future_units_by_horizon
|
||||
m.confidence = confidence
|
||||
return m
|
||||
|
||||
|
||||
def _competitor_stub(obj_id: int, relevance_weight: float) -> MagicMock:
|
||||
c = MagicMock()
|
||||
c.obj_id = obj_id
|
||||
c.comm_name = f"ЖК-{obj_id}"
|
||||
c.obj_class = "комфорт"
|
||||
c.distance_m = 500.0
|
||||
c.flats_total = 100
|
||||
c.velocity_per_month = 5.0
|
||||
c.relevance_weight = relevance_weight
|
||||
return c
|
||||
|
||||
|
||||
def _competitors_stub(n: int = 3) -> MagicMock:
|
||||
resp = MagicMock()
|
||||
resp.competitors = [_competitor_stub(i, 0.9 - 0.1 * i) for i in range(n)]
|
||||
return resp
|
||||
|
||||
|
||||
class _Patches:
|
||||
"""Контекст-менеджер: патчит ВСЕ reused §9.x сервисы разом (зеркало духа
|
||||
test_future_supply @patch, но 7 зависимостей — держим в одном месте).
|
||||
|
||||
Стабы передаются по коротким алиасам (_ALIAS) → dotted-path константа цели.
|
||||
Возвращает dict {dotted_path: MagicMock} для проверки call_args.
|
||||
"""
|
||||
|
||||
# Короткий алиас (kwarg-имя) → реальная dotted-path цель патча.
|
||||
_ALIAS: ClassVar[dict[str, str]] = {
|
||||
"_MACRO": _MACRO,
|
||||
"_METRICS": _METRICS,
|
||||
"_NORM": _NORM,
|
||||
"_MACRO_COEF": _MACRO_COEF,
|
||||
"_SENS": _SENS,
|
||||
"_SUPPLY": _SUPPLY,
|
||||
"_COMPETITORS": _COMPETITORS,
|
||||
}
|
||||
|
||||
def __init__(self, **stubs: object) -> None:
|
||||
self._stubs = stubs
|
||||
self._ctxs: list[Any] = []
|
||||
|
||||
def __enter__(self) -> dict[str, MagicMock]:
|
||||
defaults: dict[str, object] = {
|
||||
_MACRO: [_macro_pt(dt.date(2025, 3, 1), 21.0)],
|
||||
_METRICS: _metrics_stub(),
|
||||
_NORM: _norm_stub(),
|
||||
_MACRO_COEF: _macro_coef_stub(),
|
||||
_SENS: _sens_stub(),
|
||||
_SUPPLY: _supply_stub(),
|
||||
_COMPETITORS: _competitors_stub(),
|
||||
}
|
||||
for alias, value in self._stubs.items():
|
||||
target = self._ALIAS.get(alias, alias)
|
||||
defaults[target] = value
|
||||
mocks: dict[str, MagicMock] = {}
|
||||
for target, value in defaults.items():
|
||||
p = patch(target, return_value=value)
|
||||
mocks[target] = p.start()
|
||||
self._ctxs.append(p)
|
||||
return mocks
|
||||
|
||||
def __exit__(self, *exc: object) -> None:
|
||||
for p in self._ctxs:
|
||||
p.stop()
|
||||
|
||||
|
||||
def _run(**over: object) -> list[DemandSupplyForecast]:
|
||||
spec = SegmentSpec(obj_class="комфорт", district="Академический")
|
||||
return compute_demand_supply_forecast(
|
||||
MagicMock(),
|
||||
spec=spec,
|
||||
district="Академический",
|
||||
cad_num="66:41:0303161:123",
|
||||
**over, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
# ── orchestrator: спрос = pace × §9.4 × §9.5 × h ТОЧНО ─────────────────────────
|
||||
|
||||
|
||||
class TestComputeDemand:
|
||||
def test_demand_is_pace_times_norm_times_macro_times_h(self) -> None:
|
||||
with _Patches(
|
||||
_METRICS=_metrics_stub(unit_velocity=10.0),
|
||||
_NORM=_norm_stub(coefficient=0.8),
|
||||
_MACRO_COEF=_macro_coef_stub(coefficient=1.1),
|
||||
):
|
||||
res = _run(horizons=[12])
|
||||
f = res[0]
|
||||
assert f.base_pace_units_per_mo == 10.0
|
||||
assert f.demand_norm_coefficient == 0.8
|
||||
assert f.macro_coefficient == 1.1
|
||||
# ровно 10 × 0.8 × 1.1 × 12 (ЛИНЕЙНО, без компаунда).
|
||||
assert f.projected_demand_units == pytest.approx(10.0 * 0.8 * 1.1 * 12)
|
||||
|
||||
def test_demand_linear_across_horizons(self) -> None:
|
||||
with _Patches():
|
||||
res = _run(horizons=[6, 12, 24])
|
||||
d6, d12, d24 = (f.projected_demand_units for f in res)
|
||||
assert d6 is not None and d12 is not None and d24 is not None
|
||||
# линейность: 12-мес = 2×6-мес, 24-мес = 4×6-мес.
|
||||
assert d12 == pytest.approx(2.0 * d6)
|
||||
assert d24 == pytest.approx(4.0 * d6)
|
||||
|
||||
def test_none_pace_yields_none_demand(self) -> None:
|
||||
with _Patches(_METRICS=_metrics_stub(unit_velocity=None)):
|
||||
res = _run(horizons=[12])
|
||||
assert res[0].base_pace_units_per_mo is None
|
||||
assert res[0].projected_demand_units is None
|
||||
|
||||
|
||||
# ── ⚠️ orchestrator: β УЧТЁН РОВНО ОДИН РАЗ (главный тест корректности) ────────
|
||||
|
||||
|
||||
class TestBetaNotDoubleApplied:
|
||||
"""§9.4 уже применяет β внутри; §9.6 здесь — только для explain-фразы.
|
||||
projected_demand НЕ должен зависеть от rate_sensitivity.beta/x_pct."""
|
||||
|
||||
def _demand_with_sens(self, *, beta: float, x_pct: float) -> float:
|
||||
with _Patches(
|
||||
_METRICS=_metrics_stub(unit_velocity=10.0),
|
||||
_NORM=_norm_stub(coefficient=0.8),
|
||||
_MACRO_COEF=_macro_coef_stub(coefficient=1.1),
|
||||
_SENS=_sens_stub(beta=beta, x_pct=x_pct),
|
||||
):
|
||||
res = _run(horizons=[12])
|
||||
demand = res[0].projected_demand_units
|
||||
assert demand is not None
|
||||
return demand
|
||||
|
||||
def test_demand_independent_of_sensitivity_beta(self) -> None:
|
||||
# Меняем β/x_pct §9.6 РАДИКАЛЬНО — спрос обязан остаться идентичным
|
||||
# (β применяется ТОЛЬКО внутри §9.4 demand_norm, не повторно здесь).
|
||||
mild = self._demand_with_sens(beta=-0.1, x_pct=-5.0)
|
||||
wild = self._demand_with_sens(beta=-5.0, x_pct=-99.0)
|
||||
positive = self._demand_with_sens(beta=2.0, x_pct=50.0)
|
||||
assert mild == pytest.approx(wild)
|
||||
assert mild == pytest.approx(positive)
|
||||
|
||||
def test_demand_equals_pace_norm_macro_h_exactly(self) -> None:
|
||||
# Спрос = ровно pace×§9.4×§9.5×h — НЕТ скрытого ×(1+x_pct) или ×exp(β).
|
||||
demand = self._demand_with_sens(beta=-3.0, x_pct=-90.0)
|
||||
assert demand == pytest.approx(10.0 * 0.8 * 1.1 * 12)
|
||||
|
||||
def test_sensitivity_phrase_surfaced_for_explain(self) -> None:
|
||||
# §9.6 ВСЁ ЖЕ используется — но только .phrase в выводе (explainability).
|
||||
with _Patches(_SENS=_sens_stub(phrase="ставка ↑ → продажи ↓ на 40%")):
|
||||
res = _run(horizons=[12])
|
||||
assert res[0].rate_sensitivity_phrase == "ставка ↑ → продажи ↓ на 40%"
|
||||
|
||||
def test_norm_coefficient_is_the_only_beta_channel(self) -> None:
|
||||
# Меняем §9.4 coefficient → спрос ДОЛЖЕН измениться (это единственный β-канал).
|
||||
with _Patches(_NORM=_norm_stub(coefficient=0.5)):
|
||||
low = _run(horizons=[12])[0].projected_demand_units
|
||||
with _Patches(_NORM=_norm_stub(coefficient=1.0)):
|
||||
high = _run(horizons=[12])[0].projected_demand_units
|
||||
assert low is not None and high is not None
|
||||
assert high == pytest.approx(2.0 * low)
|
||||
|
||||
|
||||
# ── orchestrator: предложение (фаза hidden + absorbed-clamp) ───────────────────
|
||||
|
||||
|
||||
class TestComputeSupply:
|
||||
def test_hidden_release_phased_by_horizon(self) -> None:
|
||||
# hidden=200; на 6 мес фаза ⅓ → 66.67; future=40; open=300.
|
||||
with _Patches(
|
||||
_SUPPLY=_supply_stub(open_units=300, hidden_units=200, future_units_by_horizon=40.0),
|
||||
_METRICS=_metrics_stub(unit_velocity=0.0), # спрос 0 → absorbed 0 → валовое всё
|
||||
):
|
||||
res = _run(horizons=[6])
|
||||
f = res[0]
|
||||
assert f.open_units == 300
|
||||
assert f.hidden_release_units == pytest.approx(200.0 * (1.0 / 3.0))
|
||||
assert f.future_online_units == pytest.approx(40.0)
|
||||
# спрос 0 → поглощения нет → чистое = валовое = 300 + 66.67 + 40.
|
||||
assert f.projected_supply_units == pytest.approx(300.0 + 200.0 / 3.0 + 40.0)
|
||||
|
||||
def test_absorbed_reduces_supply_clamped(self) -> None:
|
||||
# спрос огромный → поглощает всё валовое → чистое предложение 0 (clamp ≥0).
|
||||
with _Patches(
|
||||
_METRICS=_metrics_stub(unit_velocity=10_000.0),
|
||||
_SUPPLY=_supply_stub(open_units=100, hidden_units=100, future_units_by_horizon=50.0),
|
||||
):
|
||||
res = _run(horizons=[18])
|
||||
assert res[0].projected_supply_units == 0.0
|
||||
|
||||
def test_supply_never_negative(self) -> None:
|
||||
with _Patches(_METRICS=_metrics_stub(unit_velocity=500.0)):
|
||||
for f in _run(horizons=[6, 12, 18, 24]):
|
||||
assert f.projected_supply_units >= 0.0
|
||||
|
||||
|
||||
# ── orchestrator: баланс / индекс дефицита ─────────────────────────────────────
|
||||
|
||||
|
||||
class TestComputeBalance:
|
||||
def test_balance_and_ratio_and_index(self) -> None:
|
||||
# Спрос 10×1.0×1.0×12 = 120; supply: open0+hidden0+future0 → 0 валовое,
|
||||
# но absorbed=min(0,120)=0 → supply 0 → ratio None, index None.
|
||||
with _Patches(
|
||||
_METRICS=_metrics_stub(unit_velocity=10.0),
|
||||
_NORM=_norm_stub(coefficient=1.0),
|
||||
_MACRO_COEF=_macro_coef_stub(coefficient=1.0),
|
||||
_SUPPLY=_supply_stub(open_units=0, hidden_units=0, future_units_by_horizon=0.0),
|
||||
):
|
||||
res = _run(horizons=[12])
|
||||
f = res[0]
|
||||
assert f.projected_demand_units == pytest.approx(120.0)
|
||||
assert f.projected_supply_units == 0.0
|
||||
# supply ≤0 → ratio/index None (НЕ выдуманный ∞-дефицит).
|
||||
assert f.balance_ratio is None
|
||||
assert f.deficit_index is None
|
||||
# balance_units всё равно считается (120 − 0 = 120).
|
||||
assert f.balance_units == pytest.approx(120.0)
|
||||
|
||||
def test_oversupply_negative_index(self) -> None:
|
||||
# Малый спрос, большое предложение (спрос не поглощает всё) → index < 0.
|
||||
with _Patches(
|
||||
_METRICS=_metrics_stub(unit_velocity=1.0),
|
||||
_NORM=_norm_stub(coefficient=1.0),
|
||||
_MACRO_COEF=_macro_coef_stub(coefficient=1.0),
|
||||
_SUPPLY=_supply_stub(open_units=1000, hidden_units=0, future_units_by_horizon=0.0),
|
||||
):
|
||||
res = _run(horizons=[12])
|
||||
f = res[0]
|
||||
# спрос 12; supply = 1000 − 12 = 988 → ratio 12/988 << 1 → index −1 (затоварка).
|
||||
assert f.projected_supply_units == pytest.approx(988.0)
|
||||
assert f.balance_ratio is not None and f.balance_ratio < 1.0
|
||||
assert f.deficit_index is not None and f.deficit_index < 0.0
|
||||
|
||||
|
||||
# ── orchestrator: confidence = MIN ≤ medium ────────────────────────────────────
|
||||
|
||||
|
||||
class TestComputeConfidence:
|
||||
def test_all_high_capped_to_medium(self) -> None:
|
||||
# все компоненты high → MIN high, но advisory-cap роняет до medium.
|
||||
with _Patches(
|
||||
_METRICS=_metrics_stub(confidence="high"),
|
||||
_NORM=_norm_stub(confidence="high"),
|
||||
_MACRO_COEF=_macro_coef_stub(confidence="high"),
|
||||
_SUPPLY=_supply_stub(confidence="high"),
|
||||
):
|
||||
res = _run(horizons=[12])
|
||||
assert res[0].confidence == "medium"
|
||||
|
||||
def test_low_component_drags_to_low(self) -> None:
|
||||
# один low (тонкий §9.3) → MIN low (cap не поднимает).
|
||||
with _Patches(
|
||||
_METRICS=_metrics_stub(confidence="high"),
|
||||
_NORM=_norm_stub(confidence="high"),
|
||||
_MACRO_COEF=_macro_coef_stub(confidence="high"),
|
||||
_SUPPLY=_supply_stub(confidence="low"),
|
||||
):
|
||||
res = _run(horizons=[12])
|
||||
assert res[0].confidence == "low"
|
||||
|
||||
def test_never_exceeds_medium(self) -> None:
|
||||
for sup_conf in ("high", "medium", "low"):
|
||||
with _Patches(_SUPPLY=_supply_stub(confidence=sup_conf)):
|
||||
res = _run(horizons=[12])
|
||||
assert res[0].confidence in {"low", "medium"}
|
||||
assert res[0].confidence != "high"
|
||||
|
||||
|
||||
# ── orchestrator: advisory ВСЕГДА True ─────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAdvisoryFlag:
|
||||
def test_advisory_always_true(self) -> None:
|
||||
with _Patches():
|
||||
res = _run(horizons=[6, 12, 18, 24])
|
||||
assert all(f.advisory is True for f in res)
|
||||
|
||||
def test_advisory_true_even_on_thin_data(self) -> None:
|
||||
with _Patches(
|
||||
_METRICS=_metrics_stub(unit_velocity=None, confidence="low"),
|
||||
_SUPPLY=_supply_stub(
|
||||
open_units=0, hidden_units=0, future_units_by_horizon=0.0, confidence="low"
|
||||
),
|
||||
):
|
||||
res = _run(horizons=[12])
|
||||
assert res[0].advisory is True
|
||||
|
||||
|
||||
# ── orchestrator: one result per horizon + rate_path ──────────────────────────
|
||||
|
||||
|
||||
class TestHorizonsAndRatePath:
|
||||
def test_one_result_per_horizon(self) -> None:
|
||||
with _Patches():
|
||||
res = _run(horizons=[6, 12, 18, 24])
|
||||
assert [f.horizon_months for f in res] == [6, 12, 18, 24]
|
||||
|
||||
def test_default_horizons(self) -> None:
|
||||
with _Patches():
|
||||
res = _run() # без horizons → дефолт (6,12,18,24)
|
||||
assert [f.horizon_months for f in res] == [6, 12, 18, 24]
|
||||
|
||||
def test_explicit_rate_path_used(self) -> None:
|
||||
# caller-сценарий: разные ставки на разных горизонтах → попадают в rate_future.
|
||||
with _Patches():
|
||||
res = _run(horizons=[6, 12], rate_path={6: 15.0, 12: 20.0})
|
||||
assert res[0].rate_future == 15.0
|
||||
assert res[1].rate_future == 20.0
|
||||
|
||||
def test_default_rate_path_holds_last_rate(self) -> None:
|
||||
# без rate_path → hold_last_rate из макро-ряда (21.0 в стабе).
|
||||
with _Patches(_MACRO=[_macro_pt(dt.date(2025, 3, 1), 21.0)]):
|
||||
res = _run(horizons=[6, 12])
|
||||
assert res[0].rate_future == 21.0
|
||||
assert res[1].rate_future == 21.0
|
||||
|
||||
def test_norm_called_with_horizon_rate(self) -> None:
|
||||
# §9.4 получает rate_future именно из rate_path (сценарная ставка).
|
||||
with _Patches() as mocks:
|
||||
_run(horizons=[12], rate_path={12: 19.5})
|
||||
norm_calls = mocks[_NORM].call_args_list
|
||||
assert any(call.kwargs.get("rate_future") == 19.5 for call in norm_calls)
|
||||
|
||||
|
||||
# ── orchestrator: §9.7 competitors (top-N, graceful) ──────────────────────────
|
||||
|
||||
|
||||
class TestFutureCompetitors:
|
||||
def test_top_n_competitors_surfaced(self) -> None:
|
||||
with _Patches(_COMPETITORS=_competitors_stub(n=10)):
|
||||
res = _run(horizons=[12])
|
||||
comps = res[0].future_competitors
|
||||
assert len(comps) == _TOP_COMPETITORS # top-N среза
|
||||
assert comps[0]["relevance_weight"] == pytest.approx(0.9)
|
||||
assert "obj_id" in comps[0] and "distance_m" in comps[0]
|
||||
|
||||
def test_competitors_request_carries_horizon(self) -> None:
|
||||
with _Patches() as mocks:
|
||||
_run(horizons=[24])
|
||||
# CompetitorsRequest собран с horizon_months=24 (horizon-aware §9.7).
|
||||
call = mocks[_COMPETITORS].call_args
|
||||
request = call.args[2]
|
||||
assert request.horizon_months == 24
|
||||
|
||||
def test_competitor_lookup_failure_graceful(self) -> None:
|
||||
# нет геометрии участка (ValueError) → [] (НЕ валим прогноз).
|
||||
with _Patches():
|
||||
with patch(_COMPETITORS, side_effect=ValueError("геометрия не найдена")):
|
||||
res = _run(horizons=[12])
|
||||
assert res[0].future_competitors == []
|
||||
# остальной прогноз всё равно собран.
|
||||
assert res[0].projected_demand_units is not None
|
||||
|
||||
|
||||
# ── orchestrator: graceful empty → None-поля, никогда не crash ─────────────────
|
||||
|
||||
|
||||
class TestGraceful:
|
||||
def test_empty_everything_yields_none_fields(self) -> None:
|
||||
with _Patches(
|
||||
_MACRO=[],
|
||||
_METRICS=_metrics_stub(unit_velocity=None, confidence="low"),
|
||||
_NORM=_norm_stub(coefficient=1.0, confidence="low"),
|
||||
_MACRO_COEF=_macro_coef_stub(coefficient=1.0, confidence="low"),
|
||||
_SUPPLY=_supply_stub(
|
||||
open_units=0, hidden_units=0, future_units_by_horizon=0.0, confidence="low"
|
||||
),
|
||||
_COMPETITORS=MagicMock(competitors=[]),
|
||||
):
|
||||
res = _run(horizons=[12])
|
||||
f = res[0]
|
||||
assert f.base_pace_units_per_mo is None
|
||||
assert f.projected_demand_units is None
|
||||
assert f.projected_supply_units == 0.0
|
||||
assert f.balance_units is None # demand None → balance None
|
||||
assert f.balance_ratio is None
|
||||
assert f.deficit_index is None
|
||||
assert f.future_competitors == []
|
||||
assert f.confidence == "low"
|
||||
assert f.advisory is True
|
||||
|
||||
def test_rate_future_none_when_no_macro(self) -> None:
|
||||
# пустой макро-ряд → hold_last_rate даёт None → rate_future None (§9.4 нейтраль).
|
||||
with _Patches(_MACRO=[]):
|
||||
res = _run(horizons=[12])
|
||||
assert res[0].rate_future is None
|
||||
# demand_norm_coefficient None (rate_future None → §9.4 не применяли как канал).
|
||||
assert res[0].demand_norm_coefficient is None
|
||||
|
||||
def test_returns_list_always(self) -> None:
|
||||
with _Patches():
|
||||
res = _run(horizons=[6, 12])
|
||||
assert isinstance(res, list)
|
||||
assert all(isinstance(f, DemandSupplyForecast) for f in res)
|
||||
|
||||
|
||||
# ── sanity: math import used (deficit lib) ────────────────────────────────────
|
||||
|
||||
|
||||
def test_math_log_used_in_deficit() -> None:
|
||||
# _deficit_index использует math.log — sanity на знаковую лог-шкалу.
|
||||
assert _deficit_index(math.e ** math.log(_DEFICIT_RATIO_SATURATION)) == pytest.approx(1.0)
|
||||
Loading…
Add table
Reference in a new issue