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
381 lines
24 KiB
Python
381 lines
24 KiB
Python
"""§7.9 Mortgage Affordability Index (MAI) — прокси платёжной нагрузки по РЫНОЧНОЙ ставке.
|
||
|
||
#981 (952-B, Site Finder v2 / «GG-форсайт» ТЗ §7.9), EPIC 8 «что строить +
|
||
доступность». Это **СБОРОЧНЫЙ слой**: своей §9.x-математики не пересобирает —
|
||
переиспользует 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 = «рыночный аннуитет ÷ доход домохозяйства». ДОХОД домохозяйства
|
||
(#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 → аннуитет деградирует к
|
||
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.macro_series import get_monthly_macro
|
||
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
|
||
|
||
# Метка вида ставки в выводе. По умолчанию — рыночный прокси через ключевую ставку
|
||
# ЦБ (#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, когда ключевая ставка ДОСТУПНА (рыночный путь отработал).
|
||
_DEGRADED_REASON: str = (
|
||
"Прокси платёжной нагрузки по РЫНОЧНОЙ ставке, НЕ истинный §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 — прокси платёжной нагрузки по РЫНОЧНОЙ ставке (считается на лету).
|
||
|
||
Все денежные поля = None при недостатке данных (НИКОГДА 0-как-заглушка).
|
||
`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 # годовая рыночная ставка (%) — 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 # сегментная цена ₽/м² (вход тела кредита)
|
||
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)
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# Ставки — рыночный прокси (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-логика выше тестируется без него.
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
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 — прокси платёжной нагрузки по РЫНОЧНОЙ ставке для сегмента (#981).
|
||
|
||
ADVISORY / LOW-CONFIDENCE: это ПРОКСИ, а НЕ истинный §7.9 индекс доступности.
|
||
Числитель теперь РЫНОЧНЫЙ (ключевая ставка ЦБ), но дохода домохозяйства (#946)
|
||
в данных нет — см. module docstring и `degraded_reason`. `payment_to_income`
|
||
ВСЕГДА None, `confidence` ВСЕГДА 'low' (нет знаменателя-дохода).
|
||
|
||
Что считает:
|
||
• 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,
|
||
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()
|
||
# #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
|
||
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 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,
|
||
_round_or_none(monthly_payment, 0),
|
||
)
|
||
|
||
return MortgageAffordabilityIndex(
|
||
segment=segment,
|
||
monthly_payment_rub=monthly_payment,
|
||
rate_used=rate,
|
||
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
|
||
)
|
||
|
||
|
||
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)
|