fix(affordability): calibrate key_rate->market mortgage spread to 4.5pp
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m29s
Deploy / build-worker (push) Successful in 2m40s
Deploy / deploy (push) Successful in 1m13s

_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.
This commit is contained in:
Light1YT 2026-06-04 17:21:29 +05:00
parent cc66f51863
commit 4844847cae
2 changed files with 72 additions and 36 deletions

View file

@ -13,9 +13,12 @@
БД действительно нет, НО ключевая ставка ЦБ ЕСТЬ: `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%.
прокси стоимости заёмных средств (рыночная ипотека = 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/сравнение),
но в индекс по умолчанию НЕ идёт.
@ -24,7 +27,8 @@ ADVISORY / LOW-CONFIDENCE (КРИТИЧНО — честность важнее
(#946) в данных проекта ОТСУТСТВУЕТ → `payment_to_income` ВСЕГДА None,
`confidence='low'` ВСЕГДА (числитель теперь рыночный, но знаменатель-дохода нет).
`degraded_reason` объясняет остаточный пробел (доход) и сделанный выбор прокси
ставки (key_rate, спред 0). Цифра для explainability/прототипа, не для решения.
ставки (key_rate + калиброванный рыночный спред 4.5 п.п.). Цифра для
explainability/прототипа, не для решения.
Graceful-on-thin-data (дух demand_supply_forecast / market_metrics): нет ставки
платёж None; нет цены сегмента платёж None; ставка 0 аннуитет деградирует к
@ -71,9 +75,16 @@ _RATE_KIND_SUBSIDIZED: str = "subsidized_weighted"
_RATE_KIND_UNAVAILABLE: str = "market_unavailable"
# Спред банка над ключевой ставкой для рыночной ипотеки (п.п.). Рыночная ставка ≈
# key_rate + спред; берём 0 — консервативная НИЖНЯЯ граница рыночного платежа (не
# завышаем сверх наблюдаемого сигнала). Tunable, если появится надёжный ряд спреда.
_KEY_RATE_MARKET_SPREAD_PP: float = 0.0
# 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 её не даёт). Каведат
@ -86,11 +97,13 @@ _PRICE_SOURCE: SalesSource = "objective_lots"
_DEGRADED_REASON: str = (
"Прокси платёжной нагрузки по РЫНОЧНОЙ ставке, НЕ истинный §7.9 индекс "
"доступности: (1) рыночная стоимость денег взята как ключевая ставка ЦБ "
"(key_rate, ~16-21%) + спред банка 0 п.п. — прямого ряда «рыночная ипотека "
"без льгот» в БД нет, key_rate — консервативная нижняя граница рыночного "
"платежа (честнее субсидированных ~7-8%); (2) доход домохозяйства (#946) "
"недоступен, поэтому payment_to_income не рассчитывается (всегда None). "
"confidence='low' — знаменатель-дохода отсутствует."
"(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'"
"знаменатель-дохода отсутствует."
)
# Текст деградации, когда ключевой ставки в БД НЕТ вовсе (рыночный сигнал
@ -210,9 +223,9 @@ def _current_market_rate(
Прямого ряда «рыночная ипотека без льгот» в БД нет, но ключевая ставка ЦБ есть
(`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%.
добавляем калиброванный рыночный спред банка (по умолчанию 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' и НЕ подменяет
@ -220,8 +233,8 @@ def _current_market_rate(
Args:
db: SQLAlchemy sync Session.
spread_pp: спред банка над ключевой ставкой в п.п. (по умолчанию
_KEY_RATE_MARKET_SPREAD_PP = 0.0).
spread_pp: калиброванный рыночный спред банка над ключевой ставкой в п.п.
(по умолчанию _KEY_RATE_MARKET_SPREAD_PP = 4.5).
Returns:
(рыночная ставка %, период YYYY-MM последней точки) либо (None, None).

View file

@ -50,6 +50,13 @@ _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 ────────────────────────────────────────────────────────────
@ -179,35 +186,37 @@ def _run(**over: object) -> MortgageAffordabilityIndex:
class TestHonestyContract:
def test_payment_to_income_always_none(self) -> None:
# Дохода в данных нет (#946) → payment_to_income ВСЕГДА None.
with patch(_RATE, return_value=(18.0, "2026-01")):
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=(18.0, "2026-01")):
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-якорь: ставка рыночная (~16-21%), НЕ subsidized_weighted 7.83%.
with patch(_RATE, return_value=(18.0, "2026-01")):
# #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 == 18.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=(18.0, "2026-01")):
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=(18.0, "2026-01")):
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()
@ -221,20 +230,21 @@ class TestHonestyContract:
class TestPaymentComputation:
def test_monthly_payment_uses_annuity(self) -> None:
# price 120k × ref 50 м² = 6 000 000 ₽ тело; рыночная ставка 18%, 240 мес.
with patch(_RATE, return_value=(18.0, "2026-01")):
# 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, 18.0, _ANNUITY_TERM_MONTHS)
expected = _annuity(principal, _MOCK_MARKET_RATE, _ANNUITY_TERM_MONTHS)
assert res.monthly_payment_rub == pytest.approx(expected)
assert res.rate_used == 18.0
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=(18.0, "2026-01")):
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()
@ -242,7 +252,7 @@ class TestPaymentComputation:
def test_segment_price_used_when_arg_none(self) -> None:
# Без price_per_m2 → сегментная средняя из build_sales_series (reuse).
with patch(_RATE, return_value=(18.0, "2026-01")):
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.
@ -255,7 +265,7 @@ class TestPaymentComputation:
class TestPaymentAtScenario:
def test_scenario_payment_per_horizon(self) -> None:
# rate_path: разные ставки на горизонтах → платёж считается на каждой.
with patch(_RATE, return_value=(18.0, "2026-01")):
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
@ -269,13 +279,13 @@ class TestPaymentAtScenario:
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=(18.0, "2026-01")):
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=(18.0, "2026-01")):
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 == {}
@ -299,7 +309,7 @@ class TestGraceful:
def test_no_price_yields_none_payment(self) -> None:
# Тонкий сегмент (нет цены) → principal None → платёж None.
with patch(_RATE, return_value=(18.0, "2026-01")):
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
@ -354,7 +364,7 @@ def _macro_pt(month: dt.date, key_rate: float | None) -> MonthlyMacro:
class TestCurrentMarketRate:
def test_takes_latest_key_rate_with_spread(self) -> None:
# Самая свежая непустая key_rate (ряд ASC) + спред по умолчанию (0 п.п.).
# Самая свежая непустая key_rate (ряд ASC) + калиброванный рыночный спред.
macro = [
_macro_pt(dt.date(2025, 11, 1), 19.0),
_macro_pt(dt.date(2025, 12, 1), 20.0),
@ -367,6 +377,18 @@ class TestCurrentMarketRate:
# рыночный диапазон, НЕ субсидированный ~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):
@ -374,13 +396,14 @@ class TestCurrentMarketRate:
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)
assert rate == pytest.approx(21.0 + _KEY_RATE_MARKET_SPREAD_PP)
assert period == "2025-12"
def test_no_key_rate_yields_none(self) -> None: