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 «что строить +
|
||||
доступность». Это **СБОРОЧНЫЙ слой**: своей §9.x-математики не пересобирает —
|
||||
переиспользует helper'ы (`_current_mortgage_rate` §аналитики, `build_sales_series`
|
||||
для сегментной цены ₽/м²). Всё ДЕТЕРМИНИРОВАННО, БЕЗ LLM (чистая аннуитет-арифметика
|
||||
+ reuse; своего SQL НЕТ).
|
||||
переиспользует helper'ы (`get_monthly_macro` §9.5/§9.6 для ключевой ставки ЦБ,
|
||||
`build_sales_series` для сегментной цены ₽/м²). Всё ДЕТЕРМИНИРОВАННО, БЕЗ LLM
|
||||
(чистая аннуитет-арифметика + 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 (КРИТИЧНО — честность важнее цифры):
|
||||
Настоящий §7.9 индекс доступности = «рыночный аннуитет (~20%) ÷ доход
|
||||
домохозяйства». Обе входные величины в нашей БД ОТСУТСТВУЮТ:
|
||||
1. РЫНОЧНАЯ ставка (~20% по ЦБ Янв 2026) — её серии в cbr_mortgage_series НЕТ.
|
||||
Единственная доступная ставка — `_current_mortgage_rate`: СРЕДНЕВЗВЕШЕННАЯ
|
||||
С льготами (семейная/IT/ДВ), ~7-8% — она ЗАНИЖАЕТ платёж против рынка.
|
||||
2. ДОХОД домохозяйства (#946) — в данных проекта его нет вовсе.
|
||||
Поэтому MAI здесь = ПРОКСИ платёжной нагрузки (annuity на эталонную квартиру по
|
||||
субсидированной ставке), а НЕ истинный рыночный индекс §7.9: `payment_to_income`
|
||||
ВСЕГДА None (нет дохода), `rate_kind='subsidized_weighted'` (не рыночная),
|
||||
`confidence='low'` ВСЕГДА (деградация по двум каналам), `degraded_reason`
|
||||
объясняет оба пробела. Цифра — для explainability/прототипа, не для решения.
|
||||
Истинный §7.9 = «рыночный аннуитет ÷ доход домохозяйства». ДОХОД домохозяйства
|
||||
(#946) в данных проекта ОТСУТСТВУЕТ → `payment_to_income` ВСЕГДА None,
|
||||
`confidence='low'` ВСЕГДА (числитель теперь рыночный, но знаменатель-дохода нет).
|
||||
`degraded_reason` объясняет остаточный пробел (доход) и сделанный выбор прокси
|
||||
ставки (key_rate, спред 0). Цифра — для explainability/прототипа, не для решения.
|
||||
|
||||
Graceful-on-thin-data (дух demand_supply_forecast / market_metrics): нет ставки →
|
||||
платёж None; нет цены сегмента → платёж None; ставка ≤0 → аннуитет деградирует к
|
||||
|
|
@ -35,6 +42,7 @@ from typing import Any, Literal
|
|||
from sqlalchemy.orm import Session
|
||||
|
||||
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 (
|
||||
SalesSource,
|
||||
SegmentSpec,
|
||||
|
|
@ -55,42 +63,64 @@ _REF_AREA_M2: float = 50.0
|
|||
# (медиана ЦБ ~ 20-25 лет; берём круглые 20 для детерминизма). Tunable.
|
||||
_ANNUITY_TERM_MONTHS: int = 240
|
||||
|
||||
# Метка вида ставки в выводе: всегда субсидированная средневзвешенная (рыночной
|
||||
# ~20% в БД нет). Зафиксирована константой — downstream не должен принять её за
|
||||
# рыночную ставку §7.9.
|
||||
# Метка вида ставки в выводе. По умолчанию — рыночный прокси через ключевую ставку
|
||||
# ЦБ (#981 DoD: рыночная стоимость денег, НЕ субсидия 7.83%). Субсидированная метка
|
||||
# сохранена для explain-сравнения, но в индекс по умолчанию НЕ идёт.
|
||||
_RATE_KIND_KEY_RATE_PROXY: str = "key_rate_proxy"
|
||||
_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) — несёт
|
||||
# per-lot цену для price/room-сегментации (Source A её не даёт). Каведат
|
||||
# survivorship bias задокументирован в sales_series; для прокси-индекса приемлемо.
|
||||
_PRICE_SOURCE: SalesSource = "objective_lots"
|
||||
|
||||
# Текст деградации — объясняет ОБА пробела (рыночная ставка + доход), чтобы вывод
|
||||
# нельзя было прочитать как истинный §7.9 индекс. Используется в degraded_reason.
|
||||
# Текст деградации — объясняет выбор рыночного прокси ставки И остаточный пробел
|
||||
# (доход), чтобы вывод нельзя было прочитать как истинный §7.9 индекс. Используется
|
||||
# в degraded_reason, когда ключевая ставка ДОСТУПНА (рыночный путь отработал).
|
||||
_DEGRADED_REASON: str = (
|
||||
"Прокси платёжной нагрузки, НЕ истинный §7.9 индекс доступности: "
|
||||
"(1) использована СУБСИДИРОВАННАЯ средневзвешенная ставка ИЖК (~7-8%) — "
|
||||
"рыночной ставки без льгот (~20%) в БД нет, платёж занижен против рынка; "
|
||||
"(2) доход домохозяйства (#946) недоступен, поэтому payment_to_income не "
|
||||
"рассчитывается (всегда None). confidence='low' — деградация по двум каналам."
|
||||
"Прокси платёжной нагрузки по РЫНОЧНОЙ ставке, НЕ истинный §7.9 индекс "
|
||||
"доступности: (1) рыночная стоимость денег взята как ключевая ставка ЦБ "
|
||||
"(key_rate, ~16-21%) + спред банка 0 п.п. — прямого ряда «рыночная ипотека "
|
||||
"без льгот» в БД нет, key_rate — консервативная нижняя граница рыночного "
|
||||
"платежа (честнее субсидированных ~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)
|
||||
class MortgageAffordabilityIndex:
|
||||
"""§7.9 MAI — ДЕГРАДИРОВАННЫЙ прокси платёжной нагрузки (считается на лету).
|
||||
"""§7.9 MAI — прокси платёжной нагрузки по РЫНОЧНОЙ ставке (считается на лету).
|
||||
|
||||
Все денежные поля = None при недостатке данных (НИКОГДА 0-как-заглушка).
|
||||
`payment_to_income` ВСЕГДА None (дохода нет в данных, #946). `rate_kind` ВСЕГДА
|
||||
'subsidized_weighted' (рыночной ставки нет). `confidence` ВСЕГДА 'low' (прокси,
|
||||
не истинный индекс §7.9). `degraded_reason` объясняет оба пробела. Это
|
||||
`payment_to_income` ВСЕГДА None (дохода нет в данных, #946). `rate_kind`:
|
||||
'key_rate_proxy' (ключевая ставка ЦБ как рыночный прокси — путь по умолчанию,
|
||||
#981 DoD) или 'market_unavailable' (если key_rate в БД нет — rate_used тогда
|
||||
None, субсидией НЕ подменяем). `confidence` ВСЕГДА 'low' (нет дохода-знаменателя).
|
||||
`degraded_reason` объясняет выбор прокси ставки + пробел дохода. Это
|
||||
explainability-прокси, НЕ основание для инвест-решения.
|
||||
"""
|
||||
|
||||
segment: dict[str, str | None]
|
||||
monthly_payment_rub: float | None # аннуитет на эталонную квартиру (₽/мес)
|
||||
rate_used: float | None # годовая ставка (%) — субсидированная средневзвеш.
|
||||
rate_kind: str # ВСЕГДА 'subsidized_weighted'
|
||||
rate_used: float | None # годовая рыночная ставка (%) — key_rate + спред (#981)
|
||||
rate_kind: str # 'key_rate_proxy' | 'market_unavailable'
|
||||
rate_period: str | None # период точки ставки (explain)
|
||||
ref_area_m2: float # эталонная площадь кредита (м²)
|
||||
price_per_m2: float | None # сегментная цена ₽/м² (вход тела кредита)
|
||||
|
|
@ -167,6 +197,60 @@ def _annuity(
|
|||
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-логика выше тестируется без него.
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -181,15 +265,18 @@ def compute_affordability(
|
|||
ref_area_m2: float = _REF_AREA_M2,
|
||||
price_source: SalesSource = _PRICE_SOURCE,
|
||||
) -> MortgageAffordabilityIndex:
|
||||
"""§7.9 MAI — ДЕГРАДИРОВАННЫЙ прокси платёжной нагрузки для сегмента.
|
||||
"""§7.9 MAI — прокси платёжной нагрузки по РЫНОЧНОЙ ставке для сегмента (#981).
|
||||
|
||||
ADVISORY / LOW-CONFIDENCE: это ПРОКСИ, а НЕ истинный §7.9 индекс доступности.
|
||||
Рыночной ставки (~20%) и дохода домохозяйства (#946) в данных нет — см. module
|
||||
docstring и `degraded_reason`. `payment_to_income` ВСЕГДА None.
|
||||
Числитель теперь РЫНОЧНЫЙ (ключевая ставка ЦБ), но дохода домохозяйства (#946)
|
||||
в данных нет — см. module docstring и `degraded_reason`. `payment_to_income`
|
||||
ВСЕГДА None, `confidence` ВСЕГДА 'low' (нет знаменателя-дохода).
|
||||
|
||||
Что считает:
|
||||
• rate, period = _current_mortgage_rate(db) — субсидированная средневзвеш.
|
||||
ставка (~7-8%; ЕДИНСТВЕННАЯ в БД). Это rate_kind='subsidized_weighted'.
|
||||
• rate, period = _current_market_rate(db) — ключевая ставка ЦБ + спред как
|
||||
рыночный прокси (~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,
|
||||
усреднение НЕнулевых месяцев). None → платёж None (graceful).
|
||||
• monthly_payment = _annuity(principal=price×ref_area, annual_rate=rate,
|
||||
|
|
@ -213,7 +300,16 @@ def compute_affordability(
|
|||
MortgageAffordabilityIndex (всегда; тонко → None-поля, confidence='low').
|
||||
"""
|
||||
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 = (
|
||||
price_per_m2
|
||||
|
|
@ -234,10 +330,12 @@ def compute_affordability(
|
|||
payment_at_scenario = scenario
|
||||
|
||||
logger.info(
|
||||
"affordability: segment=%s rate=%s period=%s price_per_m2=%s ref_area=%.1f "
|
||||
"monthly_payment=%s confidence=low (DEGRADED proxy, payment_to_income=None)",
|
||||
"affordability: segment=%s rate=%s kind=%s period=%s price_per_m2=%s "
|
||||
"ref_area=%.1f monthly_payment=%s confidence=low "
|
||||
"(market proxy, payment_to_income=None)",
|
||||
segment,
|
||||
_round_or_none(rate, 2),
|
||||
rate_kind,
|
||||
rate_period,
|
||||
_round_or_none(resolved_price, 0),
|
||||
ref_area_m2,
|
||||
|
|
@ -248,15 +346,15 @@ def compute_affordability(
|
|||
segment=segment,
|
||||
monthly_payment_rub=monthly_payment,
|
||||
rate_used=rate,
|
||||
rate_kind=_RATE_KIND_SUBSIDIZED,
|
||||
rate_kind=rate_kind,
|
||||
rate_period=rate_period,
|
||||
ref_area_m2=ref_area_m2,
|
||||
price_per_m2=resolved_price,
|
||||
annuity_term_months=_ANNUITY_TERM_MONTHS,
|
||||
payment_to_income=None, # дохода нет в данных (#946) — НИКОГДА не считаем
|
||||
payment_at_scenario=payment_at_scenario,
|
||||
degraded_reason=_DEGRADED_REASON,
|
||||
confidence="low", # ВСЕГДА low — деградированный прокси, не истинный §7.9
|
||||
degraded_reason=degraded_reason,
|
||||
confidence="low", # ВСЕГДА low — нет дохода-знаменателя, не истинный §7.9
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -43,8 +43,10 @@ ADVISORY-СТАТУС: движок СОВЕТУЮЩИЙ и НЕ подключ
|
|||
|
||||
Graceful-on-thin-data (дух future_supply / market_metrics): любой тонкий вход →
|
||||
соответствующее поле None (НИКОГДА 0-как-заглушка), НИКОГДА не crash, НИКОГДА
|
||||
деления на ноль. supply ≤ 0 / None → balance_ratio=None, deficit_index=None,
|
||||
confidence занижен (не выдумываем «бесконечный дефицит»). Детерминированно.
|
||||
деления на ноль. supply ≤ 0 / None → balance_ratio=None (∞-кратность не выдумываем);
|
||||
deficit_index при этом = +1.0 ТОЛЬКО если спрос > 0 (предложение исчерпано под спрос
|
||||
= ПИК дефицита по семантике [−1,+1], #980), иначе None (нет сигнала: и спрос ≤0).
|
||||
confidence занижается выше по стеку. Детерминированно.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -119,7 +121,9 @@ class DemandSupplyForecast:
|
|||
(НИКОГДА 0-как-заглушка). `advisory` ВСЕГДА True (движок не для production-решений
|
||||
до бэктеста #951). `deficit_index` ∈ [−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 (β НЕ участвует в арифметике
|
||||
спроса дважды — он уже учтён внутри demand_norm_coefficient §9.4).
|
||||
"""
|
||||
|
|
@ -335,6 +339,48 @@ def _deficit_index(
|
|||
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:
|
||||
"""Итоговая уверенность = MIN компонентных (худшая тянет вниз). Зеркало vocab.
|
||||
|
||||
|
|
@ -419,8 +465,9 @@ def compute_demand_supply_forecast(
|
|||
• §9.7 future_competitors = top-N get_competitors(horizon_months=h).
|
||||
• confidence = MIN(market, §9.4, §9.5, future_supply) ≤ 'medium'.
|
||||
|
||||
Graceful: любой тонкий вход → поле None, НЕ crash. supply ≤0/None → ratio/index
|
||||
None. Конкуренты недоступны (нет геометрии cad_num) → []. Детерминированно.
|
||||
Graceful: любой тонкий вход → поле None, НЕ crash. supply ≤0/None → ratio None;
|
||||
index = +1.0 если спрос > 0 (пик дефицита, #980), иначе None. Конкуренты
|
||||
недоступны (нет геометрии cad_num) → []. Детерминированно.
|
||||
|
||||
Args:
|
||||
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_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) ────────────────────
|
||||
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-сервисов):
|
||||
• pure _annuity: textbook-кейс (формула P·i·(1+i)^n/((1+i)^n−1)); rate=0 →
|
||||
principal/months (без /0); None principal/rate → None; months ≤0 → None.
|
||||
• compute_affordability через MagicMock-сессию + @patch _current_mortgage_rate /
|
||||
build_sales_series: payment_to_income ВСЕГДА None; rate_kind=='subsidized_weighted';
|
||||
• compute_affordability через MagicMock-сессию + @patch _current_market_rate /
|
||||
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-горизонт;
|
||||
цена из аргумента vs сегментная (reuse); graceful нет ставки/цены → платёж None.
|
||||
• _current_market_rate: берёт самую свежую key_rate из get_monthly_macro + спред.
|
||||
|
||||
Детерминированно, без LLM. Мокаем reused-сервисы + db (нет живой БД).
|
||||
"""
|
||||
|
|
@ -17,6 +20,7 @@ import os
|
|||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
import datetime as dt
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
|
@ -24,18 +28,26 @@ import pytest
|
|||
from app.services.forecasting.affordability import (
|
||||
_ANNUITY_TERM_MONTHS,
|
||||
_DEGRADED_REASON,
|
||||
_KEY_RATE_MARKET_SPREAD_PP,
|
||||
_RATE_KIND_KEY_RATE_PROXY,
|
||||
_RATE_KIND_SUBSIDIZED,
|
||||
_RATE_KIND_UNAVAILABLE,
|
||||
_REF_AREA_M2,
|
||||
MortgageAffordabilityIndex,
|
||||
_annuity,
|
||||
_current_market_rate,
|
||||
_round_or_none,
|
||||
_segment_avg_price,
|
||||
compute_affordability,
|
||||
)
|
||||
from app.services.forecasting.macro_series import MonthlyMacro
|
||||
from app.services.forecasting.sales_series import SegmentSpec
|
||||
|
||||
# Пути патча 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"
|
||||
|
||||
|
||||
|
|
@ -107,8 +119,8 @@ def _make_index(**over: object) -> MortgageAffordabilityIndex:
|
|||
"price_bucket": None,
|
||||
},
|
||||
"monthly_payment_rub": 27_522.16,
|
||||
"rate_used": 7.5,
|
||||
"rate_kind": _RATE_KIND_SUBSIDIZED,
|
||||
"rate_used": 18.0,
|
||||
"rate_kind": _RATE_KIND_KEY_RATE_PROXY,
|
||||
"rate_period": "2026-01",
|
||||
"ref_area_m2": _REF_AREA_M2,
|
||||
"price_per_m2": 120_000.0,
|
||||
|
|
@ -132,7 +144,8 @@ class TestAsDict:
|
|||
def test_honesty_fields_preserved(self) -> None:
|
||||
d = _make_index().as_dict()
|
||||
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["degraded_reason"]
|
||||
|
||||
|
|
@ -166,29 +179,40 @@ 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=(7.5, "2026-01")):
|
||||
with patch(_RATE, return_value=(18.0, "2026-01")):
|
||||
res = _run(price_per_m2=120_000.0)
|
||||
assert res.payment_to_income is None
|
||||
|
||||
def test_rate_kind_is_subsidized_weighted(self) -> None:
|
||||
# Рыночной ставки (~20%) нет → метка всегда subsidized_weighted.
|
||||
with patch(_RATE, return_value=(7.5, "2026-01")):
|
||||
def test_rate_kind_is_key_rate_proxy(self) -> None:
|
||||
# #981 DoD: рыночный прокси (key_rate ЦБ), НЕ субсидированная средневзвешенная.
|
||||
with patch(_RATE, return_value=(18.0, "2026-01")):
|
||||
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:
|
||||
# Деградированный прокси → 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)
|
||||
assert res.confidence == "low"
|
||||
|
||||
def test_degraded_reason_explains_both_gaps(self) -> None:
|
||||
# degraded_reason упоминает ОБА пробела: рыночную ставку И доход.
|
||||
with patch(_RATE, return_value=(7.5, "2026-01")):
|
||||
def test_degraded_reason_explains_proxy_and_income_gap(self) -> None:
|
||||
# degraded_reason объясняет выбор прокси ставки (key_rate) И пробел дохода.
|
||||
with patch(_RATE, return_value=(18.0, "2026-01")):
|
||||
res = _run(price_per_m2=120_000.0)
|
||||
assert res.degraded_reason
|
||||
lower = res.degraded_reason.lower()
|
||||
assert "ставк" in lower # упоминает ставку
|
||||
assert "key_rate" in lower # называет рыночный прокси явно
|
||||
assert "доход" in lower # упоминает доход
|
||||
|
||||
|
||||
|
|
@ -197,20 +221,20 @@ class TestHonestyContract:
|
|||
|
||||
class TestPaymentComputation:
|
||||
def test_monthly_payment_uses_annuity(self) -> None:
|
||||
# price 120k × ref 50 м² = 6 000 000 ₽ тело; ставка 7.5%, 240 мес.
|
||||
with patch(_RATE, return_value=(7.5, "2026-01")):
|
||||
# price 120k × ref 50 м² = 6 000 000 ₽ тело; рыночная ставка 18%, 240 мес.
|
||||
with patch(_RATE, return_value=(18.0, "2026-01")):
|
||||
res = _run(price_per_m2=120_000.0)
|
||||
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.rate_used == 7.5
|
||||
assert res.rate_used == 18.0
|
||||
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=(7.5, "2026-01")):
|
||||
with patch(_RATE, return_value=(18.0, "2026-01")):
|
||||
with patch(_SERIES) as series_mock:
|
||||
res = _run(price_per_m2=150_000.0)
|
||||
series_mock.assert_not_called()
|
||||
|
|
@ -218,7 +242,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=(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])):
|
||||
res = _run()
|
||||
# средняя НЕнулевых: (100k + 140k) / 2 = 120k.
|
||||
|
|
@ -231,7 +255,7 @@ class TestPaymentComputation:
|
|||
class TestPaymentAtScenario:
|
||||
def test_scenario_payment_per_horizon(self) -> None:
|
||||
# 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})
|
||||
assert res.payment_at_scenario is not None
|
||||
principal = 120_000.0 * _REF_AREA_M2
|
||||
|
|
@ -245,13 +269,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=(7.5, "2026-01")):
|
||||
with patch(_RATE, return_value=(18.0, "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=(7.5, "2026-01")):
|
||||
with patch(_RATE, return_value=(18.0, "2026-01")):
|
||||
with patch(_SERIES, return_value=_series_stub([None])):
|
||||
res = _run(rate_path={6: 8.0})
|
||||
assert res.payment_at_scenario == {}
|
||||
|
|
@ -261,17 +285,21 @@ class TestPaymentAtScenario:
|
|||
|
||||
|
||||
class TestGraceful:
|
||||
def test_no_rate_yields_none_payment(self) -> None:
|
||||
# Ставки нет в БД → платёж None, но индекс возвращается (не crash).
|
||||
def test_no_rate_yields_none_payment_and_market_unavailable(self) -> None:
|
||||
# Рыночной ставки (key_rate) нет в БД → платёж None, rate_used None,
|
||||
# rate_kind='market_unavailable' (#981: НЕ подменяем субсидией). Не crash.
|
||||
with patch(_RATE, return_value=(None, None)):
|
||||
res = _run(price_per_m2=120_000.0)
|
||||
assert res.monthly_payment_rub 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"
|
||||
|
||||
def test_no_price_yields_none_payment(self) -> 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])):
|
||||
res = _run()
|
||||
assert res.price_per_m2 is None
|
||||
|
|
@ -307,3 +335,57 @@ class TestSegmentAvgPrice:
|
|||
with patch(_SERIES, return_value=_series_stub([])):
|
||||
price = _segment_avg_price(MagicMock(), spec=spec, source="objective_lots")
|
||||
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,
|
||||
_cap_confidence,
|
||||
_deficit_index,
|
||||
_deficit_index_from_balance,
|
||||
_hidden_release_fraction,
|
||||
_min_confidence,
|
||||
_project_demand,
|
||||
|
|
@ -249,6 +250,44 @@ class TestDeficitIndex:
|
|||
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 ───────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -666,9 +705,10 @@ class TestComputeSupply:
|
|||
|
||||
|
||||
class TestComputeBalance:
|
||||
def test_balance_and_ratio_and_index(self) -> None:
|
||||
# Спрос 10×1.0×1.0×12 = 120; supply: open0+hidden0+future0 → 0 валовое,
|
||||
# но absorbed=min(0,120)=0 → supply 0 → ratio None, index None.
|
||||
def test_demand_with_zero_supply_is_strongest_deficit(self) -> None:
|
||||
# #980: спрос 10×1.0×1.0×12 = 120; supply open0+hidden0+future0 → 0 валовое.
|
||||
# balance_ratio честно None (спрос/0), НО deficit_index = +1.0 (пик дефицита,
|
||||
# demand>0 при исчерпанном предложении) — РАНЬШЕ тут был None (баг #980).
|
||||
with _Patches(
|
||||
_METRICS=_metrics_stub(unit_velocity=10.0),
|
||||
_NORM=_norm_stub(coefficient=1.0),
|
||||
|
|
@ -679,12 +719,44 @@ class TestComputeBalance:
|
|||
f = res[0]
|
||||
assert f.projected_demand_units == pytest.approx(120.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.deficit_index is None
|
||||
assert f.deficit_index == 1.0
|
||||
# balance_units всё равно считается (120 − 0 = 120).
|
||||
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:
|
||||
# Малый спрос, большое предложение (спрос не поглощает всё) → index < 0.
|
||||
with _Patches(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue