fix(forecasting): #980 strongest deficit→deficit_index +1.0; #981 MAI uses CBR key rate
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / deploy (push) Successful in 1m7s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m39s
Deploy / build-worker (push) Successful in 2m39s

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
This commit is contained in:
Light1YT 2026-06-04 11:10:26 +05:00
parent 33d1a63bc8
commit 9ff80f3ac0
4 changed files with 383 additions and 81 deletions

View file

@ -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
)

View file

@ -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 при неизмеримом предложении.
(предложения больше спроса «риск»). Предложение исчерпано под спрос (supply0,
demand>0) +1.0 (пик дефицита, #980, хотя balance_ratio тогда None); None только
при отсутствии сигнала (supply0 И demand0/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)

View file

@ -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)^n1)); rate=0
principal/months (без /0); None principal/rate None; months 0 None.
compute_affordability через MagicMock-сессию + @patch _current_mortgage_rate /
build_sales_series: payment_to_income ВСЕГДА None; rate_kind=='subsidized_weighted';
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)

View file

@ -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(