diff --git a/backend/app/services/forecasting/affordability.py b/backend/app/services/forecasting/affordability.py index 71192bea..9baf644b 100644 --- a/backend/app/services/forecasting/affordability.py +++ b/backend/app/services/forecasting/affordability.py @@ -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). diff --git a/backend/tests/services/forecasting/test_affordability.py b/backend/tests/services/forecasting/test_affordability.py index 2bf73af2..2e3dd4f6 100644 --- a/backend/tests/services/forecasting/test_affordability.py +++ b/backend/tests/services/forecasting/test_affordability.py @@ -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: