_KEY_RATE_MARKET_SPREAD_PP was a 0.0 placeholder, so §7.9 affordability payments used the bare CBR key_rate (~14.5%), understating borrower cost and OVERSTATING affordability. Calibrate to 4.5pp from the prod anchor (macro_indicator mortgage_rate_primary_domrf 19.125% @ 2026-04-19 vs key_rate ~14.5% -> observed ~4.6pp, rounded conservatively; inside the typical RF 3-5pp band), so rate_used = key_rate + 4.5 ~= 19% matches the directly-observed market primary mortgage rate. Makes affordability LESS optimistic / more accurate. Docs + tests updated to the symbolic spread (new regression anchor pins spread==4.5 and key_rate 14.5 -> ~19.0); rate_kind/graceful-fallback semantics unchanged. Forecasting suite 841 passed; ruff clean.
414 lines
21 KiB
Python
414 lines
21 KiB
Python
"""Unit-тесты §7.9 MAI — прокси платёжной нагрузки по РЫНОЧНОЙ ставке (#981, ADVISORY).
|
||
|
||
Чистые тесты — БЕЗ живой БД (чистая аннуитет-математика + мок reused-сервисов):
|
||
• pure _annuity: textbook-кейс (формула P·i·(1+i)^n/((1+i)^n−1)); rate=0 →
|
||
principal/months (без /0); None principal/rate → None; months ≤0 → None.
|
||
• compute_affordability через MagicMock-сессию + @patch _current_market_rate /
|
||
build_sales_series: #981 DoD — НЕ субсидированные 7.83%, ставка рыночная
|
||
(key_rate ~16-21%), rate_kind=='key_rate_proxy'; нет key_rate → rate_used None +
|
||
'market_unavailable' (НЕ подмена субсидией); payment_to_income ВСЕГДА None;
|
||
confidence=='low'; degraded_reason непуст; payment_at_scenario per-горизонт;
|
||
цена из аргумента vs сегментная (reuse); graceful нет ставки/цены → платёж None.
|
||
• _current_market_rate: берёт самую свежую key_rate из get_monthly_macro + спред.
|
||
|
||
Детерминированно, без LLM. Мокаем reused-сервисы + db (нет живой БД).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
import datetime as dt
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
from app.services.forecasting.affordability import (
|
||
_ANNUITY_TERM_MONTHS,
|
||
_DEGRADED_REASON,
|
||
_KEY_RATE_MARKET_SPREAD_PP,
|
||
_RATE_KIND_KEY_RATE_PROXY,
|
||
_RATE_KIND_SUBSIDIZED,
|
||
_RATE_KIND_UNAVAILABLE,
|
||
_REF_AREA_M2,
|
||
MortgageAffordabilityIndex,
|
||
_annuity,
|
||
_current_market_rate,
|
||
_round_or_none,
|
||
_segment_avg_price,
|
||
compute_affordability,
|
||
)
|
||
from app.services.forecasting.macro_series import MonthlyMacro
|
||
from app.services.forecasting.sales_series import SegmentSpec
|
||
|
||
# Пути патча reused-сервисов (импортированы в модуль affordability).
|
||
# #981: индекс берёт РЫНОЧНУЮ ставку через _current_market_rate (key_rate ЦБ),
|
||
# поэтому патчим её (не субсидированный _current_mortgage_rate).
|
||
_RATE = "app.services.forecasting.affordability._current_market_rate"
|
||
_MACRO = "app.services.forecasting.affordability.get_monthly_macro"
|
||
_SERIES = "app.services.forecasting.affordability.build_sales_series"
|
||
|
||
# Мокаемая ключевая ставка ЦБ для orchestrator-тестов. _current_market_rate отдаёт
|
||
# РЫНОЧНУЮ ставку = key_rate + _KEY_RATE_MARKET_SPREAD_PP, поэтому ожидаемый rate_used
|
||
# выражаем символически (_MOCK_KEY_RATE + _KEY_RATE_MARKET_SPREAD_PP) — тест остаётся
|
||
# верным при будущей перекалибровке спреда И ломается при регрессии спреда к 0.
|
||
_MOCK_KEY_RATE: float = 18.0
|
||
_MOCK_MARKET_RATE: float = _MOCK_KEY_RATE + _KEY_RATE_MARKET_SPREAD_PP
|
||
|
||
|
||
# ── pure: _annuity ────────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestAnnuity:
|
||
def test_textbook_case_matches_formula(self) -> None:
|
||
# Тело 2 500 000 ₽, 12% годовых, 240 мес. Сверяем с явной формулой
|
||
# P·i·(1+i)^n / ((1+i)^n − 1), i = rate/100/12.
|
||
principal = 2_500_000.0
|
||
rate = 12.0
|
||
months = 240
|
||
i = rate / 100.0 / 12.0
|
||
growth = (1.0 + i) ** months
|
||
expected = principal * i * growth / (growth - 1.0)
|
||
assert _annuity(principal, rate, months) == pytest.approx(expected)
|
||
|
||
def test_textbook_case_known_value(self) -> None:
|
||
# Та же вводная — захардкоженное hand-verified значение (~27 527.15 ₽/мес).
|
||
assert _annuity(2_500_000.0, 12.0, 240) == pytest.approx(27_527.15, abs=0.5)
|
||
|
||
def test_zero_rate_is_principal_over_months(self) -> None:
|
||
# Беспроцентный кредит: формула вырождается (i=0 → 0/0) → равномерное тело.
|
||
assert _annuity(2_400_000.0, 0.0, 240) == pytest.approx(10_000.0)
|
||
|
||
def test_negative_rate_degrades_to_principal_over_months(self) -> None:
|
||
# Артефактно-отрицательная ставка → та же беспроцентная деградация (не /0).
|
||
assert _annuity(1_200_000.0, -3.0, 120) == pytest.approx(10_000.0)
|
||
|
||
def test_none_principal_returns_none(self) -> None:
|
||
assert _annuity(None, 12.0, 240) is None
|
||
|
||
def test_none_rate_returns_none(self) -> None:
|
||
assert _annuity(2_500_000.0, None, 240) is None
|
||
|
||
def test_zero_or_negative_months_returns_none(self) -> None:
|
||
assert _annuity(2_500_000.0, 12.0, 0) is None
|
||
assert _annuity(2_500_000.0, 12.0, -12) is None
|
||
|
||
def test_higher_rate_means_higher_payment(self) -> None:
|
||
# Монотонность: выше ставка → выше платёж (при равном теле/сроке).
|
||
low = _annuity(2_500_000.0, 7.0, 240)
|
||
high = _annuity(2_500_000.0, 20.0, 240)
|
||
assert low is not None and high is not None
|
||
assert high > low
|
||
|
||
|
||
# ── pure: _round_or_none ──────────────────────────────────────────────────────
|
||
|
||
|
||
class TestRoundOrNone:
|
||
def test_rounds(self) -> None:
|
||
assert _round_or_none(27_522.16, 0) == 27_522.0
|
||
|
||
def test_none_passthrough(self) -> None:
|
||
assert _round_or_none(None, 0) is None
|
||
|
||
|
||
# ── MortgageAffordabilityIndex.as_dict ────────────────────────────────────────
|
||
|
||
|
||
def _make_index(**over: object) -> MortgageAffordabilityIndex:
|
||
base: dict[str, object] = {
|
||
"segment": {
|
||
"obj_class": "комфорт",
|
||
"room_bucket": "2-к 45-60",
|
||
"district": "X",
|
||
"price_bucket": None,
|
||
},
|
||
"monthly_payment_rub": 27_522.16,
|
||
"rate_used": 18.0,
|
||
"rate_kind": _RATE_KIND_KEY_RATE_PROXY,
|
||
"rate_period": "2026-01",
|
||
"ref_area_m2": _REF_AREA_M2,
|
||
"price_per_m2": 120_000.0,
|
||
"annuity_term_months": _ANNUITY_TERM_MONTHS,
|
||
"payment_to_income": None,
|
||
"payment_at_scenario": {6: 27_000.0, 12: 28_000.0},
|
||
"degraded_reason": _DEGRADED_REASON,
|
||
"confidence": "low",
|
||
}
|
||
base.update(over)
|
||
return MortgageAffordabilityIndex(**base) # type: ignore[arg-type]
|
||
|
||
|
||
class TestAsDict:
|
||
def test_rounds_money_fields(self) -> None:
|
||
d = _make_index().as_dict()
|
||
assert d["monthly_payment_rub"] == 27_522.0
|
||
assert d["price_per_m2"] == 120_000.0
|
||
assert d["payment_at_scenario"] == {6: 27_000.0, 12: 28_000.0}
|
||
|
||
def test_honesty_fields_preserved(self) -> None:
|
||
d = _make_index().as_dict()
|
||
assert d["payment_to_income"] is None
|
||
# #981: рыночный прокси (key_rate), НЕ субсидированная средневзвешенная.
|
||
assert d["rate_kind"] == "key_rate_proxy"
|
||
assert d["confidence"] == "low"
|
||
assert d["degraded_reason"]
|
||
|
||
def test_none_fields_survive(self) -> None:
|
||
d = _make_index(
|
||
monthly_payment_rub=None, price_per_m2=None, payment_at_scenario=None
|
||
).as_dict()
|
||
assert d["monthly_payment_rub"] is None
|
||
assert d["price_per_m2"] is None
|
||
assert d["payment_at_scenario"] is None
|
||
|
||
|
||
# ── orchestrator helpers (стабы reused-сервисов) ──────────────────────────────
|
||
|
||
|
||
def _series_stub(prices: list[float | None]) -> MagicMock:
|
||
"""Стаб build_sales_series: несёт avg_price_per_m2-список (для _segment_avg_price)."""
|
||
m = MagicMock()
|
||
m.avg_price_per_m2 = prices
|
||
return m
|
||
|
||
|
||
def _run(**over: object) -> MortgageAffordabilityIndex:
|
||
spec = SegmentSpec(obj_class="комфорт", room_bucket="2-к 45-60", district="Академический")
|
||
return compute_affordability(MagicMock(), spec=spec, **over) # type: ignore[arg-type]
|
||
|
||
|
||
# ── orchestrator: honesty-контракт (КРИТИЧНО) ─────────────────────────────────
|
||
|
||
|
||
class TestHonestyContract:
|
||
def test_payment_to_income_always_none(self) -> None:
|
||
# Дохода в данных нет (#946) → payment_to_income ВСЕГДА None.
|
||
with patch(_RATE, return_value=(_MOCK_MARKET_RATE, "2026-01")):
|
||
res = _run(price_per_m2=120_000.0)
|
||
assert res.payment_to_income is None
|
||
|
||
def test_rate_kind_is_key_rate_proxy(self) -> None:
|
||
# #981 DoD: рыночный прокси (key_rate ЦБ), НЕ субсидированная средневзвешенная.
|
||
with patch(_RATE, return_value=(_MOCK_MARKET_RATE, "2026-01")):
|
||
res = _run(price_per_m2=120_000.0)
|
||
assert res.rate_kind == "key_rate_proxy"
|
||
assert res.rate_kind != _RATE_KIND_SUBSIDIZED
|
||
|
||
def test_rate_used_is_market_not_subsidized(self) -> None:
|
||
# #981 DoD-якорь: ставка рыночная (key_rate + калиброванный спред 4.5 п.п.),
|
||
# НЕ subsidized_weighted 7.83%. rate_used = key_rate + _KEY_RATE_MARKET_SPREAD_PP
|
||
# (символически — устойчиво к перекалибровке спреда).
|
||
with patch(_RATE, return_value=(_MOCK_MARKET_RATE, "2026-01")):
|
||
res = _run(price_per_m2=120_000.0)
|
||
assert res.rate_used == pytest.approx(_MOCK_KEY_RATE + _KEY_RATE_MARKET_SPREAD_PP)
|
||
assert res.rate_used is not None and res.rate_used > 15.0
|
||
# точно НЕ субсидированный бленд ~7.83%.
|
||
assert res.rate_used != pytest.approx(7.83, abs=0.5)
|
||
|
||
def test_confidence_always_low(self) -> None:
|
||
# Деградированный прокси → confidence ВСЕГДА low (даже при полных входах).
|
||
with patch(_RATE, return_value=(_MOCK_MARKET_RATE, "2026-01")):
|
||
res = _run(price_per_m2=120_000.0)
|
||
assert res.confidence == "low"
|
||
|
||
def test_degraded_reason_explains_proxy_and_income_gap(self) -> None:
|
||
# degraded_reason объясняет выбор прокси ставки (key_rate) И пробел дохода.
|
||
with patch(_RATE, return_value=(_MOCK_MARKET_RATE, "2026-01")):
|
||
res = _run(price_per_m2=120_000.0)
|
||
assert res.degraded_reason
|
||
lower = res.degraded_reason.lower()
|
||
assert "ставк" in lower # упоминает ставку
|
||
assert "key_rate" in lower # называет рыночный прокси явно
|
||
assert "доход" in lower # упоминает доход
|
||
|
||
|
||
# ── orchestrator: расчёт платежа ──────────────────────────────────────────────
|
||
|
||
|
||
class TestPaymentComputation:
|
||
def test_monthly_payment_uses_annuity(self) -> None:
|
||
# price 120k × ref 50 м² = 6 000 000 ₽ тело; рыночная ставка = key_rate +
|
||
# калиброванный спред 4.5 п.п., 240 мес. Платёж считается на рыночной ставке.
|
||
with patch(_RATE, return_value=(_MOCK_MARKET_RATE, "2026-01")):
|
||
res = _run(price_per_m2=120_000.0)
|
||
principal = 120_000.0 * _REF_AREA_M2
|
||
expected = _annuity(principal, _MOCK_MARKET_RATE, _ANNUITY_TERM_MONTHS)
|
||
assert res.monthly_payment_rub == pytest.approx(expected)
|
||
assert res.rate_used == pytest.approx(_MOCK_KEY_RATE + _KEY_RATE_MARKET_SPREAD_PP)
|
||
assert res.rate_period == "2026-01"
|
||
assert res.price_per_m2 == 120_000.0
|
||
assert res.ref_area_m2 == _REF_AREA_M2
|
||
|
||
def test_explicit_price_overrides_segment(self) -> None:
|
||
# Явная цена в аргументе → build_sales_series НЕ вызывается.
|
||
with patch(_RATE, return_value=(_MOCK_MARKET_RATE, "2026-01")):
|
||
with patch(_SERIES) as series_mock:
|
||
res = _run(price_per_m2=150_000.0)
|
||
series_mock.assert_not_called()
|
||
assert res.price_per_m2 == 150_000.0
|
||
|
||
def test_segment_price_used_when_arg_none(self) -> None:
|
||
# Без price_per_m2 → сегментная средняя из build_sales_series (reuse).
|
||
with patch(_RATE, return_value=(_MOCK_MARKET_RATE, "2026-01")):
|
||
with patch(_SERIES, return_value=_series_stub([100_000.0, None, 140_000.0])):
|
||
res = _run()
|
||
# средняя НЕнулевых: (100k + 140k) / 2 = 120k.
|
||
assert res.price_per_m2 == pytest.approx(120_000.0)
|
||
|
||
|
||
# ── orchestrator: payment_at_scenario per-горизонт ────────────────────────────
|
||
|
||
|
||
class TestPaymentAtScenario:
|
||
def test_scenario_payment_per_horizon(self) -> None:
|
||
# rate_path: разные ставки на горизонтах → платёж считается на каждой.
|
||
with patch(_RATE, return_value=(_MOCK_MARKET_RATE, "2026-01")):
|
||
res = _run(price_per_m2=120_000.0, rate_path={6: 8.0, 12: 20.0})
|
||
assert res.payment_at_scenario is not None
|
||
principal = 120_000.0 * _REF_AREA_M2
|
||
assert res.payment_at_scenario[6] == pytest.approx(
|
||
_annuity(principal, 8.0, _ANNUITY_TERM_MONTHS)
|
||
)
|
||
assert res.payment_at_scenario[12] == pytest.approx(
|
||
_annuity(principal, 20.0, _ANNUITY_TERM_MONTHS)
|
||
)
|
||
# Выше ставка → выше платёж на этом горизонте.
|
||
assert res.payment_at_scenario[12] > res.payment_at_scenario[6]
|
||
|
||
def test_no_rate_path_yields_none_scenario(self) -> None:
|
||
with patch(_RATE, return_value=(_MOCK_MARKET_RATE, "2026-01")):
|
||
res = _run(price_per_m2=120_000.0)
|
||
assert res.payment_at_scenario is None
|
||
|
||
def test_scenario_drops_uncomputable_horizon(self) -> None:
|
||
# Нет цены → principal None → платёж на горизонте None → ключ выпадает.
|
||
with patch(_RATE, return_value=(_MOCK_MARKET_RATE, "2026-01")):
|
||
with patch(_SERIES, return_value=_series_stub([None])):
|
||
res = _run(rate_path={6: 8.0})
|
||
assert res.payment_at_scenario == {}
|
||
|
||
|
||
# ── orchestrator: graceful (нет ставки / нет цены) ────────────────────────────
|
||
|
||
|
||
class TestGraceful:
|
||
def test_no_rate_yields_none_payment_and_market_unavailable(self) -> None:
|
||
# Рыночной ставки (key_rate) нет в БД → платёж None, rate_used None,
|
||
# rate_kind='market_unavailable' (#981: НЕ подменяем субсидией). Не crash.
|
||
with patch(_RATE, return_value=(None, None)):
|
||
res = _run(price_per_m2=120_000.0)
|
||
assert res.monthly_payment_rub is None
|
||
assert res.rate_used is None
|
||
assert res.rate_kind == _RATE_KIND_UNAVAILABLE
|
||
assert res.rate_kind != _RATE_KIND_SUBSIDIZED
|
||
assert "market_unavailable" in res.degraded_reason.lower()
|
||
assert res.confidence == "low"
|
||
|
||
def test_no_price_yields_none_payment(self) -> None:
|
||
# Тонкий сегмент (нет цены) → principal None → платёж None.
|
||
with patch(_RATE, return_value=(_MOCK_MARKET_RATE, "2026-01")):
|
||
with patch(_SERIES, return_value=_series_stub([None, None])):
|
||
res = _run()
|
||
assert res.price_per_m2 is None
|
||
assert res.monthly_payment_rub is None
|
||
|
||
def test_returns_index_always(self) -> None:
|
||
with patch(_RATE, return_value=(None, None)):
|
||
with patch(_SERIES, return_value=_series_stub([])):
|
||
res = _run()
|
||
assert isinstance(res, MortgageAffordabilityIndex)
|
||
assert res.payment_to_income is None
|
||
assert res.confidence == "low"
|
||
|
||
|
||
# ── pure-ish: _segment_avg_price ──────────────────────────────────────────────
|
||
|
||
|
||
class TestSegmentAvgPrice:
|
||
def test_averages_nonnull_months(self) -> None:
|
||
spec = SegmentSpec(obj_class="комфорт")
|
||
with patch(_SERIES, return_value=_series_stub([110_000.0, None, 130_000.0])):
|
||
price = _segment_avg_price(MagicMock(), spec=spec, source="objective_lots")
|
||
assert price == pytest.approx(120_000.0)
|
||
|
||
def test_all_none_yields_none(self) -> None:
|
||
spec = SegmentSpec(obj_class="комфорт")
|
||
with patch(_SERIES, return_value=_series_stub([None, None])):
|
||
price = _segment_avg_price(MagicMock(), spec=spec, source="objective_lots")
|
||
assert price is None
|
||
|
||
def test_empty_series_yields_none(self) -> None:
|
||
spec = SegmentSpec(obj_class="комфорт")
|
||
with patch(_SERIES, return_value=_series_stub([])):
|
||
price = _segment_avg_price(MagicMock(), spec=spec, source="objective_lots")
|
||
assert price is None
|
||
|
||
|
||
# ── _current_market_rate (#981: key_rate ЦБ как рыночный прокси) ───────────────
|
||
|
||
|
||
def _macro_pt(month: dt.date, key_rate: float | None) -> MonthlyMacro:
|
||
return MonthlyMacro(
|
||
month=month,
|
||
key_rate=key_rate,
|
||
mortgage_rate_weighted=None,
|
||
mortgage_issued_count=None,
|
||
mortgage_issued_volume=None,
|
||
mortgage_debt=None,
|
||
mortgage_overdue=None,
|
||
)
|
||
|
||
|
||
class TestCurrentMarketRate:
|
||
def test_takes_latest_key_rate_with_spread(self) -> None:
|
||
# Самая свежая непустая key_rate (ряд ASC) + калиброванный рыночный спред.
|
||
macro = [
|
||
_macro_pt(dt.date(2025, 11, 1), 19.0),
|
||
_macro_pt(dt.date(2025, 12, 1), 20.0),
|
||
_macro_pt(dt.date(2026, 1, 1), 21.0),
|
||
]
|
||
with patch(_MACRO, return_value=macro):
|
||
rate, period = _current_market_rate(MagicMock())
|
||
assert rate == pytest.approx(21.0 + _KEY_RATE_MARKET_SPREAD_PP)
|
||
assert period == "2026-01"
|
||
# рыночный диапазон, НЕ субсидированный ~7.83%.
|
||
assert rate is not None and rate > 15.0
|
||
|
||
def test_default_spread_is_calibrated_market_spread(self) -> None:
|
||
# Регрессионный якорь: дефолтный спред = 4.5 п.п. (калибровка по прод
|
||
# mortgage_rate_primary_domrf 19.125% vs key_rate ≈ 14.5%). Мокаем key_rate K
|
||
# → _current_market_rate без явного spread_pp обязан вернуть K + 4.5. Откат
|
||
# спреда к 0 (старый stopgap, занижавший заёмную стоимость) здесь упадёт.
|
||
assert _KEY_RATE_MARKET_SPREAD_PP == 4.5
|
||
key_rate = 14.5 # прод-якорь ключевой ставки ЦБ
|
||
with patch(_MACRO, return_value=[_macro_pt(dt.date(2026, 6, 1), key_rate)]):
|
||
rate, _period = _current_market_rate(MagicMock())
|
||
assert rate == pytest.approx(key_rate + _KEY_RATE_MARKET_SPREAD_PP)
|
||
assert rate == pytest.approx(19.0) # ≈ наблюдаемая рыночная ипотека ~19%
|
||
|
||
def test_spread_applied(self) -> None:
|
||
macro = [_macro_pt(dt.date(2026, 1, 1), 18.0)]
|
||
with patch(_MACRO, return_value=macro):
|
||
rate, _period = _current_market_rate(MagicMock(), spread_pp=2.5)
|
||
assert rate == pytest.approx(20.5)
|
||
|
||
def test_ignores_trailing_none_key_rate(self) -> None:
|
||
# Хвостовой None key_rate игнорируется → берётся предыдущая 21.0 + спред.
|
||
macro = [
|
||
_macro_pt(dt.date(2025, 12, 1), 21.0),
|
||
_macro_pt(dt.date(2026, 1, 1), None),
|
||
]
|
||
with patch(_MACRO, return_value=macro):
|
||
rate, period = _current_market_rate(MagicMock())
|
||
assert rate == pytest.approx(21.0 + _KEY_RATE_MARKET_SPREAD_PP)
|
||
assert period == "2025-12"
|
||
|
||
def test_no_key_rate_yields_none(self) -> None:
|
||
# Пустой macro_indicator / нет ни одной key_rate → (None, None) (data-gap).
|
||
with patch(_MACRO, return_value=[_macro_pt(dt.date(2026, 1, 1), None)]):
|
||
assert _current_market_rate(MagicMock()) == (None, None)
|
||
with patch(_MACRO, return_value=[]):
|
||
assert _current_market_rate(MagicMock()) == (None, None)
|