From 9ff80f3ac0430346fbe85368fdda89def9b94270 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Thu, 4 Jun 2026 11:10:26 +0500 Subject: [PATCH] =?UTF-8?q?fix(forecasting):=20#980=20strongest=20deficit?= =?UTF-8?q?=E2=86=92deficit=5Findex=20+1.0;=20#981=20MAI=20uses=20CBR=20ke?= =?UTF-8?q?y=20rate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit REOPENED #980: when effective competing supply is exhausted under positive demand (projected_supply<=0, demand>0), deficit_index now caps to +1.0 (peak of [-1,+1]) instead of None. balance_ratio stays None (demand/0 undefined), but the strongest build signal no longer reads downstream as thin data (market_fit fell to 0.5, what_to_build dropped the cell). No-signal (supply<=0 AND demand<=0) stays None. REOPENED #981: MAI now uses CBR key rate (macro_indicator key_rate/rf via get_monthly_macro) as the market borrowing-cost proxy (~16-21%) instead of the subsidized weighted rate (~7.83%), per §7.9 DoD. rate_kind='key_rate_proxy'. If key_rate absent → rate_kind='market_unavailable' (no silent subsidy fallback). Income (#946) still missing → payment_to_income None, confidence low. 778 forecasting tests green. Refs #980 #981 --- .../app/services/forecasting/affordability.py | 182 ++++++++++++++---- .../forecasting/demand_supply_forecast.py | 62 +++++- .../forecasting/test_affordability.py | 138 ++++++++++--- .../test_demand_supply_forecast.py | 82 +++++++- 4 files changed, 383 insertions(+), 81 deletions(-) diff --git a/backend/app/services/forecasting/affordability.py b/backend/app/services/forecasting/affordability.py index 875dd329..71192bea 100644 --- a/backend/app/services/forecasting/affordability.py +++ b/backend/app/services/forecasting/affordability.py @@ -1,23 +1,30 @@ -"""§7.9 Mortgage Affordability Index (MAI) — ДЕГРАДИРОВАННЫЙ прокси платёжной нагрузки. +"""§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 НЕТ). +переиспользует helper'ы (`get_monthly_macro` §9.5/§9.6 для ключевой ставки ЦБ, +`build_sales_series` для сегментной цены ₽/м²). Всё ДЕТЕРМИНИРОВАННО, БЕЗ LLM +(чистая аннуитет-арифметика + reuse; своего SQL НЕТ). + +РЫНОЧНАЯ СТАВКА — KEY_RATE ПРОКСИ (#981 DoD: «НЕ mortgage_rate_weighted 7.83%»): + DoD §7.9 требует РЫНОЧНОЙ стоимости заёмных денег (~16-21% по ЦБ Янв 2026), НЕ + субсидированной средневзвешенной ИЖК-ставки (~7-8% с семейной/IT/ДВ-льготами — + она ЗАНИЖАЕТ платёж против рынка). Прямого ряда «рыночная ипотека без льгот» в + БД действительно нет, НО ключевая ставка ЦБ ЕСТЬ: `macro_indicator` + (indicator_type='key_rate', region='rf', source='cbr'), залитая `cbr_macro_sync` + и читаемая через `get_monthly_macro`. Ключевая ставка — ПРАВОМЕРНЫЙ рыночный + прокси стоимости заёмных средств (рыночная ипотека = key_rate + спред банка; мы + берём спред = 0 п.п. — консервативная НИЖНЯЯ граница рыночного платежа, честнее + субсидии). Поэтому `rate_kind='key_rate_proxy'`, `rate_used` ≈ 16-21%, НЕ 7.83%. + Субсидированный путь сохранён в `_current_subsidized_rate` (explain/сравнение), + но в индекс по умолчанию НЕ идёт. 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/прототипа, не для решения. + Истинный §7.9 = «рыночный аннуитет ÷ доход домохозяйства». ДОХОД домохозяйства + (#946) в данных проекта ОТСУТСТВУЕТ → `payment_to_income` ВСЕГДА None, + `confidence='low'` ВСЕГДА (числитель теперь рыночный, но знаменатель-дохода нет). + `degraded_reason` объясняет остаточный пробел (доход) и сделанный выбор прокси + ставки (key_rate, спред 0). Цифра — для explainability/прототипа, не для решения. Graceful-on-thin-data (дух demand_supply_forecast / market_metrics): нет ставки → платёж None; нет цены сегмента → платёж None; ставка ≤0 → аннуитет деградирует к @@ -35,6 +42,7 @@ from typing import Any, Literal from sqlalchemy.orm import Session from app.services.analytics_queries import _current_mortgage_rate +from app.services.forecasting.macro_series import get_monthly_macro from app.services.forecasting.sales_series import ( SalesSource, SegmentSpec, @@ -55,42 +63,64 @@ _REF_AREA_M2: float = 50.0 # (медиана ЦБ ~ 20-25 лет; берём круглые 20 для детерминизма). Tunable. _ANNUITY_TERM_MONTHS: int = 240 -# Метка вида ставки в выводе: всегда субсидированная средневзвешенная (рыночной -# ~20% в БД нет). Зафиксирована константой — downstream не должен принять её за -# рыночную ставку §7.9. +# Метка вида ставки в выводе. По умолчанию — рыночный прокси через ключевую ставку +# ЦБ (#981 DoD: рыночная стоимость денег, НЕ субсидия 7.83%). Субсидированная метка +# сохранена для explain-сравнения, но в индекс по умолчанию НЕ идёт. +_RATE_KIND_KEY_RATE_PROXY: str = "key_rate_proxy" _RATE_KIND_SUBSIDIZED: str = "subsidized_weighted" +_RATE_KIND_UNAVAILABLE: str = "market_unavailable" + +# Спред банка над ключевой ставкой для рыночной ипотеки (п.п.). Рыночная ставка ≈ +# key_rate + спред; берём 0 — консервативная НИЖНЯЯ граница рыночного платежа (не +# завышаем сверх наблюдаемого сигнала). Tunable, если появится надёжный ряд спреда. +_KEY_RATE_MARKET_SPREAD_PP: float = 0.0 # Источник сегментной цены ₽/м² по умолчанию: Source B (objective_lots) — несёт # per-lot цену для price/room-сегментации (Source A её не даёт). Каведат # survivorship bias задокументирован в sales_series; для прокси-индекса приемлемо. _PRICE_SOURCE: SalesSource = "objective_lots" -# Текст деградации — объясняет ОБА пробела (рыночная ставка + доход), чтобы вывод -# нельзя было прочитать как истинный §7.9 индекс. Используется в degraded_reason. +# Текст деградации — объясняет выбор рыночного прокси ставки И остаточный пробел +# (доход), чтобы вывод нельзя было прочитать как истинный §7.9 индекс. Используется +# в degraded_reason, когда ключевая ставка ДОСТУПНА (рыночный путь отработал). _DEGRADED_REASON: str = ( - "Прокси платёжной нагрузки, НЕ истинный §7.9 индекс доступности: " - "(1) использована СУБСИДИРОВАННАЯ средневзвешенная ставка ИЖК (~7-8%) — " - "рыночной ставки без льгот (~20%) в БД нет, платёж занижен против рынка; " - "(2) доход домохозяйства (#946) недоступен, поэтому payment_to_income не " - "рассчитывается (всегда None). confidence='low' — деградация по двум каналам." + "Прокси платёжной нагрузки по РЫНОЧНОЙ ставке, НЕ истинный §7.9 индекс " + "доступности: (1) рыночная стоимость денег взята как ключевая ставка ЦБ " + "(key_rate, ~16-21%) + спред банка 0 п.п. — прямого ряда «рыночная ипотека " + "без льгот» в БД нет, key_rate — консервативная нижняя граница рыночного " + "платежа (честнее субсидированных ~7-8%); (2) доход домохозяйства (#946) " + "недоступен, поэтому payment_to_income не рассчитывается (всегда None). " + "confidence='low' — знаменатель-дохода отсутствует." +) + +# Текст деградации, когда ключевой ставки в БД НЕТ вовсе (рыночный сигнал +# недоступен) — НЕ подменяем субсидией, честно сообщаем data-gap (rate_used=None). +_DEGRADED_REASON_NO_MARKET: str = ( + "Прокси платёжной нагрузки НЕ рассчитан: рыночной ставки нет — ни прямого " + "ряда рыночной ипотеки, ни ключевой ставки ЦБ (key_rate) в macro_indicator. " + "Субсидированной ставкой (~7-8%) НЕ подменяем (исказила бы рыночный платёж). " + "rate_used=None, rate_kind='market_unavailable'. Доход (#946) также " + "недоступен → payment_to_income None. confidence='low' — data-gap по ставке." ) @dataclass(frozen=True) class MortgageAffordabilityIndex: - """§7.9 MAI — ДЕГРАДИРОВАННЫЙ прокси платёжной нагрузки (считается на лету). + """§7.9 MAI — прокси платёжной нагрузки по РЫНОЧНОЙ ставке (считается на лету). Все денежные поля = None при недостатке данных (НИКОГДА 0-как-заглушка). - `payment_to_income` ВСЕГДА None (дохода нет в данных, #946). `rate_kind` ВСЕГДА - 'subsidized_weighted' (рыночной ставки нет). `confidence` ВСЕГДА 'low' (прокси, - не истинный индекс §7.9). `degraded_reason` объясняет оба пробела. Это + `payment_to_income` ВСЕГДА None (дохода нет в данных, #946). `rate_kind`: + 'key_rate_proxy' (ключевая ставка ЦБ как рыночный прокси — путь по умолчанию, + #981 DoD) или 'market_unavailable' (если key_rate в БД нет — rate_used тогда + None, субсидией НЕ подменяем). `confidence` ВСЕГДА 'low' (нет дохода-знаменателя). + `degraded_reason` объясняет выбор прокси ставки + пробел дохода. Это explainability-прокси, НЕ основание для инвест-решения. """ segment: dict[str, str | None] monthly_payment_rub: float | None # аннуитет на эталонную квартиру (₽/мес) - rate_used: float | None # годовая ставка (%) — субсидированная средневзвеш. - rate_kind: str # ВСЕГДА 'subsidized_weighted' + rate_used: float | None # годовая рыночная ставка (%) — key_rate + спред (#981) + rate_kind: str # 'key_rate_proxy' | 'market_unavailable' rate_period: str | None # период точки ставки (explain) ref_area_m2: float # эталонная площадь кредита (м²) price_per_m2: float | None # сегментная цена ₽/м² (вход тела кредита) @@ -167,6 +197,60 @@ def _annuity( return principal * i * growth / (growth - 1.0) +# ────────────────────────────────────────────────────────────────────────────── +# Ставки — рыночный прокси (key_rate, #981) + субсидированный (explain-only). +# ────────────────────────────────────────────────────────────────────────────── + + +def _current_market_rate( + db: Session, *, spread_pp: float = _KEY_RATE_MARKET_SPREAD_PP +) -> tuple[float | None, str | None]: + """Рыночная стоимость заёмных денег как ключевая ставка ЦБ + спред (#981 DoD). + + Прямого ряда «рыночная ипотека без льгот» в БД нет, но ключевая ставка ЦБ есть + (`macro_indicator` indicator_type='key_rate', region='rf', залита cbr_macro_sync, + читается через get_monthly_macro). Берём САМУЮ СВЕЖУЮ непустую key_rate и + добавляем спред банка (по умолчанию 0 п.п. — консервативная нижняя граница + рыночного платежа). Это rate_kind='key_rate_proxy', ~16-21% (Янв 2026) — НЕ + субсидированные 7.83%. + + Graceful: нет ни одной точки key_rate (пустой macro_indicator / сбой БД) → + (None, None) — caller помечает rate_kind='market_unavailable' и НЕ подменяет + субсидией. Reuse get_monthly_macro (свой SQL не пишем). Детерминированно. + + Args: + db: SQLAlchemy sync Session. + spread_pp: спред банка над ключевой ставкой в п.п. (по умолчанию + _KEY_RATE_MARKET_SPREAD_PP = 0.0). + + Returns: + (рыночная ставка %, период YYYY-MM последней точки) либо (None, None). + """ + macro = get_monthly_macro(db) + latest_rate: float | None = None + latest_period: str | None = None + for m in macro: # ряд ASC по month → последняя непустая = самая свежая + if m.key_rate is not None: + latest_rate = m.key_rate + latest_period = m.month.strftime("%Y-%m") + if latest_rate is None: + return None, None + return latest_rate + spread_pp, latest_period + + +# `_current_subsidized_rate` — субсидированная средневзвешенная ИЖК-ставка (~7-8%). +# СОХРАНЕНА для explain/сравнения, но в индекс по умолчанию НЕ идёт (#981 DoD требует +# рыночную). Тонкий алиас на §аналитики _current_mortgage_rate (источник истины). +def _current_subsidized_rate(db: Session) -> tuple[float | None, str | None]: + """Субсидированная средневзвешенная ИЖК-ставка (~7-8%) — explain-only (#981). + + Reuse analytics_queries._current_mortgage_rate. НЕ используется в индексе по + умолчанию (рыночный путь — _current_market_rate); оставлена для явного + сравнения «рыночная vs субсидия» downstream при необходимости. + """ + return _current_mortgage_rate(db) + + # ────────────────────────────────────────────────────────────────────────────── # DB-оркестратор — тонкий, graceful. Pure-логика выше тестируется без него. # ────────────────────────────────────────────────────────────────────────────── @@ -181,15 +265,18 @@ def compute_affordability( ref_area_m2: float = _REF_AREA_M2, price_source: SalesSource = _PRICE_SOURCE, ) -> MortgageAffordabilityIndex: - """§7.9 MAI — ДЕГРАДИРОВАННЫЙ прокси платёжной нагрузки для сегмента. + """§7.9 MAI — прокси платёжной нагрузки по РЫНОЧНОЙ ставке для сегмента (#981). ADVISORY / LOW-CONFIDENCE: это ПРОКСИ, а НЕ истинный §7.9 индекс доступности. - Рыночной ставки (~20%) и дохода домохозяйства (#946) в данных нет — см. module - docstring и `degraded_reason`. `payment_to_income` ВСЕГДА None. + Числитель теперь РЫНОЧНЫЙ (ключевая ставка ЦБ), но дохода домохозяйства (#946) + в данных нет — см. module docstring и `degraded_reason`. `payment_to_income` + ВСЕГДА None, `confidence` ВСЕГДА 'low' (нет знаменателя-дохода). Что считает: - • rate, period = _current_mortgage_rate(db) — субсидированная средневзвеш. - ставка (~7-8%; ЕДИНСТВЕННАЯ в БД). Это rate_kind='subsidized_weighted'. + • rate, period = _current_market_rate(db) — ключевая ставка ЦБ + спред как + рыночный прокси (~16-21%; #981 DoD). rate_kind='key_rate_proxy'. Если + key_rate в БД нет → (None, None), rate_kind='market_unavailable' (субсидией + НЕ подменяем — это исказило бы рыночный платёж; честный data-gap). • price_per_m2 — из аргумента ИЛИ сегментная средняя (reuse build_sales_series, усреднение НЕнулевых месяцев). None → платёж None (graceful). • monthly_payment = _annuity(principal=price×ref_area, annual_rate=rate, @@ -213,7 +300,16 @@ def compute_affordability( MortgageAffordabilityIndex (всегда; тонко → None-поля, confidence='low'). """ segment = spec.as_dict() - rate, rate_period = _current_mortgage_rate(db) + # #981 DoD: рыночная ставка (key_rate ЦБ + спред), НЕ субсидированная 7.83%. + # Нет key_rate в БД → rate None, помечаем market_unavailable (НЕ подменяем + # субсидией — это исказило бы рыночный платёж). + rate, rate_period = _current_market_rate(db) + if rate is not None: + rate_kind = _RATE_KIND_KEY_RATE_PROXY + degraded_reason = _DEGRADED_REASON + else: + rate_kind = _RATE_KIND_UNAVAILABLE + degraded_reason = _DEGRADED_REASON_NO_MARKET resolved_price = ( price_per_m2 @@ -234,10 +330,12 @@ def compute_affordability( 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)", + "affordability: segment=%s rate=%s kind=%s period=%s price_per_m2=%s " + "ref_area=%.1f monthly_payment=%s confidence=low " + "(market proxy, payment_to_income=None)", segment, _round_or_none(rate, 2), + rate_kind, rate_period, _round_or_none(resolved_price, 0), ref_area_m2, @@ -248,15 +346,15 @@ def compute_affordability( segment=segment, monthly_payment_rub=monthly_payment, rate_used=rate, - rate_kind=_RATE_KIND_SUBSIDIZED, + rate_kind=rate_kind, 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 + degraded_reason=degraded_reason, + confidence="low", # ВСЕГДА low — нет дохода-знаменателя, не истинный §7.9 ) diff --git a/backend/app/services/forecasting/demand_supply_forecast.py b/backend/app/services/forecasting/demand_supply_forecast.py index b46ca799..7f0dd748 100644 --- a/backend/app/services/forecasting/demand_supply_forecast.py +++ b/backend/app/services/forecasting/demand_supply_forecast.py @@ -43,8 +43,10 @@ ADVISORY-СТАТУС: движок СОВЕТУЮЩИЙ и НЕ подключ Graceful-on-thin-data (дух future_supply / market_metrics): любой тонкий вход → соответствующее поле None (НИКОГДА 0-как-заглушка), НИКОГДА не crash, НИКОГДА -деления на ноль. supply ≤ 0 / None → balance_ratio=None, deficit_index=None, -confidence занижен (не выдумываем «бесконечный дефицит»). Детерминированно. +деления на ноль. supply ≤ 0 / None → balance_ratio=None (∞-кратность не выдумываем); +deficit_index при этом = +1.0 ТОЛЬКО если спрос > 0 (предложение исчерпано под спрос += ПИК дефицита по семантике [−1,+1], #980), иначе None (нет сигнала: и спрос ≤0). +confidence занижается выше по стеку. Детерминированно. """ from __future__ import annotations @@ -119,7 +121,9 @@ class DemandSupplyForecast: (НИКОГДА 0-как-заглушка). `advisory` ВСЕГДА True (движок не для production-решений до бэктеста #951). `deficit_index` ∈ [−1,+1] когда задан: +1 = сильный дефицит (мало предложения под спрос — «хорошо» для девелопера), −1 = сильная затоварка - (предложения больше спроса — «риск»); None при неизмеримом предложении. + (предложения больше спроса — «риск»). Предложение исчерпано под спрос (supply≤0, + demand>0) → +1.0 (пик дефицита, #980, хотя balance_ratio тогда None); None только + при отсутствии сигнала (supply≤0 И demand≤0/None). `rate_sensitivity_phrase` — ТОЛЬКО explainability (β НЕ участвует в арифметике спроса дважды — он уже учтён внутри demand_norm_coefficient §9.4). """ @@ -335,6 +339,48 @@ def _deficit_index( return max(-1.0, min(1.0, raw)) +def _deficit_index_from_balance( + projected_demand_units: float | None, + projected_supply_units: float | None, + balance_ratio: float | None, + *, + saturation: float = _DEFICIT_RATIO_SATURATION, +) -> float | None: + """deficit_index с явной обработкой исчерпанного предложения (#980). PURE. + + Нормальный путь: supply > 0 → `_deficit_index(balance_ratio)` (лог-шкала). + + Граничный случай СИЛЬНЕЙШЕГО дефицита (#980): спрос поглотил ВСЁ предложение + (effective `projected_supply_units` ≤ 0) ПРИ положительном спросе. balance_ratio + тогда честно None (спрос/0 неотличим от «нет данных» — оставляем None как есть), + НО индекс по документированной семантике (∈ [−1,+1], +1 = сильный дефицит) + обязан быть +1.0, а НЕ None: исчерпанное под спрос предложение — это пик шкалы + дефицита, а не «нет сигнала». Раньше тут возвращался None — самый сильный + дефицит читался downstream как тонкие данные (market_fit падал к 0.5, ячейка + выпадала из what_to_build). Кап = +1.0 (саму ∞-кратность не выдумываем — клампим + к пределу шкалы, зеркало `_deficit_index` clamp-а). + + «Нет сигнала вообще» (supply ≤ 0 И спрос ≤ 0 / None) → None: ни дефицита, ни + затоварки измерить нельзя (нечему конкурировать и нечему поглощать). + + Args: + projected_demand_units: спроектированный спрос (None / ≤0 → нет дефицит-сигнала). + projected_supply_units: чистое конкурирующее предложение (≤0 → исчерпано). + balance_ratio: спрос/предложение (вход нормального пути; None при supply ≤0). + saturation: ratio, при котором индекс достигает ±1 (proxy в _deficit_index). + + Returns: + Индекс в [−1,+1] или None (нет измеримого сигнала). + """ + if projected_supply_units is not None and projected_supply_units <= 0: + # Предложение исчерпано: дефицит = +1 ТОЛЬКО при положительном спросе, + # иначе сигнала нет (None) — не путаем «пик дефицита» с «пустым рынком». + if projected_demand_units is not None and projected_demand_units > 0: + return 1.0 + return None + return _deficit_index(balance_ratio, saturation=saturation) + + def _min_confidence(values: Sequence[Confidence | None]) -> Confidence: """Итоговая уверенность = MIN компонентных (худшая тянет вниз). Зеркало vocab. @@ -419,8 +465,9 @@ def compute_demand_supply_forecast( • §9.7 future_competitors = top-N get_competitors(horizon_months=h). • confidence = MIN(market, §9.4, §9.5, future_supply) ≤ 'medium'. - Graceful: любой тонкий вход → поле None, НЕ crash. supply ≤0/None → ratio/index - None. Конкуренты недоступны (нет геометрии cad_num) → []. Детерминированно. + Graceful: любой тонкий вход → поле None, НЕ crash. supply ≤0/None → ratio None; + index = +1.0 если спрос > 0 (пик дефицита, #980), иначе None. Конкуренты + недоступны (нет геометрии cad_num) → []. Детерминированно. Args: db: SQLAlchemy sync Session. @@ -513,9 +560,12 @@ def _forecast_for_horizon( ) # ── БАЛАНС / индекс дефицита ──────────────────────────────────────────────── + # balance_ratio честно None при исчерпанном предложении (спрос/0 неотличим от + # «нет данных»), НО deficit_index в этом случае = +1.0 — сильнейший дефицит, не + # «нет сигнала» (#980; см. _deficit_index_from_balance). balance_units = _balance(projected_demand, projected_supply) balance_ratio = _balance_ratio(projected_demand, projected_supply) - deficit_index = _deficit_index(balance_ratio) + deficit_index = _deficit_index_from_balance(projected_demand, projected_supply, balance_ratio) # ── §9.7 будущие конкуренты (top-N по relevance_weight) ──────────────────── future_competitors = _future_competitors(db, cad_num=cad_num, horizon=horizon) diff --git a/backend/tests/services/forecasting/test_affordability.py b/backend/tests/services/forecasting/test_affordability.py index 2c272c8e..2bf73af2 100644 --- a/backend/tests/services/forecasting/test_affordability.py +++ b/backend/tests/services/forecasting/test_affordability.py @@ -1,12 +1,15 @@ -"""Unit-тесты §7.9 MAI — ДЕГРАДИРОВАННЫЙ прокси платёжной нагрузки (#981, ADVISORY). +"""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'; + • compute_affordability через MagicMock-сессию + @patch _current_market_rate / + build_sales_series: #981 DoD — НЕ субсидированные 7.83%, ставка рыночная + (key_rate ~16-21%), rate_kind=='key_rate_proxy'; нет key_rate → rate_used None + + 'market_unavailable' (НЕ подмена субсидией); payment_to_income ВСЕГДА None; confidence=='low'; degraded_reason непуст; payment_at_scenario per-горизонт; цена из аргумента vs сегментная (reuse); graceful нет ставки/цены → платёж None. + • _current_market_rate: берёт самую свежую key_rate из get_monthly_macro + спред. Детерминированно, без LLM. Мокаем reused-сервисы + db (нет живой БД). """ @@ -17,6 +20,7 @@ import os os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") +import datetime as dt from unittest.mock import MagicMock, patch import pytest @@ -24,18 +28,26 @@ import pytest from app.services.forecasting.affordability import ( _ANNUITY_TERM_MONTHS, _DEGRADED_REASON, + _KEY_RATE_MARKET_SPREAD_PP, + _RATE_KIND_KEY_RATE_PROXY, _RATE_KIND_SUBSIDIZED, + _RATE_KIND_UNAVAILABLE, _REF_AREA_M2, MortgageAffordabilityIndex, _annuity, + _current_market_rate, _round_or_none, _segment_avg_price, compute_affordability, ) +from app.services.forecasting.macro_series import MonthlyMacro from app.services.forecasting.sales_series import SegmentSpec # Пути патча reused-сервисов (импортированы в модуль affordability). -_RATE = "app.services.forecasting.affordability._current_mortgage_rate" +# #981: индекс берёт РЫНОЧНУЮ ставку через _current_market_rate (key_rate ЦБ), +# поэтому патчим её (не субсидированный _current_mortgage_rate). +_RATE = "app.services.forecasting.affordability._current_market_rate" +_MACRO = "app.services.forecasting.affordability.get_monthly_macro" _SERIES = "app.services.forecasting.affordability.build_sales_series" @@ -107,8 +119,8 @@ def _make_index(**over: object) -> MortgageAffordabilityIndex: "price_bucket": None, }, "monthly_payment_rub": 27_522.16, - "rate_used": 7.5, - "rate_kind": _RATE_KIND_SUBSIDIZED, + "rate_used": 18.0, + "rate_kind": _RATE_KIND_KEY_RATE_PROXY, "rate_period": "2026-01", "ref_area_m2": _REF_AREA_M2, "price_per_m2": 120_000.0, @@ -132,7 +144,8 @@ class TestAsDict: 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" + # #981: рыночный прокси (key_rate), НЕ субсидированная средневзвешенная. + assert d["rate_kind"] == "key_rate_proxy" assert d["confidence"] == "low" assert d["degraded_reason"] @@ -166,29 +179,40 @@ def _run(**over: object) -> MortgageAffordabilityIndex: 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")): + with patch(_RATE, return_value=(18.0, "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")): + def test_rate_kind_is_key_rate_proxy(self) -> None: + # #981 DoD: рыночный прокси (key_rate ЦБ), НЕ субсидированная средневзвешенная. + with patch(_RATE, return_value=(18.0, "2026-01")): res = _run(price_per_m2=120_000.0) - assert res.rate_kind == "subsidized_weighted" + assert res.rate_kind == "key_rate_proxy" + assert res.rate_kind != _RATE_KIND_SUBSIDIZED + + def test_rate_used_is_market_not_subsidized(self) -> None: + # #981 DoD-якорь: ставка рыночная (~16-21%), НЕ subsidized_weighted 7.83%. + with patch(_RATE, return_value=(18.0, "2026-01")): + res = _run(price_per_m2=120_000.0) + assert res.rate_used == 18.0 + assert res.rate_used is not None and res.rate_used > 15.0 + # точно НЕ субсидированный бленд ~7.83%. + assert res.rate_used != pytest.approx(7.83, abs=0.5) def test_confidence_always_low(self) -> None: # Деградированный прокси → confidence ВСЕГДА low (даже при полных входах). - with patch(_RATE, return_value=(7.5, "2026-01")): + with patch(_RATE, return_value=(18.0, "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")): + def test_degraded_reason_explains_proxy_and_income_gap(self) -> None: + # degraded_reason объясняет выбор прокси ставки (key_rate) И пробел дохода. + with patch(_RATE, return_value=(18.0, "2026-01")): res = _run(price_per_m2=120_000.0) assert res.degraded_reason lower = res.degraded_reason.lower() assert "ставк" in lower # упоминает ставку + assert "key_rate" in lower # называет рыночный прокси явно assert "доход" in lower # упоминает доход @@ -197,20 +221,20 @@ class TestHonestyContract: 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")): + # price 120k × ref 50 м² = 6 000 000 ₽ тело; рыночная ставка 18%, 240 мес. + with patch(_RATE, return_value=(18.0, "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) + expected = _annuity(principal, 18.0, _ANNUITY_TERM_MONTHS) assert res.monthly_payment_rub == pytest.approx(expected) - assert res.rate_used == 7.5 + assert res.rate_used == 18.0 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(_RATE, return_value=(18.0, "2026-01")): with patch(_SERIES) as series_mock: res = _run(price_per_m2=150_000.0) series_mock.assert_not_called() @@ -218,7 +242,7 @@ class TestPaymentComputation: 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(_RATE, return_value=(18.0, "2026-01")): with patch(_SERIES, return_value=_series_stub([100_000.0, None, 140_000.0])): res = _run() # средняя НЕнулевых: (100k + 140k) / 2 = 120k. @@ -231,7 +255,7 @@ class TestPaymentComputation: class TestPaymentAtScenario: def test_scenario_payment_per_horizon(self) -> None: # rate_path: разные ставки на горизонтах → платёж считается на каждой. - with patch(_RATE, return_value=(7.5, "2026-01")): + with patch(_RATE, return_value=(18.0, "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 @@ -245,13 +269,13 @@ class TestPaymentAtScenario: 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")): + with patch(_RATE, return_value=(18.0, "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(_RATE, return_value=(18.0, "2026-01")): with patch(_SERIES, return_value=_series_stub([None])): res = _run(rate_path={6: 8.0}) assert res.payment_at_scenario == {} @@ -261,17 +285,21 @@ class TestPaymentAtScenario: class TestGraceful: - def test_no_rate_yields_none_payment(self) -> None: - # Ставки нет в БД → платёж None, но индекс возвращается (не crash). + def test_no_rate_yields_none_payment_and_market_unavailable(self) -> None: + # Рыночной ставки (key_rate) нет в БД → платёж None, rate_used None, + # rate_kind='market_unavailable' (#981: НЕ подменяем субсидией). Не 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.rate_kind == _RATE_KIND_UNAVAILABLE + assert res.rate_kind != _RATE_KIND_SUBSIDIZED + assert "market_unavailable" in res.degraded_reason.lower() 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(_RATE, return_value=(18.0, "2026-01")): with patch(_SERIES, return_value=_series_stub([None, None])): res = _run() assert res.price_per_m2 is None @@ -307,3 +335,57 @@ class TestSegmentAvgPrice: with patch(_SERIES, return_value=_series_stub([])): price = _segment_avg_price(MagicMock(), spec=spec, source="objective_lots") assert price is None + + +# ── _current_market_rate (#981: key_rate ЦБ как рыночный прокси) ─────────────── + + +def _macro_pt(month: dt.date, key_rate: float | None) -> MonthlyMacro: + return MonthlyMacro( + month=month, + key_rate=key_rate, + mortgage_rate_weighted=None, + mortgage_issued_count=None, + mortgage_issued_volume=None, + mortgage_debt=None, + mortgage_overdue=None, + ) + + +class TestCurrentMarketRate: + def test_takes_latest_key_rate_with_spread(self) -> None: + # Самая свежая непустая key_rate (ряд ASC) + спред по умолчанию (0 п.п.). + macro = [ + _macro_pt(dt.date(2025, 11, 1), 19.0), + _macro_pt(dt.date(2025, 12, 1), 20.0), + _macro_pt(dt.date(2026, 1, 1), 21.0), + ] + with patch(_MACRO, return_value=macro): + rate, period = _current_market_rate(MagicMock()) + assert rate == pytest.approx(21.0 + _KEY_RATE_MARKET_SPREAD_PP) + assert period == "2026-01" + # рыночный диапазон, НЕ субсидированный ~7.83%. + assert rate is not None and rate > 15.0 + + def test_spread_applied(self) -> None: + macro = [_macro_pt(dt.date(2026, 1, 1), 18.0)] + with patch(_MACRO, return_value=macro): + rate, _period = _current_market_rate(MagicMock(), spread_pp=2.5) + assert rate == pytest.approx(20.5) + + def test_ignores_trailing_none_key_rate(self) -> None: + macro = [ + _macro_pt(dt.date(2025, 12, 1), 21.0), + _macro_pt(dt.date(2026, 1, 1), None), + ] + with patch(_MACRO, return_value=macro): + rate, period = _current_market_rate(MagicMock()) + assert rate == pytest.approx(21.0) + assert period == "2025-12" + + def test_no_key_rate_yields_none(self) -> None: + # Пустой macro_indicator / нет ни одной key_rate → (None, None) (data-gap). + with patch(_MACRO, return_value=[_macro_pt(dt.date(2026, 1, 1), None)]): + assert _current_market_rate(MagicMock()) == (None, None) + with patch(_MACRO, return_value=[]): + assert _current_market_rate(MagicMock()) == (None, None) diff --git a/backend/tests/services/forecasting/test_demand_supply_forecast.py b/backend/tests/services/forecasting/test_demand_supply_forecast.py index b27c8b3b..de4fa616 100644 --- a/backend/tests/services/forecasting/test_demand_supply_forecast.py +++ b/backend/tests/services/forecasting/test_demand_supply_forecast.py @@ -39,6 +39,7 @@ from app.services.forecasting.demand_supply_forecast import ( _balance_ratio, _cap_confidence, _deficit_index, + _deficit_index_from_balance, _hidden_release_fraction, _min_confidence, _project_demand, @@ -249,6 +250,44 @@ class TestDeficitIndex: assert _deficit_index(1.0, saturation=1.0) == 0.0 +# ── pure: _deficit_index_from_balance (#980 — исчерпанное предложение = +1) ──── + + +class TestDeficitIndexFromBalance: + def test_supply_exhausted_with_positive_demand_is_plus_one(self) -> None: + # #980 ЯДРО: спрос > 0 поглотил ВСЁ предложение (supply 0) → +1.0 (был None). + # balance_ratio честно None (спрос/0), но индекс = пик дефицита. + assert _deficit_index_from_balance(120.0, 0.0, None) == 1.0 + + def test_negative_supply_with_positive_demand_is_plus_one(self) -> None: + # Артефактно-отрицательное предложение трактуем как исчерпанное → +1.0. + assert _deficit_index_from_balance(50.0, -3.0, None) == 1.0 + + def test_no_signal_supply_and_demand_both_zero_is_none(self) -> None: + # Ни дефицита, ни затоварки измерить нельзя (пустой рынок) → None. + assert _deficit_index_from_balance(0.0, 0.0, None) is None + + def test_supply_zero_demand_none_is_none(self) -> None: + # Спрос неизвестен + предложение исчерпано → нет сигнала (None, не +1). + assert _deficit_index_from_balance(None, 0.0, None) is None + + def test_normal_path_delegates_to_deficit_index(self) -> None: + # supply > 0 → обычный лог-путь (ровно _deficit_index(balance_ratio)). + ratio = _DEFICIT_RATIO_SATURATION # +1 на лог-шкале + assert _deficit_index_from_balance(200.0, 100.0, ratio) == pytest.approx( + _deficit_index(ratio) + ) + + def test_normal_oversupply_negative(self) -> None: + # supply > 0, спрос << предложения → отрицательный индекс (как раньше). + ratio = 1.0 / _DEFICIT_RATIO_SATURATION + idx = _deficit_index_from_balance(50.0, 100.0, ratio) + assert idx is not None and idx < 0.0 + + def test_normal_balance_zero(self) -> None: + assert _deficit_index_from_balance(100.0, 100.0, 1.0) == pytest.approx(0.0) + + # ── pure: _min_confidence / _cap_confidence ─────────────────────────────────── @@ -666,9 +705,10 @@ class TestComputeSupply: class TestComputeBalance: - def test_balance_and_ratio_and_index(self) -> None: - # Спрос 10×1.0×1.0×12 = 120; supply: open0+hidden0+future0 → 0 валовое, - # но absorbed=min(0,120)=0 → supply 0 → ratio None, index None. + def test_demand_with_zero_supply_is_strongest_deficit(self) -> None: + # #980: спрос 10×1.0×1.0×12 = 120; supply open0+hidden0+future0 → 0 валовое. + # balance_ratio честно None (спрос/0), НО deficit_index = +1.0 (пик дефицита, + # demand>0 при исчерпанном предложении) — РАНЬШЕ тут был None (баг #980). with _Patches( _METRICS=_metrics_stub(unit_velocity=10.0), _NORM=_norm_stub(coefficient=1.0), @@ -679,12 +719,44 @@ class TestComputeBalance: f = res[0] assert f.projected_demand_units == pytest.approx(120.0) assert f.projected_supply_units == 0.0 - # supply ≤0 → ratio/index None (НЕ выдуманный ∞-дефицит). + # balance_ratio None (∞-кратность не выдумываем), но индекс = +1.0. assert f.balance_ratio is None - assert f.deficit_index is None + assert f.deficit_index == 1.0 # balance_units всё равно считается (120 − 0 = 120). assert f.balance_units == pytest.approx(120.0) + def test_gross_supply_fully_absorbed_is_plus_one(self) -> None: + # #980 ЦЕЛЕВОЙ КЕЙС: валовое предложение > 0, но спрос огромный поглощает + # ВСЁ → effective projected_supply = 0 → deficit_index = +1.0 (был None). + with _Patches( + _METRICS=_metrics_stub(unit_velocity=10_000.0), # спрос >> валового + _NORM=_norm_stub(coefficient=1.0), + _MACRO_COEF=_macro_coef_stub(coefficient=1.0), + _SUPPLY=_supply_stub(open_units=100, hidden_units=100, future_units_by_horizon=50.0), + ): + res = _run(horizons=[18]) + f = res[0] + # валовое было положительным, но поглощено в 0. + assert f.projected_supply_units == 0.0 + assert f.projected_demand_units is not None and f.projected_demand_units > 0 + assert f.balance_ratio is None + assert f.deficit_index == 1.0 # сильнейший дефицит, НЕ None + + def test_no_demand_no_supply_index_none(self) -> None: + # Нет сигнала вообще (спрос 0 И предложение 0) → deficit_index None. + with _Patches( + _METRICS=_metrics_stub(unit_velocity=0.0), + _NORM=_norm_stub(coefficient=1.0), + _MACRO_COEF=_macro_coef_stub(coefficient=1.0), + _SUPPLY=_supply_stub(open_units=0, hidden_units=0, future_units_by_horizon=0.0), + ): + res = _run(horizons=[12]) + f = res[0] + assert f.projected_demand_units == pytest.approx(0.0) + assert f.projected_supply_units == 0.0 + assert f.balance_ratio is None + assert f.deficit_index is None # ни дефицита, ни затоварки — нет сигнала + def test_oversupply_negative_index(self) -> None: # Малый спрос, большое предложение (спрос не поглощает всё) → index < 0. with _Patches(