"""§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]) — платёж на каждом горизонте сценарной ставки (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)