From 78b667b0181d50dc922a9f9c101624184d9d6edc Mon Sep 17 00:00:00 2001 From: Light1YT Date: Wed, 3 Jun 2026 12:07:12 +0500 Subject: [PATCH] =?UTF-8?q?feat(forecasting):=20=C2=A79.7=20what-to-build?= =?UTF-8?q?=20ranker=20+=20=C2=A77.9=20MAI=20proxy=20(#981,=20952-B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/app/services/forecasting/__init__.py | 18 + .../app/services/forecasting/affordability.py | 283 +++++++++++ .../app/services/forecasting/what_to_build.py | 362 ++++++++++++++ .../forecasting/test_affordability.py | 309 ++++++++++++ .../forecasting/test_what_to_build.py | 472 ++++++++++++++++++ 5 files changed, 1444 insertions(+) create mode 100644 backend/app/services/forecasting/affordability.py create mode 100644 backend/app/services/forecasting/what_to_build.py create mode 100644 backend/tests/services/forecasting/test_affordability.py create mode 100644 backend/tests/services/forecasting/test_what_to_build.py diff --git a/backend/app/services/forecasting/__init__.py b/backend/app/services/forecasting/__init__.py index 9622498e..2eedfbf8 100644 --- a/backend/app/services/forecasting/__init__.py +++ b/backend/app/services/forecasting/__init__.py @@ -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", diff --git a/backend/app/services/forecasting/affordability.py b/backend/app/services/forecasting/affordability.py new file mode 100644 index 00000000..875dd329 --- /dev/null +++ b/backend/app/services/forecasting/affordability.py @@ -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) diff --git a/backend/app/services/forecasting/what_to_build.py b/backend/app/services/forecasting/what_to_build.py new file mode 100644 index 00000000..761838cb --- /dev/null +++ b/backend/app/services/forecasting/what_to_build.py @@ -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, + ) diff --git a/backend/tests/services/forecasting/test_affordability.py b/backend/tests/services/forecasting/test_affordability.py new file mode 100644 index 00000000..2c272c8e --- /dev/null +++ b/backend/tests/services/forecasting/test_affordability.py @@ -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 diff --git a/backend/tests/services/forecasting/test_what_to_build.py b/backend/tests/services/forecasting/test_what_to_build.py new file mode 100644 index 00000000..559d8c79 --- /dev/null +++ b/backend/tests/services/forecasting/test_what_to_build.py @@ -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