gendesign/backend/app/services/forecasting/special_indices.py
bot-backend 008f9d9ed1
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m28s
Deploy / build-worker (push) Successful in 2m31s
Deploy / deploy (push) Successful in 1m8s
feat(forecasting): §25 six special indices (#986, 954-C) (#1018)
2026-06-03 08:24:08 +00:00

862 lines
42 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""§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,
)