gendesign/backend/app/services/forecasting/special_indices.py
Light1YT cd48a095c0
Some checks failed
Deploy / build-worker (push) Successful in 2m32s
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m32s
Deploy / deploy (push) Has been cancelled
feat(forecasting): activate §25.3 cannibalization timing axis from launch window (#1169)
Derive candidate_release_month = report-as-of (date.today()) + §25.1 Launch
Window peak-deficit horizon, threaded into _build_cannibalization so the timing
overlap axis activates against own-portfolio release_month (near-in-time own
projects raise cannibalization risk). Launch Window now computed once in
compute_special_indices and reused (no double-compute). Launch Window
unavailable -> candidate_release_month None -> timing axis gracefully excluded
(None-not-0); cannibalization still scores on class/price/geo. Adds stdlib
_add_months helper (year-boundary safe, no new dep). Deterministic. 168 tests.

§25.3 now: class+price+timing+geo active; unit-mix remains phase-2.

Refs #1169
2026-06-09 07:10:59 +00:00

1483 lines
76 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 ← §25.3 ИСТИННОЕ пересечение рек. сегмента-кандидата с НАШИМ
портфелем (own_portfolio #1169) по 4 осям (класс/аудитория,
цена ₽/м², квартирография, тайминг) + гео-вес близости наших
проектов к участку. Портфель пуст (own_developer_ids не задан И
нет manual-строк) → FALLBACK на ПРОКСИ (доля конкурентов того же
класса, явно помеченный). DEGRADE-to-None, если нет геометрии.
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
import math
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import date
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 (
_PRICE_BAND_BUSINESS_MAX,
_PRICE_BAND_COMFORT_MAX,
_PRICE_BAND_ECONOMY_MAX,
PRICE_BUCKET_BUSINESS,
PRICE_BUCKET_COMFORT,
PRICE_BUCKET_ECONOMY,
PRICE_BUCKET_PREMIUM,
SegmentSpec,
)
from app.services.forecasting.what_to_build import rank_segments
from app.services.site_finder.competitors import _CLASS_ORDER, _normalize_class, get_competitors
from app.services.site_finder.market_metrics import compute_market_metrics
from app.services.site_finder.own_portfolio import OwnProject, get_own_portfolio
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
# ── §25.3 own-portfolio overlap (TRUE cannibalization, #1169 PR2) ──────────────
# Похожесть аудитории/класса по дистанции в _CLASS_ORDER (competitors._CLASS_ORDER):
# тот же класс = полная каннибализация аудитории, соседний (1 шаг) — частичная,
# дальше — слабая. Чуть резче, чем competitors._CLASS_SIM_* (там relevance-
# корректировка), т.к. каннибализация бьёт ПО АУДИТОРИИ: 0 шагов→1.0, 1→0.5, 2→0.2,
# ≥3→0.05. Класс одной из сторон не распознан → ось НЕДОСТУПНА (None, НЕ 0).
_OVERLAP_CLASS_BY_STEPS: dict[int, float] = {0: 1.0, 1: 0.5, 2: 0.2}
_OVERLAP_CLASS_FAR: float = 0.05
# Период полу-затухания (мес) тайминговой близости запусков: |Δмес| = half_life →
# вклад тайминга 0.5; exp(-|Δ|/half_life). 12 мес — год между выходами ≈ половинное
# пересечение окон продаж (типовая экспозиция ЖК на рынке). Tunable.
_TIMING_HALF_LIFE_MONTHS: float = 12.0
# Масштаб (км) гео-затухания веса нашего проекта: weight = exp(-distance_km/scale).
# 3 км → вес 0.37, 1 км → 0.72, 10 км → 0.04. Ближний наш проект каннибализирует
# сильнее (общий локальный спрос); дальний — почти нет. Tunable.
_GEO_WEIGHT_SCALE_KM: float = 3.0
# Гео-вес нашего проекта без координат (тайминг/цена сравнимы, но близость неизвестна):
# НЕ исключаем проект и НЕ даём полный вес — низкий floor, чтобы он мог сигналить, но
# не доминировал над проектом с подтверждённой близостью. None-not-0 дух (есть проект,
# но геопривязка слабая → down-weight, а не выкидываем).
_GEO_WEIGHT_UNKNOWN: float = 0.1
# Минимум доступных осей пересечения для пары кандидат↔наш проект, чтобы считать оценку
# надёжной. < этого (напр. только класс) → пара low-confidence (сигнал есть, но тонкий).
_OVERLAP_MIN_AXES: int = 2
# Порог «тонкого» портфеля для honest-confidence (§26): портфель только из current
# (нет future-пайплайна) ИЛИ проектов меньше этого числа → confidence='low' + нота.
_THIN_PORTFOLIO_MAX_PROJECTS: int = 2
# 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)
# ── §25.3 TRUE own-portfolio overlap — pure-оси (None-not-0) + агрегация ────────
def _class_overlap(candidate_class: str | None, own_class: str | None) -> float | None:
"""Ось аудитория/класс: пересечение классов кандидата и нашего проекта. PURE.
Нормализуем оба класса (_normalize_class — регистро/язык-независимо: 'Комфорт'/
'comfort'/'комфорт-класс' → один ключ) и берём похожесть по дистанции в _CLASS_ORDER:
0 шагов (тот же класс) → 1.0, 1 (соседний) → 0.5, 2 → 0.2, ≥3 → 0.05. Класс ХОТЯ БЫ
одной стороны не распознан → None (ось НЕДОСТУПНА — НЕ фабрикуем 0). PURE.
"""
c = _normalize_class(candidate_class)
o = _normalize_class(own_class)
if c is None or o is None:
return None
steps = abs(_CLASS_ORDER[c] - _CLASS_ORDER[o])
return _OVERLAP_CLASS_BY_STEPS.get(steps, _OVERLAP_CLASS_FAR)
def _price_bucket_to_band(price_bucket: str | None) -> tuple[float, float] | None:
"""price_bucket кандидата → ценовая вилка [lo, hi] ₽/м² (зеркало _PRICE_BAND_*). PURE.
Кандидат-сегмент несёт лишь bucket-ключ (не вилку); разворачиваем его в тот же
диапазон, что sales_series.price_bucket_of: эконом [0, 120k), комфорт [120k, 160k),
бизнес [160k, 220k), премиум [220k, +inf). Неизвестный/None bucket → None (ось цены
НЕДОСТУПНА — НЕ фабрикуем). PURE.
"""
if price_bucket == PRICE_BUCKET_ECONOMY:
return (0.0, _PRICE_BAND_ECONOMY_MAX)
if price_bucket == PRICE_BUCKET_COMFORT:
return (_PRICE_BAND_ECONOMY_MAX, _PRICE_BAND_COMFORT_MAX)
if price_bucket == PRICE_BUCKET_BUSINESS:
return (_PRICE_BAND_COMFORT_MAX, _PRICE_BAND_BUSINESS_MAX)
if price_bucket == PRICE_BUCKET_PREMIUM:
return (_PRICE_BAND_BUSINESS_MAX, math.inf)
return None
def _price_overlap(
candidate_band: tuple[float, float] | None,
own_lo: float | None,
own_hi: float | None,
) -> float | None:
"""Ось цены: доля пересечения ценовых вилок кандидата и нашего проекта. PURE.
Пересечение интервалов [c_lo,c_hi] и [o_lo,o_hi]; нормируем на УЖЕ узкую вилку
(min ширины) → полное накрытие более узкого диапазона = 1.0 (стороны полностью
конкурируют в этой ценовой зоне). Любая граница / вилка кандидата неизвестна или
обе вилки нулевой ширины → None (ось НЕДОСТУПНА — НЕ фабрикуем 0). Открытая правая
граница (премиум, +inf) поддержана: ширина считается от конечной нижней. PURE.
"""
if candidate_band is None or own_lo is None or own_hi is None:
return None
c_lo, c_hi = candidate_band
o_lo, o_hi = (own_lo, own_hi) if own_lo <= own_hi else (own_hi, own_lo)
lo = max(c_lo, o_lo)
hi = min(c_hi, o_hi)
overlap = max(0.0, hi - lo)
if not math.isfinite(overlap):
# Обе вилки уходят в +inf (оба премиум) — считаем полным пересечением.
return 1.0
c_width = c_hi - c_lo
o_width = o_hi - o_lo
widths = [w for w in (c_width, o_width) if math.isfinite(w) and w > 0]
if not widths:
return None
return _clamp01(overlap / min(widths))
def _unit_mix_similarity(
candidate_mix: dict[str, float] | None,
own_mix: dict[str, float] | None,
) -> float | None:
"""Ось квартирографии: похожесть распределений долей. PURE.
similarity = 1 0.5·Σ|p_k q_k| по ОБЪЕДИНЕНИЮ ключей (отсутствующий ключ = доля 0).
Для нормированных распределений (Σ=1) это ∈ [0,1]: идентичные миксы → 1.0,
непересекающиеся → 0.0. Доли нормируем на свою сумму (страховка от «не-в-сумме-1»
входа), clamp [0,1]. Любой микс None/пустой → None (ось НЕДОСТУПНА — НЕ фабрикуем).
PURE, не мутирует вход.
"""
if not candidate_mix or not own_mix:
return None
cand = _normalize_shares(candidate_mix)
own = _normalize_shares(own_mix)
if cand is None or own is None:
return None
keys = set(cand) | set(own)
l1 = sum(abs(cand.get(k, 0.0) - own.get(k, 0.0)) for k in sorted(keys))
return _clamp01(1.0 - 0.5 * l1)
def _normalize_shares(mix: dict[str, float]) -> dict[str, float] | None:
"""Нормировать доли на их сумму → {key: share}, Σ=1. PURE.
Отрицательные/None доли отбрасываем (грязь); сумма ≤ 0 → None (нет осмысленного
распределения). Ключи сортируются для детерминизма обхода. PURE.
"""
clean = {k: float(v) for k, v in mix.items() if v is not None and float(v) > 0}
total = sum(clean.values())
if total <= 0:
return None
return {k: clean[k] / total for k in sorted(clean)}
def _timing_overlap(
candidate_month: date | None,
own_month: date | None,
*,
half_life_months: float = _TIMING_HALF_LIFE_MONTHS,
) -> float | None:
"""Ось тайминга: временна́я близость окон запуска. PURE.
exp(|Δмесяцев| / half_life): одновременный выход → 1.0, расхождение в half_life мес
→ 0.5, дальше затухает. Чем ближе наши запуски, тем сильнее пересекаются окна продаж
= выше каннибализация. Любая дата None → None (ось НЕДОСТУПНА — НЕ фабрикуем). PURE.
"""
if candidate_month is None or own_month is None:
return None
months_apart = abs(_months_between(candidate_month, own_month))
if half_life_months <= 0:
return 1.0 if months_apart == 0 else 0.0
# Истинный период полу-затухания: 0.5 ** (Δ/half_life) → ровно 0.5 при Δ=half_life.
return _clamp01(0.5 ** (months_apart / half_life_months))
def _months_between(a: date, b: date) -> int:
"""Целое число месяцев между двумя датами (знак = направление). PURE."""
return (a.year - b.year) * 12 + (a.month - b.month)
def _add_months(base: date, months: int) -> date:
"""Прибавить N месяцев к дате → ПЕРВОЕ число итогового месяца. PURE.
Без сторонних зависимостей (НЕ dateutil): год-границу считаем вручную через divmod
(зеркалит macro_series._shift_months). months может быть отрицательным/нулём. Результат
нормализован к 1-му числу — совпадает с тем, как own-портфель хранит release_month
(own_portfolio._month_start) и как тайминговая ось сравнивает запуски. Напр. октябрь +
6 → апрель следующего года. Детерминированно.
"""
total = base.year * 12 + (base.month - 1) + months
year, month0 = divmod(total, 12)
return date(year, month0 + 1, 1)
def _geo_weight(
distance_km: float | None,
*,
scale_km: float = _GEO_WEIGHT_SCALE_KM,
unknown_weight: float = _GEO_WEIGHT_UNKNOWN,
) -> float:
"""Гео-вес нашего проекта по близости к участку кандидата. PURE.
exp(distance_km / scale): на участке (0 км) → 1.0, дальше затухает (1 км ≈ 0.72,
3 км ≈ 0.37). Расстояние неизвестно (нет координат у проекта/участка) → НИЗКИЙ floor
`unknown_weight` (проект ещё может сигналить, но не доминирует — близость не
подтверждена). Это ВЕС (не ось пересечения), поэтому всегда задан (не None). PURE.
Args:
distance_km: расстояние от участка до нашего проекта (км) или None.
scale_km: масштаб затухания.
unknown_weight: floor-вес при неизвестном расстоянии.
Returns:
Вес ∈ (0,1].
"""
if distance_km is None:
return unknown_weight
if scale_km <= 0:
return 1.0 if distance_km <= 0 else 0.0
return _clamp01(math.exp(-max(0.0, distance_km) / scale_km))
@dataclass(frozen=True)
class _PairOverlap:
"""Пересечение кандидата с ОДНИМ нашим проектом (промежуточный, не публичный).
`overlap` — среднее ДОСТУПНЫХ осей ∈ [0,1] или None (ни одной оси). `n_axes` —
сколько осей реально посчитано (None-оси исключены). `geo_weight` — вес близости.
`signal` = overlap × geo_weight (гео-взвешенный вклад проекта). `axes` — пер-ось
breakdown для explainability.
"""
overlap: float | None
n_axes: int
geo_weight: float
signal: float | None
axes: dict[str, float | None]
def _own_portfolio_overlap(
*,
class_overlap: float | None,
price_overlap: float | None,
unit_mix_overlap: float | None,
timing_overlap: float | None,
geo_weight: float,
) -> _PairOverlap:
"""Свести оси пересечения кандидат↔наш-проект в гео-взвешенный сигнал. PURE.
Усредняем ТОЛЬКО доступные (не-None) оси — пропущенная ось НЕ тянет пересечение к 0
(None-not-0): нет данных по оси → её просто нет в среднем. Ни одной оси → overlap
None (пара неинформативна). signal = overlap × geo_weight: близкий и сильно
пересекающийся наш проект даёт высокий сигнал каннибализации. PURE.
Args:
class_overlap/price_overlap/unit_mix_overlap/timing_overlap: оси ∈ [0,1] | None.
geo_weight: вес близости проекта к участку ∈ (0,1].
Returns:
_PairOverlap (overlap=None, signal=None, если ни одной оси не доступно).
"""
axes: dict[str, float | None] = {
"class": class_overlap,
"price": price_overlap,
"unit_mix": unit_mix_overlap,
"timing": timing_overlap,
}
available = [v for v in axes.values() if v is not None]
if not available:
return _PairOverlap(overlap=None, n_axes=0, geo_weight=geo_weight, signal=None, axes=axes)
overlap = sum(available) / len(available)
return _PairOverlap(
overlap=overlap,
n_axes=len(available),
geo_weight=geo_weight,
signal=overlap * geo_weight,
axes=axes,
)
def _aggregate_overlap(pairs: Sequence[_PairOverlap]) -> float | None:
"""Свести пер-проектные гео-взвешенные сигналы в индекс каннибализации. PURE.
Берём МАКСИМУМ signal (= overlap × geo_weight) по нашим проектам, а НЕ среднее:
ОДИН близкий сильно-пересекающийся проект — уже самостоятельный риск каннибализации,
и среднее размыло бы его длинным хвостом далёких/непохожих проектов (soft-max=max —
«сильнейший каннибализатор»). Ни одной пары с сигналом → None (нечего агрегировать,
НЕ 0). Детерминированно (max по числам). PURE.
"""
signals = [p.signal for p in pairs if p.signal is not None]
if not signals:
return None
return _clamp01(max(signals))
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),
}
# ──────────────────────────────────────────────────────────────────────────────
# §25.3 cannibalization — центроид участка (гео-вес наших проектов). psycopg v3.
# ──────────────────────────────────────────────────────────────────────────────
# Центроид участка кандидата (lon/lat) для гео-веса наших проектов — reuse семантики
# competitors._PARCEL_CENTROID_SQL (cad_parcels_geom → fallback cad_quarters_geom).
# Параметризован, без f-string. Гео тут — ВЕС (не ось пересечения): нет геометрии →
# все гео-веса падают на _GEO_WEIGHT_UNKNOWN (overlap всё равно считается по остальным
# осям). НИКОГДА :x::type.
_PARCEL_CENTROID_SQL = text(
"""
SELECT ST_X(pt) AS lon, ST_Y(pt) AS lat
FROM (
SELECT ST_Centroid(geom) AS pt
FROM cad_parcels_geom
WHERE cad_num = :cad_num AND geom IS NOT NULL
UNION ALL
SELECT ST_Centroid(geom) AS pt
FROM cad_quarters_geom
WHERE cad_number = :quarter AND geom IS NOT NULL
) sub
LIMIT 1
"""
)
# Средний радиус Земли (км) для гаверсинуса. Распределённость наших проектов в пределах
# города — гаверсинус точен достаточно (PostGIS-geography для пары точек тут избыточен).
_EARTH_RADIUS_KM: float = 6371.0
def _quarter_from_cad(cad_num: str) -> str:
"""Кадастровый квартал из номера участка (66:41:0303161:123 → 66:41:0303161). PURE.
Нестандартный формат → cad_num как есть (fallback). Зеркалит competitors._quarter_from_cad.
"""
parts = cad_num.split(":")
if len(parts) >= 3:
return ":".join(parts[:3])
return cad_num
def _haversine_km(lon1: float, lat1: float, lon2: float, lat2: float) -> float:
"""Гаверсинус-расстояние (км) между двумя (lon,lat). PURE.
Детерминированно; для внутригородских пар точек точности достаточно (гео — лишь вес).
"""
rlat1, rlat2 = math.radians(lat1), math.radians(lat2)
dlat = math.radians(lat2 - lat1)
dlon = math.radians(lon2 - lon1)
h = math.sin(dlat / 2) ** 2 + math.cos(rlat1) * math.cos(rlat2) * math.sin(dlon / 2) ** 2
return 2.0 * _EARTH_RADIUS_KM * math.asin(min(1.0, math.sqrt(h)))
def _query_parcel_centroid(db: Session, *, cad_num: str) -> tuple[float, float] | None:
"""Центроид участка (lon, lat) для гео-веса. Graceful → None.
Нет геометрии / сбой → None (гео-веса упадут на floor; overlap считается по остальным
осям, индекс НЕ деградирует целиком). Параметризовано (psycopg v3). Детерминированно.
"""
try:
row = (
db.execute(
_PARCEL_CENTROID_SQL,
{"cad_num": cad_num, "quarter": _quarter_from_cad(cad_num)},
)
.mappings()
.first()
)
except Exception:
logger.exception("cannibalization: centroid query failed for cad_num=%s", cad_num)
return None
if not row or row["lon"] is None or row["lat"] is None:
return None
return (float(row["lon"]), float(row["lat"]))
# ──────────────────────────────────────────────────────────────────────────────
# 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 _launch_window_horizon(launch_window: SpecialIndex) -> int | None:
"""Извлечь рекомендованный горизонт запуска (мес) из готового Launch Window. PURE.
§25.1 кладёт выбранный горизонт пикового дефицита в detail['best_horizon_months'].
Индекс недоступен (deficit None на всех горизонтах → method='unavailable', detail без
горизонта) → None: тайминговая ось §25.3 НЕ активируется (graceful, НЕ фабрикуем дату).
Читаем из УЖЕ посчитанного индекса — Launch Window НЕ пересчитывается. PURE.
"""
horizon = launch_window.detail.get("best_horizon_months")
return horizon if isinstance(horizon, int) else None
def _candidate_release_month(
launch_window: SpecialIndex, *, as_of: date
) -> date | None:
"""Когда рекомендованный проект реально выйдет на рынок (тайминг §25.3). PURE.
= as_of (дата отчёта) + горизонт окна запуска §25.1: на этот месяц #980 предсказывает
ПИКОВЫЙ дефицит, т.е. рекомендованный к запуску момент. Этот месяц затем сравнивается с
release_month наших проектов (тайминговая ось каннибализации). Launch Window недоступен
→ None: ось тайминга остаётся исключённой (None-not-0), а не фабрикуется. PURE.
"""
horizon = _launch_window_horizon(launch_window)
if horizon is None:
return None
return _add_months(as_of, horizon)
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,
)
# Метка-каведат для проксированной каннибализации (own-portfolio не сконфигурирован).
_PROXY_REASON: str = (
"own-portfolio не сконфигурирован (own_developer_ids не задан И нет manual-строк) "
"→ ПРОКСИ: плотность конкурентов того же класса (НЕ истинное пересечение портфеля)"
)
def _build_cannibalization_proxy(
db: Session, *, spec: SegmentSpec, cad_num: str | None
) -> SpecialIndex:
"""Index 3 FALLBACK — ПРОКСИ: доля relevance-силы конкурентов В классе. Degrade.
Используется ТОЛЬКО когда наш портфель пуст (own-portfolio не сконфигурирован):
честно помечается method='proxy_*' и detail.proxy=True, чтобы прокси НИКОГДА не
выдавался за истинный own-portfolio индекс. Логика — прежний _cannibalization_index.
"""
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 = _normalize_class(spec.obj_class)
same_class_relevance = [
c.relevance_weight
for c in competitors
if target is not None and _normalize_class(c.obj_class) == target
]
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=f"прокси · {spec.obj_class}" if spec.obj_class else "прокси",
confidence=_cap_confidence("low"), # прокси — заведомо слабее истинного
detail={
"mode": "proxy",
"proxy": True,
"proxy_reason": _PROXY_REASON,
"n_competitors": len(competitors),
"n_same_class": len(same_class_relevance),
"target_class": spec.obj_class,
"radius_km": _COMPETITOR_RADIUS_KM,
},
method="proxy_same_class_relevance_share",
advisory=_ADVISORY,
)
def _build_cannibalization_true(
*,
spec: SegmentSpec,
portfolio: Sequence[OwnProject],
centroid: tuple[float, float] | None,
candidate_unit_mix: dict[str, float] | None,
candidate_release_month: date | None,
) -> SpecialIndex:
"""Index 3 TRUE — §25.3 пересечение кандидата с НАШИМ портфелем по 4 осям + гео.
Для каждого нашего проекта считаем оси (класс/цена/квартирография/тайминг —
None-not-0 при тонких данных) + гео-вес близости к участку, сводим в гео-взвешенный
сигнал, агрегируем МАКСИМУМОМ (сильнейший каннибализатор). Топ-проекты — в detail
(explainability §16). Confidence отражает покрытие портфеля (тонкий/только-current →
low + нота §26). Чистая агрегация над pure-осями — без БД здесь. Детерминированно.
"""
candidate_band = _price_bucket_to_band(spec.price_bucket)
pairs: list[_PairOverlap] = []
contributions: list[tuple[float, OwnProject, _PairOverlap]] = []
for own in portfolio:
distance_km = _own_distance_km(centroid, own)
price_overlap = _price_overlap(
candidate_band, own.price_min_per_m2, own.price_max_per_m2
)
pair = _own_portfolio_overlap(
class_overlap=_class_overlap(spec.obj_class, own.obj_class),
price_overlap=price_overlap,
unit_mix_overlap=_unit_mix_similarity(candidate_unit_mix, own.unit_mix),
timing_overlap=_timing_overlap(candidate_release_month, own.release_month),
geo_weight=_geo_weight(distance_km),
)
pairs.append(pair)
if pair.signal is not None:
contributions.append((pair.signal, own, pair))
value = _aggregate_overlap(pairs)
if value is None:
return _unavailable(
KEY_CANNIBALIZATION,
reason=(
"ни по одному нашему проекту нет сопоставимых осей "
"(класс/цена/квартирография/тайминг) — пересечение неизмеримо (НЕ фабрикуем)"
),
)
# Топ-вкладчики (сильнейшие каннибализаторы) — детерминированно по signal DESC,
# tie-break по имени проекта (стабильно, без RNG/порядка set).
contributions.sort(key=lambda t: (-t[0], t[1].name))
top = contributions[: _COMPETITOR_TOP_N]
confidence = _cap_confidence(_portfolio_confidence(portfolio))
return SpecialIndex(
key=KEY_CANNIBALIZATION,
value=value,
label=_top_contributor_label(top),
confidence=confidence,
detail={
"mode": "own_portfolio",
"proxy": False,
"n_own_projects": len(portfolio),
"n_current": sum(1 for p in portfolio if p.source == "current"),
"n_future": sum(1 for p in portfolio if p.source == "future"),
"has_geometry": centroid is not None,
"axes_available": _axes_available_summary(pairs),
"top_contributors": [_contribution_detail(s, own, pair) for s, own, pair in top],
"confidence_note": _portfolio_confidence_note(portfolio),
},
method="own_portfolio_overlap",
advisory=_ADVISORY,
)
def _own_distance_km(centroid: tuple[float, float] | None, own: OwnProject) -> float | None:
"""Расстояние (км) от центроида участка до нашего проекта или None. PURE.
Нет центроида ИЛИ нет координат проекта → None (гео-вес упадёт на floor). PURE.
"""
if centroid is None or own.lon is None or own.lat is None:
return None
return _haversine_km(centroid[0], centroid[1], own.lon, own.lat)
def _portfolio_confidence(portfolio: Sequence[OwnProject]) -> Confidence:
"""Honest-confidence по покрытию портфеля (§26). PURE.
Тонкий портфель (< _THIN_PORTFOLIO_MAX_PROJECTS проектов) ИЛИ ТОЛЬКО current
(нет future-пайплайна — мы не знаем своих будущих запусков) → 'low' (данных мало).
Иначе 'medium' (всё равно ≤ cap: слой advisory). PURE.
"""
has_future = any(p.source == "future" for p in portfolio)
if len(portfolio) < _THIN_PORTFOLIO_MAX_PROJECTS or not has_future:
return "low"
return "medium"
def _portfolio_confidence_note(portfolio: Sequence[OwnProject]) -> str | None:
"""RU-нота §26 о тонком покрытии портфеля или None, если покрытие достаточное. PURE."""
n = len(portfolio)
has_future = any(p.source == "future" for p in portfolio)
if n < _THIN_PORTFOLIO_MAX_PROJECTS:
return f"данных мало: в портфеле {n} проект(ов) — оценка ориентировочная"
if not has_future:
return "только текущие проекты (нет future-пайплайна) — будущие запуски не учтены"
return None
def _axes_available_summary(pairs: Sequence[_PairOverlap]) -> dict[str, int]:
"""Сколько пар имеют каждую ось доступной (explainability «что реально считалось»). PURE."""
summary = {"class": 0, "price": 0, "unit_mix": 0, "timing": 0}
for pair in pairs:
for axis, val in pair.axes.items():
if val is not None:
summary[axis] += 1
return summary
def _contribution_detail(
signal: float, own: OwnProject, pair: _PairOverlap
) -> dict[str, Any]:
"""Explainability-карточка одного топ-каннибализатора. PURE."""
return {
"name": own.name,
"source": own.source,
"obj_class": own.obj_class,
"district": own.district,
"signal": _round_or_none(signal, 3),
"overlap": _round_or_none(pair.overlap, 3),
"geo_weight": _round_or_none(pair.geo_weight, 3),
"n_axes": pair.n_axes,
"axes": {k: _round_or_none(v, 3) for k, v in pair.axes.items()},
"low_confidence_pair": pair.n_axes < _OVERLAP_MIN_AXES,
}
def _top_contributor_label(
top: Sequence[tuple[float, OwnProject, _PairOverlap]],
) -> str | None:
"""Человекочитаемый label = сильнейший наш каннибализатор. PURE."""
if not top:
return None
_, own, _ = top[0]
return f"пересечение с «{own.name}»"
def _build_cannibalization(
db: Session,
*,
spec: SegmentSpec,
cad_num: str | None,
candidate_unit_mix: dict[str, float] | None = None,
candidate_release_month: date | None = None,
) -> SpecialIndex:
"""Index 3 — Cannibalization (§25.3): истинное own-portfolio-пересечение / прокси.
Портфель НЕпуст → истинный индекс (overlap кандидата с нашими проектами по 4 осям +
гео-вес близости к участку); explainability называет режим own-portfolio и топ-
каннибализаторов. Портфель ПУСТ (own_developer_ids не задан И нет manual-строк) →
FALLBACK на ПРОКСИ (_build_cannibalization_proxy), явно помеченный method='proxy_*'
— прокси НИКОГДА не выдаётся за истинный индекс. Детерминированно.
candidate_release_month — месяц выхода кандидата на рынок; оркестратор выводит его
из §25.1 Launch Window (дата отчёта + горизонт пикового дефицита) и передаёт сюда,
активируя тайминговую ось. Launch Window недоступен → None → ось тайминга честно
ИСКЛЮЧАЕТСЯ (None-not-0, НЕ фабрикуется). candidate_unit_mix — опциональная ось
квартирографии кандидата (phase-2: нужен project-level рекомендованный микс; пока
обычно None). Любая не переданная ось ИСКЛЮЧАЕТСЯ из среднего, а НЕ зануляется.
"""
portfolio = get_own_portfolio(db)
if not portfolio:
logger.info(
"cannibalization: own-portfolio пуст → FALLBACK на прокси (cad_num=%s)", cad_num
)
return _build_cannibalization_proxy(db, spec=spec, cad_num=cad_num)
# Гео — лишь ВЕС: нет геометрии → веса на floor, overlap считается по остальным осям.
centroid = _query_parcel_centroid(db, cad_num=cad_num) if cad_num else None
return _build_cannibalization_true(
spec=spec,
portfolio=portfolio,
centroid=centroid,
candidate_unit_mix=candidate_unit_mix,
candidate_release_month=candidate_release_month,
)
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.
§25.1 Launch Window считается ПЕРВЫМ и переиспользуется: его горизонт пикового
дефицита + дата отчёта (date.today() — даты отчёта в scope нет) дают месяц выхода
кандидата на рынок, который передаётся в §25.3 каннибализацию и активирует её
тайминговую ось. Launch Window недоступен → месяц None → ось тайминга честно
исключается (None-not-0), каннибализация считается по классу/цене/гео.
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()
def _run(key: str, builder: Any) -> SpecialIndex:
"""Выполнить builder одного индекса в собственном try/except (graceful)."""
try:
return builder() # type: ignore[no-any-return]
except Exception:
# Сбой одного индекса НЕ валит карточку: деградация-None, остальные считаются.
logger.exception(
"special_indices: index=%s failed (segment=%s district=%s cad_num=%s)",
key,
segment,
district,
cad_num,
)
return _unavailable(key, reason="ошибка расчёта (см. логи)")
# §25.1 Launch Window считаем ПЕРВЫМ и ОДИН раз: §25.3 каннибализация выводит из его
# горизонта тайминг кандидата (дата отчёта + горизонт пикового дефицита = когда проект
# реально выйдет на рынок). Готовый индекс переиспользуется ниже (НЕ пересчитывается).
launch_window = _run(
KEY_LAUNCH_WINDOW,
lambda: _build_launch_window(
db, spec=spec, district=district, cad_num=cad_num, horizons=horizons
),
)
# report-as-of: дата отчёта в scope недоступна → date.today() (app-код — допустимо).
# Launch Window недоступен → release_month None → тайминговая ось §25.3 исключается.
candidate_release_month = _candidate_release_month(launch_window, as_of=date.today())
# (ключ, нулевой-аргумент builder) — каждый вызывается в _run (общий try/except).
builders: dict[str, Any] = {
KEY_LAUNCH_WINDOW: lambda: launch_window, # уже посчитан выше
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, candidate_release_month=candidate_release_month
),
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] = {key: _run(key, builders[key]) for key in _INDEX_KEYS}
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,
)