"""§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 НЕТ). 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/прототипа, не для решения. 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.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 # Метка вида ставки в выводе: всегда субсидированная средневзвешенная (рыночной # ~20% в БД нет). Зафиксирована константой — downstream не должен принять её за # рыночную ставку §7.9. _RATE_KIND_SUBSIDIZED: str = "subsidized_weighted" # Источник сегментной цены ₽/м² по умолчанию: 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) использована СУБСИДИРОВАННАЯ средневзвешенная ставка ИЖК (~7-8%) — " "рыночной ставки без льгот (~20%) в БД нет, платёж занижен против рынка; " "(2) доход домохозяйства (#946) недоступен, поэтому payment_to_income не " "рассчитывается (всегда None). confidence='low' — деградация по двум каналам." ) @dataclass(frozen=True) class MortgageAffordabilityIndex: """§7.9 MAI — ДЕГРАДИРОВАННЫЙ прокси платёжной нагрузки (считается на лету). Все денежные поля = None при недостатке данных (НИКОГДА 0-как-заглушка). `payment_to_income` ВСЕГДА None (дохода нет в данных, #946). `rate_kind` ВСЕГДА 'subsidized_weighted' (рыночной ставки нет). `confidence` ВСЕГДА 'low' (прокси, не истинный индекс §7.9). `degraded_reason` объясняет оба пробела. Это explainability-прокси, НЕ основание для инвест-решения. """ segment: dict[str, str | None] monthly_payment_rub: float | None # аннуитет на эталонную квартиру (₽/мес) rate_used: float | None # годовая ставка (%) — субсидированная средневзвеш. rate_kind: str # ВСЕГДА 'subsidized_weighted' 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) # ────────────────────────────────────────────────────────────────────────────── # 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 — ДЕГРАДИРОВАННЫЙ прокси платёжной нагрузки для сегмента. ADVISORY / LOW-CONFIDENCE: это ПРОКСИ, а НЕ истинный §7.9 индекс доступности. Рыночной ставки (~20%) и дохода домохозяйства (#946) в данных нет — см. module docstring и `degraded_reason`. `payment_to_income` ВСЕГДА None. Что считает: • rate, period = _current_mortgage_rate(db) — субсидированная средневзвеш. ставка (~7-8%; ЕДИНСТВЕННАЯ в БД). Это rate_kind='subsidized_weighted'. • 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() rate, rate_period = _current_mortgage_rate(db) 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 period=%s price_per_m2=%s ref_area=%.1f " "monthly_payment=%s confidence=low (DEGRADED proxy, payment_to_income=None)", segment, _round_or_none(rate, 2), 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_SUBSIDIZED, 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)