feat(forecasting): §9.7 what-to-build ranker + §7.9 MAI proxy (#981, 952-B)

EPIC 8 advisory deliverables over the #980 demand/supply engine, deterministic,
no LLM, no new SQL:
- what_to_build.rank_segments: runs compute_demand_supply_forecast across a
  class×room_bucket[×price] grid (default 3×5×1=15) per district/cad_num/horizon,
  drops deficit_index=None cells, ranks deficit_index DESC (tie-break confidence
  DESC then stable segment key). +deficit=build-signal, −=затоварка.
- affordability.compute_affordability: §7.9 MAI degraded payment-burden proxy via
  subsidized weighted rate (_current_mortgage_rate) + annuity on 50m²/240mo.
  payment_to_income always None (#946 no income), rate_kind always
  subsidized_weighted (market ~20% absent), confidence always low.
- Pure _rank_key / _annuity (rate≤0 → principal/months guard) unit-tested.
62 new tests; 368 forecasting tests green. Advisory + un-wired.
This commit is contained in:
Light1YT 2026-06-03 12:07:12 +05:00
parent 70ffa399fc
commit 78b667b018
5 changed files with 1444 additions and 0 deletions

View file

@ -13,6 +13,10 @@
demand_normalization (#951f) — §9.4 нормализация спроса под смену режима ставки (ADVISORY).
demand_supply_forecast (#952a) — §9.8 центральный движок: спрос (§9.4×§9.5) vs
предложение (§9.3) по горизонтам баланс/индекс дефицита (СБОРКА, ADVISORY).
what_to_build (#981/952-B) — §9.7 ранкер сетки сегментов по deficit_index
(прогон #980 per-cell → DESC «что строить»; СБОРКА, ADVISORY).
affordability (#981/952-B) — §7.9 MAI: ДЕГРАДИРОВАННЫЙ прокси платёжной
нагрузки (субсид. ставка, дохода нет low-confidence; СБОРКА, ADVISORY).
Источники данных:
макро таблица macro_indicator через reader site_finder/macro.py (reuse).
@ -21,6 +25,10 @@
from __future__ import annotations
from app.services.forecasting.affordability import (
MortgageAffordabilityIndex,
compute_affordability,
)
from app.services.forecasting.demand_normalization import (
DemandNormalization,
compute_demand_normalization,
@ -65,19 +73,28 @@ from app.services.forecasting.sales_series import (
price_bucket_of,
room_area_bucket_of,
)
from app.services.forecasting.what_to_build import (
RankedSegment,
WhatToBuildRanking,
rank_segments,
)
__all__ = [
"DemandNormalization",
"DemandSupplyForecast",
"MacroCoefficient",
"MonthlyMacro",
"MortgageAffordabilityIndex",
"RankedSegment",
"RateSensitivity",
"SalesSeries",
"SegmentSpec",
"WhatToBuildRanking",
"assemble_coefficient",
"best_lag",
"build_sales_series",
"classify_regime",
"compute_affordability",
"compute_demand_normalization",
"compute_demand_supply_forecast",
"compute_macro_coefficient",
@ -95,6 +112,7 @@ __all__ = [
"normalization_factor",
"ols_slope_r2",
"price_bucket_of",
"rank_segments",
"renormalize_contributions",
"room_area_bucket_of",
"segment_steepness",

View file

@ -0,0 +1,283 @@
"""§7.9 Mortgage Affordability Index (MAI) — ДЕГРАДИРОВАННЫЙ прокси платёжной нагрузки.
#981 (952-B, Site Finder v2 / «GG-форсайт» ТЗ §7.9), EPIC 8 «что строить +
доступность». Это **СБОРОЧНЫЙ слой**: своей §9.x-математики не пересобирает
переиспользует helper'ы (`_current_mortgage_rate` §аналитики, `build_sales_series`
для сегментной цены /м²). Всё ДЕТЕРМИНИРОВАННО, БЕЗ LLM (чистая аннуитет-арифметика
+ reuse; своего SQL НЕТ).
ADVISORY / LOW-CONFIDENCE (КРИТИЧНО честность важнее цифры):
Настоящий §7.9 индекс доступности = «рыночный аннуитет (~20%) ÷ доход
домохозяйства». Обе входные величины в нашей БД ОТСУТСТВУЮТ:
1. РЫНОЧНАЯ ставка (~20% по ЦБ Янв 2026) её серии в cbr_mortgage_series НЕТ.
Единственная доступная ставка `_current_mortgage_rate`: СРЕДНЕВЗВЕШЕННАЯ
С льготами (семейная/IT/ДВ), ~7-8% она ЗАНИЖАЕТ платёж против рынка.
2. ДОХОД домохозяйства (#946) — в данных проекта его нет вовсе.
Поэтому MAI здесь = ПРОКСИ платёжной нагрузки (annuity на эталонную квартиру по
субсидированной ставке), а НЕ истинный рыночный индекс §7.9: `payment_to_income`
ВСЕГДА None (нет дохода), `rate_kind='subsidized_weighted'` (не рыночная),
`confidence='low'` ВСЕГДА (деградация по двум каналам), `degraded_reason`
объясняет оба пробела. Цифра для explainability/прототипа, не для решения.
Graceful-on-thin-data (дух demand_supply_forecast / market_metrics): нет ставки
платёж None; нет цены сегмента платёж None; ставка 0 аннуитет деградирует к
principal/months (без деления на ноль). НИКОГДА не crash, НИКОГДА 0-как-заглушка.
Детерминированно.
"""
from __future__ import annotations
import logging
import statistics
from dataclasses import dataclass
from typing import Any, Literal
from sqlalchemy.orm import Session
from app.services.analytics_queries import _current_mortgage_rate
from app.services.forecasting.sales_series import (
SalesSource,
SegmentSpec,
build_sales_series,
)
logger = logging.getLogger(__name__)
# ── Named-константы ───────────────────────────────────────────────────────────
# Эталонная площадь квартиры (м²) для расчёта «типового» тела кредита. ~50 м² ≈
# медианная 1-2-комнатная квартира масс-рынка ЕКБ (зеркалит ROOM_AREA_BUCKET 1-к
# 30-45 / 2-к 45-60). Держим явной константой — индекс сопоставим между сегментами
# при ФИКСИРОВАННОЙ площади (меняется только цена ₽/м²), а не плавает с метражом.
_REF_AREA_M2: float = 50.0
# Срок ипотеки (мес) для аннуитета. 240 мес = 20 лет — типовой срок ИЖК в РФ
# (медиана ЦБ ~ 20-25 лет; берём круглые 20 для детерминизма). Tunable.
_ANNUITY_TERM_MONTHS: int = 240
# Метка вида ставки в выводе: всегда субсидированная средневзвешенная (рыночной
# ~20% в БД нет). Зафиксирована константой — downstream не должен принять её за
# рыночную ставку §7.9.
_RATE_KIND_SUBSIDIZED: str = "subsidized_weighted"
# Источник сегментной цены ₽/м² по умолчанию: Source B (objective_lots) — несёт
# per-lot цену для price/room-сегментации (Source A её не даёт). Каведат
# survivorship bias задокументирован в sales_series; для прокси-индекса приемлемо.
_PRICE_SOURCE: SalesSource = "objective_lots"
# Текст деградации — объясняет ОБА пробела (рыночная ставка + доход), чтобы вывод
# нельзя было прочитать как истинный §7.9 индекс. Используется в degraded_reason.
_DEGRADED_REASON: str = (
"Прокси платёжной нагрузки, НЕ истинный §7.9 индекс доступности: "
"(1) использована СУБСИДИРОВАННАЯ средневзвешенная ставка ИЖК (~7-8%) — "
"рыночной ставки без льгот (~20%) в БД нет, платёж занижен против рынка; "
"(2) доход домохозяйства (#946) недоступен, поэтому payment_to_income не "
"рассчитывается (всегда None). confidence='low' — деградация по двум каналам."
)
@dataclass(frozen=True)
class MortgageAffordabilityIndex:
"""§7.9 MAI — ДЕГРАДИРОВАННЫЙ прокси платёжной нагрузки (считается на лету).
Все денежные поля = None при недостатке данных (НИКОГДА 0-как-заглушка).
`payment_to_income` ВСЕГДА None (дохода нет в данных, #946). `rate_kind` ВСЕГДА
'subsidized_weighted' (рыночной ставки нет). `confidence` ВСЕГДА 'low' (прокси,
не истинный индекс §7.9). `degraded_reason` объясняет оба пробела. Это
explainability-прокси, НЕ основание для инвест-решения.
"""
segment: dict[str, str | None]
monthly_payment_rub: float | None # аннуитет на эталонную квартиру (₽/мес)
rate_used: float | None # годовая ставка (%) — субсидированная средневзвеш.
rate_kind: str # ВСЕГДА 'subsidized_weighted'
rate_period: str | None # период точки ставки (explain)
ref_area_m2: float # эталонная площадь кредита (м²)
price_per_m2: float | None # сегментная цена ₽/м² (вход тела кредита)
annuity_term_months: int # срок ипотеки (мес)
payment_to_income: float | None # ВСЕГДА None — дохода нет (#946)
payment_at_scenario: dict[int, float] | None # платёж/горизонт при rate_path
degraded_reason: str # объясняет оба пробела (ставка + доход)
confidence: Literal["low"] # ВСЕГДА 'low' (деградация)
def as_dict(self) -> dict[str, Any]:
return {
"segment": dict(self.segment),
"monthly_payment_rub": _round_or_none(self.monthly_payment_rub, 0),
"rate_used": _round_or_none(self.rate_used, 2),
"rate_kind": self.rate_kind,
"rate_period": self.rate_period,
"ref_area_m2": self.ref_area_m2,
"price_per_m2": _round_or_none(self.price_per_m2, 0),
"annuity_term_months": self.annuity_term_months,
"payment_to_income": self.payment_to_income,
"payment_at_scenario": (
{h: round(v, 0) for h, v in self.payment_at_scenario.items()}
if self.payment_at_scenario is not None
else None
),
"degraded_reason": self.degraded_reason,
"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 _annuity(
principal: float | None,
annual_rate: float | None,
months: int,
) -> float | None:
"""Стандартный аннуитетный платёж по кредиту. PURE, graceful (без /0).
Формула (annual_rate в ПРОЦЕНТАХ годовых месячная доля i = rate/100/12):
payment = principal · i · (1+i)^n / ((1+i)^n 1).
Правила деградации (честность важнее «красивой» цифры):
principal None ИЛИ annual_rate None None (нет тела/ставки нечего считать).
months 0 None (срок не задан).
annual_rate 0 principal / months (беспроцентный кредит: НЕ делим на
ноль в формуле, где i=0 даёт 0/0; равномерное погашение тела).
Args:
principal: тело кредита (); None None.
annual_rate: годовая ставка в процентах (напр. 7.5); None None; 0
беспроцентная деградация principal/months.
months: срок кредита (мес); 0 None.
Returns:
Месячный платёж () или None.
"""
if principal is None or annual_rate is None:
return None
if months <= 0:
return None
if annual_rate <= 0:
# Беспроцентный кредит: формула вырождается (i=0 → 0/0). Равномерное тело.
return principal / float(months)
i = annual_rate / 100.0 / 12.0
growth = (1.0 + i) ** months
return principal * i * growth / (growth - 1.0)
# ──────────────────────────────────────────────────────────────────────────────
# DB-оркестратор — тонкий, graceful. Pure-логика выше тестируется без него.
# ──────────────────────────────────────────────────────────────────────────────
def compute_affordability(
db: Session,
*,
spec: SegmentSpec,
price_per_m2: float | None = None,
rate_path: dict[int, float] | None = None,
ref_area_m2: float = _REF_AREA_M2,
price_source: SalesSource = _PRICE_SOURCE,
) -> MortgageAffordabilityIndex:
"""§7.9 MAI — ДЕГРАДИРОВАННЫЙ прокси платёжной нагрузки для сегмента.
ADVISORY / LOW-CONFIDENCE: это ПРОКСИ, а НЕ истинный §7.9 индекс доступности.
Рыночной ставки (~20%) и дохода домохозяйства (#946) в данных нет — см. module
docstring и `degraded_reason`. `payment_to_income` ВСЕГДА None.
Что считает:
rate, period = _current_mortgage_rate(db) субсидированная средневзвеш.
ставка (~7-8%; ЕДИНСТВЕННАЯ в БД). Это rate_kind='subsidized_weighted'.
price_per_m2 из аргумента ИЛИ сегментная средняя (reuse build_sales_series,
усреднение НЕнулевых месяцев). None платёж None (graceful).
monthly_payment = _annuity(principal=price×ref_area, annual_rate=rate,
months=_ANNUITY_TERM_MONTHS).
payment_at_scenario[h] = _annuity(... annual_rate=rate_path[h]) платёж на
каждом горизонте сценарной ставки (None rate_path поле None).
Graceful: нет ставки/цены платёж None; ставка 0 аннуитет деградирует к
principal/months. НИКОГДА не crash. confidence ВСЕГДА 'low'. Детерминированно.
Args:
db: SQLAlchemy sync Session.
spec: целевой сегмент (для сегментной цены, если price_per_m2 не задан).
price_per_m2: цена /м² (None берём сегментную среднюю reuse-ом).
rate_path: сценарный {horizon: годовая ставка %} для payment_at_scenario;
None payment_at_scenario None.
ref_area_m2: эталонная площадь тела кредита (по умолчанию _REF_AREA_M2).
price_source: источник сегментной цены (по умолчанию _PRICE_SOURCE = B).
Returns:
MortgageAffordabilityIndex (всегда; тонко None-поля, confidence='low').
"""
segment = spec.as_dict()
rate, rate_period = _current_mortgage_rate(db)
resolved_price = (
price_per_m2
if price_per_m2 is not None
else _segment_avg_price(db, spec=spec, source=price_source)
)
principal = resolved_price * ref_area_m2 if resolved_price is not None else None
monthly_payment = _annuity(principal, rate, _ANNUITY_TERM_MONTHS)
payment_at_scenario: dict[int, float] | None = None
if rate_path is not None:
scenario: dict[int, float] = {}
for horizon, scenario_rate in rate_path.items():
payment = _annuity(principal, scenario_rate, _ANNUITY_TERM_MONTHS)
if payment is not None:
scenario[horizon] = payment
payment_at_scenario = scenario
logger.info(
"affordability: segment=%s rate=%s period=%s price_per_m2=%s ref_area=%.1f "
"monthly_payment=%s confidence=low (DEGRADED proxy, payment_to_income=None)",
segment,
_round_or_none(rate, 2),
rate_period,
_round_or_none(resolved_price, 0),
ref_area_m2,
_round_or_none(monthly_payment, 0),
)
return MortgageAffordabilityIndex(
segment=segment,
monthly_payment_rub=monthly_payment,
rate_used=rate,
rate_kind=_RATE_KIND_SUBSIDIZED,
rate_period=rate_period,
ref_area_m2=ref_area_m2,
price_per_m2=resolved_price,
annuity_term_months=_ANNUITY_TERM_MONTHS,
payment_to_income=None, # дохода нет в данных (#946) — НИКОГДА не считаем
payment_at_scenario=payment_at_scenario,
degraded_reason=_DEGRADED_REASON,
confidence="low", # ВСЕГДА low — деградированный прокси, не истинный §7.9
)
def _segment_avg_price(db: Session, *, spec: SegmentSpec, source: SalesSource) -> float | None:
"""Сегментная средняя цена ₽/м² — reuse build_sales_series (НЕ новый SQL).
Берём monthly ряд продаж сегмента и усредняем НЕнулевые (не-None) месячные
avg_price_per_m2 простое среднее по месяцам с реальными сделками. Нет ни
одного месяца с ценой (тонкий сегмент / сбой) None (graceful, НЕ 0). PURE-ish
(тонкая обёртка над reuse).
Args:
db: SQLAlchemy sync Session.
spec: сегмент рынка.
source: источник ряда (objective_lots несёт per-lot цену).
Returns:
Средняя цена /м² по месяцам со сделками или None.
"""
series = build_sales_series(db, spec=spec, source=source)
prices = [p for p in series.avg_price_per_m2 if p is not None]
if not prices:
return None
return statistics.fmean(prices)

View file

@ -0,0 +1,362 @@
"""§9.7 «что строить» — ранкер сегментной сетки по индексу дефицита (ТЗ §9.7).
#981 (952-B, Site Finder v2 / «GG-форсайт» ТЗ §9.7), EPIC 8 «что строить +
доступность». Это **СБОРОЧНЫЙ слой над #980**: прогоняет уже-смерженный
центральный движок `compute_demand_supply_forecast` (§9.8) по СЕТКЕ сегментов
(класс × room_bucket [× price_bucket]) для участка/горизонта и РАНЖИРУЕТ ячейки по
`deficit_index` (по убыванию = сила сигнала «строить»). Это продуктовый ответ §9.7
«строить студии? двушки? какой класс?». Своей §9.x-математики НЕ пересобирает
вызывает #980 per-cell. Всё ДЕТЕРМИНИРОВАННО, БЕЗ LLM; своего SQL НЕТ.
ИНТЕРПРЕТАЦИЯ ИНДЕКСА (наследуется от #980 _deficit_index, см. его docstring):
deficit_index > 0 НЕДОобеспечен (спроса больше предложения) = СИГНАЛ СТРОИТЬ.
deficit_index = 0 баланс спроспредложение.
deficit_index < 0 ЗАТОВАРКА (предложения больше спроса) = ИЗБЕГАТЬ.
deficit_index None тонкие данные (предложение неизмеримо) ячейка ОТБРАСЫВАЕТСЯ.
Ранжируем по убыванию: топ списка = самый сильный сигнал «строить именно это».
ADVISORY (КРИТИЧНО честность): КАЖДАЯ ячейка наследует advisory-статус #980 и его
жёсткий потолок confidence 'medium' (движок не провалидирован до бэктеста #951).
Поэтому и весь ранкинг СОВЕТУЮЩИЙ: `generated_advisory` ВСЕГДА True. Цифры для
explainability/прототипа, не основание для инвест-решения.
СЕТКА (сознательно НЕ взрываем до 1000 ячеек): основные оси room_bucket × class
(дефолтная сетка из bucket-вокабуляра sales_series). price_bucket опционален
(по умолчанию один «без фильтра цены» проход иначе ×4 ячеек ради оси, которую
#980 §9.5 учитывает грубо). Все оси — named-константы; вызывающий может сузить.
room_bucket source-семантика (см. sales_series.SegmentSpec): дефолтные ключи
Source-B room_area-вокабуляр ('Студии 15-30' / '1-к 30-45' / ), т.к. это вывод
room_area_bucket_of. #980 пробрасывает room_bucket в §9.4/§9.5 profile; для агрегатов
класс/район Source-A ('студия'/'1'/'2'/) вызывающий может передать свой набор.
Graceful (дух demand_supply_forecast): пустая сетка / все ячейки None ПУСТОЙ
ранкинг (не crash). Сбой #980 на отдельной ячейке → ячейка пропускается, остальные
ранжируются. Детерминированно (стабильная сортировка с tie-break по segment-ключу).
"""
from __future__ import annotations
import logging
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Any, Literal
from sqlalchemy.orm import Session
from app.services.forecasting.demand_supply_forecast import compute_demand_supply_forecast
from app.services.forecasting.sales_series import (
ROOM_AREA_BUCKET_1K,
ROOM_AREA_BUCKET_2K,
ROOM_AREA_BUCKET_3K,
ROOM_AREA_BUCKET_LARGE,
ROOM_AREA_BUCKET_STUDIO,
SegmentSpec,
)
logger = logging.getLogger(__name__)
Confidence = Literal["high", "medium", "low"]
# ── Named-константы (дефолтная сетка) ─────────────────────────────────────────
# Дефолтные классы недвижимости (ось «класс»). ЕКБ-вокабуляр Объектива в
# «человеческом» регистре — SQL внутри §9.x нормализует регистр. 3 массовых класса
# (премиум опускаем из дефолта — тонкий сегмент, обычно low-данные; вызывающий
# может добавить). Держим явной константой для детерминизма сетки.
_DEFAULT_CLASSES: tuple[str, ...] = ("эконом", "комфорт", "бизнес")
# Дефолтные room-bucket'ы (ось «формат») — Source-B room_area-вокабуляр
# (вывод room_area_bucket_of). 5 форматов: студии / 1-к / 2-к / 3-к / 80+. Это
# продуктовая ось §9.7 «какой формат строить».
_DEFAULT_ROOM_BUCKETS: tuple[str, ...] = (
ROOM_AREA_BUCKET_STUDIO,
ROOM_AREA_BUCKET_1K,
ROOM_AREA_BUCKET_2K,
ROOM_AREA_BUCKET_3K,
ROOM_AREA_BUCKET_LARGE,
)
# Дефолтная ось цены: один проход БЕЗ price-фильтра (None). Сознательно не дробим
# по price_bucket в дефолте — иначе сетка ×4 ради оси, которую #980 §9.5 учитывает
# лишь грубо (price_tier в profile). Вызывающий может передать реальные bucket'ы.
_DEFAULT_PRICE_BUCKETS: tuple[str | None, ...] = (None,)
# Ранг уверенности для tie-break (выше = лучше). Зеркало demand_supply_forecast.
_CONFIDENCE_RANK: dict[Confidence, int] = {"low": 0, "medium": 1, "high": 2}
@dataclass(frozen=True)
class RankedSegment:
"""Одна ячейка сетки §9.7 с её сигналом дефицита (наследует #980 на горизонте).
`deficit_index` [1,+1]: >0 = строить (недообеспечен), <0 = избегать
(затоварка). Ячейки с deficit_index None (тонкие данные) в ранкинг НЕ попадают
(отброшены ранкером). `confidence` 'medium' (наследуется от #980 advisory-cap).
"""
segment: dict[str, str | None]
deficit_index: float # не None в ранкинге (None-ячейки отброшены)
balance_units: float | None # demand supply (>0 дефицит / <0 затоварка)
confidence: Confidence # ≤ 'medium' (#980 advisory-cap)
def as_dict(self) -> dict[str, Any]:
return {
"segment": dict(self.segment),
"deficit_index": _round_or_none(self.deficit_index, 3),
"balance_units": _round_or_none(self.balance_units, 1),
"confidence": self.confidence,
}
@dataclass(frozen=True)
class WhatToBuildRanking:
"""§9.7 ранжированный ответ «что строить» для участка/горизонта (ТЗ §9.7).
`ranked` отсортирован по `deficit_index` УБЫВАНИЕ (топ = сильнейший сигнал
«строить»); ячейки с deficit_index None отброшены (тонкие данные). Пустой
`ranked` = вся сетка тонкая / нет данных (graceful, не crash).
`generated_advisory` ВСЕГДА True каждая ячейка наследует advisory-статус #980;
ранкинг СОВЕТУЮЩИЙ, не основание для инвест-решения.
"""
district: str | None
cad_num: str
horizon_months: int
ranked: list[RankedSegment] # DESC по deficit_index; None-ячейки отброшены
n_cells_scanned: int # сколько ячеек сетки прогнали (включая отброшенные)
n_cells_ranked: int # сколько попало в ранкинг (deficit_index не None)
generated_advisory: bool # ВСЕГДА True (наследует advisory #980)
def as_dict(self) -> dict[str, Any]:
return {
"district": self.district,
"cad_num": self.cad_num,
"horizon_months": self.horizon_months,
"ranked": [r.as_dict() for r in self.ranked],
"n_cells_scanned": self.n_cells_scanned,
"n_cells_ranked": self.n_cells_ranked,
"generated_advisory": self.generated_advisory,
}
def _round_or_none(value: float | None, digits: int) -> float | None:
return round(value, digits) if value is not None else None
# ──────────────────────────────────────────────────────────────────────────────
# Pure-логика — без БД, полностью юнит-тестируемо.
# ──────────────────────────────────────────────────────────────────────────────
def _rank_key(cell: RankedSegment) -> tuple[float, int, str]:
"""Ключ сортировки ячейки сетки для ранжирования §9.7. PURE.
Сортируем по (deficit_index DESC, confidence DESC, segment-ключ ASC):
1. deficit_index primary (НЕГАТИВ для DESC через sorted без reverse-флага,
чтобы tie-break'и шли в естественном ASC-порядке): больше дефицит = выше.
2. confidence tie-break: при равном индексе выше та ячейка, чьему сигналу
больше доверия (negate ранга для DESC).
3. segment-ключ финальный СТАБИЛЬНЫЙ tie-break (детерминизм при равных
index+confidence): лексикографический по компактной строке сегмента.
Используется как `sorted(cells, key=_rank_key)` БЕЗ reverse: негативы
primary/secondary дают убывание, а строковый ключ возрастание. PURE.
Args:
cell: ячейка сетки (deficit_index не None None-ячейки отброшены до сорта).
Returns:
Кортеж-ключ (deficit_index, confidence_rank, segment_key).
"""
confidence_rank = _CONFIDENCE_RANK.get(cell.confidence, 0)
return (-cell.deficit_index, -confidence_rank, _segment_key(cell.segment))
def _segment_key(segment: dict[str, str | None]) -> str:
"""Стабильная компактная строка сегмента для детерминированного tie-break. PURE.
Конкатенация осей в фиксированном порядке (class|room|district|price); None
пустая строка. Не для отображения только для устойчивой сортировки.
"""
return "|".join(
str(segment.get(axis) or "")
for axis in ("obj_class", "room_bucket", "district", "price_bucket")
)
def _build_grid(
*,
district: str | None,
classes: Sequence[str],
room_buckets: Sequence[str],
price_buckets: Sequence[str | None],
) -> list[SegmentSpec]:
"""Декартова сетка SegmentSpec из осей класс × room × price. PURE.
По одному SegmentSpec на комбинацию (class, room_bucket, price_bucket); district
проставляется во все (общий для участка). Порядок детерминирован (class-внешний,
room-средний, price-внутренний) стабильный n_cells_scanned. PURE.
Args:
district: район участка (общий для всех ячеек).
classes: ось классов.
room_buckets: ось форматов (room_bucket).
price_buckets: ось цены (может содержать None = «без фильтра цены»).
Returns:
Список SegmentSpec len = |classes|×|room_buckets|×|price_buckets|.
"""
grid: list[SegmentSpec] = []
for obj_class in classes:
for room_bucket in room_buckets:
for price_bucket in price_buckets:
grid.append(
SegmentSpec(
obj_class=obj_class,
room_bucket=room_bucket,
district=district,
price_bucket=price_bucket,
)
)
return grid
# ──────────────────────────────────────────────────────────────────────────────
# DB-оркестратор — тонкий, graceful. Pure-логика выше тестируется без него.
# ──────────────────────────────────────────────────────────────────────────────
def rank_segments(
db: Session,
*,
district: str | None,
cad_num: str,
horizon_months: int,
classes: Sequence[str] = _DEFAULT_CLASSES,
room_buckets: Sequence[str] = _DEFAULT_ROOM_BUCKETS,
price_buckets: Sequence[str | None] = _DEFAULT_PRICE_BUCKETS,
rate_path: dict[int, float] | None = None,
) -> WhatToBuildRanking:
"""§9.7 ранжировать сегменты по индексу дефицита для участка/горизонта.
ADVISORY (каждая ячейка наследует advisory-статус #980, см. module docstring) —
НЕ подключать в production-эндпоинт. СБОРКА над #980, НЕ пересчёт §9.x.
Прогоняет `compute_demand_supply_forecast` (#980) по сетке класс × room × price
(дефолт _build_grid из named-констант). Для КАЖДОЙ ячейки берёт прогноз на
`horizon_months`, собирает (segment, deficit_index, balance_units, confidence),
ОТБРАСЫВАЕТ ячейки с deficit_index None (тонкие данные предложение неизмеримо),
сортирует по `deficit_index` УБЫВАНИЕ (tie-break: confidence , затем стабильный
segment-ключ). Топ списка = сильнейший сигнал «строить именно это».
Graceful: пустая сетка / все ячейки None ПУСТОЙ ранкинг (не crash). Сбой #980
на ячейке ячейка пропускается, остальные ранжируются. Детерминированно.
Args:
db: SQLAlchemy sync Session.
district: район участка (None ЕКБ-wide).
cad_num: кадастровый номер участка вход #980 (§9.7 конкуренты).
horizon_months: горизонт прогноза (мес) ОДИН (продуктовый «на горизонте»).
classes: ось классов (по умолчанию _DEFAULT_CLASSES).
room_buckets: ось форматов (по умолчанию _DEFAULT_ROOM_BUCKETS, Source-B).
price_buckets: ось цены (по умолчанию _DEFAULT_PRICE_BUCKETS = [None]).
rate_path: сценарный {horizon: key_rate} для #980; None → hold_last_rate.
Returns:
WhatToBuildRanking DESC-ранкинг (всегда; пустой при тонкой сетке).
"""
grid = _build_grid(
district=district,
classes=classes,
room_buckets=room_buckets,
price_buckets=price_buckets,
)
cells: list[RankedSegment] = []
for spec in grid:
cell = _forecast_cell(
db,
spec=spec,
district=district,
cad_num=cad_num,
horizon_months=horizon_months,
rate_path=rate_path,
)
if cell is not None:
cells.append(cell)
ranked = sorted(cells, key=_rank_key)
logger.info(
"what_to_build: district=%s cad_num=%s horizon=%d scanned=%d ranked=%d "
"top_segment=%s top_deficit=%s (ADVISORY)",
district,
cad_num,
horizon_months,
len(grid),
len(ranked),
ranked[0].segment if ranked else None,
_round_or_none(ranked[0].deficit_index, 3) if ranked else None,
)
return WhatToBuildRanking(
district=district,
cad_num=cad_num,
horizon_months=horizon_months,
ranked=ranked,
n_cells_scanned=len(grid),
n_cells_ranked=len(ranked),
generated_advisory=True,
)
def _forecast_cell(
db: Session,
*,
spec: SegmentSpec,
district: str | None,
cad_num: str,
horizon_months: int,
rate_path: dict[int, float] | None,
) -> RankedSegment | None:
"""Прогнать #980 для ОДНОЙ ячейки сетки → RankedSegment | None. Graceful.
Вызывает compute_demand_supply_forecast на единственном горизонте, берёт его
deficit_index/balance_units/confidence. None при: deficit_index None (тонкие
данные ячейка вне ранкинга), пустом результате, ИЛИ сбое #980 (не валим всю
сетку ячейка просто выпадает). Детерминированно.
Returns:
RankedSegment или None (отбрасывается выше по стеку).
"""
try:
forecasts = compute_demand_supply_forecast(
db,
spec=spec,
district=district,
cad_num=cad_num,
horizons=[horizon_months],
rate_path=rate_path,
)
except Exception:
logger.exception(
"what_to_build: forecast failed for cell segment=%s horizon=%d",
spec.as_dict(),
horizon_months,
)
return None
if not forecasts:
return None
forecast = forecasts[0]
if forecast.deficit_index is None:
# Тонкие данные (предложение неизмеримо) — ячейка вне ранкинга (НЕ 0-сигнал).
return None
return RankedSegment(
segment=forecast.segment,
deficit_index=forecast.deficit_index,
balance_units=forecast.balance_units,
confidence=forecast.confidence,
)

View file

@ -0,0 +1,309 @@
"""Unit-тесты §7.9 MAI — ДЕГРАДИРОВАННЫЙ прокси платёжной нагрузки (#981, ADVISORY).
Чистые тесты БЕЗ живой БД (чистая аннуитет-математика + мок reused-сервисов):
pure _annuity: textbook-кейс (формула P·i·(1+i)^n/((1+i)^n1)); rate=0
principal/months (без /0); None principal/rate None; months 0 None.
compute_affordability через MagicMock-сессию + @patch _current_mortgage_rate /
build_sales_series: payment_to_income ВСЕГДА None; rate_kind=='subsidized_weighted';
confidence=='low'; degraded_reason непуст; payment_at_scenario per-горизонт;
цена из аргумента vs сегментная (reuse); graceful нет ставки/цены платёж None.
Детерминированно, без LLM. Мокаем reused-сервисы + db (нет живой БД).
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from unittest.mock import MagicMock, patch
import pytest
from app.services.forecasting.affordability import (
_ANNUITY_TERM_MONTHS,
_DEGRADED_REASON,
_RATE_KIND_SUBSIDIZED,
_REF_AREA_M2,
MortgageAffordabilityIndex,
_annuity,
_round_or_none,
_segment_avg_price,
compute_affordability,
)
from app.services.forecasting.sales_series import SegmentSpec
# Пути патча reused-сервисов (импортированы в модуль affordability).
_RATE = "app.services.forecasting.affordability._current_mortgage_rate"
_SERIES = "app.services.forecasting.affordability.build_sales_series"
# ── pure: _annuity ────────────────────────────────────────────────────────────
class TestAnnuity:
def test_textbook_case_matches_formula(self) -> None:
# Тело 2 500 000 ₽, 12% годовых, 240 мес. Сверяем с явной формулой
# P·i·(1+i)^n / ((1+i)^n 1), i = rate/100/12.
principal = 2_500_000.0
rate = 12.0
months = 240
i = rate / 100.0 / 12.0
growth = (1.0 + i) ** months
expected = principal * i * growth / (growth - 1.0)
assert _annuity(principal, rate, months) == pytest.approx(expected)
def test_textbook_case_known_value(self) -> None:
# Та же вводная — захардкоженное hand-verified значение (~27 527.15 ₽/мес).
assert _annuity(2_500_000.0, 12.0, 240) == pytest.approx(27_527.15, abs=0.5)
def test_zero_rate_is_principal_over_months(self) -> None:
# Беспроцентный кредит: формула вырождается (i=0 → 0/0) → равномерное тело.
assert _annuity(2_400_000.0, 0.0, 240) == pytest.approx(10_000.0)
def test_negative_rate_degrades_to_principal_over_months(self) -> None:
# Артефактно-отрицательная ставка → та же беспроцентная деградация (не /0).
assert _annuity(1_200_000.0, -3.0, 120) == pytest.approx(10_000.0)
def test_none_principal_returns_none(self) -> None:
assert _annuity(None, 12.0, 240) is None
def test_none_rate_returns_none(self) -> None:
assert _annuity(2_500_000.0, None, 240) is None
def test_zero_or_negative_months_returns_none(self) -> None:
assert _annuity(2_500_000.0, 12.0, 0) is None
assert _annuity(2_500_000.0, 12.0, -12) is None
def test_higher_rate_means_higher_payment(self) -> None:
# Монотонность: выше ставка → выше платёж (при равном теле/сроке).
low = _annuity(2_500_000.0, 7.0, 240)
high = _annuity(2_500_000.0, 20.0, 240)
assert low is not None and high is not None
assert high > low
# ── pure: _round_or_none ──────────────────────────────────────────────────────
class TestRoundOrNone:
def test_rounds(self) -> None:
assert _round_or_none(27_522.16, 0) == 27_522.0
def test_none_passthrough(self) -> None:
assert _round_or_none(None, 0) is None
# ── MortgageAffordabilityIndex.as_dict ────────────────────────────────────────
def _make_index(**over: object) -> MortgageAffordabilityIndex:
base: dict[str, object] = {
"segment": {
"obj_class": "комфорт",
"room_bucket": "2-к 45-60",
"district": "X",
"price_bucket": None,
},
"monthly_payment_rub": 27_522.16,
"rate_used": 7.5,
"rate_kind": _RATE_KIND_SUBSIDIZED,
"rate_period": "2026-01",
"ref_area_m2": _REF_AREA_M2,
"price_per_m2": 120_000.0,
"annuity_term_months": _ANNUITY_TERM_MONTHS,
"payment_to_income": None,
"payment_at_scenario": {6: 27_000.0, 12: 28_000.0},
"degraded_reason": _DEGRADED_REASON,
"confidence": "low",
}
base.update(over)
return MortgageAffordabilityIndex(**base) # type: ignore[arg-type]
class TestAsDict:
def test_rounds_money_fields(self) -> None:
d = _make_index().as_dict()
assert d["monthly_payment_rub"] == 27_522.0
assert d["price_per_m2"] == 120_000.0
assert d["payment_at_scenario"] == {6: 27_000.0, 12: 28_000.0}
def test_honesty_fields_preserved(self) -> None:
d = _make_index().as_dict()
assert d["payment_to_income"] is None
assert d["rate_kind"] == "subsidized_weighted"
assert d["confidence"] == "low"
assert d["degraded_reason"]
def test_none_fields_survive(self) -> None:
d = _make_index(
monthly_payment_rub=None, price_per_m2=None, payment_at_scenario=None
).as_dict()
assert d["monthly_payment_rub"] is None
assert d["price_per_m2"] is None
assert d["payment_at_scenario"] is None
# ── orchestrator helpers (стабы reused-сервисов) ──────────────────────────────
def _series_stub(prices: list[float | None]) -> MagicMock:
"""Стаб build_sales_series: несёт avg_price_per_m2-список (для _segment_avg_price)."""
m = MagicMock()
m.avg_price_per_m2 = prices
return m
def _run(**over: object) -> MortgageAffordabilityIndex:
spec = SegmentSpec(obj_class="комфорт", room_bucket="2-к 45-60", district="Академический")
return compute_affordability(MagicMock(), spec=spec, **over) # type: ignore[arg-type]
# ── orchestrator: honesty-контракт (КРИТИЧНО) ─────────────────────────────────
class TestHonestyContract:
def test_payment_to_income_always_none(self) -> None:
# Дохода в данных нет (#946) → payment_to_income ВСЕГДА None.
with patch(_RATE, return_value=(7.5, "2026-01")):
res = _run(price_per_m2=120_000.0)
assert res.payment_to_income is None
def test_rate_kind_is_subsidized_weighted(self) -> None:
# Рыночной ставки (~20%) нет → метка всегда subsidized_weighted.
with patch(_RATE, return_value=(7.5, "2026-01")):
res = _run(price_per_m2=120_000.0)
assert res.rate_kind == "subsidized_weighted"
def test_confidence_always_low(self) -> None:
# Деградированный прокси → confidence ВСЕГДА low (даже при полных входах).
with patch(_RATE, return_value=(7.5, "2026-01")):
res = _run(price_per_m2=120_000.0)
assert res.confidence == "low"
def test_degraded_reason_explains_both_gaps(self) -> None:
# degraded_reason упоминает ОБА пробела: рыночную ставку И доход.
with patch(_RATE, return_value=(7.5, "2026-01")):
res = _run(price_per_m2=120_000.0)
assert res.degraded_reason
lower = res.degraded_reason.lower()
assert "ставк" in lower # упоминает ставку
assert "доход" in lower # упоминает доход
# ── orchestrator: расчёт платежа ──────────────────────────────────────────────
class TestPaymentComputation:
def test_monthly_payment_uses_annuity(self) -> None:
# price 120k × ref 50 м² = 6 000 000 ₽ тело; ставка 7.5%, 240 мес.
with patch(_RATE, return_value=(7.5, "2026-01")):
res = _run(price_per_m2=120_000.0)
principal = 120_000.0 * _REF_AREA_M2
expected = _annuity(principal, 7.5, _ANNUITY_TERM_MONTHS)
assert res.monthly_payment_rub == pytest.approx(expected)
assert res.rate_used == 7.5
assert res.rate_period == "2026-01"
assert res.price_per_m2 == 120_000.0
assert res.ref_area_m2 == _REF_AREA_M2
def test_explicit_price_overrides_segment(self) -> None:
# Явная цена в аргументе → build_sales_series НЕ вызывается.
with patch(_RATE, return_value=(7.5, "2026-01")):
with patch(_SERIES) as series_mock:
res = _run(price_per_m2=150_000.0)
series_mock.assert_not_called()
assert res.price_per_m2 == 150_000.0
def test_segment_price_used_when_arg_none(self) -> None:
# Без price_per_m2 → сегментная средняя из build_sales_series (reuse).
with patch(_RATE, return_value=(7.5, "2026-01")):
with patch(_SERIES, return_value=_series_stub([100_000.0, None, 140_000.0])):
res = _run()
# средняя НЕнулевых: (100k + 140k) / 2 = 120k.
assert res.price_per_m2 == pytest.approx(120_000.0)
# ── orchestrator: payment_at_scenario per-горизонт ────────────────────────────
class TestPaymentAtScenario:
def test_scenario_payment_per_horizon(self) -> None:
# rate_path: разные ставки на горизонтах → платёж считается на каждой.
with patch(_RATE, return_value=(7.5, "2026-01")):
res = _run(price_per_m2=120_000.0, rate_path={6: 8.0, 12: 20.0})
assert res.payment_at_scenario is not None
principal = 120_000.0 * _REF_AREA_M2
assert res.payment_at_scenario[6] == pytest.approx(
_annuity(principal, 8.0, _ANNUITY_TERM_MONTHS)
)
assert res.payment_at_scenario[12] == pytest.approx(
_annuity(principal, 20.0, _ANNUITY_TERM_MONTHS)
)
# Выше ставка → выше платёж на этом горизонте.
assert res.payment_at_scenario[12] > res.payment_at_scenario[6]
def test_no_rate_path_yields_none_scenario(self) -> None:
with patch(_RATE, return_value=(7.5, "2026-01")):
res = _run(price_per_m2=120_000.0)
assert res.payment_at_scenario is None
def test_scenario_drops_uncomputable_horizon(self) -> None:
# Нет цены → principal None → платёж на горизонте None → ключ выпадает.
with patch(_RATE, return_value=(7.5, "2026-01")):
with patch(_SERIES, return_value=_series_stub([None])):
res = _run(rate_path={6: 8.0})
assert res.payment_at_scenario == {}
# ── orchestrator: graceful (нет ставки / нет цены) ────────────────────────────
class TestGraceful:
def test_no_rate_yields_none_payment(self) -> None:
# Ставки нет в БД → платёж None, но индекс возвращается (не crash).
with patch(_RATE, return_value=(None, None)):
res = _run(price_per_m2=120_000.0)
assert res.monthly_payment_rub is None
assert res.rate_used is None
assert res.confidence == "low"
def test_no_price_yields_none_payment(self) -> None:
# Тонкий сегмент (нет цены) → principal None → платёж None.
with patch(_RATE, return_value=(7.5, "2026-01")):
with patch(_SERIES, return_value=_series_stub([None, None])):
res = _run()
assert res.price_per_m2 is None
assert res.monthly_payment_rub is None
def test_returns_index_always(self) -> None:
with patch(_RATE, return_value=(None, None)):
with patch(_SERIES, return_value=_series_stub([])):
res = _run()
assert isinstance(res, MortgageAffordabilityIndex)
assert res.payment_to_income is None
assert res.confidence == "low"
# ── pure-ish: _segment_avg_price ──────────────────────────────────────────────
class TestSegmentAvgPrice:
def test_averages_nonnull_months(self) -> None:
spec = SegmentSpec(obj_class="комфорт")
with patch(_SERIES, return_value=_series_stub([110_000.0, None, 130_000.0])):
price = _segment_avg_price(MagicMock(), spec=spec, source="objective_lots")
assert price == pytest.approx(120_000.0)
def test_all_none_yields_none(self) -> None:
spec = SegmentSpec(obj_class="комфорт")
with patch(_SERIES, return_value=_series_stub([None, None])):
price = _segment_avg_price(MagicMock(), spec=spec, source="objective_lots")
assert price is None
def test_empty_series_yields_none(self) -> None:
spec = SegmentSpec(obj_class="комфорт")
with patch(_SERIES, return_value=_series_stub([])):
price = _segment_avg_price(MagicMock(), spec=spec, source="objective_lots")
assert price is None

View file

@ -0,0 +1,472 @@
"""Unit-тесты §9.7 ранкера «что строить» (#981, ADVISORY).
Чистые тесты БЕЗ живой БД (мок #980 compute_demand_supply_forecast → ячейки с
известным deficit_index):
pure _rank_key / _segment_key / _build_grid (декартова сетка, размер, порядок).
rank_segments через MagicMock-сессию + @patch #980: сортировка по deficit_index
УБЫВАНИЕ; отбрасывание ячеек с deficit_index None (тонкие данные); tie-break
(равный индекс выше confidence стабильный segment-ключ); generated_advisory
ВСЕГДА True; graceful (вся сетка None пустой ранкинг; сбой #980 на ячейке →
ячейка выпадает, не crash); n_cells_scanned/ranked корректны.
Детерминированно, без LLM. Мокаем #980 + db (нет живой БД).
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from app.services.forecasting.what_to_build import (
_DEFAULT_CLASSES,
_DEFAULT_PRICE_BUCKETS,
_DEFAULT_ROOM_BUCKETS,
RankedSegment,
WhatToBuildRanking,
_build_grid,
_rank_key,
_round_or_none,
_segment_key,
rank_segments,
)
# Путь патча #980 (импортирован в модуль what_to_build).
_FORECAST = "app.services.forecasting.what_to_build.compute_demand_supply_forecast"
# ── pure: _segment_key ────────────────────────────────────────────────────────
class TestSegmentKey:
def test_fixed_axis_order(self) -> None:
seg = {
"obj_class": "комфорт",
"room_bucket": "2-к 45-60",
"district": "X",
"price_bucket": "бизнес",
}
assert _segment_key(seg) == "комфорт|2-к 45-60|X|бизнес"
def test_none_axes_become_empty(self) -> None:
seg = {"obj_class": "эконом", "room_bucket": None, "district": None, "price_bucket": None}
assert _segment_key(seg) == "эконом|||"
def test_deterministic(self) -> None:
seg = {"obj_class": "a", "room_bucket": "b", "district": "c", "price_bucket": "d"}
assert _segment_key(seg) == _segment_key(dict(seg))
# ── pure: _rank_key (сорт-ключ для DESC + tie-break) ──────────────────────────
def _cell(
*,
deficit_index: float,
confidence: str = "medium",
obj_class: str = "комфорт",
room_bucket: str = "2-к 45-60",
balance_units: float | None = None,
) -> RankedSegment:
return RankedSegment(
segment={
"obj_class": obj_class,
"room_bucket": room_bucket,
"district": "X",
"price_bucket": None,
},
deficit_index=deficit_index,
balance_units=balance_units,
confidence=confidence, # type: ignore[arg-type]
)
class TestRankKey:
def test_higher_deficit_sorts_first(self) -> None:
# sorted(key=_rank_key) БЕЗ reverse: больший deficit_index → меньший ключ → выше.
cells = [_cell(deficit_index=0.2), _cell(deficit_index=0.9), _cell(deficit_index=-0.5)]
ranked = sorted(cells, key=_rank_key)
assert [c.deficit_index for c in ranked] == [0.9, 0.2, -0.5]
def test_tie_break_by_confidence(self) -> None:
# Равный deficit_index → выше confidence идёт первой.
low = _cell(deficit_index=0.5, confidence="low", obj_class="a")
high = _cell(deficit_index=0.5, confidence="high", obj_class="b")
med = _cell(deficit_index=0.5, confidence="medium", obj_class="c")
ranked = sorted([low, high, med], key=_rank_key)
assert [c.confidence for c in ranked] == ["high", "medium", "low"]
def test_tie_break_stable_segment_key(self) -> None:
# Равный index И confidence → стабильный лексикографический segment-ключ.
b = _cell(deficit_index=0.5, confidence="medium", obj_class="бизнес")
a = _cell(deficit_index=0.5, confidence="medium", obj_class="комфорт")
# 'бизнес' < 'комфорт' лексикографически → b первой.
ranked = sorted([a, b], key=_rank_key)
assert ranked[0].segment["obj_class"] == "бизнес"
assert ranked[1].segment["obj_class"] == "комфорт"
def test_key_shape(self) -> None:
key = _rank_key(_cell(deficit_index=0.7, confidence="high"))
assert key[0] == pytest.approx(-0.7) # negate для DESC
assert key[1] == -2 # high rank=2, negate
assert isinstance(key[2], str)
# ── pure: _build_grid ─────────────────────────────────────────────────────────
class TestBuildGrid:
def test_cartesian_size(self) -> None:
grid = _build_grid(
district="X",
classes=["эконом", "комфорт"],
room_buckets=["студ", "1-к", "2-к"],
price_buckets=[None],
)
assert len(grid) == 2 * 3 * 1
def test_price_axis_multiplies(self) -> None:
grid = _build_grid(
district="X",
classes=["комфорт"],
room_buckets=["2-к"],
price_buckets=["эконом", "комфорт", "бизнес", "премиум"],
)
assert len(grid) == 4
def test_district_propagated(self) -> None:
grid = _build_grid(
district="Академический",
classes=["комфорт"],
room_buckets=["2-к"],
price_buckets=[None],
)
assert all(spec.district == "Академический" for spec in grid)
def test_axes_assigned(self) -> None:
grid = _build_grid(
district="X", classes=["бизнес"], room_buckets=["3-к"], price_buckets=["премиум"]
)
spec = grid[0]
assert spec.obj_class == "бизнес"
assert spec.room_bucket == "3-к"
assert spec.price_bucket == "премиум"
def test_deterministic_order(self) -> None:
# class-внешний, room-средний, price-внутренний.
grid = _build_grid(
district="X", classes=["a", "b"], room_buckets=["r1", "r2"], price_buckets=[None]
)
order = [(s.obj_class, s.room_bucket) for s in grid]
assert order == [("a", "r1"), ("a", "r2"), ("b", "r1"), ("b", "r2")]
def test_default_grid_cell_count(self) -> None:
# Дефолтная сетка: 3 класса × 5 room × 1 price = 15 ячеек.
grid = _build_grid(
district="X",
classes=_DEFAULT_CLASSES,
room_buckets=_DEFAULT_ROOM_BUCKETS,
price_buckets=_DEFAULT_PRICE_BUCKETS,
)
assert len(grid) == 15
# ── pure: _round_or_none ──────────────────────────────────────────────────────
class TestRoundOrNone:
def test_rounds(self) -> None:
assert _round_or_none(0.123456, 3) == 0.123
def test_none_passthrough(self) -> None:
assert _round_or_none(None, 3) is None
# ── dataclass as_dict ─────────────────────────────────────────────────────────
class TestAsDict:
def test_ranked_segment_as_dict(self) -> None:
d = _cell(deficit_index=0.789, balance_units=42.49).as_dict()
assert d["deficit_index"] == 0.789
assert d["balance_units"] == 42.5
assert d["confidence"] == "medium"
def test_ranking_as_dict(self) -> None:
ranking = WhatToBuildRanking(
district="X",
cad_num="66:41:1:1",
horizon_months=12,
ranked=[_cell(deficit_index=0.5)],
n_cells_scanned=15,
n_cells_ranked=1,
generated_advisory=True,
)
d = ranking.as_dict()
assert d["horizon_months"] == 12
assert d["n_cells_scanned"] == 15
assert d["n_cells_ranked"] == 1
assert d["generated_advisory"] is True
assert len(d["ranked"]) == 1
# ── orchestrator helpers (стаб #980) ──────────────────────────────────────────
def _forecast_stub(deficit_index: float | None, *, confidence: str = "medium") -> MagicMock:
"""Одиночный DemandSupplyForecast-стаб с заданным deficit_index/confidence."""
f = MagicMock()
f.deficit_index = deficit_index
f.balance_units = None if deficit_index is None else deficit_index * 100.0
f.confidence = confidence
return f
def _segment_indexed_side_effect(
index_by_room: dict[str, float | None],
*,
confidence_by_room: dict[str, str] | None = None,
) -> Any:
"""side_effect для #980: deficit_index/confidence зависят от room_bucket spec.
Позволяет задать разный сигнал по ячейкам сетки (по room_bucket), чтобы
проверить сортировку/отбрасывание. Возвращает [forecast] (список на 1 горизонт);
forecast.segment = spec.as_dict() (как делает настоящий #980).
"""
conf_map = confidence_by_room or {}
def _side(db: Any, *, spec: Any, horizons: Any, **_: Any) -> list[MagicMock]:
room = spec.room_bucket
deficit = index_by_room.get(room)
confidence = conf_map.get(room, "medium")
f = _forecast_stub(deficit, confidence=confidence)
f.segment = spec.as_dict()
return [f]
return _side
def _run(**over: object) -> WhatToBuildRanking:
kwargs: dict[str, object] = {
"district": "Академический",
"cad_num": "66:41:0303161:123",
"horizon_months": 12,
}
kwargs.update(over)
return rank_segments(MagicMock(), **kwargs) # type: ignore[arg-type]
# ── orchestrator: сортировка DESC по deficit_index ────────────────────────────
class TestRankingOrder:
def test_sorted_descending_by_deficit_index(self) -> None:
# Сетка из 3 room (1 класс, 1 price) с разным deficit_index → DESC-порядок.
side = _segment_indexed_side_effect(
{"студия": 0.1, "1-к": 0.9, "2-к": -0.4},
)
with patch(_FORECAST, side_effect=side):
res = _run(
classes=["комфорт"],
room_buckets=["студия", "1-к", "2-к"],
price_buckets=[None],
)
indices = [r.deficit_index for r in res.ranked]
assert indices == [0.9, 0.1, -0.4]
# Топ-ячейка = сильнейший build-сигнал (room_bucket '1-к').
assert res.ranked[0].segment["room_bucket"] == "1-к"
def test_balance_units_carried(self) -> None:
side = _segment_indexed_side_effect({"2-к": 0.5})
with patch(_FORECAST, side_effect=side):
res = _run(classes=["комфорт"], room_buckets=["2-к"], price_buckets=[None])
assert res.ranked[0].balance_units == pytest.approx(50.0)
# ── orchestrator: отбрасывание None-ячеек (тонкие данные) ─────────────────────
class TestNoneDrop:
def test_none_deficit_cells_dropped(self) -> None:
# 2-к имеет deficit_index None (тонкие данные) → НЕ в ранкинге.
side = _segment_indexed_side_effect(
{"студия": 0.3, "1-к": None, "2-к": 0.7},
)
with patch(_FORECAST, side_effect=side):
res = _run(
classes=["комфорт"],
room_buckets=["студия", "1-к", "2-к"],
price_buckets=[None],
)
rooms = [r.segment["room_bucket"] for r in res.ranked]
assert "1-к" not in rooms # None-ячейка отброшена
assert rooms == ["2-к", "студия"] # DESC по 0.7, 0.3
assert res.n_cells_scanned == 3
assert res.n_cells_ranked == 2
def test_all_none_yields_empty_ranking(self) -> None:
# Вся сетка тонкая → пустой ранкинг (graceful, не crash).
side = _segment_indexed_side_effect(
{"студия": None, "1-к": None},
)
with patch(_FORECAST, side_effect=side):
res = _run(classes=["комфорт"], room_buckets=["студия", "1-к"], price_buckets=[None])
assert res.ranked == []
assert res.n_cells_ranked == 0
assert res.n_cells_scanned == 2
assert res.generated_advisory is True
# ── orchestrator: tie-break ───────────────────────────────────────────────────
class TestTieBreak:
def test_equal_deficit_higher_confidence_first(self) -> None:
# Равный deficit_index по двум room → выше confidence первой.
side = _segment_indexed_side_effect(
{"студия": 0.5, "1-к": 0.5},
confidence_by_room={"студия": "low", "1-к": "medium"},
)
with patch(_FORECAST, side_effect=side):
res = _run(classes=["комфорт"], room_buckets=["студия", "1-к"], price_buckets=[None])
assert res.ranked[0].segment["room_bucket"] == "1-к" # medium > low
assert res.ranked[1].segment["room_bucket"] == "студия"
def test_equal_deficit_and_confidence_stable_segment_key(self) -> None:
# Равные index+confidence по двум классам → стабильный segment-ключ (ASC).
side = _segment_indexed_side_effect({"2-к": 0.5}) # одинаковый index по обоим классам
with patch(_FORECAST, side_effect=side):
res = _run(classes=["комфорт", "бизнес"], room_buckets=["2-к"], price_buckets=[None])
# 'бизнес' < 'комфорт' лексикографически → первым.
assert res.ranked[0].segment["obj_class"] == "бизнес"
assert res.ranked[1].segment["obj_class"] == "комфорт"
# ── orchestrator: generated_advisory ВСЕГДА True ──────────────────────────────
class TestAdvisoryFlag:
def test_advisory_always_true(self) -> None:
side = _segment_indexed_side_effect({"2-к": 0.5})
with patch(_FORECAST, side_effect=side):
res = _run(classes=["комфорт"], room_buckets=["2-к"], price_buckets=[None])
assert res.generated_advisory is True
def test_advisory_true_even_when_empty(self) -> None:
side = _segment_indexed_side_effect({"2-к": None})
with patch(_FORECAST, side_effect=side):
res = _run(classes=["комфорт"], room_buckets=["2-к"], price_buckets=[None])
assert res.generated_advisory is True
def test_cells_inherit_capped_confidence(self) -> None:
# confidence ячейки = то, что вернул #980 (он сам ≤ medium); ранкер не поднимает.
side = _segment_indexed_side_effect({"2-к": 0.5}, confidence_by_room={"2-к": "medium"})
with patch(_FORECAST, side_effect=side):
res = _run(classes=["комфорт"], room_buckets=["2-к"], price_buckets=[None])
assert res.ranked[0].confidence == "medium"
# ── orchestrator: graceful (сбой #980 на ячейке / пустая сетка) ───────────────
class TestGraceful:
def test_forecast_exception_on_cell_skips_it(self) -> None:
# #980 кидает на 1-к → ячейка выпадает, остальные ранжируются (не crash).
def _side(db: Any, *, spec: Any, horizons: Any, **_: Any) -> list[MagicMock]:
if spec.room_bucket == "1-к":
raise ValueError("boom")
f = _forecast_stub(0.5)
f.segment = spec.as_dict()
return [f]
with patch(_FORECAST, side_effect=_side):
res = _run(
classes=["комфорт"],
room_buckets=["студия", "1-к", "2-к"],
price_buckets=[None],
)
rooms = [r.segment["room_bucket"] for r in res.ranked]
assert "1-к" not in rooms
assert set(rooms) == {"студия", "2-к"}
assert res.n_cells_scanned == 3 # сетка прогнана целиком
assert res.n_cells_ranked == 2
def test_empty_forecast_list_skips_cell(self) -> None:
# #980 вернул [] для ячейки → ячейка выпадает.
def _side(db: Any, *, spec: Any, horizons: Any, **_: Any) -> list[MagicMock]:
if spec.room_bucket == "студия":
return []
f = _forecast_stub(0.5)
f.segment = spec.as_dict()
return [f]
with patch(_FORECAST, side_effect=_side):
res = _run(classes=["комфорт"], room_buckets=["студия", "2-к"], price_buckets=[None])
rooms = [r.segment["room_bucket"] for r in res.ranked]
assert rooms == ["2-к"]
def test_empty_grid_yields_empty_ranking(self) -> None:
# Пустые оси → пустая сетка → пустой ранкинг (не вызываем #980 вовсе).
with patch(_FORECAST) as fc:
res = _run(classes=[], room_buckets=[], price_buckets=[None])
fc.assert_not_called()
assert res.ranked == []
assert res.n_cells_scanned == 0
assert res.n_cells_ranked == 0
def test_returns_ranking_always(self) -> None:
side = _segment_indexed_side_effect({"2-к": 0.5})
with patch(_FORECAST, side_effect=side):
res = _run(classes=["комфорт"], room_buckets=["2-к"], price_buckets=[None])
assert isinstance(res, WhatToBuildRanking)
assert all(isinstance(r, RankedSegment) for r in res.ranked)
# ── orchestrator: горизонт пробрасывается в #980 ──────────────────────────────
class TestHorizonAndRatePath:
def test_horizon_passed_to_forecast(self) -> None:
captured: dict[str, Any] = {}
def _side(db: Any, *, spec: Any, horizons: Any, **_: Any) -> list[MagicMock]:
captured["horizons"] = horizons
f = _forecast_stub(0.5)
f.segment = spec.as_dict()
return [f]
with patch(_FORECAST, side_effect=_side):
_run(horizon_months=24, classes=["комфорт"], room_buckets=["2-к"], price_buckets=[None])
assert captured["horizons"] == [24]
def test_rate_path_passed_to_forecast(self) -> None:
captured: dict[str, Any] = {}
def _side(db: Any, *, spec: Any, horizons: Any, rate_path: Any = None, **_: Any) -> Any:
captured["rate_path"] = rate_path
f = _forecast_stub(0.5)
f.segment = spec.as_dict()
return [f]
with patch(_FORECAST, side_effect=_side):
_run(
rate_path={12: 18.0},
classes=["комфорт"],
room_buckets=["2-к"],
price_buckets=[None],
)
assert captured["rate_path"] == {12: 18.0}
def test_default_grid_scans_fifteen_cells(self) -> None:
# Дефолтная сетка (без override осей) = 15 ячеек прогона #980.
side = _segment_indexed_side_effect(
{r: 0.5 for r in _DEFAULT_ROOM_BUCKETS},
)
with patch(_FORECAST, side_effect=side):
res = _run()
assert res.n_cells_scanned == 15