affordability.py:339-342: payment_at_scenario считает аннуитет по rate_path[h] без спреда, хотя rate_path в стеке = key_rate ЦБ → занижен и на иной базе, чем monthly_payment_rub (латент… #1639

Closed
opened 2026-06-16 18:46:26 +00:00 by bot-backend · 0 comments
Collaborator

Severity: low · Категория: logic/unit-contract-mismatch · Файл: backend/app/services/forecasting/affordability.py:276-343, 296-298

Файл: backend/app/services/forecasting/affordability.py:307-308 (docstring контракта rate_path), 337-343 (payment_at_scenario), 218-251/319/334 (_current_market_rate + monthly_payment).

Суть несогласованности: monthly_payment_rub считается по РЫНОЧНОЙ ставке = key_rate ЦБ + калиброванный спред 4.5 п.п. (_current_market_rate, строка 251 return latest_rate + spread_pp; вызов 319; аннуитет 334). А payment_at_scenario[h] (строки 339-342) считает _annuity(principal, scenario_rate, ...), где scenario_rate = rate_path[h] подставляется НАПРЯМУЮ как annual_rate, БЕЗ добавления спреда. Docstring 307-308 объявляет rate_path как '{horizon: годовая ставка %}', но во всём остальном forecasting-стеке rate_path = key_rate ЦБ: demand_supply_forecast.py:586 ('сценарный {horizon: key_rate}'), :598-637 rate_path → rate_future → §9.6 rate-sensitivity (ставка ЦБ, не ипотечная); scenarios строят конверты key_rate.

Импакт: при передаче такого rate_path (key_rate ~16%) аннуитет посчитается по ~16% вместо рыночных ~20.5% (key_rate+4.5), payment_at_scenario СИСТЕМАТИЧЕСКИ НИЖЕ базового monthly_payment_rub при той же базовой ставке — две величины одного объекта на разной базе ставки. Нарушает инвариант module docstring 'rate_used ≈ key_rate + 4.5' и даёт ложно оптимистичный price_feasibility downstream.

ЛАТЕНТНОСТЬ: подтверждено — ни один текущий вызывающий не передаёт rate_path: product_scoring.py:659 compute_affordability(db, spec=spec); special_indices.py:1532 compute_affordability(db, spec=spec, ref_area_m2=...). Поэтому payment_at_scenario всегда None в проде. Грядущая ловушка контракта.

Фикс: в payment_at_scenario применять тот же key_rate→рыночная спред: _annuity(principal, scenario_rate + _KEY_RATE_MARKET_SPREAD_PP, _ANNUITY_TERM_MONTHS), либо явно зафиксировать/переименовать параметр, что rate_path сюда передаётся УЖЕ как рыночная ставка (а не key_rate стека).

Почему баг: Внутренняя несогласованность: monthly_payment_rub применяет спред key_rate→рыночная, а payment_at_scenario — нет, при том что rate_path по соглашению стека несёт key_rate. Это нарушает заявленный в module docstring инвариант 'rate_used ≈ key_rate + 4.5' для сценарного пути. Импакт сейчас латентный: ни один текущий вызывающий не передаёт rate_path (product_scoring.py:659 и special_indices.py:1532 зовут compute_affordability БЕЗ rate_path), поэтому payment_at_scenario всегда None в проде. Но это ловушка контракта: первый же вызов со сценарными ставками (которые в этом стеке = key_rate) даст занижённые платежи и ложно более оптимистичный price_feasibility.

Как чинить: Применять тот же спред к сценарным ставкам, что и к базовой: либо принимать rate_path как key_rate и внутри делать _annuity(principal, scenario_rate + _KEY_RATE_MARKET_SPREAD_PP, ...), либо переименовать аргумент/уточнить контракт, чтобы было однозначно, что сюда подаётся уже-рыночная ставка, и добавить проверку согласованности с monthly_payment_rub.


deep-audit backend v2 (2-й проход, после PR #1543) · verify+harden, confidence 0.72 · unit B26

**Severity:** low · **Категория:** logic/unit-contract-mismatch · **Файл:** `backend/app/services/forecasting/affordability.py:276-343, 296-298` Файл: backend/app/services/forecasting/affordability.py:307-308 (docstring контракта rate_path), 337-343 (payment_at_scenario), 218-251/319/334 (_current_market_rate + monthly_payment). Суть несогласованности: monthly_payment_rub считается по РЫНОЧНОЙ ставке = key_rate ЦБ + калиброванный спред 4.5 п.п. (_current_market_rate, строка 251 `return latest_rate + spread_pp`; вызов 319; аннуитет 334). А payment_at_scenario[h] (строки 339-342) считает `_annuity(principal, scenario_rate, ...)`, где scenario_rate = rate_path[h] подставляется НАПРЯМУЮ как annual_rate, БЕЗ добавления спреда. Docstring 307-308 объявляет rate_path как '{horizon: годовая ставка %}', но во всём остальном forecasting-стеке rate_path = key_rate ЦБ: demand_supply_forecast.py:586 ('сценарный {horizon: key_rate}'), :598-637 rate_path → rate_future → §9.6 rate-sensitivity (ставка ЦБ, не ипотечная); scenarios строят конверты key_rate. Импакт: при передаче такого rate_path (key_rate ~16%) аннуитет посчитается по ~16% вместо рыночных ~20.5% (key_rate+4.5), payment_at_scenario СИСТЕМАТИЧЕСКИ НИЖЕ базового monthly_payment_rub при той же базовой ставке — две величины одного объекта на разной базе ставки. Нарушает инвариант module docstring 'rate_used ≈ key_rate + 4.5' и даёт ложно оптимистичный price_feasibility downstream. ЛАТЕНТНОСТЬ: подтверждено — ни один текущий вызывающий не передаёт rate_path: product_scoring.py:659 `compute_affordability(db, spec=spec)`; special_indices.py:1532 `compute_affordability(db, spec=spec, ref_area_m2=...)`. Поэтому payment_at_scenario всегда None в проде. Грядущая ловушка контракта. Фикс: в payment_at_scenario применять тот же key_rate→рыночная спред: `_annuity(principal, scenario_rate + _KEY_RATE_MARKET_SPREAD_PP, _ANNUITY_TERM_MONTHS)`, либо явно зафиксировать/переименовать параметр, что rate_path сюда передаётся УЖЕ как рыночная ставка (а не key_rate стека). **Почему баг:** Внутренняя несогласованность: monthly_payment_rub применяет спред key_rate→рыночная, а payment_at_scenario — нет, при том что rate_path по соглашению стека несёт key_rate. Это нарушает заявленный в module docstring инвариант 'rate_used ≈ key_rate + 4.5' для сценарного пути. Импакт сейчас латентный: ни один текущий вызывающий не передаёт rate_path (product_scoring.py:659 и special_indices.py:1532 зовут compute_affordability БЕЗ rate_path), поэтому payment_at_scenario всегда None в проде. Но это ловушка контракта: первый же вызов со сценарными ставками (которые в этом стеке = key_rate) даст занижённые платежи и ложно более оптимистичный price_feasibility. **Как чинить:** Применять тот же спред к сценарным ставкам, что и к базовой: либо принимать rate_path как key_rate и внутри делать `_annuity(principal, scenario_rate + _KEY_RATE_MARKET_SPREAD_PP, ...)`, либо переименовать аргумент/уточнить контракт, чтобы было однозначно, что сюда подаётся уже-рыночная ставка, и добавить проверку согласованности с monthly_payment_rub. --- <sub>deep-audit backend v2 (2-й проход, после PR #1543) · verify+harden, confidence 0.72 · unit B26</sub>
bot-backend added the
week ревью 1
label 2026-06-16 18:46:26 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#1639
No description provided.