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:
parent
70ffa399fc
commit
78b667b018
5 changed files with 1444 additions and 0 deletions
|
|
@ -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",
|
||||
|
|
|
|||
283
backend/app/services/forecasting/affordability.py
Normal file
283
backend/app/services/forecasting/affordability.py
Normal 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)
|
||||
362
backend/app/services/forecasting/what_to_build.py
Normal file
362
backend/app/services/forecasting/what_to_build.py
Normal 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,
|
||||
)
|
||||
309
backend/tests/services/forecasting/test_affordability.py
Normal file
309
backend/tests/services/forecasting/test_affordability.py
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
"""Unit-тесты §7.9 MAI — ДЕГРАДИРОВАННЫЙ прокси платёжной нагрузки (#981, ADVISORY).
|
||||
|
||||
Чистые тесты — БЕЗ живой БД (чистая аннуитет-математика + мок reused-сервисов):
|
||||
• pure _annuity: textbook-кейс (формула P·i·(1+i)^n/((1+i)^n−1)); 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
|
||||
472
backend/tests/services/forecasting/test_what_to_build.py
Normal file
472
backend/tests/services/forecasting/test_what_to_build.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue