gendesign/backend/tests/services/forecasting/test_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

417 lines
21 KiB
Python
Raw Permalink 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.

"""Unit-тесты §7.9 MAI — прокси платёжной нагрузки по РЫНОЧНОЙ ставке (#981, ADVISORY).
Чистые тесты — БЕЗ живой БД (чистая аннуитет-математика + мок reused-сервисов):
• pure _annuity: textbook-кейс (формула P·i·(1+i)^n/((1+i)^n1)); 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
# rate_path несёт КЛЮЧЕВУЮ ставку сценария; affordability приводит к рыночной
# базе (+ _KEY_RATE_MARKET_SPREAD_PP), как и monthly_payment_rub (#1639). Ожидания
# выражаем символически — тест переживёт перекалибровку спреда.
assert res.payment_at_scenario[6] == pytest.approx(
_annuity(principal, 8.0 + _KEY_RATE_MARKET_SPREAD_PP, _ANNUITY_TERM_MONTHS)
)
assert res.payment_at_scenario[12] == pytest.approx(
_annuity(principal, 20.0 + _KEY_RATE_MARKET_SPREAD_PP, _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)