gendesign/backend/app/services/forecasting/affordability.py
bot-backend 14f3ef2019
All checks were successful
Deploy / build-worker (push) Successful in 2m47s
Deploy / deploy (push) Successful in 1m20s
Deploy / changes (push) Successful in 9s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m52s
fix(week-review): backend-аудит v2 — 82 фиксов (#1660)
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-06-17 17:13:38 +00:00

409 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""§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 + спред банка). Спред
КАЛИБРОВАН = 4.5 п.п. по прод-якорю `mortgage_rate_primary_domrf` (рыночная
первичная ипотека) 19.125% @ 2026-04-19 vs key_rate ≈ 14.5% → наблюдаемый спред
≈ 4.6 п.п. (округлён до 4.5 консервативно; типичный диапазон РФ 3-5 п.п.). Поэтому
`rate_used` ≈ key_rate + 4.5 ≈ 19% — РЕАЛЬНАЯ рыночная стоимость заёмных денег, а
НЕ голая ключевая ставка как нижняя граница. `rate_kind='key_rate_proxy'`, НЕ 7.83%.
Субсидированный путь сохранён в `_current_subsidized_rate` (explain/сравнение),
но в индекс по умолчанию НЕ идёт.
ADVISORY / LOW-CONFIDENCE (КРИТИЧНО — честность важнее цифры):
Истинный §7.9 = «рыночный аннуитет ÷ доход домохозяйства». ДОХОД домохозяйства
(#946) в данных проекта ОТСУТСТВУЕТ → `payment_to_income` ВСЕГДА None,
`confidence='low'` ВСЕГДА (числитель теперь рыночный, но знаменатель-дохода нет).
`degraded_reason` объясняет остаточный пробел (доход) и сделанный выбор прокси
ставки (key_rate + калиброванный рыночный спред 4.5 п.п.). Цифра — для
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 + спред. Калибровано по прод-якорю: mortgage_rate_primary_domrf
# (rf/domrf — РЫНОЧНАЯ первичная ипотека) = 19.125% @ 2026-04-19 при key_rate ЦБ
# ≈ 14.5% на ту же дату → наблюдаемый спред ≈ 4.6 п.п. Округляем до 4.5 п.п.
# (слегка консервативно, внутри типичного для РФ диапазона key_rate→рыночная
# ипотека 3-5 п.п.). Так rate_used ≈ key_rate + 4.5 (реальная рыночная стоимость
# заёмных денег ~19%), а НЕ голая ключевая ставка как нижняя граница. Tunable.
# TODO: при появлении надёжного МЕСЯЧНОГО ряда mortgage_rate_primary_domrf сделать
# спред data-driven (сейчас это единичный национальный снимок — слишком тонко,
# чтобы зависеть от него напрямую, поэтому фиксируем калиброванной константой).
_KEY_RATE_MARKET_SPREAD_PP: float = 4.5
# Источник сегментной цены ₽/м² по умолчанию: 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) + калиброванный рыночный спред 4.5 п.п. (≈ key_rate + 4.5, ~19%) — "
"прямого месячного ряда «рыночная ипотека без льгот» в БД нет, спред откалиброван "
"по mortgage_rate_primary_domrf 19.125% vs key_rate ≈ 14.5% (наблюдаемый ≈4.6 "
"п.п.), что отражает реальную рыночную стоимость заёмных денег (честнее "
"субсидированных ~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 и
добавляем калиброванный рыночный спред банка (по умолчанию 4.5 п.п. — реальная
рыночная стоимость денег, ≈ key_rate + 4.5 ~19%, а НЕ нижняя граница). Это
rate_kind='key_rate_proxy'НЕ субсидированные 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 = 4.5).
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] + спред) —
платёж на каждом горизонте сценарной КЛЮЧЕВОЙ ставки ЦБ, приведённой к той
же рыночной базе, что и monthly_payment_rub (key_rate + калиброванный спред
4.5 п.п.); None rate_path → поле None (#1639).
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
(контракт #952/#984: конверт key_rate, НЕ рыночная — к ней внутри
добавляется тот же спред _KEY_RATE_MARKET_SPREAD_PP, что и в базовом
monthly_payment_rub); 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():
# rate_path[h] = КЛЮЧЕВАЯ ставка ЦБ сценария (#952/#984 контракт:
# demand_supply_forecast.py:586, scenarios.py — конверт key_rate), НЕ
# рыночная. Приводим к той же рыночной базе, что и monthly_payment_rub:
# key_rate + калиброванный спред (_current_market_rate, строка 251).
# Иначе сценарный платёж считался бы по «голой» key_rate (≈ на 4.5 п.п.
# ниже базовой ставки) и был бы НЕсопоставим с monthly_payment_rub (#1639).
market_scenario_rate = (
scenario_rate + _KEY_RATE_MARKET_SPREAD_PP
if scenario_rate is not None
else None
)
payment = _annuity(principal, market_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)