gendesign/backend/app/services/forecasting/affordability.py
Light1YT 9ff80f3ac0
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
fix(forecasting): #980 strongest deficit→deficit_index +1.0; #981 MAI uses CBR key rate
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
2026-06-04 11:10:26 +05:00

381 lines
24 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 + спред банка; мы
берём спред = 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)