fix(affordability): calibrate key_rate→market mortgage spread to 4.5pp #1052
2 changed files with 72 additions and 36 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue