feat(forecasting): §25 six special indices (#986, 954-C) #1018
3 changed files with 1752 additions and 0 deletions
|
|
@ -22,6 +22,9 @@
|
|||
• product_scoring (#985/954-B) — §14.2 десять продуктовых скоров ∈ [0,1] (выше=
|
||||
лучше) + взвешенный overall (renorm над доступными) + §16 причина на скор;
|
||||
сводит #950…#984 + live-стек, graceful-None, ADVISORY.
|
||||
• special_indices (#986/954-C) — §25 шесть специальных индексов (Launch Window,
|
||||
Product Void, Cannibalization, Competitor Strength, Artificial Demand, Cost-of-
|
||||
Error); сборка над #980/#981/§9.1/§9.2/§7.9, per-index graceful-None, ADVISORY.
|
||||
|
||||
Источники данных:
|
||||
• макро — таблица macro_indicator через reader site_finder/macro.py (reuse).
|
||||
|
|
@ -88,6 +91,11 @@ from app.services.forecasting.scenarios import (
|
|||
build_rate_envelopes,
|
||||
compute_scenarios,
|
||||
)
|
||||
from app.services.forecasting.special_indices import (
|
||||
SpecialIndex,
|
||||
SpecialIndices,
|
||||
compute_special_indices,
|
||||
)
|
||||
from app.services.forecasting.what_to_build import (
|
||||
RankedSegment,
|
||||
WhatToBuildRanking,
|
||||
|
|
@ -107,6 +115,8 @@ __all__ = [
|
|||
"SalesSeries",
|
||||
"ScenarioForecast",
|
||||
"SegmentSpec",
|
||||
"SpecialIndex",
|
||||
"SpecialIndices",
|
||||
"WhatToBuildRanking",
|
||||
"assemble_coefficient",
|
||||
"best_lag",
|
||||
|
|
@ -120,6 +130,7 @@ __all__ = [
|
|||
"compute_rate_sensitivity",
|
||||
"compute_scenarios",
|
||||
"compute_score_card",
|
||||
"compute_special_indices",
|
||||
"f_issuance",
|
||||
"f_mortgage_rate",
|
||||
"f_overdue",
|
||||
|
|
|
|||
862
backend/app/services/forecasting/special_indices.py
Normal file
862
backend/app/services/forecasting/special_indices.py
Normal file
|
|
@ -0,0 +1,862 @@
|
|||
"""§25 шесть «специальных индексов» Site Finder v2 — ДЕТЕРМИНИРОВАННЫЙ advisory-слой.
|
||||
|
||||
#986 (954-C, Site Finder v2 / «GG-форсайт» ТЗ §25), EPIC 10 «специальные индексы».
|
||||
Это **СБОРОЧНЫЙ слой над уже-смерженным форсайт-стеком**: своей §9.x-математики НЕ
|
||||
пересобирает — прогоняет/читает построенные сервисы (#980 demand_supply_forecast,
|
||||
#981 what_to_build, §9.1 competitors, §9.2 market_metrics, §7.9 affordability) и
|
||||
сводит их выводы в шесть продуктовых индексов §25. Всё ДЕТЕРМИНИРОВАННО, БЕЗ LLM.
|
||||
|
||||
Шесть индексов §25 (источник — переиспользуемый сервис):
|
||||
1. Launch Window ← скан deficit_index #980 по горизонтам {6,12,18,24}: горизонт,
|
||||
где дефицит ПИКОВЫЙ = рекомендованное окно запуска. SOLID.
|
||||
2. Product Void ← #981 rank_segments: сегменты с deficit_index ≥ _VOID_THRESHOLD
|
||||
(белое пятно — недообеспеченный спрос). QUICK-WIN, SOLID.
|
||||
3. Cannibalization ← §9.1 competitors: доля конкурентов В ТОМ ЖЕ классе, взвешенная
|
||||
relevance — пересечение рек. сегмента с чужим/своим стоком.
|
||||
DEGRADE-to-None, если нет геометрии cad_num.
|
||||
4. Competitor Strength ← §9.1 competitors: среднее relevance_weight топ-N. DEGRADE,
|
||||
если нет cad_num/геометрии.
|
||||
5. Artificial Demand ← НОВЫЙ read objective_lots: доля проданных лотов с ипотекой
|
||||
(encumbrance_type/bank_name) — спрос, «подпёртый» кредитом
|
||||
(преим. субсидированным). QUICK-WIN, SOLID; DEGRADE если
|
||||
нет проданных лотов (НЕ фабрикуем).
|
||||
6. Cost-of-Error ← §9.2 market_metrics (overstock) × средний чек (§7.9 цена ₽/м²
|
||||
× эталонная площадь): магнитуда убытка от неверного продукта.
|
||||
|
||||
ADVISORY (КРИТИЧНО — честность): каждый индекс наследует advisory-статус источника
|
||||
(#980/§9.x не провалидированы до бэктеста #951; §7.9 — деградированный прокси). Поэтому
|
||||
`advisory` ВСЕГДА True и confidence жёстко ≤ _CONFIDENCE_CAP ('medium'). Цифры — для
|
||||
explainability/прототипа, НЕ основание для инвест-решения.
|
||||
|
||||
Graceful-degrade (дух demand_supply_forecast / market_metrics): КАЖДЫЙ индекс считается
|
||||
в собственном try/except — сбой/тонкие данные одного НЕ валит карточку: индекс отдаёт
|
||||
value=None, method='unavailable', confidence='low', а остальные пять считаются. Карточка
|
||||
`SpecialIndices` возвращается ВСЕГДА (никогда не crash, никогда 0-как-заглушка).
|
||||
Детерминированно (стабильные источники + чистые helper'ы).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.schemas.parcel import CompetitorsRequest
|
||||
from app.services.forecasting.affordability import compute_affordability
|
||||
from app.services.forecasting.demand_supply_forecast import compute_demand_supply_forecast
|
||||
from app.services.forecasting.sales_series import SegmentSpec
|
||||
from app.services.forecasting.what_to_build import rank_segments
|
||||
from app.services.site_finder.competitors import get_competitors
|
||||
from app.services.site_finder.market_metrics import compute_market_metrics
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
Confidence = Literal["high", "medium", "low"]
|
||||
|
||||
# ── Названия (ключи) шести индексов §25 — стабильный публичный контракт ─────────
|
||||
KEY_LAUNCH_WINDOW = "launch_window"
|
||||
KEY_PRODUCT_VOID = "product_void"
|
||||
KEY_CANNIBALIZATION = "cannibalization"
|
||||
KEY_COMPETITOR_STRENGTH = "competitor_strength"
|
||||
KEY_ARTIFICIAL_DEMAND = "artificial_demand"
|
||||
KEY_COST_OF_ERROR = "cost_of_error"
|
||||
|
||||
# Порядок шести индексов в карточке (детерминированный обход оркестратора).
|
||||
_INDEX_KEYS: tuple[str, ...] = (
|
||||
KEY_LAUNCH_WINDOW,
|
||||
KEY_PRODUCT_VOID,
|
||||
KEY_CANNIBALIZATION,
|
||||
KEY_COMPETITOR_STRENGTH,
|
||||
KEY_ARTIFICIAL_DEMAND,
|
||||
KEY_COST_OF_ERROR,
|
||||
)
|
||||
|
||||
# ── Named-константы (вся «магия» вынесена; tunable) ───────────────────────────
|
||||
|
||||
# Дефолтные горизонты скана Launch Window (мес) — полгода…2 года (зеркалит
|
||||
# _DEFAULT_HORIZONS #980). Окно запуска = горизонт пикового дефицита из этого набора.
|
||||
_DEFAULT_HORIZONS: tuple[int, ...] = (6, 12, 18, 24)
|
||||
|
||||
# Порог deficit_index, при котором сегмент считается «белым пятном» (Product Void):
|
||||
# спрос ощутимо обгоняет предложение. 0.25 на лог-шкале #980 [−1,+1] ≈ умеренно-
|
||||
# сильный дефицит (не любой плюс). Tunable — поднять = строже определение пустоты.
|
||||
_VOID_THRESHOLD: float = 0.25
|
||||
|
||||
# Горизонт (мес), на котором #981 ранжирует сетку для Product Void. 12 мес = типовой
|
||||
# средне-срочный продуктовый горизонт (год до выхода в продажу). Tunable.
|
||||
_VOID_HORIZON_MONTHS: int = 12
|
||||
|
||||
# Сколько топ-конкурентов усредняем для Competitor Strength (среднее relevance_weight
|
||||
# топ-N). 5 — устойчивая оценка «силы окружения» без хвоста нерелевантных. Tunable.
|
||||
_COMPETITOR_TOP_N: int = 5
|
||||
|
||||
# Радиус (км) для §9.1 competitors при Cannibalization/Competitor Strength. 1.0 км =
|
||||
# дефолт CompetitorsRequest (пешая доступность — реальная зона пересечения спроса).
|
||||
_COMPETITOR_RADIUS_KM: float = 1.0
|
||||
|
||||
# Горизонт (мес) для horizon-aware relevance конкурентов (stage_at_horizon в §9.1):
|
||||
# конкурент, распроданный к нашему запуску, менее релевантен. 12 мес — дефолт.
|
||||
_COMPETITOR_HORIZON_MONTHS: int = 12
|
||||
|
||||
# Saturation для нормализации пикового дефицита Launch Window в [0,1]: при каком
|
||||
# deficit_index сила окна достигает 1.0. 0.5 = половина шкалы #980 уже «сильное окно».
|
||||
# Линейная отсечка max(0, min(1, peak / saturation)). Tunable.
|
||||
_LAUNCH_PEAK_SATURATION: float = 0.5
|
||||
|
||||
# Эталонная площадь (м²) для среднего чека Cost-of-Error. 50 м² зеркалит §7.9
|
||||
# _REF_AREA_M2 (медианная 1-2-к масс-рынка ЕКБ) — чек сопоставим между сегментами
|
||||
# при фиксированной площади (плавает только цена ₽/м²). Tunable.
|
||||
_COST_TICKET_AREA_M2: float = 50.0
|
||||
|
||||
# Нормировка среднего чека (₽) в [0,1] для Cost-of-Error: чек, при котором ценовой
|
||||
# множитель достигает 1.0. 15 млн ₽ ≈ дорогой чек масс-рынка ЕКБ (50 м² × 300 тыс/м²).
|
||||
# Tunable — выше = «дорогим» считается больший чек.
|
||||
_COST_TICKET_NORM_RUB: float = 15_000_000.0
|
||||
|
||||
# Жёсткий потолок итогового confidence: весь слой advisory (источники не валидированы
|
||||
# до бэктеста #951; §7.9 — прокси). Зеркалит _CONFIDENCE_CAP #980.
|
||||
_CONFIDENCE_CAP: Confidence = "medium"
|
||||
|
||||
# Ранг уверенности (выше = лучше). Зеркало demand_supply_forecast/what_to_build.
|
||||
_CONFIDENCE_RANK: dict[Confidence, int] = {"low": 0, "medium": 1, "high": 2}
|
||||
_RANK_TO_CONFIDENCE: dict[int, Confidence] = {0: "low", 1: "medium", 2: "high"}
|
||||
|
||||
# Метка метода у индекса, который не удалось посчитать (сбой/тонкие данные).
|
||||
_METHOD_UNAVAILABLE: str = "unavailable"
|
||||
|
||||
# Весь слой советующий — не основание для инвест-решения.
|
||||
_ADVISORY: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SpecialIndex:
|
||||
"""Один из шести §25 индексов — нормализованное значение + объяснение. ADVISORY.
|
||||
|
||||
`value` ∈ [0,1] когда задан (выше = сильнее сигнал индекса), либо None при
|
||||
тонких данных/сбое (НИКОГДА 0-как-заглушка). `label` — человекочитаемый дескриптор
|
||||
(напр. рекомендованный горизонт Launch Window «18 мес» или RU-метка силы). `method`
|
||||
описывает источник/способ ('unavailable' если не посчитан). `detail` — опорные
|
||||
числа (explainability). `advisory` ВСЕГДА True.
|
||||
"""
|
||||
|
||||
key: str
|
||||
value: float | None
|
||||
label: str | None
|
||||
confidence: Confidence # ≤ _CONFIDENCE_CAP (advisory-cap)
|
||||
detail: dict[str, Any]
|
||||
method: str # источник/способ; '_METHOD_UNAVAILABLE' если не посчитан
|
||||
advisory: bool # ВСЕГДА True
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"key": self.key,
|
||||
"value": _round_or_none(self.value, 3),
|
||||
"label": self.label,
|
||||
"confidence": self.confidence,
|
||||
"detail": dict(self.detail),
|
||||
"method": self.method,
|
||||
"advisory": self.advisory,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SpecialIndices:
|
||||
"""§25 карточка шести специальных индексов для сегмента/района/участка. ADVISORY.
|
||||
|
||||
`indices` содержит ВСЕ шесть ключей (_INDEX_KEYS) — даже недоступные (value=None,
|
||||
method='unavailable'): контракт стабилен, карточка возвращается ВСЕГДА (graceful).
|
||||
`confidence` = MIN по доступным индексам, ≤ _CONFIDENCE_CAP. `advisory` ВСЕГДА True —
|
||||
каждый индекс наследует advisory-статус источника; карточка СОВЕТУЮЩАЯ, не основание
|
||||
для инвест-решения.
|
||||
"""
|
||||
|
||||
segment: dict[str, str | None]
|
||||
district: str | None
|
||||
indices: dict[str, SpecialIndex] # все 6 ключей _INDEX_KEYS
|
||||
advisory: bool # ВСЕГДА True
|
||||
confidence: Confidence # MIN по доступным, ≤ _CONFIDENCE_CAP
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"segment": dict(self.segment),
|
||||
"district": self.district,
|
||||
"indices": {k: v.as_dict() for k, v in self.indices.items()},
|
||||
"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-математика — без БД, полностью юнит-тестируемо.
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _clamp01(value: float) -> float:
|
||||
"""Зажать в [0,1]. PURE."""
|
||||
return max(0.0, min(1.0, value))
|
||||
|
||||
|
||||
def _cap_confidence(confidence: Confidence, *, cap: Confidence = _CONFIDENCE_CAP) -> Confidence:
|
||||
"""Ограничить confidence сверху (advisory-слой не надёжнее cap). PURE.
|
||||
|
||||
Ранг-минимум(confidence, cap): high+cap-medium → medium; low → low. Зеркалит #980.
|
||||
"""
|
||||
capped_rank = min(_CONFIDENCE_RANK[confidence], _CONFIDENCE_RANK[cap])
|
||||
return _RANK_TO_CONFIDENCE[capped_rank]
|
||||
|
||||
|
||||
def _min_confidence(values: Sequence[Confidence | None]) -> Confidence:
|
||||
"""Итоговая уверенность = MIN компонентных (худшая тянет вниз). PURE.
|
||||
|
||||
None в списке игнорируем (компонент без сигнала). Пустой/весь-None → 'low'.
|
||||
Зеркалит demand_supply_forecast._min_confidence.
|
||||
"""
|
||||
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 _pick_launch_window(
|
||||
deficit_by_horizon: dict[int, float | None],
|
||||
*,
|
||||
saturation: float = _LAUNCH_PEAK_SATURATION,
|
||||
) -> tuple[int | None, float | None]:
|
||||
"""Выбрать горизонт пикового дефицита и нормализованную силу окна. PURE.
|
||||
|
||||
Из {горизонт: deficit_index} берём горизонт с МАКСИМАЛЬНЫМ deficit_index (None-
|
||||
горизонты игнорируются — там предложение неизмеримо). Сила окна = clamp01(peak /
|
||||
saturation): положительный пик нормализуется в [0,1], НЕположительный пик (баланс/
|
||||
затоварка на всех горизонтах) → 0.0 (окна «строить» нет, но горизонт-аргмакс
|
||||
возвращаем для explainability). Все deficit None → (None, None) — деградация.
|
||||
|
||||
Tie-break при равных deficit_index: МЕНЬШИЙ горизонт (раньше выходить выгоднее).
|
||||
Детерминированно.
|
||||
|
||||
Args:
|
||||
deficit_by_horizon: deficit_index #980 по горизонтам (мес).
|
||||
saturation: deficit_index, при котором сила окна = 1.0.
|
||||
|
||||
Returns:
|
||||
(best_horizon | None, window_strength ∈ [0,1] | None).
|
||||
"""
|
||||
scored = [(h, d) for h, d in deficit_by_horizon.items() if d is not None]
|
||||
if not scored:
|
||||
return (None, None)
|
||||
# max по deficit, tie-break — меньший горизонт (negate deficit для max через min-ключ).
|
||||
best_horizon, best_deficit = min(scored, key=lambda hd: (-hd[1], hd[0]))
|
||||
if saturation <= 0:
|
||||
strength = 1.0 if best_deficit > 0 else 0.0
|
||||
else:
|
||||
strength = _clamp01(best_deficit / saturation)
|
||||
return (best_horizon, strength)
|
||||
|
||||
|
||||
def _void_index(deficits: Sequence[float | None], *, threshold: float = _VOID_THRESHOLD) -> float:
|
||||
"""Доля сегментов сетки с deficit_index ≥ threshold (Product Void). PURE.
|
||||
|
||||
«Белое пятно» = сегмент, где спрос ощутимо обгоняет предложение (deficit_index ≥
|
||||
threshold). Индекс = доля таких сегментов среди ВСЕХ ОЦЕНЁННЫХ (deficit_index не
|
||||
None — тонкие сегменты в знаменатель не входят, они уже отброшены #981). Пустой
|
||||
вход / все None → 0.0 (нет измеримой пустоты, НЕ None: 0 — валидный «пустоты нет»).
|
||||
∈ [0,1]. PURE.
|
||||
|
||||
Args:
|
||||
deficits: deficit_index ранжированных сегментов (#981 уже отбросил None-ячейки).
|
||||
threshold: порог «белого пятна».
|
||||
|
||||
Returns:
|
||||
Доля сегментов-пустот ∈ [0,1].
|
||||
"""
|
||||
measured = [d for d in deficits if d is not None]
|
||||
if not measured:
|
||||
return 0.0
|
||||
n_void = sum(1 for d in measured if d >= threshold)
|
||||
return n_void / len(measured)
|
||||
|
||||
|
||||
def _count_void(deficits: Sequence[float | None], *, threshold: float = _VOID_THRESHOLD) -> int:
|
||||
"""Число сегментов-пустот (deficit_index ≥ threshold). PURE (для detail)."""
|
||||
return sum(1 for d in deficits if d is not None and d >= threshold)
|
||||
|
||||
|
||||
def _competitor_strength(
|
||||
relevance_weights: Sequence[float | None], *, top_n: int = _COMPETITOR_TOP_N
|
||||
) -> float | None:
|
||||
"""Среднее relevance_weight топ-N конкурентов (Competitor Strength). PURE.
|
||||
|
||||
Сортируем relevance_weight по убыванию, берём топ-N, усредняем. relevance_weight
|
||||
уже ∈ [0,1] (детерминированный §9.1), поэтому и среднее ∈ [0,1]. None-веса
|
||||
игнорируем. Нет конкурентов / все None → None (неизмеримо, НЕ 0 — отсутствие
|
||||
конкурентов ≠ «нулевая сила», это деградация по данным). PURE.
|
||||
|
||||
Args:
|
||||
relevance_weights: relevance_weight конкурентов (§9.1).
|
||||
top_n: сколько верхних усреднять.
|
||||
|
||||
Returns:
|
||||
Среднее топ-N ∈ [0,1] или None.
|
||||
"""
|
||||
weights = sorted((w for w in relevance_weights if w is not None), reverse=True)
|
||||
if not weights:
|
||||
return None
|
||||
top = weights[: max(1, top_n)]
|
||||
return sum(top) / len(top)
|
||||
|
||||
|
||||
def _cannibalization_index(
|
||||
same_class_relevance: Sequence[float | None],
|
||||
all_relevance: Sequence[float | None],
|
||||
) -> float | None:
|
||||
"""Каннибализация = доля «силы» конкурентов В ТОМ ЖЕ классе. PURE.
|
||||
|
||||
Пересечение рекомендованного сегмента с чужим/своим стоком: чем больше суммарной
|
||||
relevance-«силы» приходится на конкурентов В ТОМ ЖЕ классе (sum same-class /
|
||||
sum all), тем выше риск каннибализации. relevance-веса ∈ [0,1] → доля ∈ [0,1].
|
||||
Нет конкурентов вообще (пустой all) → None (неизмеримо — нет окружения для
|
||||
пересечения). Конкуренты есть, но ни одного в нашем классе → 0.0 (валидно: нет
|
||||
пересечения = нет каннибализации). PURE.
|
||||
|
||||
Args:
|
||||
same_class_relevance: relevance_weight конкурентов В классе рек. сегмента.
|
||||
all_relevance: relevance_weight ВСЕХ конкурентов в радиусе.
|
||||
|
||||
Returns:
|
||||
Доля same-class-силы ∈ [0,1] или None.
|
||||
"""
|
||||
total = sum(w for w in all_relevance if w is not None)
|
||||
if total <= 0:
|
||||
return None
|
||||
same = sum(w for w in same_class_relevance if w is not None)
|
||||
return _clamp01(same / total)
|
||||
|
||||
|
||||
def _artificial_demand_share(n_mortgage: int | None, n_sold: int | None) -> float | None:
|
||||
"""Доля проданных лотов, профинансированных ипотекой (Artificial Demand). PURE.
|
||||
|
||||
Спрос, «подпёртый» кредитом (преим. субсидированной ипотекой/спекуляцией) =
|
||||
доля сделок с зафиксированным обременением/банком среди ВСЕХ проданных лотов.
|
||||
Нет проданных лотов → None (неизмеримо — НЕ фабрикуем 0: отсутствие данных ≠
|
||||
«нулевая искусственность»). ∈ [0,1] (clamp на случай грязных данных). PURE.
|
||||
|
||||
Args:
|
||||
n_mortgage: проданных лотов с ипотечным признаком (encumbrance/bank).
|
||||
n_sold: всего проданных лотов.
|
||||
|
||||
Returns:
|
||||
Доля ипотечных сделок ∈ [0,1] или None.
|
||||
"""
|
||||
if n_mortgage is None or not n_sold or n_sold <= 0:
|
||||
return None
|
||||
return _clamp01(float(n_mortgage) / float(n_sold))
|
||||
|
||||
|
||||
def _avg_ticket_rub(
|
||||
price_per_m2: float | None, *, area_m2: float = _COST_TICKET_AREA_M2
|
||||
) -> float | None:
|
||||
"""Средний чек (₽) эталонной квартиры = цена ₽/м² × площадь. PURE.
|
||||
|
||||
None/непозитивная цена → None (нет чека — нечего считать). PURE.
|
||||
"""
|
||||
if price_per_m2 is None or price_per_m2 <= 0:
|
||||
return None
|
||||
return price_per_m2 * area_m2
|
||||
|
||||
|
||||
def _cost_of_error_index(
|
||||
oversupply_risk: float | None,
|
||||
avg_ticket_rub: float | None,
|
||||
*,
|
||||
ticket_norm_rub: float = _COST_TICKET_NORM_RUB,
|
||||
) -> float | None:
|
||||
"""Cost-of-Error = риск затоварки × нормированный чек. PURE, монотонно.
|
||||
|
||||
Магнитуда убытка от НЕВЕРНОГО продукта: высокий риск затоварки (oversupply_risk
|
||||
∈ [0,1] — overstock-доля #9.2 ИЛИ магнитуда отрицательного дефицита) × дорогой
|
||||
средний чек (нормированный в [0,1]) = дорого ошибиться. Произведение двух [0,1] →
|
||||
[0,1]; монотонно неубывающее по каждому входу. Любой вход None → None (неизмеримо).
|
||||
PURE.
|
||||
|
||||
Args:
|
||||
oversupply_risk: риск затоварки ∈ [0,1] (overstock-доля / |negative deficit|).
|
||||
avg_ticket_rub: средний чек сегмента (₽).
|
||||
ticket_norm_rub: чек, при котором ценовой множитель = 1.0.
|
||||
|
||||
Returns:
|
||||
Индекс стоимости ошибки ∈ [0,1] или None.
|
||||
"""
|
||||
if oversupply_risk is None or avg_ticket_rub is None:
|
||||
return None
|
||||
if ticket_norm_rub <= 0:
|
||||
ticket_factor = 1.0
|
||||
else:
|
||||
ticket_factor = _clamp01(avg_ticket_rub / ticket_norm_rub)
|
||||
return _clamp01(oversupply_risk) * ticket_factor
|
||||
|
||||
|
||||
def _oversupply_risk_from_deficit(deficit_index: float | None) -> float | None:
|
||||
"""Риск затоварки из deficit_index #980: магнитуда ОТРИЦАТЕЛЬНОГО дефицита. PURE.
|
||||
|
||||
deficit_index < 0 = затоварка → риск = |deficit_index| ∈ (0,1]. deficit_index ≥ 0
|
||||
(дефицит/баланс) → 0.0 (нет риска затоварки). None → None. Fallback-канал для
|
||||
Cost-of-Error, когда overstock_index §9.2 недоступен. PURE.
|
||||
"""
|
||||
if deficit_index is None:
|
||||
return None
|
||||
return -deficit_index if deficit_index < 0 else 0.0
|
||||
|
||||
|
||||
def _unavailable(key: str, *, reason: str) -> SpecialIndex:
|
||||
"""Сконструировать «недоступный» индекс (value=None, method=unavailable). PURE.
|
||||
|
||||
Единая форма деградации одного индекса: карточка остаётся полной (6 ключей),
|
||||
confidence='low', detail несёт причину. advisory ВСЕГДА True.
|
||||
"""
|
||||
return SpecialIndex(
|
||||
key=key,
|
||||
value=None,
|
||||
label=None,
|
||||
confidence="low",
|
||||
detail={"reason": reason},
|
||||
method=_METHOD_UNAVAILABLE,
|
||||
advisory=_ADVISORY,
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Artificial-Demand SQL — ЕДИНСТВЕННЫЙ новый read (objective_lots). psycopg v3.
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Доля ипотечных сделок среди проданных лотов сегмента. Ипотечный признак =
|
||||
# encumbrance_type ЛИБО bank_name заполнены (Объектив пишет банк/тип обременения
|
||||
# для кредитных сделок). Фильтры по district/class/premise_kind — все опциональны
|
||||
# (CAST(:x AS text) IS NULL OR ...). «Продан» = is_sold IS TRUE ИЛИ contract_date
|
||||
# заполнена (две независимые отметки сделки в objective_lots). psycopg v3: CAST,
|
||||
# НИКОГДА :x::type.
|
||||
_ARTIFICIAL_DEMAND_SQL = text(
|
||||
"""
|
||||
WITH sold AS (
|
||||
SELECT
|
||||
ol.objective_lot_id,
|
||||
(ol.encumbrance_type IS NOT NULL OR ol.bank_name IS NOT NULL) AS has_mortgage
|
||||
FROM objective_lots ol
|
||||
WHERE ol.premise_kind = CAST(:premise_kind AS text)
|
||||
AND (ol.is_sold IS TRUE OR ol.contract_date IS NOT NULL)
|
||||
AND (
|
||||
CAST(:district AS text) IS NULL
|
||||
OR ol.district = CAST(:district AS text)
|
||||
)
|
||||
AND (
|
||||
CAST(:obj_class AS text) IS NULL
|
||||
OR lower(ol.class) = lower(CAST(:obj_class AS text))
|
||||
)
|
||||
)
|
||||
SELECT
|
||||
COUNT(*) AS n_sold,
|
||||
COUNT(*) FILTER (WHERE has_mortgage) AS n_mortgage
|
||||
FROM sold
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _query_artificial_demand(
|
||||
db: Session,
|
||||
*,
|
||||
district: str | None,
|
||||
obj_class: str | None,
|
||||
premise_kind: str,
|
||||
) -> dict[str, int]:
|
||||
"""Прочитать (n_sold, n_mortgage) сегмента из objective_lots. Graceful.
|
||||
|
||||
Параметризовано (psycopg v3 CAST). Сбой/пустой результат → {n_sold:0,n_mortgage:0}
|
||||
(НЕ crash — выше преобразуется в деградацию-None). Детерминированно.
|
||||
"""
|
||||
row = (
|
||||
db.execute(
|
||||
_ARTIFICIAL_DEMAND_SQL,
|
||||
{
|
||||
"premise_kind": premise_kind,
|
||||
"district": district,
|
||||
"obj_class": obj_class,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
if not row:
|
||||
return {"n_sold": 0, "n_mortgage": 0}
|
||||
return {
|
||||
"n_sold": int(row["n_sold"] or 0),
|
||||
"n_mortgage": int(row["n_mortgage"] or 0),
|
||||
}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Per-index builders — каждый тонкий, graceful; pure-математика выше тестируется без БД.
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _build_launch_window(
|
||||
db: Session,
|
||||
*,
|
||||
spec: SegmentSpec,
|
||||
district: str | None,
|
||||
cad_num: str | None,
|
||||
horizons: Sequence[int],
|
||||
) -> SpecialIndex:
|
||||
"""Index 1 — Launch Window: горизонт пикового deficit_index #980. SOLID/degrade."""
|
||||
forecasts = compute_demand_supply_forecast(
|
||||
db,
|
||||
spec=spec,
|
||||
district=district,
|
||||
cad_num=cad_num or "",
|
||||
horizons=list(horizons),
|
||||
)
|
||||
deficit_by_horizon = {f.horizon_months: f.deficit_index for f in forecasts}
|
||||
confidences = [f.confidence for f in forecasts]
|
||||
best_horizon, strength = _pick_launch_window(deficit_by_horizon)
|
||||
if best_horizon is None:
|
||||
return _unavailable(
|
||||
KEY_LAUNCH_WINDOW,
|
||||
reason="deficit_index None на всех горизонтах (тонкое предложение)",
|
||||
)
|
||||
confidence = _cap_confidence(_min_confidence(confidences))
|
||||
return SpecialIndex(
|
||||
key=KEY_LAUNCH_WINDOW,
|
||||
value=strength,
|
||||
label=f"{best_horizon} мес",
|
||||
confidence=confidence,
|
||||
detail={
|
||||
"best_horizon_months": best_horizon,
|
||||
"deficit_by_horizon": {h: _round_or_none(d, 3) for h, d in deficit_by_horizon.items()},
|
||||
},
|
||||
method="deficit_peak_scan",
|
||||
advisory=_ADVISORY,
|
||||
)
|
||||
|
||||
|
||||
def _build_product_void(
|
||||
db: Session,
|
||||
*,
|
||||
district: str | None,
|
||||
cad_num: str | None,
|
||||
horizon_months: int,
|
||||
) -> SpecialIndex:
|
||||
"""Index 2 — Product Void: доля сегментов сетки с deficit_index ≥ порога. SOLID."""
|
||||
ranking = rank_segments(
|
||||
db,
|
||||
district=district,
|
||||
cad_num=cad_num or "",
|
||||
horizon_months=horizon_months,
|
||||
)
|
||||
deficits = [seg.deficit_index for seg in ranking.ranked]
|
||||
if not deficits:
|
||||
return _unavailable(
|
||||
KEY_PRODUCT_VOID,
|
||||
reason="ранкинг #981 пуст (вся сетка тонкая)",
|
||||
)
|
||||
value = _void_index(deficits)
|
||||
n_void = _count_void(deficits)
|
||||
top_void = [seg.as_dict() for seg in ranking.ranked if seg.deficit_index >= _VOID_THRESHOLD][
|
||||
:_COMPETITOR_TOP_N
|
||||
]
|
||||
confidence = _cap_confidence(_min_confidence([seg.confidence for seg in ranking.ranked]))
|
||||
return SpecialIndex(
|
||||
key=KEY_PRODUCT_VOID,
|
||||
value=value,
|
||||
label=f"{n_void} белых пятен" if n_void else "нет белых пятен",
|
||||
confidence=confidence,
|
||||
detail={
|
||||
"n_void": n_void,
|
||||
"n_ranked": ranking.n_cells_ranked,
|
||||
"threshold": _VOID_THRESHOLD,
|
||||
"top_void_segments": top_void,
|
||||
},
|
||||
method="deficit_threshold_share",
|
||||
advisory=_ADVISORY,
|
||||
)
|
||||
|
||||
|
||||
def _fetch_competitors(db: Session, *, cad_num: str) -> list[Any]:
|
||||
"""Достать конкурентов §9.1 для cad_num (horizon-aware relevance). Может бросить.
|
||||
|
||||
ValueError при отсутствии геометрии участка — пробрасывается в builder, где станет
|
||||
деградацией-None (Cannibalization/Competitor Strength требуют геометрии).
|
||||
"""
|
||||
response = get_competitors(
|
||||
db,
|
||||
cad_num,
|
||||
CompetitorsRequest(
|
||||
radius_km=_COMPETITOR_RADIUS_KM,
|
||||
horizon_months=_COMPETITOR_HORIZON_MONTHS,
|
||||
),
|
||||
)
|
||||
return list(response.competitors)
|
||||
|
||||
|
||||
def _build_competitor_strength(db: Session, *, cad_num: str | None) -> SpecialIndex:
|
||||
"""Index 4 — Competitor Strength: среднее relevance_weight топ-N. Degrade без cad_num."""
|
||||
if not cad_num:
|
||||
return _unavailable(
|
||||
KEY_COMPETITOR_STRENGTH,
|
||||
reason="нет cad_num — конкурентов §9.1 не определить",
|
||||
)
|
||||
competitors = _fetch_competitors(db, cad_num=cad_num)
|
||||
weights = [c.relevance_weight for c in competitors]
|
||||
value = _competitor_strength(weights)
|
||||
if value is None:
|
||||
return _unavailable(
|
||||
KEY_COMPETITOR_STRENGTH,
|
||||
reason="нет конкурентов с relevance_weight в радиусе",
|
||||
)
|
||||
return SpecialIndex(
|
||||
key=KEY_COMPETITOR_STRENGTH,
|
||||
value=value,
|
||||
label=f"топ-{min(_COMPETITOR_TOP_N, len(weights))} конкурентов",
|
||||
confidence=_cap_confidence("medium"),
|
||||
detail={
|
||||
"n_competitors": len(competitors),
|
||||
"top_n": _COMPETITOR_TOP_N,
|
||||
"radius_km": _COMPETITOR_RADIUS_KM,
|
||||
},
|
||||
method="relevance_weight_top_n_mean",
|
||||
advisory=_ADVISORY,
|
||||
)
|
||||
|
||||
|
||||
def _build_cannibalization(db: Session, *, spec: SegmentSpec, cad_num: str | None) -> SpecialIndex:
|
||||
"""Index 3 — Cannibalization: доля relevance-силы конкурентов В классе. Degrade."""
|
||||
if not cad_num:
|
||||
return _unavailable(
|
||||
KEY_CANNIBALIZATION,
|
||||
reason="нет cad_num/геометрии — пересечение стока не определить",
|
||||
)
|
||||
competitors = _fetch_competitors(db, cad_num=cad_num)
|
||||
all_relevance = [c.relevance_weight for c in competitors]
|
||||
target_class = (spec.obj_class or "").strip().lower()
|
||||
same_class_relevance = [
|
||||
c.relevance_weight
|
||||
for c in competitors
|
||||
if target_class and (c.obj_class or "").strip().lower() == target_class
|
||||
]
|
||||
value = _cannibalization_index(same_class_relevance, all_relevance)
|
||||
if value is None:
|
||||
return _unavailable(
|
||||
KEY_CANNIBALIZATION,
|
||||
reason="нет конкурентов в радиусе — пересечения нет",
|
||||
)
|
||||
return SpecialIndex(
|
||||
key=KEY_CANNIBALIZATION,
|
||||
value=value,
|
||||
label=spec.obj_class,
|
||||
confidence=_cap_confidence("medium"),
|
||||
detail={
|
||||
"n_competitors": len(competitors),
|
||||
"n_same_class": len(same_class_relevance),
|
||||
"target_class": spec.obj_class,
|
||||
"radius_km": _COMPETITOR_RADIUS_KM,
|
||||
},
|
||||
method="same_class_relevance_share",
|
||||
advisory=_ADVISORY,
|
||||
)
|
||||
|
||||
|
||||
def _build_artificial_demand(
|
||||
db: Session, *, spec: SegmentSpec, district: str | None, premise_kind: str
|
||||
) -> SpecialIndex:
|
||||
"""Index 5 — Artificial Demand: доля ипотечных сделок objective_lots. SOLID/degrade."""
|
||||
counts = _query_artificial_demand(
|
||||
db,
|
||||
district=district,
|
||||
obj_class=spec.obj_class,
|
||||
premise_kind=premise_kind,
|
||||
)
|
||||
n_sold = counts["n_sold"]
|
||||
n_mortgage = counts["n_mortgage"]
|
||||
value = _artificial_demand_share(n_mortgage, n_sold)
|
||||
if value is None:
|
||||
return _unavailable(
|
||||
KEY_ARTIFICIAL_DEMAND,
|
||||
reason="нет проданных лотов сегмента — доля ипотеки неизмерима (НЕ фабрикуем)",
|
||||
)
|
||||
# Уверенность по размеру выборки проданных лотов (малая выборка → low).
|
||||
confidence: Confidence = "medium" if n_sold >= 30 else "low"
|
||||
return SpecialIndex(
|
||||
key=KEY_ARTIFICIAL_DEMAND,
|
||||
value=value,
|
||||
label=f"{round(value * 100)}% сделок с ипотекой",
|
||||
confidence=_cap_confidence(confidence),
|
||||
detail={
|
||||
"n_sold": n_sold,
|
||||
"n_mortgage": n_mortgage,
|
||||
},
|
||||
method="mortgage_share_objective_lots",
|
||||
advisory=_ADVISORY,
|
||||
)
|
||||
|
||||
|
||||
def _build_cost_of_error(
|
||||
db: Session,
|
||||
*,
|
||||
spec: SegmentSpec,
|
||||
district: str | None,
|
||||
cad_num: str | None,
|
||||
premise_kind: str,
|
||||
) -> SpecialIndex:
|
||||
"""Index 6 — Cost-of-Error: риск затоварки × средний чек. SOLID/degrade.
|
||||
|
||||
Риск затоварки — overstock_index §9.2 (предпочтительно); если он None — fallback
|
||||
на магнитуду отрицательного deficit_index #980 (затоварка). Чек — сегментная цена
|
||||
₽/м² §7.9 (compute_affordability, reuse build_sales_series) × эталонная площадь.
|
||||
Любой канал тонкий → None.
|
||||
"""
|
||||
metrics = compute_market_metrics(db, district=district, premise_kind=premise_kind)
|
||||
oversupply_risk = metrics.overstock_index
|
||||
risk_source = "overstock_index"
|
||||
|
||||
if oversupply_risk is None and cad_num:
|
||||
# Fallback-канал: магнитуда отрицательного дефицита #980 на дефолтном горизонте.
|
||||
forecasts = compute_demand_supply_forecast(
|
||||
db,
|
||||
spec=spec,
|
||||
district=district,
|
||||
cad_num=cad_num,
|
||||
horizons=[_VOID_HORIZON_MONTHS],
|
||||
)
|
||||
if forecasts:
|
||||
oversupply_risk = _oversupply_risk_from_deficit(forecasts[0].deficit_index)
|
||||
risk_source = "negative_deficit"
|
||||
|
||||
# Сегментная цена ₽/м² — из §7.9 (reuse build_sales_series внутри); ref-площадь общая.
|
||||
price_per_m2 = compute_affordability(
|
||||
db, spec=spec, ref_area_m2=_COST_TICKET_AREA_M2
|
||||
).price_per_m2
|
||||
avg_ticket = _avg_ticket_rub(price_per_m2)
|
||||
value = _cost_of_error_index(oversupply_risk, avg_ticket)
|
||||
if value is None:
|
||||
return _unavailable(
|
||||
KEY_COST_OF_ERROR,
|
||||
reason="нет риска затоварки или среднего чека (тонкие §9.2/§7.9)",
|
||||
)
|
||||
return SpecialIndex(
|
||||
key=KEY_COST_OF_ERROR,
|
||||
value=value,
|
||||
label="риск дорогой ошибки",
|
||||
confidence=_cap_confidence(metrics.confidence),
|
||||
detail={
|
||||
"oversupply_risk": _round_or_none(oversupply_risk, 3),
|
||||
"risk_source": risk_source,
|
||||
"avg_ticket_rub": _round_or_none(avg_ticket, 0),
|
||||
"price_per_m2": _round_or_none(price_per_m2, 0),
|
||||
"ref_area_m2": _COST_TICKET_AREA_M2,
|
||||
},
|
||||
method="oversupply_x_ticket",
|
||||
advisory=_ADVISORY,
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# DB-оркестратор — каждый индекс в своём try/except; карточка возвращается ВСЕГДА.
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def compute_special_indices(
|
||||
db: Session,
|
||||
*,
|
||||
spec: SegmentSpec,
|
||||
district: str | None = None,
|
||||
cad_num: str | None = None,
|
||||
horizons: Sequence[int] = _DEFAULT_HORIZONS,
|
||||
premise_kind: str = "квартира",
|
||||
) -> SpecialIndices:
|
||||
"""§25 собрать карточку шести специальных индексов для сегмента/района/участка.
|
||||
|
||||
ADVISORY (каждый индекс наследует advisory-статус источника, см. module docstring) —
|
||||
НЕ подключать в production-эндпоинт как основание для решения. СБОРКА над
|
||||
форсайт-стеком, НЕ пересчёт §9.x.
|
||||
|
||||
Считает шесть индексов §25, КАЖДЫЙ в собственном try/except: сбой/тонкие данные
|
||||
одного индекса → этот индекс отдаёт value=None + method='unavailable' (деградация),
|
||||
а остальные пять считаются. Карточка возвращается ВСЕГДА (graceful, никогда crash).
|
||||
`advisory` ВСЕГДА True; `confidence` = MIN по доступным индексам, ≤ _CONFIDENCE_CAP.
|
||||
|
||||
Quick-win SOLID (данные есть): Product Void (#981), Artificial Demand (ипотечный
|
||||
признак objective_lots). Остальные деградируют изящно при тонких данных: Launch
|
||||
Window — если deficit None на всех горизонтах; Cannibalization / Competitor
|
||||
Strength — если нет cad_num/геометрии; Cost-of-Error — если тонкие §9.2/#980.
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy sync Session.
|
||||
spec: целевой (рекомендованный) сегмент рынка.
|
||||
district: район участка (None → ЕКБ-wide).
|
||||
cad_num: кадастровый номер участка (None → Cannibalization/Competitor Strength
|
||||
деградируют; Launch Window/Cost-of-Error работают без конкурентов).
|
||||
horizons: горизонты скана Launch Window (по умолчанию _DEFAULT_HORIZONS).
|
||||
premise_kind: тип помещения (по умолчанию 'квартира').
|
||||
|
||||
Returns:
|
||||
SpecialIndices — карточка с ВСЕМИ шестью ключами (всегда; тонкие → None+unavail).
|
||||
"""
|
||||
segment = spec.as_dict()
|
||||
|
||||
# (ключ, нулевой-аргумент builder) — каждый вызывается в общем try/except ниже.
|
||||
builders: dict[str, Any] = {
|
||||
KEY_LAUNCH_WINDOW: lambda: _build_launch_window(
|
||||
db, spec=spec, district=district, cad_num=cad_num, horizons=horizons
|
||||
),
|
||||
KEY_PRODUCT_VOID: lambda: _build_product_void(
|
||||
db, district=district, cad_num=cad_num, horizon_months=_VOID_HORIZON_MONTHS
|
||||
),
|
||||
KEY_CANNIBALIZATION: lambda: _build_cannibalization(db, spec=spec, cad_num=cad_num),
|
||||
KEY_COMPETITOR_STRENGTH: lambda: _build_competitor_strength(db, cad_num=cad_num),
|
||||
KEY_ARTIFICIAL_DEMAND: lambda: _build_artificial_demand(
|
||||
db, spec=spec, district=district, premise_kind=premise_kind
|
||||
),
|
||||
KEY_COST_OF_ERROR: lambda: _build_cost_of_error(
|
||||
db, spec=spec, district=district, cad_num=cad_num, premise_kind=premise_kind
|
||||
),
|
||||
}
|
||||
|
||||
indices: dict[str, SpecialIndex] = {}
|
||||
for key in _INDEX_KEYS:
|
||||
try:
|
||||
indices[key] = builders[key]()
|
||||
except Exception:
|
||||
# Сбой одного индекса НЕ валит карточку: деградация-None, остальные считаются.
|
||||
logger.exception(
|
||||
"special_indices: index=%s failed (segment=%s district=%s cad_num=%s)",
|
||||
key,
|
||||
segment,
|
||||
district,
|
||||
cad_num,
|
||||
)
|
||||
indices[key] = _unavailable(key, reason="ошибка расчёта (см. логи)")
|
||||
|
||||
confidence = _min_confidence([idx.confidence for idx in indices.values()])
|
||||
|
||||
n_available = sum(1 for idx in indices.values() if idx.value is not None)
|
||||
logger.info(
|
||||
"special_indices: segment=%s district=%s cad_num=%s available=%d/6 "
|
||||
"confidence=%s (ADVISORY)",
|
||||
segment,
|
||||
district,
|
||||
cad_num,
|
||||
n_available,
|
||||
confidence,
|
||||
)
|
||||
|
||||
return SpecialIndices(
|
||||
segment=segment,
|
||||
district=district,
|
||||
indices=indices,
|
||||
advisory=_ADVISORY,
|
||||
confidence=confidence,
|
||||
)
|
||||
879
backend/tests/services/forecasting/test_special_indices.py
Normal file
879
backend/tests/services/forecasting/test_special_indices.py
Normal file
|
|
@ -0,0 +1,879 @@
|
|||
"""Тесты §25 special_indices (#986/954-C) — pure-helpers + mocked-БД + оркестратор.
|
||||
|
||||
Покрытие:
|
||||
• Pure-математика БЕЗ БД: Product Void (порог/доля/счёт), Launch Window (пик +
|
||||
tie-break + нормализация), Competitor Strength (топ-N среднее), Cannibalization
|
||||
(доля same-class), Artificial Demand (доля ипотеки), Cost-of-Error (монотонность
|
||||
+ произведение), нормализации в [0,1], confidence-helpers, _avg_ticket.
|
||||
• Artificial-Demand SQL: MagicMock-сессия — форма SQL (CAST(:x AS type), не ::),
|
||||
параметры; сигнал есть → индекс; нет проданных → None + caveat.
|
||||
• compute_special_indices: @patch бэкенд-сервисов — 6 индексов присутствуют,
|
||||
advisory True, per-index graceful (сбой одного → unavailable, карточка цела).
|
||||
|
||||
Всё детерминировано, без БД (сессия мокается). Зеркалит стиль test_what_to_build /
|
||||
test_market_metrics.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.forecasting.sales_series import SegmentSpec
|
||||
from app.services.forecasting.special_indices import (
|
||||
_INDEX_KEYS,
|
||||
_METHOD_UNAVAILABLE,
|
||||
KEY_ARTIFICIAL_DEMAND,
|
||||
KEY_CANNIBALIZATION,
|
||||
KEY_COMPETITOR_STRENGTH,
|
||||
KEY_COST_OF_ERROR,
|
||||
KEY_LAUNCH_WINDOW,
|
||||
KEY_PRODUCT_VOID,
|
||||
_artificial_demand_share,
|
||||
_avg_ticket_rub,
|
||||
_cannibalization_index,
|
||||
_cap_confidence,
|
||||
_clamp01,
|
||||
_competitor_strength,
|
||||
_cost_of_error_index,
|
||||
_count_void,
|
||||
_min_confidence,
|
||||
_oversupply_risk_from_deficit,
|
||||
_pick_launch_window,
|
||||
_query_artificial_demand,
|
||||
_void_index,
|
||||
compute_special_indices,
|
||||
)
|
||||
|
||||
# Patch-таргеты — имена, импортированные В модуль special_indices. _DSF используется
|
||||
# точечно (re-patch поверх _full_stack_patch); остальные сервисы патчатся через
|
||||
# patch.multiple(_MOD, ...) по kwargs-имени, поэтому отдельных констант им не нужно.
|
||||
_MOD = "app.services.forecasting.special_indices"
|
||||
_DSF = f"{_MOD}.compute_demand_supply_forecast"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Pure: _clamp01 / confidence-helpers
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestClamp01:
|
||||
def test_in_range_passthrough(self) -> None:
|
||||
assert _clamp01(0.4) == 0.4
|
||||
|
||||
def test_above_one_clamped(self) -> None:
|
||||
assert _clamp01(1.7) == 1.0
|
||||
|
||||
def test_below_zero_clamped(self) -> None:
|
||||
assert _clamp01(-0.3) == 0.0
|
||||
|
||||
|
||||
class TestCapConfidence:
|
||||
def test_high_capped_to_medium(self) -> None:
|
||||
assert _cap_confidence("high") == "medium"
|
||||
|
||||
def test_medium_unchanged(self) -> None:
|
||||
assert _cap_confidence("medium") == "medium"
|
||||
|
||||
def test_low_unchanged(self) -> None:
|
||||
assert _cap_confidence("low") == "low"
|
||||
|
||||
|
||||
class TestMinConfidence:
|
||||
def test_worst_wins(self) -> None:
|
||||
assert _min_confidence(["high", "low", "medium"]) == "low"
|
||||
|
||||
def test_none_ignored(self) -> None:
|
||||
assert _min_confidence(["medium", None, "high"]) == "medium"
|
||||
|
||||
def test_all_none_low(self) -> None:
|
||||
assert _min_confidence([None, None]) == "low"
|
||||
|
||||
def test_empty_low(self) -> None:
|
||||
assert _min_confidence([]) == "low"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Pure: Product Void (порог / доля / счёт)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestVoidIndex:
|
||||
def test_share_above_threshold(self) -> None:
|
||||
# 2 из 4 измеренных ≥ 0.25 → 0.5.
|
||||
assert _void_index([0.3, 0.1, 0.9, -0.2]) == 0.5
|
||||
|
||||
def test_threshold_is_inclusive(self) -> None:
|
||||
# ровно на пороге 0.25 → считается пустотой.
|
||||
assert _void_index([0.25]) == 1.0
|
||||
|
||||
def test_below_threshold_excluded(self) -> None:
|
||||
# 0.24 < 0.25 → не пустота.
|
||||
assert _void_index([0.24]) == 0.0
|
||||
|
||||
def test_none_cells_excluded_from_denominator(self) -> None:
|
||||
# None не входит ни в числитель, ни в знаменатель: 1 из 2 измеренных.
|
||||
assert _void_index([0.9, None, 0.0]) == 0.5
|
||||
|
||||
def test_empty_yields_zero(self) -> None:
|
||||
assert _void_index([]) == 0.0
|
||||
|
||||
def test_all_none_yields_zero(self) -> None:
|
||||
assert _void_index([None, None]) == 0.0
|
||||
|
||||
def test_in_range(self) -> None:
|
||||
v = _void_index([0.9, 0.8, 0.7])
|
||||
assert 0.0 <= v <= 1.0
|
||||
assert v == 1.0
|
||||
|
||||
def test_custom_threshold(self) -> None:
|
||||
assert _void_index([0.5, 0.4], threshold=0.45) == 0.5
|
||||
|
||||
|
||||
class TestCountVoid:
|
||||
def test_counts_at_or_above_threshold(self) -> None:
|
||||
assert _count_void([0.3, 0.25, 0.1, 0.99]) == 3
|
||||
|
||||
def test_none_skipped(self) -> None:
|
||||
assert _count_void([None, 0.9, None]) == 1
|
||||
|
||||
def test_empty_zero(self) -> None:
|
||||
assert _count_void([]) == 0
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Pure: Launch Window (пик + tie-break + нормализация)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPickLaunchWindow:
|
||||
def test_picks_peak_horizon(self) -> None:
|
||||
h, strength = _pick_launch_window({6: 0.1, 12: 0.4, 18: 0.2, 24: -0.1})
|
||||
assert h == 12
|
||||
# 0.4 / 0.5 (saturation) = 0.8.
|
||||
assert strength == pytest.approx(0.8)
|
||||
|
||||
def test_strength_in_range(self) -> None:
|
||||
_, strength = _pick_launch_window({6: 0.9, 12: 0.95})
|
||||
assert strength is not None
|
||||
assert 0.0 <= strength <= 1.0
|
||||
assert strength == 1.0 # 0.95/0.5 clamp → 1.0
|
||||
|
||||
def test_tie_break_prefers_earlier_horizon(self) -> None:
|
||||
# равный пиковый deficit на 6 и 18 → берём меньший горизонт (раньше выгоднее).
|
||||
h, _ = _pick_launch_window({6: 0.5, 18: 0.5})
|
||||
assert h == 6
|
||||
|
||||
def test_none_horizons_ignored(self) -> None:
|
||||
# 12 — None (тонко), пик среди измеренных = 24.
|
||||
h, _ = _pick_launch_window({6: 0.1, 12: None, 24: 0.3})
|
||||
assert h == 24
|
||||
|
||||
def test_all_none_degrades(self) -> None:
|
||||
assert _pick_launch_window({6: None, 12: None}) == (None, None)
|
||||
|
||||
def test_empty_degrades(self) -> None:
|
||||
assert _pick_launch_window({}) == (None, None)
|
||||
|
||||
def test_nonpositive_peak_zero_strength_but_horizon_returned(self) -> None:
|
||||
# все горизонты ≤0 (баланс/затоварка): окна «строить» нет (strength 0),
|
||||
# но аргмакс-горизонт возвращаем (наименее плохой) для explainability.
|
||||
h, strength = _pick_launch_window({6: -0.3, 12: -0.1, 24: -0.5})
|
||||
assert h == 12 # наибольший (наименее отрицательный) deficit
|
||||
assert strength == 0.0
|
||||
|
||||
def test_saturation_nonpositive_degrades_to_sign(self) -> None:
|
||||
h, strength = _pick_launch_window({6: 0.2}, saturation=0.0)
|
||||
assert h == 6
|
||||
assert strength == 1.0
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Pure: Competitor Strength (топ-N среднее)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCompetitorStrength:
|
||||
def test_mean_of_top_n(self) -> None:
|
||||
# топ-5 по убыванию из 6: 0.9,0.8,0.7,0.6,0.5 → mean 0.7.
|
||||
v = _competitor_strength([0.1, 0.5, 0.9, 0.6, 0.8, 0.7])
|
||||
assert v == pytest.approx(0.7)
|
||||
|
||||
def test_fewer_than_top_n(self) -> None:
|
||||
v = _competitor_strength([0.4, 0.6])
|
||||
assert v == pytest.approx(0.5)
|
||||
|
||||
def test_none_weights_skipped(self) -> None:
|
||||
v = _competitor_strength([None, 0.8, None, 0.4])
|
||||
assert v == pytest.approx(0.6)
|
||||
|
||||
def test_empty_is_none(self) -> None:
|
||||
assert _competitor_strength([]) is None
|
||||
|
||||
def test_all_none_is_none(self) -> None:
|
||||
assert _competitor_strength([None, None]) is None
|
||||
|
||||
def test_in_range(self) -> None:
|
||||
v = _competitor_strength([0.9, 0.95, 1.0])
|
||||
assert v is not None and 0.0 <= v <= 1.0
|
||||
|
||||
def test_custom_top_n(self) -> None:
|
||||
v = _competitor_strength([0.9, 0.8, 0.1], top_n=2)
|
||||
assert v == pytest.approx(0.85)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Pure: Cannibalization (доля same-class)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCannibalizationIndex:
|
||||
def test_share_of_same_class(self) -> None:
|
||||
# same = 0.6+0.4 = 1.0; all = 0.6+0.4+0.5+0.5 = 2.0 → 0.5.
|
||||
v = _cannibalization_index([0.6, 0.4], [0.6, 0.4, 0.5, 0.5])
|
||||
assert v == pytest.approx(0.5)
|
||||
|
||||
def test_no_same_class_is_zero(self) -> None:
|
||||
# конкуренты есть, но ни одного в нашем классе → 0.0 (валидно).
|
||||
v = _cannibalization_index([], [0.5, 0.5])
|
||||
assert v == 0.0
|
||||
|
||||
def test_no_competitors_is_none(self) -> None:
|
||||
# окружения нет вообще → None (неизмеримо).
|
||||
assert _cannibalization_index([], []) is None
|
||||
|
||||
def test_all_same_class_is_one(self) -> None:
|
||||
v = _cannibalization_index([0.5, 0.5], [0.5, 0.5])
|
||||
assert v == 1.0
|
||||
|
||||
def test_none_weights_skipped(self) -> None:
|
||||
v = _cannibalization_index([0.5], [0.5, None, 0.5])
|
||||
assert v == pytest.approx(0.5)
|
||||
|
||||
def test_in_range(self) -> None:
|
||||
v = _cannibalization_index([0.3], [0.3, 0.7])
|
||||
assert v is not None and 0.0 <= v <= 1.0
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Pure: Artificial Demand (доля ипотеки)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestArtificialDemandShare:
|
||||
def test_basic_share(self) -> None:
|
||||
assert _artificial_demand_share(30, 100) == pytest.approx(0.3)
|
||||
|
||||
def test_full_mortgage(self) -> None:
|
||||
assert _artificial_demand_share(50, 50) == 1.0
|
||||
|
||||
def test_zero_mortgage(self) -> None:
|
||||
assert _artificial_demand_share(0, 80) == 0.0
|
||||
|
||||
def test_no_sold_is_none(self) -> None:
|
||||
# нет проданных лотов → None (НЕ фабрикуем 0).
|
||||
assert _artificial_demand_share(0, 0) is None
|
||||
|
||||
def test_none_mortgage_is_none(self) -> None:
|
||||
assert _artificial_demand_share(None, 50) is None
|
||||
|
||||
def test_clamped_to_one_on_dirty_data(self) -> None:
|
||||
# грязные данные (mortgage > sold) → clamp 1.0, не >1.
|
||||
assert _artificial_demand_share(120, 100) == 1.0
|
||||
|
||||
def test_in_range(self) -> None:
|
||||
v = _artificial_demand_share(17, 23)
|
||||
assert v is not None and 0.0 <= v <= 1.0
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Pure: Cost-of-Error (монотонность + произведение + чек)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAvgTicket:
|
||||
def test_price_times_area(self) -> None:
|
||||
# 200_000 ₽/м² × 50 м² = 10 млн.
|
||||
assert _avg_ticket_rub(200_000.0) == 10_000_000.0
|
||||
|
||||
def test_none_price_is_none(self) -> None:
|
||||
assert _avg_ticket_rub(None) is None
|
||||
|
||||
def test_nonpositive_price_is_none(self) -> None:
|
||||
assert _avg_ticket_rub(0.0) is None
|
||||
|
||||
def test_custom_area(self) -> None:
|
||||
assert _avg_ticket_rub(100_000.0, area_m2=80.0) == 8_000_000.0
|
||||
|
||||
|
||||
class TestOversupplyRiskFromDeficit:
|
||||
def test_negative_deficit_is_magnitude(self) -> None:
|
||||
assert _oversupply_risk_from_deficit(-0.4) == pytest.approx(0.4)
|
||||
|
||||
def test_positive_deficit_zero_risk(self) -> None:
|
||||
assert _oversupply_risk_from_deficit(0.6) == 0.0
|
||||
|
||||
def test_zero_deficit_zero_risk(self) -> None:
|
||||
assert _oversupply_risk_from_deficit(0.0) == 0.0
|
||||
|
||||
def test_none_is_none(self) -> None:
|
||||
assert _oversupply_risk_from_deficit(None) is None
|
||||
|
||||
|
||||
class TestCostOfErrorIndex:
|
||||
def test_product_of_risk_and_ticket(self) -> None:
|
||||
# risk 0.5 × (10млн/15млн clamp=0.6667) ≈ 0.3333.
|
||||
v = _cost_of_error_index(0.5, 10_000_000.0)
|
||||
assert v == pytest.approx(0.5 * (10_000_000.0 / 15_000_000.0))
|
||||
|
||||
def test_monotonic_in_risk(self) -> None:
|
||||
low = _cost_of_error_index(0.2, 10_000_000.0)
|
||||
high = _cost_of_error_index(0.8, 10_000_000.0)
|
||||
assert low is not None and high is not None
|
||||
assert high > low
|
||||
|
||||
def test_monotonic_in_ticket(self) -> None:
|
||||
cheap = _cost_of_error_index(0.5, 5_000_000.0)
|
||||
pricey = _cost_of_error_index(0.5, 12_000_000.0)
|
||||
assert cheap is not None and pricey is not None
|
||||
assert pricey > cheap
|
||||
|
||||
def test_in_range(self) -> None:
|
||||
v = _cost_of_error_index(0.9, 30_000_000.0) # ticket clamps to 1.0
|
||||
assert v is not None and 0.0 <= v <= 1.0
|
||||
assert v == pytest.approx(0.9)
|
||||
|
||||
def test_none_risk_is_none(self) -> None:
|
||||
assert _cost_of_error_index(None, 10_000_000.0) is None
|
||||
|
||||
def test_none_ticket_is_none(self) -> None:
|
||||
assert _cost_of_error_index(0.5, None) is None
|
||||
|
||||
def test_nonpositive_norm_degrades_to_risk_only(self) -> None:
|
||||
v = _cost_of_error_index(0.4, 10_000_000.0, ticket_norm_rub=0.0)
|
||||
assert v == pytest.approx(0.4)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Artificial-Demand SQL: MagicMock-сессия (форма SQL + параметры + сигнал/деградация)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _mock_db_one(row: dict[str, Any] | None) -> MagicMock:
|
||||
"""Сессия, чей единственный execute().mappings().first() вернёт row."""
|
||||
db = MagicMock()
|
||||
result = MagicMock()
|
||||
result.mappings.return_value.first.return_value = row
|
||||
db.execute.return_value = result
|
||||
return db
|
||||
|
||||
|
||||
def _executed_sql(db: MagicMock, call_index: int = 0) -> str:
|
||||
args, _ = db.execute.call_args_list[call_index]
|
||||
return str(args[0])
|
||||
|
||||
|
||||
def _executed_params(db: MagicMock, call_index: int = 0) -> dict[str, Any]:
|
||||
args, _ = db.execute.call_args_list[call_index]
|
||||
return args[1]
|
||||
|
||||
|
||||
class TestArtificialDemandSQL:
|
||||
def test_sql_uses_cast_not_double_colon(self) -> None:
|
||||
db = _mock_db_one({"n_sold": 10, "n_mortgage": 4})
|
||||
_query_artificial_demand(
|
||||
db, district="Академический", obj_class="комфорт", premise_kind="квартира"
|
||||
)
|
||||
sql = _executed_sql(db)
|
||||
assert "CAST(:premise_kind AS text)" in sql
|
||||
assert "CAST(:district AS text)" in sql
|
||||
assert "CAST(:obj_class AS text)" in sql
|
||||
# psycopg v3: никогда :x::type
|
||||
assert ":premise_kind::" not in sql
|
||||
assert ":district::" not in sql
|
||||
assert ":obj_class::" not in sql
|
||||
|
||||
def test_sql_reads_mortgage_signal_columns(self) -> None:
|
||||
db = _mock_db_one({"n_sold": 10, "n_mortgage": 4})
|
||||
_query_artificial_demand(db, district=None, obj_class=None, premise_kind="квартира")
|
||||
sql = _executed_sql(db)
|
||||
# сигнал ипотеки = encumbrance_type / bank_name (реальные колонки objective_lots).
|
||||
assert "encumbrance_type" in sql
|
||||
assert "bank_name" in sql
|
||||
assert "objective_lots" in sql
|
||||
|
||||
def test_params_passed(self) -> None:
|
||||
db = _mock_db_one({"n_sold": 1, "n_mortgage": 0})
|
||||
_query_artificial_demand(
|
||||
db, district="Пионерский", obj_class="бизнес", premise_kind="квартира"
|
||||
)
|
||||
p = _executed_params(db)
|
||||
assert p["district"] == "Пионерский"
|
||||
assert p["obj_class"] == "бизнес"
|
||||
assert p["premise_kind"] == "квартира"
|
||||
|
||||
def test_signal_present_returns_counts(self) -> None:
|
||||
db = _mock_db_one({"n_sold": 80, "n_mortgage": 52})
|
||||
out = _query_artificial_demand(db, district=None, obj_class=None, premise_kind="квартира")
|
||||
assert out == {"n_sold": 80, "n_mortgage": 52}
|
||||
|
||||
def test_empty_row_degrades_to_zeros(self) -> None:
|
||||
db = _mock_db_one(None)
|
||||
out = _query_artificial_demand(db, district=None, obj_class=None, premise_kind="квартира")
|
||||
assert out == {"n_sold": 0, "n_mortgage": 0}
|
||||
|
||||
def test_null_counts_coerced_to_zero(self) -> None:
|
||||
db = _mock_db_one({"n_sold": None, "n_mortgage": None})
|
||||
out = _query_artificial_demand(db, district=None, obj_class=None, premise_kind="квартира")
|
||||
assert out == {"n_sold": 0, "n_mortgage": 0}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Artificial-Demand builder via mocked rows: signal → index; no signal → None+caveat
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
_SPEC = SegmentSpec(obj_class="комфорт", room_bucket="2-к 45-60", district="Академический")
|
||||
|
||||
|
||||
def _patch_all_unavailable() -> Any:
|
||||
"""Контекст: все 5 НЕ-Artificial бэкенда брошены/пусты (изолируем Artificial)."""
|
||||
return patch.multiple(
|
||||
_MOD,
|
||||
compute_demand_supply_forecast=MagicMock(return_value=[]),
|
||||
rank_segments=MagicMock(return_value=MagicMock(ranked=[], n_cells_ranked=0)),
|
||||
get_competitors=MagicMock(side_effect=ValueError("no geom")),
|
||||
compute_market_metrics=MagicMock(
|
||||
return_value=MagicMock(overstock_index=None, confidence="low")
|
||||
),
|
||||
compute_affordability=MagicMock(return_value=MagicMock(price_per_m2=None)),
|
||||
)
|
||||
|
||||
|
||||
class TestArtificialDemandBuilder:
|
||||
def test_signal_present_yields_index(self) -> None:
|
||||
db = MagicMock()
|
||||
with (
|
||||
_patch_all_unavailable(),
|
||||
patch(
|
||||
f"{_MOD}._query_artificial_demand",
|
||||
return_value={"n_sold": 100, "n_mortgage": 65},
|
||||
),
|
||||
):
|
||||
card = compute_special_indices(db, spec=_SPEC, district="Академический")
|
||||
idx = card.indices[KEY_ARTIFICIAL_DEMAND]
|
||||
assert idx.value == pytest.approx(0.65)
|
||||
assert idx.method == "mortgage_share_objective_lots"
|
||||
assert idx.detail["n_sold"] == 100
|
||||
assert idx.detail["n_mortgage"] == 65
|
||||
assert idx.advisory is True
|
||||
|
||||
def test_no_signal_yields_none_with_caveat(self) -> None:
|
||||
db = MagicMock()
|
||||
with (
|
||||
_patch_all_unavailable(),
|
||||
patch(
|
||||
f"{_MOD}._query_artificial_demand",
|
||||
return_value={"n_sold": 0, "n_mortgage": 0},
|
||||
),
|
||||
):
|
||||
card = compute_special_indices(db, spec=_SPEC, district="Академический")
|
||||
idx = card.indices[KEY_ARTIFICIAL_DEMAND]
|
||||
assert idx.value is None
|
||||
assert idx.method == _METHOD_UNAVAILABLE
|
||||
assert "reason" in idx.detail
|
||||
assert "фабрикуем" in idx.detail["reason"] # явный caveat «не фабрикуем»
|
||||
|
||||
def test_small_sample_low_confidence(self) -> None:
|
||||
db = MagicMock()
|
||||
with (
|
||||
_patch_all_unavailable(),
|
||||
patch(
|
||||
f"{_MOD}._query_artificial_demand",
|
||||
return_value={"n_sold": 5, "n_mortgage": 3},
|
||||
),
|
||||
):
|
||||
card = compute_special_indices(db, spec=_SPEC, district="Академический")
|
||||
idx = card.indices[KEY_ARTIFICIAL_DEMAND]
|
||||
assert idx.value == pytest.approx(0.6)
|
||||
assert idx.confidence == "low" # n_sold < 30
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# compute_special_indices: @patch бэкенда — 6 индексов, advisory, graceful per-index
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _forecast_stub(deficit: float | None, *, horizon: int, confidence: str = "medium") -> MagicMock:
|
||||
f = MagicMock()
|
||||
f.deficit_index = deficit
|
||||
f.horizon_months = horizon
|
||||
f.confidence = confidence
|
||||
return f
|
||||
|
||||
|
||||
def _ranked_stub(deficit: float, *, confidence: str = "medium") -> MagicMock:
|
||||
seg = MagicMock()
|
||||
seg.deficit_index = deficit
|
||||
seg.confidence = confidence
|
||||
seg.as_dict.return_value = {"deficit_index": deficit}
|
||||
return seg
|
||||
|
||||
|
||||
def _competitor_stub(relevance: float | None, obj_class: str | None) -> MagicMock:
|
||||
c = MagicMock()
|
||||
c.relevance_weight = relevance
|
||||
c.obj_class = obj_class
|
||||
return c
|
||||
|
||||
|
||||
def _full_stack_patch() -> Any:
|
||||
"""Все 5 бэкенд-сервисов отдают здоровые данные → все 6 индексов считаются."""
|
||||
forecasts = [_forecast_stub(0.1 * h, horizon=h) for h in (6, 12, 18, 24)]
|
||||
ranking = MagicMock()
|
||||
ranking.ranked = [_ranked_stub(0.5), _ranked_stub(0.3), _ranked_stub(-0.1)]
|
||||
ranking.n_cells_ranked = 3
|
||||
competitors_resp = MagicMock()
|
||||
competitors_resp.competitors = [
|
||||
_competitor_stub(0.8, "комфорт"),
|
||||
_competitor_stub(0.6, "комфорт"),
|
||||
_competitor_stub(0.5, "бизнес"),
|
||||
]
|
||||
metrics = MagicMock()
|
||||
metrics.overstock_index = 0.4
|
||||
metrics.confidence = "medium"
|
||||
afford = MagicMock()
|
||||
afford.price_per_m2 = 200_000.0
|
||||
return patch.multiple(
|
||||
_MOD,
|
||||
compute_demand_supply_forecast=MagicMock(return_value=forecasts),
|
||||
rank_segments=MagicMock(return_value=ranking),
|
||||
get_competitors=MagicMock(return_value=competitors_resp),
|
||||
compute_market_metrics=MagicMock(return_value=metrics),
|
||||
compute_affordability=MagicMock(return_value=afford),
|
||||
)
|
||||
|
||||
|
||||
class TestComputeSpecialIndicesShape:
|
||||
def test_all_six_indices_present(self) -> None:
|
||||
db = MagicMock()
|
||||
with (
|
||||
_full_stack_patch(),
|
||||
patch(
|
||||
f"{_MOD}._query_artificial_demand",
|
||||
return_value={"n_sold": 100, "n_mortgage": 40},
|
||||
),
|
||||
):
|
||||
card = compute_special_indices(
|
||||
db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123"
|
||||
)
|
||||
assert set(card.indices.keys()) == set(_INDEX_KEYS)
|
||||
assert len(card.indices) == 6
|
||||
|
||||
def test_advisory_always_true(self) -> None:
|
||||
db = MagicMock()
|
||||
with (
|
||||
_full_stack_patch(),
|
||||
patch(
|
||||
f"{_MOD}._query_artificial_demand",
|
||||
return_value={"n_sold": 100, "n_mortgage": 40},
|
||||
),
|
||||
):
|
||||
card = compute_special_indices(
|
||||
db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123"
|
||||
)
|
||||
assert card.advisory is True
|
||||
assert all(idx.advisory is True for idx in card.indices.values())
|
||||
|
||||
def test_all_values_in_range_when_present(self) -> None:
|
||||
db = MagicMock()
|
||||
with (
|
||||
_full_stack_patch(),
|
||||
patch(
|
||||
f"{_MOD}._query_artificial_demand",
|
||||
return_value={"n_sold": 100, "n_mortgage": 40},
|
||||
),
|
||||
):
|
||||
card = compute_special_indices(
|
||||
db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123"
|
||||
)
|
||||
for idx in card.indices.values():
|
||||
if idx.value is not None:
|
||||
assert 0.0 <= idx.value <= 1.0, idx.key
|
||||
|
||||
def test_confidence_capped_at_medium(self) -> None:
|
||||
db = MagicMock()
|
||||
with (
|
||||
_full_stack_patch(),
|
||||
patch(
|
||||
f"{_MOD}._query_artificial_demand",
|
||||
return_value={"n_sold": 100, "n_mortgage": 40},
|
||||
),
|
||||
):
|
||||
card = compute_special_indices(
|
||||
db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123"
|
||||
)
|
||||
assert card.confidence in ("low", "medium") # никогда high
|
||||
for idx in card.indices.values():
|
||||
assert idx.confidence in ("low", "medium")
|
||||
|
||||
def test_as_dict_roundtrip(self) -> None:
|
||||
db = MagicMock()
|
||||
with (
|
||||
_full_stack_patch(),
|
||||
patch(
|
||||
f"{_MOD}._query_artificial_demand",
|
||||
return_value={"n_sold": 100, "n_mortgage": 40},
|
||||
),
|
||||
):
|
||||
card = compute_special_indices(
|
||||
db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123"
|
||||
)
|
||||
d = card.as_dict()
|
||||
assert d["advisory"] is True
|
||||
assert set(d["indices"].keys()) == set(_INDEX_KEYS)
|
||||
assert d["district"] == "Академический"
|
||||
# каждый индекс сериализуется с ключами контракта.
|
||||
for sub in d["indices"].values():
|
||||
assert set(sub.keys()) == {
|
||||
"key",
|
||||
"value",
|
||||
"label",
|
||||
"confidence",
|
||||
"detail",
|
||||
"method",
|
||||
"advisory",
|
||||
}
|
||||
|
||||
|
||||
class TestComputeSpecialIndicesValues:
|
||||
def test_launch_window_picks_peak_horizon(self) -> None:
|
||||
db = MagicMock()
|
||||
with (
|
||||
_full_stack_patch(),
|
||||
patch(
|
||||
f"{_MOD}._query_artificial_demand",
|
||||
return_value={"n_sold": 100, "n_mortgage": 40},
|
||||
),
|
||||
):
|
||||
card = compute_special_indices(
|
||||
db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123"
|
||||
)
|
||||
lw = card.indices[KEY_LAUNCH_WINDOW]
|
||||
# deficit = 0.1*h → пик на h=24 (0.1*24=2.4 clamps), label «24 мес».
|
||||
assert lw.label == "24 мес"
|
||||
assert lw.detail["best_horizon_months"] == 24
|
||||
|
||||
def test_product_void_counts_segments(self) -> None:
|
||||
db = MagicMock()
|
||||
with (
|
||||
_full_stack_patch(),
|
||||
patch(
|
||||
f"{_MOD}._query_artificial_demand",
|
||||
return_value={"n_sold": 100, "n_mortgage": 40},
|
||||
),
|
||||
):
|
||||
card = compute_special_indices(
|
||||
db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123"
|
||||
)
|
||||
pv = card.indices[KEY_PRODUCT_VOID]
|
||||
# ranked deficits [0.5,0.3,-0.1]: 2 ≥ 0.25 → share 2/3.
|
||||
assert pv.value == pytest.approx(2 / 3)
|
||||
assert pv.detail["n_void"] == 2
|
||||
|
||||
def test_competitor_strength_mean_top_n(self) -> None:
|
||||
db = MagicMock()
|
||||
with (
|
||||
_full_stack_patch(),
|
||||
patch(
|
||||
f"{_MOD}._query_artificial_demand",
|
||||
return_value={"n_sold": 100, "n_mortgage": 40},
|
||||
),
|
||||
):
|
||||
card = compute_special_indices(
|
||||
db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123"
|
||||
)
|
||||
cs = card.indices[KEY_COMPETITOR_STRENGTH]
|
||||
# relevance [0.8,0.6,0.5] → mean 0.6333.
|
||||
assert cs.value == pytest.approx((0.8 + 0.6 + 0.5) / 3)
|
||||
|
||||
def test_cannibalization_same_class_share(self) -> None:
|
||||
db = MagicMock()
|
||||
with (
|
||||
_full_stack_patch(),
|
||||
patch(
|
||||
f"{_MOD}._query_artificial_demand",
|
||||
return_value={"n_sold": 100, "n_mortgage": 40},
|
||||
),
|
||||
):
|
||||
card = compute_special_indices(
|
||||
db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123"
|
||||
)
|
||||
can = card.indices[KEY_CANNIBALIZATION]
|
||||
# same-class (комфорт) = 0.8+0.6=1.4; all=1.9 → 0.7368.
|
||||
assert can.value == pytest.approx((0.8 + 0.6) / (0.8 + 0.6 + 0.5))
|
||||
|
||||
def test_cost_of_error_product(self) -> None:
|
||||
db = MagicMock()
|
||||
with (
|
||||
_full_stack_patch(),
|
||||
patch(
|
||||
f"{_MOD}._query_artificial_demand",
|
||||
return_value={"n_sold": 100, "n_mortgage": 40},
|
||||
),
|
||||
):
|
||||
card = compute_special_indices(
|
||||
db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123"
|
||||
)
|
||||
coe = card.indices[KEY_COST_OF_ERROR]
|
||||
# risk 0.4 × ticket(200k×50=10млн / 15млн = 0.6667) ≈ 0.2667.
|
||||
ticket_factor = (200_000.0 * 50.0) / 15_000_000.0
|
||||
assert coe.value == pytest.approx(0.4 * ticket_factor)
|
||||
assert coe.detail["risk_source"] == "overstock_index"
|
||||
|
||||
|
||||
class TestComputeSpecialIndicesGraceful:
|
||||
def test_no_cad_num_degrades_competitor_indices(self) -> None:
|
||||
# без cad_num: Cannibalization + Competitor Strength → unavailable, остальные ок.
|
||||
db = MagicMock()
|
||||
with (
|
||||
_full_stack_patch(),
|
||||
patch(
|
||||
f"{_MOD}._query_artificial_demand",
|
||||
return_value={"n_sold": 100, "n_mortgage": 40},
|
||||
),
|
||||
):
|
||||
card = compute_special_indices(db, spec=_SPEC, district="Академический")
|
||||
assert card.indices[KEY_CANNIBALIZATION].method == _METHOD_UNAVAILABLE
|
||||
assert card.indices[KEY_COMPETITOR_STRENGTH].method == _METHOD_UNAVAILABLE
|
||||
assert card.indices[KEY_CANNIBALIZATION].value is None
|
||||
# quick-win Artificial Demand всё равно посчитан.
|
||||
assert card.indices[KEY_ARTIFICIAL_DEMAND].value == pytest.approx(0.4)
|
||||
|
||||
def test_per_index_failure_isolated(self) -> None:
|
||||
# один сервис (rank_segments) бросает → Product Void unavailable, остальные 5 ок.
|
||||
forecasts = [_forecast_stub(0.2, horizon=h) for h in (6, 12, 18, 24)]
|
||||
metrics = MagicMock(overstock_index=0.3, confidence="medium")
|
||||
afford = MagicMock(price_per_m2=180_000.0)
|
||||
comp = MagicMock()
|
||||
comp.competitors = [_competitor_stub(0.7, "комфорт")]
|
||||
db = MagicMock()
|
||||
with (
|
||||
patch.multiple(
|
||||
_MOD,
|
||||
compute_demand_supply_forecast=MagicMock(return_value=forecasts),
|
||||
rank_segments=MagicMock(side_effect=RuntimeError("boom")),
|
||||
get_competitors=MagicMock(return_value=comp),
|
||||
compute_market_metrics=MagicMock(return_value=metrics),
|
||||
compute_affordability=MagicMock(return_value=afford),
|
||||
),
|
||||
patch(
|
||||
f"{_MOD}._query_artificial_demand",
|
||||
return_value={"n_sold": 50, "n_mortgage": 20},
|
||||
),
|
||||
):
|
||||
card = compute_special_indices(
|
||||
db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123"
|
||||
)
|
||||
# сбойный индекс — unavailable; карточка цела (все 6 ключей).
|
||||
assert card.indices[KEY_PRODUCT_VOID].method == _METHOD_UNAVAILABLE
|
||||
assert card.indices[KEY_PRODUCT_VOID].value is None
|
||||
assert len(card.indices) == 6
|
||||
# остальные посчитаны.
|
||||
assert card.indices[KEY_LAUNCH_WINDOW].value is not None
|
||||
assert card.indices[KEY_ARTIFICIAL_DEMAND].value == pytest.approx(0.4)
|
||||
assert card.advisory is True
|
||||
|
||||
def test_all_backends_fail_card_still_returns(self) -> None:
|
||||
# тотальный сбой: каждый индекс unavailable, но карточка возвращается (не crash).
|
||||
db = MagicMock()
|
||||
with (
|
||||
patch.multiple(
|
||||
_MOD,
|
||||
compute_demand_supply_forecast=MagicMock(side_effect=RuntimeError("x")),
|
||||
rank_segments=MagicMock(side_effect=RuntimeError("x")),
|
||||
get_competitors=MagicMock(side_effect=RuntimeError("x")),
|
||||
compute_market_metrics=MagicMock(side_effect=RuntimeError("x")),
|
||||
compute_affordability=MagicMock(side_effect=RuntimeError("x")),
|
||||
),
|
||||
patch(
|
||||
f"{_MOD}._query_artificial_demand",
|
||||
side_effect=RuntimeError("x"),
|
||||
),
|
||||
):
|
||||
card = compute_special_indices(
|
||||
db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123"
|
||||
)
|
||||
assert len(card.indices) == 6
|
||||
assert all(idx.method == _METHOD_UNAVAILABLE for idx in card.indices.values())
|
||||
assert all(idx.value is None for idx in card.indices.values())
|
||||
assert card.advisory is True
|
||||
assert card.confidence == "low"
|
||||
|
||||
def test_launch_window_all_none_degrades(self) -> None:
|
||||
# deficit None на всех горизонтах → Launch Window unavailable.
|
||||
forecasts = [_forecast_stub(None, horizon=h) for h in (6, 12, 18, 24)]
|
||||
db = MagicMock()
|
||||
with (
|
||||
_full_stack_patch(),
|
||||
patch(_DSF, return_value=forecasts),
|
||||
patch(
|
||||
f"{_MOD}._query_artificial_demand",
|
||||
return_value={"n_sold": 100, "n_mortgage": 40},
|
||||
),
|
||||
):
|
||||
card = compute_special_indices(
|
||||
db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123"
|
||||
)
|
||||
assert card.indices[KEY_LAUNCH_WINDOW].method == _METHOD_UNAVAILABLE
|
||||
assert card.indices[KEY_LAUNCH_WINDOW].value is None
|
||||
|
||||
def test_cost_of_error_falls_back_to_negative_deficit(self) -> None:
|
||||
# overstock_index None → Cost-of-Error берёт магнитуду отрицательного дефицита.
|
||||
metrics = MagicMock(overstock_index=None, confidence="medium")
|
||||
afford = MagicMock(price_per_m2=200_000.0)
|
||||
# forecast на _VOID_HORIZON_MONTHS=12 (fallback) с отрицательным дефицитом.
|
||||
forecasts = [_forecast_stub(-0.5, horizon=12)]
|
||||
comp = MagicMock()
|
||||
comp.competitors = [_competitor_stub(0.7, "комфорт")]
|
||||
db = MagicMock()
|
||||
with (
|
||||
patch.multiple(
|
||||
_MOD,
|
||||
compute_demand_supply_forecast=MagicMock(return_value=forecasts),
|
||||
rank_segments=MagicMock(return_value=MagicMock(ranked=[], n_cells_ranked=0)),
|
||||
get_competitors=MagicMock(return_value=comp),
|
||||
compute_market_metrics=MagicMock(return_value=metrics),
|
||||
compute_affordability=MagicMock(return_value=afford),
|
||||
),
|
||||
patch(
|
||||
f"{_MOD}._query_artificial_demand",
|
||||
return_value={"n_sold": 50, "n_mortgage": 20},
|
||||
),
|
||||
):
|
||||
card = compute_special_indices(
|
||||
db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123"
|
||||
)
|
||||
coe = card.indices[KEY_COST_OF_ERROR]
|
||||
assert coe.detail["risk_source"] == "negative_deficit"
|
||||
# risk |−0.5|=0.5 × ticket(10млн/15млн=0.6667) ≈ 0.3333.
|
||||
assert coe.value == pytest.approx(0.5 * ((200_000.0 * 50.0) / 15_000_000.0))
|
||||
|
||||
def test_aggregate_spec_no_class_param_is_none(self) -> None:
|
||||
# spec без obj_class → Artificial-Demand SQL получает obj_class=None (агрегат).
|
||||
db = MagicMock()
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def _capture(_db: Any, *, district: Any, obj_class: Any, premise_kind: Any) -> dict:
|
||||
captured["obj_class"] = obj_class
|
||||
captured["district"] = district
|
||||
return {"n_sold": 40, "n_mortgage": 10}
|
||||
|
||||
spec_no_class = SegmentSpec(room_bucket="2-к 45-60")
|
||||
with _full_stack_patch(), patch(f"{_MOD}._query_artificial_demand", side_effect=_capture):
|
||||
card = compute_special_indices(db, spec=spec_no_class, district=None)
|
||||
assert captured["obj_class"] is None
|
||||
assert captured["district"] is None
|
||||
assert card.indices[KEY_ARTIFICIAL_DEMAND].value == pytest.approx(0.25)
|
||||
Loading…
Add table
Reference in a new issue