REOPENED #980: when effective competing supply is exhausted under positive demand (projected_supply<=0, demand>0), deficit_index now caps to +1.0 (peak of [-1,+1]) instead of None. balance_ratio stays None (demand/0 undefined), but the strongest build signal no longer reads downstream as thin data (market_fit fell to 0.5, what_to_build dropped the cell). No-signal (supply<=0 AND demand<=0) stays None. REOPENED #981: MAI now uses CBR key rate (macro_indicator key_rate/rf via get_monthly_macro) as the market borrowing-cost proxy (~16-21%) instead of the subsidized weighted rate (~7.83%), per §7.9 DoD. rate_kind='key_rate_proxy'. If key_rate absent → rate_kind='market_unavailable' (no silent subsidy fallback). Income (#946) still missing → payment_to_income None, confidence low. 778 forecasting tests green. Refs #980 #981
This commit is contained in:
parent
33d1a63bc8
commit
9ff80f3ac0
4 changed files with 383 additions and 81 deletions
|
|
@ -1,23 +1,30 @@
|
||||||
"""§7.9 Mortgage Affordability Index (MAI) — ДЕГРАДИРОВАННЫЙ прокси платёжной нагрузки.
|
"""§7.9 Mortgage Affordability Index (MAI) — прокси платёжной нагрузки по РЫНОЧНОЙ ставке.
|
||||||
|
|
||||||
#981 (952-B, Site Finder v2 / «GG-форсайт» ТЗ §7.9), EPIC 8 «что строить +
|
#981 (952-B, Site Finder v2 / «GG-форсайт» ТЗ §7.9), EPIC 8 «что строить +
|
||||||
доступность». Это **СБОРОЧНЫЙ слой**: своей §9.x-математики не пересобирает —
|
доступность». Это **СБОРОЧНЫЙ слой**: своей §9.x-математики не пересобирает —
|
||||||
переиспользует helper'ы (`_current_mortgage_rate` §аналитики, `build_sales_series`
|
переиспользует helper'ы (`get_monthly_macro` §9.5/§9.6 для ключевой ставки ЦБ,
|
||||||
для сегментной цены ₽/м²). Всё ДЕТЕРМИНИРОВАННО, БЕЗ LLM (чистая аннуитет-арифметика
|
`build_sales_series` для сегментной цены ₽/м²). Всё ДЕТЕРМИНИРОВАННО, БЕЗ LLM
|
||||||
+ reuse; своего SQL НЕТ).
|
(чистая аннуитет-арифметика + reuse; своего SQL НЕТ).
|
||||||
|
|
||||||
|
РЫНОЧНАЯ СТАВКА — KEY_RATE ПРОКСИ (#981 DoD: «НЕ mortgage_rate_weighted 7.83%»):
|
||||||
|
DoD §7.9 требует РЫНОЧНОЙ стоимости заёмных денег (~16-21% по ЦБ Янв 2026), НЕ
|
||||||
|
субсидированной средневзвешенной ИЖК-ставки (~7-8% с семейной/IT/ДВ-льготами —
|
||||||
|
она ЗАНИЖАЕТ платёж против рынка). Прямого ряда «рыночная ипотека без льгот» в
|
||||||
|
БД действительно нет, НО ключевая ставка ЦБ ЕСТЬ: `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%.
|
||||||
|
Субсидированный путь сохранён в `_current_subsidized_rate` (explain/сравнение),
|
||||||
|
но в индекс по умолчанию НЕ идёт.
|
||||||
|
|
||||||
ADVISORY / LOW-CONFIDENCE (КРИТИЧНО — честность важнее цифры):
|
ADVISORY / LOW-CONFIDENCE (КРИТИЧНО — честность важнее цифры):
|
||||||
Настоящий §7.9 индекс доступности = «рыночный аннуитет (~20%) ÷ доход
|
Истинный §7.9 = «рыночный аннуитет ÷ доход домохозяйства». ДОХОД домохозяйства
|
||||||
домохозяйства». Обе входные величины в нашей БД ОТСУТСТВУЮТ:
|
(#946) в данных проекта ОТСУТСТВУЕТ → `payment_to_income` ВСЕГДА None,
|
||||||
1. РЫНОЧНАЯ ставка (~20% по ЦБ Янв 2026) — её серии в cbr_mortgage_series НЕТ.
|
`confidence='low'` ВСЕГДА (числитель теперь рыночный, но знаменатель-дохода нет).
|
||||||
Единственная доступная ставка — `_current_mortgage_rate`: СРЕДНЕВЗВЕШЕННАЯ
|
`degraded_reason` объясняет остаточный пробел (доход) и сделанный выбор прокси
|
||||||
С льготами (семейная/IT/ДВ), ~7-8% — она ЗАНИЖАЕТ платёж против рынка.
|
ставки (key_rate, спред 0). Цифра — для explainability/прототипа, не для решения.
|
||||||
2. ДОХОД домохозяйства (#946) — в данных проекта его нет вовсе.
|
|
||||||
Поэтому MAI здесь = ПРОКСИ платёжной нагрузки (annuity на эталонную квартиру по
|
|
||||||
субсидированной ставке), а НЕ истинный рыночный индекс §7.9: `payment_to_income`
|
|
||||||
ВСЕГДА None (нет дохода), `rate_kind='subsidized_weighted'` (не рыночная),
|
|
||||||
`confidence='low'` ВСЕГДА (деградация по двум каналам), `degraded_reason`
|
|
||||||
объясняет оба пробела. Цифра — для explainability/прототипа, не для решения.
|
|
||||||
|
|
||||||
Graceful-on-thin-data (дух demand_supply_forecast / market_metrics): нет ставки →
|
Graceful-on-thin-data (дух demand_supply_forecast / market_metrics): нет ставки →
|
||||||
платёж None; нет цены сегмента → платёж None; ставка ≤0 → аннуитет деградирует к
|
платёж None; нет цены сегмента → платёж None; ставка ≤0 → аннуитет деградирует к
|
||||||
|
|
@ -35,6 +42,7 @@ from typing import Any, Literal
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.services.analytics_queries import _current_mortgage_rate
|
from app.services.analytics_queries import _current_mortgage_rate
|
||||||
|
from app.services.forecasting.macro_series import get_monthly_macro
|
||||||
from app.services.forecasting.sales_series import (
|
from app.services.forecasting.sales_series import (
|
||||||
SalesSource,
|
SalesSource,
|
||||||
SegmentSpec,
|
SegmentSpec,
|
||||||
|
|
@ -55,42 +63,64 @@ _REF_AREA_M2: float = 50.0
|
||||||
# (медиана ЦБ ~ 20-25 лет; берём круглые 20 для детерминизма). Tunable.
|
# (медиана ЦБ ~ 20-25 лет; берём круглые 20 для детерминизма). Tunable.
|
||||||
_ANNUITY_TERM_MONTHS: int = 240
|
_ANNUITY_TERM_MONTHS: int = 240
|
||||||
|
|
||||||
# Метка вида ставки в выводе: всегда субсидированная средневзвешенная (рыночной
|
# Метка вида ставки в выводе. По умолчанию — рыночный прокси через ключевую ставку
|
||||||
# ~20% в БД нет). Зафиксирована константой — downstream не должен принять её за
|
# ЦБ (#981 DoD: рыночная стоимость денег, НЕ субсидия 7.83%). Субсидированная метка
|
||||||
# рыночную ставку §7.9.
|
# сохранена для explain-сравнения, но в индекс по умолчанию НЕ идёт.
|
||||||
|
_RATE_KIND_KEY_RATE_PROXY: str = "key_rate_proxy"
|
||||||
_RATE_KIND_SUBSIDIZED: str = "subsidized_weighted"
|
_RATE_KIND_SUBSIDIZED: str = "subsidized_weighted"
|
||||||
|
_RATE_KIND_UNAVAILABLE: str = "market_unavailable"
|
||||||
|
|
||||||
|
# Спред банка над ключевой ставкой для рыночной ипотеки (п.п.). Рыночная ставка ≈
|
||||||
|
# key_rate + спред; берём 0 — консервативная НИЖНЯЯ граница рыночного платежа (не
|
||||||
|
# завышаем сверх наблюдаемого сигнала). Tunable, если появится надёжный ряд спреда.
|
||||||
|
_KEY_RATE_MARKET_SPREAD_PP: float = 0.0
|
||||||
|
|
||||||
# Источник сегментной цены ₽/м² по умолчанию: Source B (objective_lots) — несёт
|
# Источник сегментной цены ₽/м² по умолчанию: Source B (objective_lots) — несёт
|
||||||
# per-lot цену для price/room-сегментации (Source A её не даёт). Каведат
|
# per-lot цену для price/room-сегментации (Source A её не даёт). Каведат
|
||||||
# survivorship bias задокументирован в sales_series; для прокси-индекса приемлемо.
|
# survivorship bias задокументирован в sales_series; для прокси-индекса приемлемо.
|
||||||
_PRICE_SOURCE: SalesSource = "objective_lots"
|
_PRICE_SOURCE: SalesSource = "objective_lots"
|
||||||
|
|
||||||
# Текст деградации — объясняет ОБА пробела (рыночная ставка + доход), чтобы вывод
|
# Текст деградации — объясняет выбор рыночного прокси ставки И остаточный пробел
|
||||||
# нельзя было прочитать как истинный §7.9 индекс. Используется в degraded_reason.
|
# (доход), чтобы вывод нельзя было прочитать как истинный §7.9 индекс. Используется
|
||||||
|
# в degraded_reason, когда ключевая ставка ДОСТУПНА (рыночный путь отработал).
|
||||||
_DEGRADED_REASON: str = (
|
_DEGRADED_REASON: str = (
|
||||||
"Прокси платёжной нагрузки, НЕ истинный §7.9 индекс доступности: "
|
"Прокси платёжной нагрузки по РЫНОЧНОЙ ставке, НЕ истинный §7.9 индекс "
|
||||||
"(1) использована СУБСИДИРОВАННАЯ средневзвешенная ставка ИЖК (~7-8%) — "
|
"доступности: (1) рыночная стоимость денег взята как ключевая ставка ЦБ "
|
||||||
"рыночной ставки без льгот (~20%) в БД нет, платёж занижен против рынка; "
|
"(key_rate, ~16-21%) + спред банка 0 п.п. — прямого ряда «рыночная ипотека "
|
||||||
"(2) доход домохозяйства (#946) недоступен, поэтому payment_to_income не "
|
"без льгот» в БД нет, key_rate — консервативная нижняя граница рыночного "
|
||||||
"рассчитывается (всегда None). confidence='low' — деградация по двум каналам."
|
"платежа (честнее субсидированных ~7-8%); (2) доход домохозяйства (#946) "
|
||||||
|
"недоступен, поэтому payment_to_income не рассчитывается (всегда None). "
|
||||||
|
"confidence='low' — знаменатель-дохода отсутствует."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Текст деградации, когда ключевой ставки в БД НЕТ вовсе (рыночный сигнал
|
||||||
|
# недоступен) — НЕ подменяем субсидией, честно сообщаем data-gap (rate_used=None).
|
||||||
|
_DEGRADED_REASON_NO_MARKET: str = (
|
||||||
|
"Прокси платёжной нагрузки НЕ рассчитан: рыночной ставки нет — ни прямого "
|
||||||
|
"ряда рыночной ипотеки, ни ключевой ставки ЦБ (key_rate) в macro_indicator. "
|
||||||
|
"Субсидированной ставкой (~7-8%) НЕ подменяем (исказила бы рыночный платёж). "
|
||||||
|
"rate_used=None, rate_kind='market_unavailable'. Доход (#946) также "
|
||||||
|
"недоступен → payment_to_income None. confidence='low' — data-gap по ставке."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class MortgageAffordabilityIndex:
|
class MortgageAffordabilityIndex:
|
||||||
"""§7.9 MAI — ДЕГРАДИРОВАННЫЙ прокси платёжной нагрузки (считается на лету).
|
"""§7.9 MAI — прокси платёжной нагрузки по РЫНОЧНОЙ ставке (считается на лету).
|
||||||
|
|
||||||
Все денежные поля = None при недостатке данных (НИКОГДА 0-как-заглушка).
|
Все денежные поля = None при недостатке данных (НИКОГДА 0-как-заглушка).
|
||||||
`payment_to_income` ВСЕГДА None (дохода нет в данных, #946). `rate_kind` ВСЕГДА
|
`payment_to_income` ВСЕГДА None (дохода нет в данных, #946). `rate_kind`:
|
||||||
'subsidized_weighted' (рыночной ставки нет). `confidence` ВСЕГДА 'low' (прокси,
|
'key_rate_proxy' (ключевая ставка ЦБ как рыночный прокси — путь по умолчанию,
|
||||||
не истинный индекс §7.9). `degraded_reason` объясняет оба пробела. Это
|
#981 DoD) или 'market_unavailable' (если key_rate в БД нет — rate_used тогда
|
||||||
|
None, субсидией НЕ подменяем). `confidence` ВСЕГДА 'low' (нет дохода-знаменателя).
|
||||||
|
`degraded_reason` объясняет выбор прокси ставки + пробел дохода. Это
|
||||||
explainability-прокси, НЕ основание для инвест-решения.
|
explainability-прокси, НЕ основание для инвест-решения.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
segment: dict[str, str | None]
|
segment: dict[str, str | None]
|
||||||
monthly_payment_rub: float | None # аннуитет на эталонную квартиру (₽/мес)
|
monthly_payment_rub: float | None # аннуитет на эталонную квартиру (₽/мес)
|
||||||
rate_used: float | None # годовая ставка (%) — субсидированная средневзвеш.
|
rate_used: float | None # годовая рыночная ставка (%) — key_rate + спред (#981)
|
||||||
rate_kind: str # ВСЕГДА 'subsidized_weighted'
|
rate_kind: str # 'key_rate_proxy' | 'market_unavailable'
|
||||||
rate_period: str | None # период точки ставки (explain)
|
rate_period: str | None # период точки ставки (explain)
|
||||||
ref_area_m2: float # эталонная площадь кредита (м²)
|
ref_area_m2: float # эталонная площадь кредита (м²)
|
||||||
price_per_m2: float | None # сегментная цена ₽/м² (вход тела кредита)
|
price_per_m2: float | None # сегментная цена ₽/м² (вход тела кредита)
|
||||||
|
|
@ -167,6 +197,60 @@ def _annuity(
|
||||||
return principal * i * growth / (growth - 1.0)
|
return principal * i * growth / (growth - 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Ставки — рыночный прокси (key_rate, #981) + субсидированный (explain-only).
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _current_market_rate(
|
||||||
|
db: Session, *, spread_pp: float = _KEY_RATE_MARKET_SPREAD_PP
|
||||||
|
) -> tuple[float | None, str | None]:
|
||||||
|
"""Рыночная стоимость заёмных денег как ключевая ставка ЦБ + спред (#981 DoD).
|
||||||
|
|
||||||
|
Прямого ряда «рыночная ипотека без льгот» в БД нет, но ключевая ставка ЦБ есть
|
||||||
|
(`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%.
|
||||||
|
|
||||||
|
Graceful: нет ни одной точки key_rate (пустой macro_indicator / сбой БД) →
|
||||||
|
(None, None) — caller помечает rate_kind='market_unavailable' и НЕ подменяет
|
||||||
|
субсидией. Reuse get_monthly_macro (свой SQL не пишем). Детерминированно.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: SQLAlchemy sync Session.
|
||||||
|
spread_pp: спред банка над ключевой ставкой в п.п. (по умолчанию
|
||||||
|
_KEY_RATE_MARKET_SPREAD_PP = 0.0).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(рыночная ставка %, период YYYY-MM последней точки) либо (None, None).
|
||||||
|
"""
|
||||||
|
macro = get_monthly_macro(db)
|
||||||
|
latest_rate: float | None = None
|
||||||
|
latest_period: str | None = None
|
||||||
|
for m in macro: # ряд ASC по month → последняя непустая = самая свежая
|
||||||
|
if m.key_rate is not None:
|
||||||
|
latest_rate = m.key_rate
|
||||||
|
latest_period = m.month.strftime("%Y-%m")
|
||||||
|
if latest_rate is None:
|
||||||
|
return None, None
|
||||||
|
return latest_rate + spread_pp, latest_period
|
||||||
|
|
||||||
|
|
||||||
|
# `_current_subsidized_rate` — субсидированная средневзвешенная ИЖК-ставка (~7-8%).
|
||||||
|
# СОХРАНЕНА для explain/сравнения, но в индекс по умолчанию НЕ идёт (#981 DoD требует
|
||||||
|
# рыночную). Тонкий алиас на §аналитики _current_mortgage_rate (источник истины).
|
||||||
|
def _current_subsidized_rate(db: Session) -> tuple[float | None, str | None]:
|
||||||
|
"""Субсидированная средневзвешенная ИЖК-ставка (~7-8%) — explain-only (#981).
|
||||||
|
|
||||||
|
Reuse analytics_queries._current_mortgage_rate. НЕ используется в индексе по
|
||||||
|
умолчанию (рыночный путь — _current_market_rate); оставлена для явного
|
||||||
|
сравнения «рыночная vs субсидия» downstream при необходимости.
|
||||||
|
"""
|
||||||
|
return _current_mortgage_rate(db)
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# DB-оркестратор — тонкий, graceful. Pure-логика выше тестируется без него.
|
# DB-оркестратор — тонкий, graceful. Pure-логика выше тестируется без него.
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -181,15 +265,18 @@ def compute_affordability(
|
||||||
ref_area_m2: float = _REF_AREA_M2,
|
ref_area_m2: float = _REF_AREA_M2,
|
||||||
price_source: SalesSource = _PRICE_SOURCE,
|
price_source: SalesSource = _PRICE_SOURCE,
|
||||||
) -> MortgageAffordabilityIndex:
|
) -> MortgageAffordabilityIndex:
|
||||||
"""§7.9 MAI — ДЕГРАДИРОВАННЫЙ прокси платёжной нагрузки для сегмента.
|
"""§7.9 MAI — прокси платёжной нагрузки по РЫНОЧНОЙ ставке для сегмента (#981).
|
||||||
|
|
||||||
ADVISORY / LOW-CONFIDENCE: это ПРОКСИ, а НЕ истинный §7.9 индекс доступности.
|
ADVISORY / LOW-CONFIDENCE: это ПРОКСИ, а НЕ истинный §7.9 индекс доступности.
|
||||||
Рыночной ставки (~20%) и дохода домохозяйства (#946) в данных нет — см. module
|
Числитель теперь РЫНОЧНЫЙ (ключевая ставка ЦБ), но дохода домохозяйства (#946)
|
||||||
docstring и `degraded_reason`. `payment_to_income` ВСЕГДА None.
|
в данных нет — см. module docstring и `degraded_reason`. `payment_to_income`
|
||||||
|
ВСЕГДА None, `confidence` ВСЕГДА 'low' (нет знаменателя-дохода).
|
||||||
|
|
||||||
Что считает:
|
Что считает:
|
||||||
• rate, period = _current_mortgage_rate(db) — субсидированная средневзвеш.
|
• rate, period = _current_market_rate(db) — ключевая ставка ЦБ + спред как
|
||||||
ставка (~7-8%; ЕДИНСТВЕННАЯ в БД). Это rate_kind='subsidized_weighted'.
|
рыночный прокси (~16-21%; #981 DoD). rate_kind='key_rate_proxy'. Если
|
||||||
|
key_rate в БД нет → (None, None), rate_kind='market_unavailable' (субсидией
|
||||||
|
НЕ подменяем — это исказило бы рыночный платёж; честный data-gap).
|
||||||
• price_per_m2 — из аргумента ИЛИ сегментная средняя (reuse build_sales_series,
|
• price_per_m2 — из аргумента ИЛИ сегментная средняя (reuse build_sales_series,
|
||||||
усреднение НЕнулевых месяцев). None → платёж None (graceful).
|
усреднение НЕнулевых месяцев). None → платёж None (graceful).
|
||||||
• monthly_payment = _annuity(principal=price×ref_area, annual_rate=rate,
|
• monthly_payment = _annuity(principal=price×ref_area, annual_rate=rate,
|
||||||
|
|
@ -213,7 +300,16 @@ def compute_affordability(
|
||||||
MortgageAffordabilityIndex (всегда; тонко → None-поля, confidence='low').
|
MortgageAffordabilityIndex (всегда; тонко → None-поля, confidence='low').
|
||||||
"""
|
"""
|
||||||
segment = spec.as_dict()
|
segment = spec.as_dict()
|
||||||
rate, rate_period = _current_mortgage_rate(db)
|
# #981 DoD: рыночная ставка (key_rate ЦБ + спред), НЕ субсидированная 7.83%.
|
||||||
|
# Нет key_rate в БД → rate None, помечаем market_unavailable (НЕ подменяем
|
||||||
|
# субсидией — это исказило бы рыночный платёж).
|
||||||
|
rate, rate_period = _current_market_rate(db)
|
||||||
|
if rate is not None:
|
||||||
|
rate_kind = _RATE_KIND_KEY_RATE_PROXY
|
||||||
|
degraded_reason = _DEGRADED_REASON
|
||||||
|
else:
|
||||||
|
rate_kind = _RATE_KIND_UNAVAILABLE
|
||||||
|
degraded_reason = _DEGRADED_REASON_NO_MARKET
|
||||||
|
|
||||||
resolved_price = (
|
resolved_price = (
|
||||||
price_per_m2
|
price_per_m2
|
||||||
|
|
@ -234,10 +330,12 @@ def compute_affordability(
|
||||||
payment_at_scenario = scenario
|
payment_at_scenario = scenario
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"affordability: segment=%s rate=%s period=%s price_per_m2=%s ref_area=%.1f "
|
"affordability: segment=%s rate=%s kind=%s period=%s price_per_m2=%s "
|
||||||
"monthly_payment=%s confidence=low (DEGRADED proxy, payment_to_income=None)",
|
"ref_area=%.1f monthly_payment=%s confidence=low "
|
||||||
|
"(market proxy, payment_to_income=None)",
|
||||||
segment,
|
segment,
|
||||||
_round_or_none(rate, 2),
|
_round_or_none(rate, 2),
|
||||||
|
rate_kind,
|
||||||
rate_period,
|
rate_period,
|
||||||
_round_or_none(resolved_price, 0),
|
_round_or_none(resolved_price, 0),
|
||||||
ref_area_m2,
|
ref_area_m2,
|
||||||
|
|
@ -248,15 +346,15 @@ def compute_affordability(
|
||||||
segment=segment,
|
segment=segment,
|
||||||
monthly_payment_rub=monthly_payment,
|
monthly_payment_rub=monthly_payment,
|
||||||
rate_used=rate,
|
rate_used=rate,
|
||||||
rate_kind=_RATE_KIND_SUBSIDIZED,
|
rate_kind=rate_kind,
|
||||||
rate_period=rate_period,
|
rate_period=rate_period,
|
||||||
ref_area_m2=ref_area_m2,
|
ref_area_m2=ref_area_m2,
|
||||||
price_per_m2=resolved_price,
|
price_per_m2=resolved_price,
|
||||||
annuity_term_months=_ANNUITY_TERM_MONTHS,
|
annuity_term_months=_ANNUITY_TERM_MONTHS,
|
||||||
payment_to_income=None, # дохода нет в данных (#946) — НИКОГДА не считаем
|
payment_to_income=None, # дохода нет в данных (#946) — НИКОГДА не считаем
|
||||||
payment_at_scenario=payment_at_scenario,
|
payment_at_scenario=payment_at_scenario,
|
||||||
degraded_reason=_DEGRADED_REASON,
|
degraded_reason=degraded_reason,
|
||||||
confidence="low", # ВСЕГДА low — деградированный прокси, не истинный §7.9
|
confidence="low", # ВСЕГДА low — нет дохода-знаменателя, не истинный §7.9
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,8 +43,10 @@ ADVISORY-СТАТУС: движок СОВЕТУЮЩИЙ и НЕ подключ
|
||||||
|
|
||||||
Graceful-on-thin-data (дух future_supply / market_metrics): любой тонкий вход →
|
Graceful-on-thin-data (дух future_supply / market_metrics): любой тонкий вход →
|
||||||
соответствующее поле None (НИКОГДА 0-как-заглушка), НИКОГДА не crash, НИКОГДА
|
соответствующее поле None (НИКОГДА 0-как-заглушка), НИКОГДА не crash, НИКОГДА
|
||||||
деления на ноль. supply ≤ 0 / None → balance_ratio=None, deficit_index=None,
|
деления на ноль. supply ≤ 0 / None → balance_ratio=None (∞-кратность не выдумываем);
|
||||||
confidence занижен (не выдумываем «бесконечный дефицит»). Детерминированно.
|
deficit_index при этом = +1.0 ТОЛЬКО если спрос > 0 (предложение исчерпано под спрос
|
||||||
|
= ПИК дефицита по семантике [−1,+1], #980), иначе None (нет сигнала: и спрос ≤0).
|
||||||
|
confidence занижается выше по стеку. Детерминированно.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -119,7 +121,9 @@ class DemandSupplyForecast:
|
||||||
(НИКОГДА 0-как-заглушка). `advisory` ВСЕГДА True (движок не для production-решений
|
(НИКОГДА 0-как-заглушка). `advisory` ВСЕГДА True (движок не для production-решений
|
||||||
до бэктеста #951). `deficit_index` ∈ [−1,+1] когда задан: +1 = сильный дефицит
|
до бэктеста #951). `deficit_index` ∈ [−1,+1] когда задан: +1 = сильный дефицит
|
||||||
(мало предложения под спрос — «хорошо» для девелопера), −1 = сильная затоварка
|
(мало предложения под спрос — «хорошо» для девелопера), −1 = сильная затоварка
|
||||||
(предложения больше спроса — «риск»); None при неизмеримом предложении.
|
(предложения больше спроса — «риск»). Предложение исчерпано под спрос (supply≤0,
|
||||||
|
demand>0) → +1.0 (пик дефицита, #980, хотя balance_ratio тогда None); None только
|
||||||
|
при отсутствии сигнала (supply≤0 И demand≤0/None).
|
||||||
`rate_sensitivity_phrase` — ТОЛЬКО explainability (β НЕ участвует в арифметике
|
`rate_sensitivity_phrase` — ТОЛЬКО explainability (β НЕ участвует в арифметике
|
||||||
спроса дважды — он уже учтён внутри demand_norm_coefficient §9.4).
|
спроса дважды — он уже учтён внутри demand_norm_coefficient §9.4).
|
||||||
"""
|
"""
|
||||||
|
|
@ -335,6 +339,48 @@ def _deficit_index(
|
||||||
return max(-1.0, min(1.0, raw))
|
return max(-1.0, min(1.0, raw))
|
||||||
|
|
||||||
|
|
||||||
|
def _deficit_index_from_balance(
|
||||||
|
projected_demand_units: float | None,
|
||||||
|
projected_supply_units: float | None,
|
||||||
|
balance_ratio: float | None,
|
||||||
|
*,
|
||||||
|
saturation: float = _DEFICIT_RATIO_SATURATION,
|
||||||
|
) -> float | None:
|
||||||
|
"""deficit_index с явной обработкой исчерпанного предложения (#980). PURE.
|
||||||
|
|
||||||
|
Нормальный путь: supply > 0 → `_deficit_index(balance_ratio)` (лог-шкала).
|
||||||
|
|
||||||
|
Граничный случай СИЛЬНЕЙШЕГО дефицита (#980): спрос поглотил ВСЁ предложение
|
||||||
|
(effective `projected_supply_units` ≤ 0) ПРИ положительном спросе. balance_ratio
|
||||||
|
тогда честно None (спрос/0 неотличим от «нет данных» — оставляем None как есть),
|
||||||
|
НО индекс по документированной семантике (∈ [−1,+1], +1 = сильный дефицит)
|
||||||
|
обязан быть +1.0, а НЕ None: исчерпанное под спрос предложение — это пик шкалы
|
||||||
|
дефицита, а не «нет сигнала». Раньше тут возвращался None — самый сильный
|
||||||
|
дефицит читался downstream как тонкие данные (market_fit падал к 0.5, ячейка
|
||||||
|
выпадала из what_to_build). Кап = +1.0 (саму ∞-кратность не выдумываем — клампим
|
||||||
|
к пределу шкалы, зеркало `_deficit_index` clamp-а).
|
||||||
|
|
||||||
|
«Нет сигнала вообще» (supply ≤ 0 И спрос ≤ 0 / None) → None: ни дефицита, ни
|
||||||
|
затоварки измерить нельзя (нечему конкурировать и нечему поглощать).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
projected_demand_units: спроектированный спрос (None / ≤0 → нет дефицит-сигнала).
|
||||||
|
projected_supply_units: чистое конкурирующее предложение (≤0 → исчерпано).
|
||||||
|
balance_ratio: спрос/предложение (вход нормального пути; None при supply ≤0).
|
||||||
|
saturation: ratio, при котором индекс достигает ±1 (proxy в _deficit_index).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Индекс в [−1,+1] или None (нет измеримого сигнала).
|
||||||
|
"""
|
||||||
|
if projected_supply_units is not None and projected_supply_units <= 0:
|
||||||
|
# Предложение исчерпано: дефицит = +1 ТОЛЬКО при положительном спросе,
|
||||||
|
# иначе сигнала нет (None) — не путаем «пик дефицита» с «пустым рынком».
|
||||||
|
if projected_demand_units is not None and projected_demand_units > 0:
|
||||||
|
return 1.0
|
||||||
|
return None
|
||||||
|
return _deficit_index(balance_ratio, saturation=saturation)
|
||||||
|
|
||||||
|
|
||||||
def _min_confidence(values: Sequence[Confidence | None]) -> Confidence:
|
def _min_confidence(values: Sequence[Confidence | None]) -> Confidence:
|
||||||
"""Итоговая уверенность = MIN компонентных (худшая тянет вниз). Зеркало vocab.
|
"""Итоговая уверенность = MIN компонентных (худшая тянет вниз). Зеркало vocab.
|
||||||
|
|
||||||
|
|
@ -419,8 +465,9 @@ def compute_demand_supply_forecast(
|
||||||
• §9.7 future_competitors = top-N get_competitors(horizon_months=h).
|
• §9.7 future_competitors = top-N get_competitors(horizon_months=h).
|
||||||
• confidence = MIN(market, §9.4, §9.5, future_supply) ≤ 'medium'.
|
• confidence = MIN(market, §9.4, §9.5, future_supply) ≤ 'medium'.
|
||||||
|
|
||||||
Graceful: любой тонкий вход → поле None, НЕ crash. supply ≤0/None → ratio/index
|
Graceful: любой тонкий вход → поле None, НЕ crash. supply ≤0/None → ratio None;
|
||||||
None. Конкуренты недоступны (нет геометрии cad_num) → []. Детерминированно.
|
index = +1.0 если спрос > 0 (пик дефицита, #980), иначе None. Конкуренты
|
||||||
|
недоступны (нет геометрии cad_num) → []. Детерминированно.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: SQLAlchemy sync Session.
|
db: SQLAlchemy sync Session.
|
||||||
|
|
@ -513,9 +560,12 @@ def _forecast_for_horizon(
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── БАЛАНС / индекс дефицита ────────────────────────────────────────────────
|
# ── БАЛАНС / индекс дефицита ────────────────────────────────────────────────
|
||||||
|
# balance_ratio честно None при исчерпанном предложении (спрос/0 неотличим от
|
||||||
|
# «нет данных»), НО deficit_index в этом случае = +1.0 — сильнейший дефицит, не
|
||||||
|
# «нет сигнала» (#980; см. _deficit_index_from_balance).
|
||||||
balance_units = _balance(projected_demand, projected_supply)
|
balance_units = _balance(projected_demand, projected_supply)
|
||||||
balance_ratio = _balance_ratio(projected_demand, projected_supply)
|
balance_ratio = _balance_ratio(projected_demand, projected_supply)
|
||||||
deficit_index = _deficit_index(balance_ratio)
|
deficit_index = _deficit_index_from_balance(projected_demand, projected_supply, balance_ratio)
|
||||||
|
|
||||||
# ── §9.7 будущие конкуренты (top-N по relevance_weight) ────────────────────
|
# ── §9.7 будущие конкуренты (top-N по relevance_weight) ────────────────────
|
||||||
future_competitors = _future_competitors(db, cad_num=cad_num, horizon=horizon)
|
future_competitors = _future_competitors(db, cad_num=cad_num, horizon=horizon)
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
"""Unit-тесты §7.9 MAI — ДЕГРАДИРОВАННЫЙ прокси платёжной нагрузки (#981, ADVISORY).
|
"""Unit-тесты §7.9 MAI — прокси платёжной нагрузки по РЫНОЧНОЙ ставке (#981, ADVISORY).
|
||||||
|
|
||||||
Чистые тесты — БЕЗ живой БД (чистая аннуитет-математика + мок reused-сервисов):
|
Чистые тесты — БЕЗ живой БД (чистая аннуитет-математика + мок reused-сервисов):
|
||||||
• pure _annuity: textbook-кейс (формула P·i·(1+i)^n/((1+i)^n−1)); rate=0 →
|
• pure _annuity: textbook-кейс (формула P·i·(1+i)^n/((1+i)^n−1)); rate=0 →
|
||||||
principal/months (без /0); None principal/rate → None; months ≤0 → None.
|
principal/months (без /0); None principal/rate → None; months ≤0 → None.
|
||||||
• compute_affordability через MagicMock-сессию + @patch _current_mortgage_rate /
|
• compute_affordability через MagicMock-сессию + @patch _current_market_rate /
|
||||||
build_sales_series: payment_to_income ВСЕГДА None; rate_kind=='subsidized_weighted';
|
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-горизонт;
|
confidence=='low'; degraded_reason непуст; payment_at_scenario per-горизонт;
|
||||||
цена из аргумента vs сегментная (reuse); graceful нет ставки/цены → платёж None.
|
цена из аргумента vs сегментная (reuse); graceful нет ставки/цены → платёж None.
|
||||||
|
• _current_market_rate: берёт самую свежую key_rate из get_monthly_macro + спред.
|
||||||
|
|
||||||
Детерминированно, без LLM. Мокаем reused-сервисы + db (нет живой БД).
|
Детерминированно, без LLM. Мокаем reused-сервисы + db (нет живой БД).
|
||||||
"""
|
"""
|
||||||
|
|
@ -17,6 +20,7 @@ import os
|
||||||
|
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -24,18 +28,26 @@ import pytest
|
||||||
from app.services.forecasting.affordability import (
|
from app.services.forecasting.affordability import (
|
||||||
_ANNUITY_TERM_MONTHS,
|
_ANNUITY_TERM_MONTHS,
|
||||||
_DEGRADED_REASON,
|
_DEGRADED_REASON,
|
||||||
|
_KEY_RATE_MARKET_SPREAD_PP,
|
||||||
|
_RATE_KIND_KEY_RATE_PROXY,
|
||||||
_RATE_KIND_SUBSIDIZED,
|
_RATE_KIND_SUBSIDIZED,
|
||||||
|
_RATE_KIND_UNAVAILABLE,
|
||||||
_REF_AREA_M2,
|
_REF_AREA_M2,
|
||||||
MortgageAffordabilityIndex,
|
MortgageAffordabilityIndex,
|
||||||
_annuity,
|
_annuity,
|
||||||
|
_current_market_rate,
|
||||||
_round_or_none,
|
_round_or_none,
|
||||||
_segment_avg_price,
|
_segment_avg_price,
|
||||||
compute_affordability,
|
compute_affordability,
|
||||||
)
|
)
|
||||||
|
from app.services.forecasting.macro_series import MonthlyMacro
|
||||||
from app.services.forecasting.sales_series import SegmentSpec
|
from app.services.forecasting.sales_series import SegmentSpec
|
||||||
|
|
||||||
# Пути патча reused-сервисов (импортированы в модуль affordability).
|
# Пути патча reused-сервисов (импортированы в модуль affordability).
|
||||||
_RATE = "app.services.forecasting.affordability._current_mortgage_rate"
|
# #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"
|
_SERIES = "app.services.forecasting.affordability.build_sales_series"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -107,8 +119,8 @@ def _make_index(**over: object) -> MortgageAffordabilityIndex:
|
||||||
"price_bucket": None,
|
"price_bucket": None,
|
||||||
},
|
},
|
||||||
"monthly_payment_rub": 27_522.16,
|
"monthly_payment_rub": 27_522.16,
|
||||||
"rate_used": 7.5,
|
"rate_used": 18.0,
|
||||||
"rate_kind": _RATE_KIND_SUBSIDIZED,
|
"rate_kind": _RATE_KIND_KEY_RATE_PROXY,
|
||||||
"rate_period": "2026-01",
|
"rate_period": "2026-01",
|
||||||
"ref_area_m2": _REF_AREA_M2,
|
"ref_area_m2": _REF_AREA_M2,
|
||||||
"price_per_m2": 120_000.0,
|
"price_per_m2": 120_000.0,
|
||||||
|
|
@ -132,7 +144,8 @@ class TestAsDict:
|
||||||
def test_honesty_fields_preserved(self) -> None:
|
def test_honesty_fields_preserved(self) -> None:
|
||||||
d = _make_index().as_dict()
|
d = _make_index().as_dict()
|
||||||
assert d["payment_to_income"] is None
|
assert d["payment_to_income"] is None
|
||||||
assert d["rate_kind"] == "subsidized_weighted"
|
# #981: рыночный прокси (key_rate), НЕ субсидированная средневзвешенная.
|
||||||
|
assert d["rate_kind"] == "key_rate_proxy"
|
||||||
assert d["confidence"] == "low"
|
assert d["confidence"] == "low"
|
||||||
assert d["degraded_reason"]
|
assert d["degraded_reason"]
|
||||||
|
|
||||||
|
|
@ -166,29 +179,40 @@ def _run(**over: object) -> MortgageAffordabilityIndex:
|
||||||
class TestHonestyContract:
|
class TestHonestyContract:
|
||||||
def test_payment_to_income_always_none(self) -> None:
|
def test_payment_to_income_always_none(self) -> None:
|
||||||
# Дохода в данных нет (#946) → payment_to_income ВСЕГДА None.
|
# Дохода в данных нет (#946) → payment_to_income ВСЕГДА None.
|
||||||
with patch(_RATE, return_value=(7.5, "2026-01")):
|
with patch(_RATE, return_value=(18.0, "2026-01")):
|
||||||
res = _run(price_per_m2=120_000.0)
|
res = _run(price_per_m2=120_000.0)
|
||||||
assert res.payment_to_income is None
|
assert res.payment_to_income is None
|
||||||
|
|
||||||
def test_rate_kind_is_subsidized_weighted(self) -> None:
|
def test_rate_kind_is_key_rate_proxy(self) -> None:
|
||||||
# Рыночной ставки (~20%) нет → метка всегда subsidized_weighted.
|
# #981 DoD: рыночный прокси (key_rate ЦБ), НЕ субсидированная средневзвешенная.
|
||||||
with patch(_RATE, return_value=(7.5, "2026-01")):
|
with patch(_RATE, return_value=(18.0, "2026-01")):
|
||||||
res = _run(price_per_m2=120_000.0)
|
res = _run(price_per_m2=120_000.0)
|
||||||
assert res.rate_kind == "subsidized_weighted"
|
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")):
|
||||||
|
res = _run(price_per_m2=120_000.0)
|
||||||
|
assert res.rate_used == 18.0
|
||||||
|
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:
|
def test_confidence_always_low(self) -> None:
|
||||||
# Деградированный прокси → confidence ВСЕГДА low (даже при полных входах).
|
# Деградированный прокси → confidence ВСЕГДА low (даже при полных входах).
|
||||||
with patch(_RATE, return_value=(7.5, "2026-01")):
|
with patch(_RATE, return_value=(18.0, "2026-01")):
|
||||||
res = _run(price_per_m2=120_000.0)
|
res = _run(price_per_m2=120_000.0)
|
||||||
assert res.confidence == "low"
|
assert res.confidence == "low"
|
||||||
|
|
||||||
def test_degraded_reason_explains_both_gaps(self) -> None:
|
def test_degraded_reason_explains_proxy_and_income_gap(self) -> None:
|
||||||
# degraded_reason упоминает ОБА пробела: рыночную ставку И доход.
|
# degraded_reason объясняет выбор прокси ставки (key_rate) И пробел дохода.
|
||||||
with patch(_RATE, return_value=(7.5, "2026-01")):
|
with patch(_RATE, return_value=(18.0, "2026-01")):
|
||||||
res = _run(price_per_m2=120_000.0)
|
res = _run(price_per_m2=120_000.0)
|
||||||
assert res.degraded_reason
|
assert res.degraded_reason
|
||||||
lower = res.degraded_reason.lower()
|
lower = res.degraded_reason.lower()
|
||||||
assert "ставк" in lower # упоминает ставку
|
assert "ставк" in lower # упоминает ставку
|
||||||
|
assert "key_rate" in lower # называет рыночный прокси явно
|
||||||
assert "доход" in lower # упоминает доход
|
assert "доход" in lower # упоминает доход
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -197,20 +221,20 @@ class TestHonestyContract:
|
||||||
|
|
||||||
class TestPaymentComputation:
|
class TestPaymentComputation:
|
||||||
def test_monthly_payment_uses_annuity(self) -> None:
|
def test_monthly_payment_uses_annuity(self) -> None:
|
||||||
# price 120k × ref 50 м² = 6 000 000 ₽ тело; ставка 7.5%, 240 мес.
|
# price 120k × ref 50 м² = 6 000 000 ₽ тело; рыночная ставка 18%, 240 мес.
|
||||||
with patch(_RATE, return_value=(7.5, "2026-01")):
|
with patch(_RATE, return_value=(18.0, "2026-01")):
|
||||||
res = _run(price_per_m2=120_000.0)
|
res = _run(price_per_m2=120_000.0)
|
||||||
principal = 120_000.0 * _REF_AREA_M2
|
principal = 120_000.0 * _REF_AREA_M2
|
||||||
expected = _annuity(principal, 7.5, _ANNUITY_TERM_MONTHS)
|
expected = _annuity(principal, 18.0, _ANNUITY_TERM_MONTHS)
|
||||||
assert res.monthly_payment_rub == pytest.approx(expected)
|
assert res.monthly_payment_rub == pytest.approx(expected)
|
||||||
assert res.rate_used == 7.5
|
assert res.rate_used == 18.0
|
||||||
assert res.rate_period == "2026-01"
|
assert res.rate_period == "2026-01"
|
||||||
assert res.price_per_m2 == 120_000.0
|
assert res.price_per_m2 == 120_000.0
|
||||||
assert res.ref_area_m2 == _REF_AREA_M2
|
assert res.ref_area_m2 == _REF_AREA_M2
|
||||||
|
|
||||||
def test_explicit_price_overrides_segment(self) -> None:
|
def test_explicit_price_overrides_segment(self) -> None:
|
||||||
# Явная цена в аргументе → build_sales_series НЕ вызывается.
|
# Явная цена в аргументе → build_sales_series НЕ вызывается.
|
||||||
with patch(_RATE, return_value=(7.5, "2026-01")):
|
with patch(_RATE, return_value=(18.0, "2026-01")):
|
||||||
with patch(_SERIES) as series_mock:
|
with patch(_SERIES) as series_mock:
|
||||||
res = _run(price_per_m2=150_000.0)
|
res = _run(price_per_m2=150_000.0)
|
||||||
series_mock.assert_not_called()
|
series_mock.assert_not_called()
|
||||||
|
|
@ -218,7 +242,7 @@ class TestPaymentComputation:
|
||||||
|
|
||||||
def test_segment_price_used_when_arg_none(self) -> None:
|
def test_segment_price_used_when_arg_none(self) -> None:
|
||||||
# Без price_per_m2 → сегментная средняя из build_sales_series (reuse).
|
# Без price_per_m2 → сегментная средняя из build_sales_series (reuse).
|
||||||
with patch(_RATE, return_value=(7.5, "2026-01")):
|
with patch(_RATE, return_value=(18.0, "2026-01")):
|
||||||
with patch(_SERIES, return_value=_series_stub([100_000.0, None, 140_000.0])):
|
with patch(_SERIES, return_value=_series_stub([100_000.0, None, 140_000.0])):
|
||||||
res = _run()
|
res = _run()
|
||||||
# средняя НЕнулевых: (100k + 140k) / 2 = 120k.
|
# средняя НЕнулевых: (100k + 140k) / 2 = 120k.
|
||||||
|
|
@ -231,7 +255,7 @@ class TestPaymentComputation:
|
||||||
class TestPaymentAtScenario:
|
class TestPaymentAtScenario:
|
||||||
def test_scenario_payment_per_horizon(self) -> None:
|
def test_scenario_payment_per_horizon(self) -> None:
|
||||||
# rate_path: разные ставки на горизонтах → платёж считается на каждой.
|
# rate_path: разные ставки на горизонтах → платёж считается на каждой.
|
||||||
with patch(_RATE, return_value=(7.5, "2026-01")):
|
with patch(_RATE, return_value=(18.0, "2026-01")):
|
||||||
res = _run(price_per_m2=120_000.0, rate_path={6: 8.0, 12: 20.0})
|
res = _run(price_per_m2=120_000.0, rate_path={6: 8.0, 12: 20.0})
|
||||||
assert res.payment_at_scenario is not None
|
assert res.payment_at_scenario is not None
|
||||||
principal = 120_000.0 * _REF_AREA_M2
|
principal = 120_000.0 * _REF_AREA_M2
|
||||||
|
|
@ -245,13 +269,13 @@ class TestPaymentAtScenario:
|
||||||
assert res.payment_at_scenario[12] > res.payment_at_scenario[6]
|
assert res.payment_at_scenario[12] > res.payment_at_scenario[6]
|
||||||
|
|
||||||
def test_no_rate_path_yields_none_scenario(self) -> None:
|
def test_no_rate_path_yields_none_scenario(self) -> None:
|
||||||
with patch(_RATE, return_value=(7.5, "2026-01")):
|
with patch(_RATE, return_value=(18.0, "2026-01")):
|
||||||
res = _run(price_per_m2=120_000.0)
|
res = _run(price_per_m2=120_000.0)
|
||||||
assert res.payment_at_scenario is None
|
assert res.payment_at_scenario is None
|
||||||
|
|
||||||
def test_scenario_drops_uncomputable_horizon(self) -> None:
|
def test_scenario_drops_uncomputable_horizon(self) -> None:
|
||||||
# Нет цены → principal None → платёж на горизонте None → ключ выпадает.
|
# Нет цены → principal None → платёж на горизонте None → ключ выпадает.
|
||||||
with patch(_RATE, return_value=(7.5, "2026-01")):
|
with patch(_RATE, return_value=(18.0, "2026-01")):
|
||||||
with patch(_SERIES, return_value=_series_stub([None])):
|
with patch(_SERIES, return_value=_series_stub([None])):
|
||||||
res = _run(rate_path={6: 8.0})
|
res = _run(rate_path={6: 8.0})
|
||||||
assert res.payment_at_scenario == {}
|
assert res.payment_at_scenario == {}
|
||||||
|
|
@ -261,17 +285,21 @@ class TestPaymentAtScenario:
|
||||||
|
|
||||||
|
|
||||||
class TestGraceful:
|
class TestGraceful:
|
||||||
def test_no_rate_yields_none_payment(self) -> None:
|
def test_no_rate_yields_none_payment_and_market_unavailable(self) -> None:
|
||||||
# Ставки нет в БД → платёж None, но индекс возвращается (не crash).
|
# Рыночной ставки (key_rate) нет в БД → платёж None, rate_used None,
|
||||||
|
# rate_kind='market_unavailable' (#981: НЕ подменяем субсидией). Не crash.
|
||||||
with patch(_RATE, return_value=(None, None)):
|
with patch(_RATE, return_value=(None, None)):
|
||||||
res = _run(price_per_m2=120_000.0)
|
res = _run(price_per_m2=120_000.0)
|
||||||
assert res.monthly_payment_rub is None
|
assert res.monthly_payment_rub is None
|
||||||
assert res.rate_used 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"
|
assert res.confidence == "low"
|
||||||
|
|
||||||
def test_no_price_yields_none_payment(self) -> None:
|
def test_no_price_yields_none_payment(self) -> None:
|
||||||
# Тонкий сегмент (нет цены) → principal None → платёж None.
|
# Тонкий сегмент (нет цены) → principal None → платёж None.
|
||||||
with patch(_RATE, return_value=(7.5, "2026-01")):
|
with patch(_RATE, return_value=(18.0, "2026-01")):
|
||||||
with patch(_SERIES, return_value=_series_stub([None, None])):
|
with patch(_SERIES, return_value=_series_stub([None, None])):
|
||||||
res = _run()
|
res = _run()
|
||||||
assert res.price_per_m2 is None
|
assert res.price_per_m2 is None
|
||||||
|
|
@ -307,3 +335,57 @@ class TestSegmentAvgPrice:
|
||||||
with patch(_SERIES, return_value=_series_stub([])):
|
with patch(_SERIES, return_value=_series_stub([])):
|
||||||
price = _segment_avg_price(MagicMock(), spec=spec, source="objective_lots")
|
price = _segment_avg_price(MagicMock(), spec=spec, source="objective_lots")
|
||||||
assert price is None
|
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) + спред по умолчанию (0 п.п.).
|
||||||
|
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_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:
|
||||||
|
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 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)
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ from app.services.forecasting.demand_supply_forecast import (
|
||||||
_balance_ratio,
|
_balance_ratio,
|
||||||
_cap_confidence,
|
_cap_confidence,
|
||||||
_deficit_index,
|
_deficit_index,
|
||||||
|
_deficit_index_from_balance,
|
||||||
_hidden_release_fraction,
|
_hidden_release_fraction,
|
||||||
_min_confidence,
|
_min_confidence,
|
||||||
_project_demand,
|
_project_demand,
|
||||||
|
|
@ -249,6 +250,44 @@ class TestDeficitIndex:
|
||||||
assert _deficit_index(1.0, saturation=1.0) == 0.0
|
assert _deficit_index(1.0, saturation=1.0) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ── pure: _deficit_index_from_balance (#980 — исчерпанное предложение = +1) ────
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeficitIndexFromBalance:
|
||||||
|
def test_supply_exhausted_with_positive_demand_is_plus_one(self) -> None:
|
||||||
|
# #980 ЯДРО: спрос > 0 поглотил ВСЁ предложение (supply 0) → +1.0 (был None).
|
||||||
|
# balance_ratio честно None (спрос/0), но индекс = пик дефицита.
|
||||||
|
assert _deficit_index_from_balance(120.0, 0.0, None) == 1.0
|
||||||
|
|
||||||
|
def test_negative_supply_with_positive_demand_is_plus_one(self) -> None:
|
||||||
|
# Артефактно-отрицательное предложение трактуем как исчерпанное → +1.0.
|
||||||
|
assert _deficit_index_from_balance(50.0, -3.0, None) == 1.0
|
||||||
|
|
||||||
|
def test_no_signal_supply_and_demand_both_zero_is_none(self) -> None:
|
||||||
|
# Ни дефицита, ни затоварки измерить нельзя (пустой рынок) → None.
|
||||||
|
assert _deficit_index_from_balance(0.0, 0.0, None) is None
|
||||||
|
|
||||||
|
def test_supply_zero_demand_none_is_none(self) -> None:
|
||||||
|
# Спрос неизвестен + предложение исчерпано → нет сигнала (None, не +1).
|
||||||
|
assert _deficit_index_from_balance(None, 0.0, None) is None
|
||||||
|
|
||||||
|
def test_normal_path_delegates_to_deficit_index(self) -> None:
|
||||||
|
# supply > 0 → обычный лог-путь (ровно _deficit_index(balance_ratio)).
|
||||||
|
ratio = _DEFICIT_RATIO_SATURATION # +1 на лог-шкале
|
||||||
|
assert _deficit_index_from_balance(200.0, 100.0, ratio) == pytest.approx(
|
||||||
|
_deficit_index(ratio)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_normal_oversupply_negative(self) -> None:
|
||||||
|
# supply > 0, спрос << предложения → отрицательный индекс (как раньше).
|
||||||
|
ratio = 1.0 / _DEFICIT_RATIO_SATURATION
|
||||||
|
idx = _deficit_index_from_balance(50.0, 100.0, ratio)
|
||||||
|
assert idx is not None and idx < 0.0
|
||||||
|
|
||||||
|
def test_normal_balance_zero(self) -> None:
|
||||||
|
assert _deficit_index_from_balance(100.0, 100.0, 1.0) == pytest.approx(0.0)
|
||||||
|
|
||||||
|
|
||||||
# ── pure: _min_confidence / _cap_confidence ───────────────────────────────────
|
# ── pure: _min_confidence / _cap_confidence ───────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -666,9 +705,10 @@ class TestComputeSupply:
|
||||||
|
|
||||||
|
|
||||||
class TestComputeBalance:
|
class TestComputeBalance:
|
||||||
def test_balance_and_ratio_and_index(self) -> None:
|
def test_demand_with_zero_supply_is_strongest_deficit(self) -> None:
|
||||||
# Спрос 10×1.0×1.0×12 = 120; supply: open0+hidden0+future0 → 0 валовое,
|
# #980: спрос 10×1.0×1.0×12 = 120; supply open0+hidden0+future0 → 0 валовое.
|
||||||
# но absorbed=min(0,120)=0 → supply 0 → ratio None, index None.
|
# balance_ratio честно None (спрос/0), НО deficit_index = +1.0 (пик дефицита,
|
||||||
|
# demand>0 при исчерпанном предложении) — РАНЬШЕ тут был None (баг #980).
|
||||||
with _Patches(
|
with _Patches(
|
||||||
_METRICS=_metrics_stub(unit_velocity=10.0),
|
_METRICS=_metrics_stub(unit_velocity=10.0),
|
||||||
_NORM=_norm_stub(coefficient=1.0),
|
_NORM=_norm_stub(coefficient=1.0),
|
||||||
|
|
@ -679,12 +719,44 @@ class TestComputeBalance:
|
||||||
f = res[0]
|
f = res[0]
|
||||||
assert f.projected_demand_units == pytest.approx(120.0)
|
assert f.projected_demand_units == pytest.approx(120.0)
|
||||||
assert f.projected_supply_units == 0.0
|
assert f.projected_supply_units == 0.0
|
||||||
# supply ≤0 → ratio/index None (НЕ выдуманный ∞-дефицит).
|
# balance_ratio None (∞-кратность не выдумываем), но индекс = +1.0.
|
||||||
assert f.balance_ratio is None
|
assert f.balance_ratio is None
|
||||||
assert f.deficit_index is None
|
assert f.deficit_index == 1.0
|
||||||
# balance_units всё равно считается (120 − 0 = 120).
|
# balance_units всё равно считается (120 − 0 = 120).
|
||||||
assert f.balance_units == pytest.approx(120.0)
|
assert f.balance_units == pytest.approx(120.0)
|
||||||
|
|
||||||
|
def test_gross_supply_fully_absorbed_is_plus_one(self) -> None:
|
||||||
|
# #980 ЦЕЛЕВОЙ КЕЙС: валовое предложение > 0, но спрос огромный поглощает
|
||||||
|
# ВСЁ → effective projected_supply = 0 → deficit_index = +1.0 (был None).
|
||||||
|
with _Patches(
|
||||||
|
_METRICS=_metrics_stub(unit_velocity=10_000.0), # спрос >> валового
|
||||||
|
_NORM=_norm_stub(coefficient=1.0),
|
||||||
|
_MACRO_COEF=_macro_coef_stub(coefficient=1.0),
|
||||||
|
_SUPPLY=_supply_stub(open_units=100, hidden_units=100, future_units_by_horizon=50.0),
|
||||||
|
):
|
||||||
|
res = _run(horizons=[18])
|
||||||
|
f = res[0]
|
||||||
|
# валовое было положительным, но поглощено в 0.
|
||||||
|
assert f.projected_supply_units == 0.0
|
||||||
|
assert f.projected_demand_units is not None and f.projected_demand_units > 0
|
||||||
|
assert f.balance_ratio is None
|
||||||
|
assert f.deficit_index == 1.0 # сильнейший дефицит, НЕ None
|
||||||
|
|
||||||
|
def test_no_demand_no_supply_index_none(self) -> None:
|
||||||
|
# Нет сигнала вообще (спрос 0 И предложение 0) → deficit_index None.
|
||||||
|
with _Patches(
|
||||||
|
_METRICS=_metrics_stub(unit_velocity=0.0),
|
||||||
|
_NORM=_norm_stub(coefficient=1.0),
|
||||||
|
_MACRO_COEF=_macro_coef_stub(coefficient=1.0),
|
||||||
|
_SUPPLY=_supply_stub(open_units=0, hidden_units=0, future_units_by_horizon=0.0),
|
||||||
|
):
|
||||||
|
res = _run(horizons=[12])
|
||||||
|
f = res[0]
|
||||||
|
assert f.projected_demand_units == pytest.approx(0.0)
|
||||||
|
assert f.projected_supply_units == 0.0
|
||||||
|
assert f.balance_ratio is None
|
||||||
|
assert f.deficit_index is None # ни дефицита, ни затоварки — нет сигнала
|
||||||
|
|
||||||
def test_oversupply_negative_index(self) -> None:
|
def test_oversupply_negative_index(self) -> None:
|
||||||
# Малый спрос, большое предложение (спрос не поглощает всё) → index < 0.
|
# Малый спрос, большое предложение (спрос не поглощает всё) → index < 0.
|
||||||
with _Patches(
|
with _Patches(
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue