feat(forecasting): add months-of-inventory (MOI) to §9.8 demand-supply forecast
deficit_index pins to -1.0 for every ЕКБ segment (12mo demand flow vs multi-year supply stock → log-ratio clamps) → zero discriminating power, though the oversupply is partly real. Add MOI (gross competing supply / demand_per_mo), the real-estate absorption standard, as an additive non-saturating companion that DISCRIMINATES (Уралмаш 42mo … Чермет 109mo) where deficit cannot. deficit_index math kept exactly as-is (honest absolute: -1 = genuinely oversupplied); docstrings clarify -1 is common and MOI is the discriminating companion (no recalibration). _gross_supply extract-method (single source of truth; _project_supply behavior byte-identical, code-review-verified). Surface MOI in §22 future_market (passthrough) + exec_summary key_numbers/verdict. Guards: no demand → None, no supply → 0. Prod: MOI varies 42→109mo, deficit stays -1. Discrimination test pins MOI separating two segments both at deficit -1. Refs #952.
This commit is contained in:
parent
681a922d99
commit
01a74ade7a
6 changed files with 393 additions and 21 deletions
|
|
@ -11,7 +11,16 @@
|
||||||
горизонте − поглощённое спросом (§9.3 compute_future_supply_pressure).
|
горизонте − поглощённое спросом (§9.3 compute_future_supply_pressure).
|
||||||
• БАЛАНС / индекс дефицита = знаковое насыщающее преобразование отношения
|
• БАЛАНС / индекс дефицита = знаковое насыщающее преобразование отношения
|
||||||
спрос/предложение в [−1,+1] (+1 = сильный дефицит «хорошо» / −1 =
|
спрос/предложение в [−1,+1] (+1 = сильный дефицит «хорошо» / −1 =
|
||||||
сильная затоварка «риск»).
|
сильная затоварка «риск»). Это АБСОЛЮТНЫЙ сигнал: −1 = рынок
|
||||||
|
действительно затоварен (ЕКБ-новостройка широко затоварена при текущих
|
||||||
|
ставках, поэтому −1 встречается часто и это ЧЕСТНО). Дефицит-индекс
|
||||||
|
насыщается уже при двукратном перекосе, поэтому слабо ДИСКРИМИНИРУЕТ
|
||||||
|
между сильно-затоваренными сегментами (все липнут к −1).
|
||||||
|
• MONTHS-OF-INVENTORY (MOI) = ДИСКРИМИНИРУЮЩИЙ companion-метрик к дефициту
|
||||||
|
(стандарт рынка недвижимости): сколько МЕСЯЦЕВ нужно, чтобы поглотить
|
||||||
|
конкурирующее предложение при текущем нормализованном темпе спроса
|
||||||
|
(= competing_supply / demand_per_mo). Не насыщается → различает «30 мес»
|
||||||
|
от «100 мес» там, где дефицит-индекс у обоих = −1.
|
||||||
• БУДУЩИЕ КОНКУРЕНТЫ (§9.7) — top-N по relevance_weight на горизонт.
|
• БУДУЩИЕ КОНКУРЕНТЫ (§9.7) — top-N по relevance_weight на горизонт.
|
||||||
|
|
||||||
Всё ДЕТЕРМИНИРОВАННО, БЕЗ LLM (чистая арифметика + reuse §9.x; своего SQL НЕТ).
|
Всё ДЕТЕРМИНИРОВАННО, БЕЗ LLM (чистая арифметика + reuse §9.x; своего SQL НЕТ).
|
||||||
|
|
@ -124,6 +133,15 @@ class DemandSupplyForecast:
|
||||||
(предложения больше спроса — «риск»). Предложение исчерпано под спрос (supply≤0,
|
(предложения больше спроса — «риск»). Предложение исчерпано под спрос (supply≤0,
|
||||||
demand>0) → +1.0 (пик дефицита, #980, хотя balance_ratio тогда None); None только
|
demand>0) → +1.0 (пик дефицита, #980, хотя balance_ratio тогда None); None только
|
||||||
при отсутствии сигнала (supply≤0 И demand≤0/None).
|
при отсутствии сигнала (supply≤0 И demand≤0/None).
|
||||||
|
|
||||||
|
`deficit_index` — АБСОЛЮТНЫЙ сигнал (−1 = реально затоварено; ЕКБ-новостройка
|
||||||
|
широко затоварена при текущих ставках → −1 встречается часто и это честно). Он
|
||||||
|
насыщается уже при двукратном перекосе, поэтому слабо ДИСКРИМИНИРУЕТ среди
|
||||||
|
сильно-затоваренных сегментов. `months_of_inventory` (MOI) — ДИСКРИМИНИРУЮЩИЙ
|
||||||
|
companion: сколько месяцев нужно поглотить ВАЛОВОЕ конкурирующее предложение при
|
||||||
|
текущем нормализованном темпе спроса (= gross_supply / demand_per_mo). Не
|
||||||
|
насыщается → различает «30 мес» от «100 мес» там, где дефицит у обоих = −1.
|
||||||
|
|
||||||
`rate_sensitivity_phrase` — ТОЛЬКО explainability (β НЕ участвует в арифметике
|
`rate_sensitivity_phrase` — ТОЛЬКО explainability (β НЕ участвует в арифметике
|
||||||
спроса дважды — он уже учтён внутри demand_norm_coefficient §9.4).
|
спроса дважды — он уже учтён внутри demand_norm_coefficient §9.4).
|
||||||
"""
|
"""
|
||||||
|
|
@ -144,10 +162,11 @@ class DemandSupplyForecast:
|
||||||
future_online_units: float # Σ Layer3, взвешенный по попаданию в горизонт
|
future_online_units: float # Σ Layer3, взвешенный по попаданию в горизонт
|
||||||
projected_supply_units: float # open + hidden_release + future − absorbed, ≥0
|
projected_supply_units: float # open + hidden_release + future − absorbed, ≥0
|
||||||
|
|
||||||
# ── Баланс / индекс дефицита ───────────────────────────────────────────────
|
# ── Баланс / индекс дефицита / months-of-inventory ─────────────────────────
|
||||||
balance_units: float | None # demand − supply (>0 дефицит / <0 затоварка)
|
balance_units: float | None # demand − supply (>0 дефицит / <0 затоварка)
|
||||||
balance_ratio: float | None # demand / supply (None если supply ≤ 0)
|
balance_ratio: float | None # demand / supply (None если supply ≤ 0)
|
||||||
deficit_index: float | None # знаковое насыщение balance_ratio → [−1,+1]
|
deficit_index: float | None # знаковое насыщение balance_ratio → [−1,+1] (АБСОЛЮТ.)
|
||||||
|
months_of_inventory: float | None # ВАЛОВОЕ предложение / demand_per_mo (ДИСКРИМИН.)
|
||||||
|
|
||||||
# ── Explainability ─────────────────────────────────────────────────────────
|
# ── Explainability ─────────────────────────────────────────────────────────
|
||||||
rate_future: float | None # ставка сценария на горизонте (вход §9.4)
|
rate_future: float | None # ставка сценария на горизонте (вход §9.4)
|
||||||
|
|
@ -172,6 +191,7 @@ class DemandSupplyForecast:
|
||||||
"balance_units": _round_or_none(self.balance_units, 1),
|
"balance_units": _round_or_none(self.balance_units, 1),
|
||||||
"balance_ratio": _round_or_none(self.balance_ratio, 3),
|
"balance_ratio": _round_or_none(self.balance_ratio, 3),
|
||||||
"deficit_index": _round_or_none(self.deficit_index, 3),
|
"deficit_index": _round_or_none(self.deficit_index, 3),
|
||||||
|
"months_of_inventory": _round_or_none(self.months_of_inventory, 1),
|
||||||
"rate_future": _round_or_none(self.rate_future, 2),
|
"rate_future": _round_or_none(self.rate_future, 2),
|
||||||
"rate_sensitivity_phrase": self.rate_sensitivity_phrase,
|
"rate_sensitivity_phrase": self.rate_sensitivity_phrase,
|
||||||
"future_competitors": list(self.future_competitors),
|
"future_competitors": list(self.future_competitors),
|
||||||
|
|
@ -246,6 +266,19 @@ def _project_demand(
|
||||||
return demand_per_mo * float(horizon_months)
|
return demand_per_mo * float(horizon_months)
|
||||||
|
|
||||||
|
|
||||||
|
def _gross_supply(
|
||||||
|
open_units: float, hidden_release_units: float, future_online_units: float
|
||||||
|
) -> float:
|
||||||
|
"""Валовое конкурирующее предложение = open + hidden_release + future_online. PURE.
|
||||||
|
|
||||||
|
Всё, что выйдет/доступно на горизонте, ДО вычета поглощённого спросом. Это
|
||||||
|
«total inventory» для учебной MOI (total-inventory / sales-rate) — берём
|
||||||
|
ИМЕННО валовое (а не net после absorption), т.к. MOI отвечает «сколько месяцев
|
||||||
|
рынок переваривает ВЕСЬ доступный сток при текущем темпе», что и есть стандарт.
|
||||||
|
"""
|
||||||
|
return open_units + hidden_release_units + future_online_units
|
||||||
|
|
||||||
|
|
||||||
def _project_supply(
|
def _project_supply(
|
||||||
open_units: float,
|
open_units: float,
|
||||||
hidden_release_units: float,
|
hidden_release_units: float,
|
||||||
|
|
@ -254,12 +287,13 @@ def _project_supply(
|
||||||
) -> float:
|
) -> float:
|
||||||
"""Чистое конкурирующее предложение = валовое − поглощённое спросом, clamp ≥0.
|
"""Чистое конкурирующее предложение = валовое − поглощённое спросом, clamp ≥0.
|
||||||
|
|
||||||
Валовое = open + hidden_release + future_online (всё, что выйдет/доступно на
|
Валовое (см. `_gross_supply`) = open + hidden_release + future_online (всё, что
|
||||||
горизонте). Поглощённое (absorbed) = min(валовое, projected_demand) — рынок
|
выйдет/доступно на горизонте). Поглощённое (absorbed) = min(валовое,
|
||||||
«съест» столько, сколько есть спроса, но не больше доступного объёма. Чистое =
|
projected_demand) — рынок «съест» столько, сколько есть спроса, но не больше
|
||||||
валовое − absorbed (остаток, который РЕАЛЬНО конкурирует с нашим объектом).
|
доступного объёма. Чистое = валовое − absorbed (остаток, который РЕАЛЬНО
|
||||||
Спрос None трактуем как 0 поглощения (нет данных о спросе → ничего не вычитаем
|
конкурирует с нашим объектом). Спрос None трактуем как 0 поглощения (нет данных
|
||||||
→ консервативно показываем всё валовое как конкурирующее). clamp ≥0. PURE.
|
о спросе → ничего не вычитаем → консервативно показываем всё валовое как
|
||||||
|
конкурирующее). clamp ≥0. PURE.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
open_units: Σ Layer1 (в продаже).
|
open_units: Σ Layer1 (в продаже).
|
||||||
|
|
@ -270,7 +304,7 @@ def _project_supply(
|
||||||
Returns:
|
Returns:
|
||||||
Чистое конкурирующее предложение (≥0).
|
Чистое конкурирующее предложение (≥0).
|
||||||
"""
|
"""
|
||||||
gross = open_units + hidden_release_units + future_online_units
|
gross = _gross_supply(open_units, hidden_release_units, future_online_units)
|
||||||
demand = projected_demand_units if projected_demand_units is not None else 0.0
|
demand = projected_demand_units if projected_demand_units is not None else 0.0
|
||||||
absorbed = min(gross, max(0.0, demand))
|
absorbed = min(gross, max(0.0, demand))
|
||||||
return max(0.0, gross - absorbed)
|
return max(0.0, gross - absorbed)
|
||||||
|
|
@ -310,6 +344,14 @@ def _deficit_index(
|
||||||
) -> float | None:
|
) -> float | None:
|
||||||
"""Знаковое насыщение balance_ratio → индекс дефицита ∈ [−1,+1]. PURE.
|
"""Знаковое насыщение balance_ratio → индекс дефицита ∈ [−1,+1]. PURE.
|
||||||
|
|
||||||
|
АБСОЛЮТНЫЙ сигнал, НЕ относительный рейтинг: −1 = рынок реально затоварен
|
||||||
|
(ЕКБ-новостройка широко затоварена при текущих ставках → −1 встречается часто и
|
||||||
|
это честно). Насыщается уже при ДВУКРАТНОМ перекосе, поэтому слабо
|
||||||
|
ДИСКРИМИНИРУЕТ среди сильно-затоваренных сегментов (все липнут к −1) — для
|
||||||
|
различения пользуйтесь companion-метрикой months_of_inventory (gross-предложение
|
||||||
|
/ помесячный темп спроса), которая не насыщается. Намеренно НЕ перекалибруем
|
||||||
|
шкалу под ЕКБ: индекс честен как абсолют.
|
||||||
|
|
||||||
Лог-шкала (отношение мультипликативно — ×2 и ÷2 симметричны вокруг 1.0):
|
Лог-шкала (отношение мультипликативно — ×2 и ÷2 симметричны вокруг 1.0):
|
||||||
index = clamp(log(ratio) / log(saturation), −1, +1).
|
index = clamp(log(ratio) / log(saturation), −1, +1).
|
||||||
• ratio = 1.0 (спрос = предложение) → log(1)=0 → index 0 (баланс).
|
• ratio = 1.0 (спрос = предложение) → log(1)=0 → index 0 (баланс).
|
||||||
|
|
@ -381,6 +423,63 @@ def _deficit_index_from_balance(
|
||||||
return _deficit_index(balance_ratio, saturation=saturation)
|
return _deficit_index(balance_ratio, saturation=saturation)
|
||||||
|
|
||||||
|
|
||||||
|
def _months_of_inventory(
|
||||||
|
competing_supply: float | None, demand_per_mo: float | None
|
||||||
|
) -> float | None:
|
||||||
|
"""Months-of-inventory = валовое конкурирующее предложение / темп спроса (мес). PURE.
|
||||||
|
|
||||||
|
Стандартная метрика рынка недвижимости (total-inventory / monthly-sales-rate):
|
||||||
|
сколько МЕСЯЦЕВ нужно, чтобы поглотить competing_supply при текущем
|
||||||
|
нормализованном помесячном темпе спроса. ДИСКРИМИНИРУЕТ там, где deficit_index
|
||||||
|
насыщается (см. module docstring): два затоваренных сегмента с deficit −1 у обоих
|
||||||
|
различаются по MOI (30 мес vs 100 мес).
|
||||||
|
|
||||||
|
ВАЖНО — берём ВАЛОВОЕ (gross) предложение, НЕ net-после-absorption: MOI отвечает
|
||||||
|
«сколько рынок переваривает ВЕСЬ доступный сток», поэтому net тут занизил бы.
|
||||||
|
Gross-MOI читается чуть выше net-based прикидок — это ожидаемо и корректно.
|
||||||
|
|
||||||
|
Graceful (НИКОГДА не crash, НИКОГДА /0):
|
||||||
|
• demand_per_mo None ИЛИ ≤0 → None («нет данных о спросе» ≠ «∞ запас»;
|
||||||
|
бесконечную кратность не выдумываем — зеркало _balance_ratio).
|
||||||
|
• competing_supply None → None (нет данных о предложении).
|
||||||
|
• competing_supply 0 (и темп>0) → 0.0 (пусто = поглощается мгновенно — это
|
||||||
|
ОПРЕДЕЛЁННЫЙ сигнал «нет конкурирующего стока», не «нет данных»).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
competing_supply: валовое конкурирующее предложение (ед.; None → None).
|
||||||
|
demand_per_mo: нормализованный помесячный темп спроса (ед./мес; None/≤0 → None).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Месяцы инвентаря (≥0) или None.
|
||||||
|
"""
|
||||||
|
if competing_supply is None:
|
||||||
|
return None
|
||||||
|
if demand_per_mo is None or demand_per_mo <= 0:
|
||||||
|
return None
|
||||||
|
if competing_supply <= 0:
|
||||||
|
return 0.0
|
||||||
|
return competing_supply / demand_per_mo
|
||||||
|
|
||||||
|
|
||||||
|
def _demand_per_mo(projected_demand_units: float | None, horizon_months: int) -> float | None:
|
||||||
|
"""Восстановить помесячный темп спроса из спроектированного на горизонт. PURE.
|
||||||
|
|
||||||
|
projected_demand = demand_per_mo × h (линейно, см. _project_demand), поэтому
|
||||||
|
demand_per_mo = projected_demand / h. Это вход MOI (НЕ ×h). None / непозитивный
|
||||||
|
горизонт → None (нет осмысленного темпа). PURE.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
projected_demand_units: спрос на горизонт (None → None).
|
||||||
|
horizon_months: горизонт проекции (мес; ≤0 → None).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Помесячный темп спроса (ед./мес) или None.
|
||||||
|
"""
|
||||||
|
if projected_demand_units is None or horizon_months <= 0:
|
||||||
|
return None
|
||||||
|
return projected_demand_units / float(horizon_months)
|
||||||
|
|
||||||
|
|
||||||
def _min_confidence(values: Sequence[Confidence | None]) -> Confidence:
|
def _min_confidence(values: Sequence[Confidence | None]) -> Confidence:
|
||||||
"""Итоговая уверенность = MIN компонентных (худшая тянет вниз). Зеркало vocab.
|
"""Итоговая уверенность = MIN компонентных (худшая тянет вниз). Зеркало vocab.
|
||||||
|
|
||||||
|
|
@ -461,7 +560,8 @@ def compute_demand_supply_forecast(
|
||||||
• §9.3 compute_future_supply_pressure(horizon_months=h) → open/hidden/future;
|
• §9.3 compute_future_supply_pressure(horizon_months=h) → open/hidden/future;
|
||||||
hidden_release = hidden × _hidden_release_fraction(h); чистое предложение =
|
hidden_release = hidden × _hidden_release_fraction(h); чистое предложение =
|
||||||
open + hidden_release + future − поглощённое спросом (clamp ≥0).
|
open + hidden_release + future − поглощённое спросом (clamp ≥0).
|
||||||
• balance / ratio / знаковый deficit_index.
|
• balance / ratio / знаковый deficit_index (АБСОЛЮТ) + months_of_inventory
|
||||||
|
(ДИСКРИМИНИРУЮЩИЙ companion = gross-предложение / помесячный темп спроса).
|
||||||
• §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'.
|
||||||
|
|
||||||
|
|
@ -555,17 +655,22 @@ def _forecast_for_horizon(
|
||||||
hidden_fraction = _hidden_release_fraction(horizon)
|
hidden_fraction = _hidden_release_fraction(horizon)
|
||||||
hidden_release = fsp.hidden_units * hidden_fraction
|
hidden_release = fsp.hidden_units * hidden_fraction
|
||||||
future_online = fsp.future_units_by_horizon
|
future_online = fsp.future_units_by_horizon
|
||||||
|
gross_supply = _gross_supply(fsp.open_units, hidden_release, future_online)
|
||||||
projected_supply = _project_supply(
|
projected_supply = _project_supply(
|
||||||
fsp.open_units, hidden_release, future_online, projected_demand
|
fsp.open_units, hidden_release, future_online, projected_demand
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── БАЛАНС / индекс дефицита ────────────────────────────────────────────────
|
# ── БАЛАНС / индекс дефицита / months-of-inventory ──────────────────────────
|
||||||
# balance_ratio честно None при исчерпанном предложении (спрос/0 неотличим от
|
# balance_ratio честно None при исчерпанном предложении (спрос/0 неотличим от
|
||||||
# «нет данных»), НО deficit_index в этом случае = +1.0 — сильнейший дефицит, не
|
# «нет данных»), НО deficit_index в этом случае = +1.0 — сильнейший дефицит, не
|
||||||
# «нет сигнала» (#980; см. _deficit_index_from_balance).
|
# «нет сигнала» (#980; см. _deficit_index_from_balance). deficit_index —
|
||||||
|
# АБСОЛЮТНЫЙ сигнал (часто −1 на затоваренном ЕКБ); months_of_inventory —
|
||||||
|
# ДИСКРИМИНИРУЮЩИЙ companion (gross-предложение / помесячный темп спроса).
|
||||||
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_from_balance(projected_demand, projected_supply, balance_ratio)
|
deficit_index = _deficit_index_from_balance(projected_demand, projected_supply, balance_ratio)
|
||||||
|
demand_per_mo = _demand_per_mo(projected_demand, horizon)
|
||||||
|
months_of_inventory = _months_of_inventory(gross_supply, demand_per_mo)
|
||||||
|
|
||||||
# ── §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)
|
||||||
|
|
@ -577,7 +682,7 @@ def _forecast_for_horizon(
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"demand_supply_forecast: segment=%s h=%d base_pace=%s norm=%s macro=%s "
|
"demand_supply_forecast: segment=%s h=%d base_pace=%s norm=%s macro=%s "
|
||||||
"demand=%s supply=%.1f balance=%s ratio=%s deficit_index=%s confidence=%s",
|
"demand=%s supply=%.1f balance=%s ratio=%s deficit_index=%s moi=%s confidence=%s",
|
||||||
segment,
|
segment,
|
||||||
horizon,
|
horizon,
|
||||||
_round_or_none(base_pace, 2),
|
_round_or_none(base_pace, 2),
|
||||||
|
|
@ -588,6 +693,7 @@ def _forecast_for_horizon(
|
||||||
_round_or_none(balance_units, 1),
|
_round_or_none(balance_units, 1),
|
||||||
_round_or_none(balance_ratio, 3),
|
_round_or_none(balance_ratio, 3),
|
||||||
_round_or_none(deficit_index, 3),
|
_round_or_none(deficit_index, 3),
|
||||||
|
_round_or_none(months_of_inventory, 1),
|
||||||
confidence,
|
confidence,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -605,6 +711,7 @@ def _forecast_for_horizon(
|
||||||
balance_units=balance_units,
|
balance_units=balance_units,
|
||||||
balance_ratio=balance_ratio,
|
balance_ratio=balance_ratio,
|
||||||
deficit_index=deficit_index,
|
deficit_index=deficit_index,
|
||||||
|
months_of_inventory=months_of_inventory,
|
||||||
rate_future=rate_future,
|
rate_future=rate_future,
|
||||||
rate_sensitivity_phrase=sensitivity_phrase,
|
rate_sensitivity_phrase=sensitivity_phrase,
|
||||||
future_competitors=future_competitors,
|
future_competitors=future_competitors,
|
||||||
|
|
|
||||||
|
|
@ -297,6 +297,27 @@ def _primary_deficit_index(forecasts: Sequence[dict[str, Any]]) -> float | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _primary_months_of_inventory(forecasts: Sequence[dict[str, Any]]) -> float | None:
|
||||||
|
"""months_of_inventory основного горизонта (или первого доступного). PURE.
|
||||||
|
|
||||||
|
Зеркало `_primary_deficit_index`, но для MOI (#952 ДИСКРИМИНИРУЮЩИЙ companion к
|
||||||
|
дефициту). Предпочитаем _PRIMARY_HORIZON_MONTHS (12 мес); иначе первый forecast
|
||||||
|
с не-None months_of_inventory. Нет → None.
|
||||||
|
"""
|
||||||
|
primary = next(
|
||||||
|
(f for f in forecasts if f.get("horizon_months") == _PRIMARY_HORIZON_MONTHS),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if primary is not None and primary.get("months_of_inventory") is not None:
|
||||||
|
moi = primary["months_of_inventory"]
|
||||||
|
return float(moi) if isinstance(moi, (int, float)) and not isinstance(moi, bool) else None
|
||||||
|
for f in forecasts:
|
||||||
|
moi = f.get("months_of_inventory")
|
||||||
|
if isinstance(moi, (int, float)) and not isinstance(moi, bool):
|
||||||
|
return float(moi)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _recommended_class(product_tz: dict[str, Any] | None) -> str | None:
|
def _recommended_class(product_tz: dict[str, Any] | None) -> str | None:
|
||||||
"""Рекомендованный класс из product_tz-вывода (overlay class_reco / obj_class). PURE."""
|
"""Рекомендованный класс из product_tz-вывода (overlay class_reco / obj_class). PURE."""
|
||||||
if not isinstance(product_tz, dict):
|
if not isinstance(product_tz, dict):
|
||||||
|
|
@ -400,7 +421,12 @@ def _build_future_market(
|
||||||
|
|
||||||
|
|
||||||
def _future_market_summary(forecasts: Sequence[dict[str, Any]]) -> str | None:
|
def _future_market_summary(forecasts: Sequence[dict[str, Any]]) -> str | None:
|
||||||
"""Короткий RU-текст про будущий рынок (дефицит/затоварка осн. горизонта). PURE."""
|
"""Короткий RU-текст про будущий рынок (дефицит/затоварка осн. горизонта). PURE.
|
||||||
|
|
||||||
|
Индекс дефицита — АБСОЛЮТНЫЙ вердикт; months_of_inventory (если есть) —
|
||||||
|
ДИСКРИМИНИРУЮЩИЙ headline «≈N мес конкурирующего предложения» (различает
|
||||||
|
затоваренные сегменты, где дефицит у всех липнет к −1).
|
||||||
|
"""
|
||||||
di = _primary_deficit_index(forecasts)
|
di = _primary_deficit_index(forecasts)
|
||||||
if di is None:
|
if di is None:
|
||||||
return None
|
return None
|
||||||
|
|
@ -412,7 +438,9 @@ def _future_market_summary(forecasts: Sequence[dict[str, Any]]) -> str | None:
|
||||||
verdict = "баланс спроса и предложения"
|
verdict = "баланс спроса и предложения"
|
||||||
else:
|
else:
|
||||||
verdict = "затоварка (предложения больше спроса)"
|
verdict = "затоварка (предложения больше спроса)"
|
||||||
return f"Прогноз: {verdict}, индекс дефицита {_signed(di)} на горизонте."
|
moi = _primary_months_of_inventory(forecasts)
|
||||||
|
moi_part = f", ≈{round(moi, 1)} мес конкурирующего предложения" if moi is not None else ""
|
||||||
|
return f"Прогноз: {verdict}, индекс дефицита {_signed(di)}{moi_part} на горизонте."
|
||||||
|
|
||||||
|
|
||||||
def _build_product_tz(product_tz: dict[str, Any] | None) -> ReportProductTz:
|
def _build_product_tz(product_tz: dict[str, Any] | None) -> ReportProductTz:
|
||||||
|
|
@ -600,21 +628,25 @@ def _build_exec_summary(
|
||||||
ДЕТЕРМИНИРОВАННЫЙ шаблонный синтез (НЕ LLM):
|
ДЕТЕРМИНИРОВАННЫЙ шаблонный синтез (НЕ LLM):
|
||||||
• headline — одна фраза «что строить здесь» из рекомендованного класса +
|
• headline — одна фраза «что строить здесь» из рекомендованного класса +
|
||||||
силы дефицита (или честный «данных недостаточно»).
|
силы дефицита (или честный «данных недостаточно»).
|
||||||
• verdict — абзац: дефицит осн. горизонта + overall-скор + уровень уверенности.
|
• verdict — абзац: дефицит осн. горизонта (АБСОЛЮТ) + months_of_inventory
|
||||||
• key_numbers — плоские опорные числа (deficit_index / overall_score / confidence).
|
(ДИСКРИМИНИРУЮЩИЙ headline «≈N мес конкурирующего предложения») + overall-скор
|
||||||
|
+ уровень уверенности.
|
||||||
|
• key_numbers — плоские опорные числа (deficit_index / months_of_inventory /
|
||||||
|
overall_score / confidence).
|
||||||
• overall_confidence — уровень из §15-секции (#990).
|
• overall_confidence — уровень из §15-секции (#990).
|
||||||
|
|
||||||
Любого сигнала нет → соответствующая часть опускается; всё пусто → честный headline
|
Любого сигнала нет → соответствующая часть опускается; всё пусто → честный headline
|
||||||
«недостаточно данных» (секция всё равно валидна).
|
«недостаточно данных» (секция всё равно валидна).
|
||||||
"""
|
"""
|
||||||
di = _primary_deficit_index(forecasts)
|
di = _primary_deficit_index(forecasts)
|
||||||
|
moi = _primary_months_of_inventory(forecasts)
|
||||||
obj_class = _recommended_class(product_tz)
|
obj_class = _recommended_class(product_tz)
|
||||||
overall = _overall_score(product_scores)
|
overall = _overall_score(product_scores)
|
||||||
level: ReportConfidenceLevel | None = confidence.level
|
level: ReportConfidenceLevel | None = confidence.level
|
||||||
|
|
||||||
headline = _exec_headline(obj_class, di)
|
headline = _exec_headline(obj_class, di)
|
||||||
verdict = _exec_verdict(di, overall, level)
|
verdict = _exec_verdict(di, moi, overall, level)
|
||||||
key_numbers = _exec_key_numbers(di, overall, level)
|
key_numbers = _exec_key_numbers(di, moi, overall, level)
|
||||||
|
|
||||||
return ReportExecSummary(
|
return ReportExecSummary(
|
||||||
headline=headline,
|
headline=headline,
|
||||||
|
|
@ -647,16 +679,21 @@ def _exec_headline(obj_class: str | None, deficit_index: float | None) -> str:
|
||||||
|
|
||||||
def _exec_verdict(
|
def _exec_verdict(
|
||||||
deficit_index: float | None,
|
deficit_index: float | None,
|
||||||
|
months_of_inventory: float | None,
|
||||||
overall: float | None,
|
overall: float | None,
|
||||||
level: ReportConfidenceLevel | None,
|
level: ReportConfidenceLevel | None,
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""Развёрнутый абзац-резюме из дефицита, overall-скора и уверенности. PURE.
|
"""Развёрнутый абзац-резюме из дефицита, MOI, overall-скора и уверенности. PURE.
|
||||||
|
|
||||||
Шаблонная RU-фраза; ни одного сигнала → None (graceful — вердикт опционален).
|
Шаблонная RU-фраза; ни одного сигнала → None (graceful — вердикт опционален).
|
||||||
|
months_of_inventory — ДИСКРИМИНИРУЮЩИЙ headline (интерпретируемо «≈N мес
|
||||||
|
конкурирующего предложения»), рядом с АБСОЛЮТНЫМ индексом дефицита.
|
||||||
"""
|
"""
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
if deficit_index is not None:
|
if deficit_index is not None:
|
||||||
parts.append(f"индекс дефицита {_signed(deficit_index)} на целевом горизонте")
|
parts.append(f"индекс дефицита {_signed(deficit_index)} на целевом горизонте")
|
||||||
|
if months_of_inventory is not None:
|
||||||
|
parts.append(f"≈{round(months_of_inventory, 1)} мес конкурирующего предложения")
|
||||||
if overall is not None:
|
if overall is not None:
|
||||||
parts.append(f"итоговый продуктовый скор {round(overall, 2)}")
|
parts.append(f"итоговый продуктовый скор {round(overall, 2)}")
|
||||||
if level is not None:
|
if level is not None:
|
||||||
|
|
@ -672,16 +709,20 @@ def _exec_verdict(
|
||||||
|
|
||||||
def _exec_key_numbers(
|
def _exec_key_numbers(
|
||||||
deficit_index: float | None,
|
deficit_index: float | None,
|
||||||
|
months_of_inventory: float | None,
|
||||||
overall: float | None,
|
overall: float | None,
|
||||||
level: ReportConfidenceLevel | None,
|
level: ReportConfidenceLevel | None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Плоские опорные числа exec_summary (JSON-safe). PURE.
|
"""Плоские опорные числа exec_summary (JSON-safe). PURE.
|
||||||
|
|
||||||
Кладём только заданные сигналы (None опускаем) — частичный набор валиден.
|
Кладём только заданные сигналы (None опускаем) — частичный набор валиден.
|
||||||
|
months_of_inventory — ДИСКРИМИНИРУЮЩИЙ headline рядом с АБСОЛЮТНЫМ дефицитом.
|
||||||
"""
|
"""
|
||||||
numbers: dict[str, Any] = {}
|
numbers: dict[str, Any] = {}
|
||||||
if deficit_index is not None:
|
if deficit_index is not None:
|
||||||
numbers["deficit_index"] = round(deficit_index, 3)
|
numbers["deficit_index"] = round(deficit_index, 3)
|
||||||
|
if months_of_inventory is not None:
|
||||||
|
numbers["months_of_inventory"] = round(months_of_inventory, 1)
|
||||||
if overall is not None:
|
if overall is not None:
|
||||||
numbers["overall_score"] = round(overall, 3)
|
numbers["overall_score"] = round(overall, 3)
|
||||||
if level is not None:
|
if level is not None:
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ def _forecast(horizon: int, demand: float, supply: float, deficit: float) -> dic
|
||||||
balance_units=demand - supply,
|
balance_units=demand - supply,
|
||||||
balance_ratio=(demand / supply if supply else None),
|
balance_ratio=(demand / supply if supply else None),
|
||||||
deficit_index=deficit,
|
deficit_index=deficit,
|
||||||
|
months_of_inventory=(supply / (demand / horizon) if demand else None),
|
||||||
rate_future=18.0,
|
rate_future=18.0,
|
||||||
rate_sensitivity_phrase="спрос эластичен к ставке",
|
rate_sensitivity_phrase="спрос эластичен к ставке",
|
||||||
future_competitors=[{"obj_id": 3, "comm_name": "ЖК Гамма"}],
|
future_competitors=[{"obj_id": 3, "comm_name": "ЖК Гамма"}],
|
||||||
|
|
@ -102,6 +103,7 @@ def _scenario(name: str, deficit_12mo: float) -> dict[str, object]:
|
||||||
balance_units=220.0,
|
balance_units=220.0,
|
||||||
balance_ratio=1.314,
|
balance_ratio=1.314,
|
||||||
deficit_index=deficit_12mo,
|
deficit_index=deficit_12mo,
|
||||||
|
months_of_inventory=9.1,
|
||||||
rate_future=18.0,
|
rate_future=18.0,
|
||||||
rate_sensitivity_phrase=None,
|
rate_sensitivity_phrase=None,
|
||||||
future_competitors=[],
|
future_competitors=[],
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ def _forecast(horizon: int, demand: float, supply: float, deficit: float) -> dic
|
||||||
balance_units=demand - supply,
|
balance_units=demand - supply,
|
||||||
balance_ratio=(demand / supply if supply else None),
|
balance_ratio=(demand / supply if supply else None),
|
||||||
deficit_index=deficit,
|
deficit_index=deficit,
|
||||||
|
months_of_inventory=(supply / (demand / horizon) if demand else None),
|
||||||
rate_future=18.0,
|
rate_future=18.0,
|
||||||
rate_sensitivity_phrase="спрос эластичен к ставке",
|
rate_sensitivity_phrase="спрос эластичен к ставке",
|
||||||
future_competitors=[{"obj_id": 3, "comm_name": "ЖК Гамма"}],
|
future_competitors=[{"obj_id": 3, "comm_name": "ЖК Гамма"}],
|
||||||
|
|
@ -104,6 +105,7 @@ def _scenario(name: str, deficit_12mo: float) -> dict[str, object]:
|
||||||
balance_units=220.0,
|
balance_units=220.0,
|
||||||
balance_ratio=1.314,
|
balance_ratio=1.314,
|
||||||
deficit_index=deficit_12mo,
|
deficit_index=deficit_12mo,
|
||||||
|
months_of_inventory=9.1,
|
||||||
rate_future=18.0,
|
rate_future=18.0,
|
||||||
rate_sensitivity_phrase=None,
|
rate_sensitivity_phrase=None,
|
||||||
future_competitors=[],
|
future_competitors=[],
|
||||||
|
|
|
||||||
|
|
@ -40,8 +40,11 @@ from app.services.forecasting.demand_supply_forecast import (
|
||||||
_cap_confidence,
|
_cap_confidence,
|
||||||
_deficit_index,
|
_deficit_index,
|
||||||
_deficit_index_from_balance,
|
_deficit_index_from_balance,
|
||||||
|
_demand_per_mo,
|
||||||
|
_gross_supply,
|
||||||
_hidden_release_fraction,
|
_hidden_release_fraction,
|
||||||
_min_confidence,
|
_min_confidence,
|
||||||
|
_months_of_inventory,
|
||||||
_project_demand,
|
_project_demand,
|
||||||
_project_supply,
|
_project_supply,
|
||||||
_round_or_none,
|
_round_or_none,
|
||||||
|
|
@ -166,6 +169,82 @@ class TestProjectSupply:
|
||||||
assert _project_supply(40.0, 30.0, 20.0, demand) >= 0.0
|
assert _project_supply(40.0, 30.0, 20.0, demand) >= 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ── pure: _gross_supply (валовое, ДО absorption) ──────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestGrossSupply:
|
||||||
|
def test_sum_of_layers(self) -> None:
|
||||||
|
# валовое = open + hidden_release + future (без вычета спроса).
|
||||||
|
assert _gross_supply(100.0, 50.0, 30.0) == pytest.approx(180.0)
|
||||||
|
|
||||||
|
def test_gross_independent_of_demand(self) -> None:
|
||||||
|
# gross НЕ зависит от спроса (в отличие от _project_supply net) — это «total
|
||||||
|
# inventory» для MOI; net = _project_supply ≤ gross.
|
||||||
|
gross = _gross_supply(100.0, 50.0, 30.0)
|
||||||
|
net = _project_supply(100.0, 50.0, 30.0, 120.0)
|
||||||
|
assert gross == pytest.approx(180.0)
|
||||||
|
assert net == pytest.approx(60.0) # 180 − 120 absorbed
|
||||||
|
assert net <= gross
|
||||||
|
|
||||||
|
def test_empty_is_zero(self) -> None:
|
||||||
|
assert _gross_supply(0.0, 0.0, 0.0) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ── pure: _demand_per_mo (восстановить помесячный темп из проекции на h) ───────
|
||||||
|
|
||||||
|
|
||||||
|
class TestDemandPerMo:
|
||||||
|
def test_divides_by_horizon(self) -> None:
|
||||||
|
# projected = demand_per_mo × h → demand_per_mo = projected / h.
|
||||||
|
assert _demand_per_mo(120.0, 12) == pytest.approx(10.0)
|
||||||
|
|
||||||
|
def test_none_demand_is_none(self) -> None:
|
||||||
|
assert _demand_per_mo(None, 12) is None
|
||||||
|
|
||||||
|
def test_nonpositive_horizon_is_none(self) -> None:
|
||||||
|
assert _demand_per_mo(120.0, 0) is None
|
||||||
|
assert _demand_per_mo(120.0, -6) is None
|
||||||
|
|
||||||
|
def test_zero_demand_is_zero(self) -> None:
|
||||||
|
# спрос 0 (но известен) → темп 0 (НЕ None — это определённый сигнал).
|
||||||
|
assert _demand_per_mo(0.0, 12) == pytest.approx(0.0)
|
||||||
|
|
||||||
|
|
||||||
|
# ── pure: _months_of_inventory (gross / темп; ДИСКРИМИНИРУЮЩИЙ companion) ───────
|
||||||
|
|
||||||
|
|
||||||
|
class TestMonthsOfInventory:
|
||||||
|
def test_known_supply_and_pace(self) -> None:
|
||||||
|
# supply 12000, темп 1000 ед./мес → 12.0 мес.
|
||||||
|
assert _months_of_inventory(12000.0, 1000.0) == pytest.approx(12.0)
|
||||||
|
|
||||||
|
def test_supply_over_pace_basic(self) -> None:
|
||||||
|
assert _months_of_inventory(49078.0, 1138.0) == pytest.approx(49078.0 / 1138.0)
|
||||||
|
|
||||||
|
def test_none_demand_is_none(self) -> None:
|
||||||
|
# «нет данных о спросе» ≠ «∞ запас» → None (не выдумываем бесконечность).
|
||||||
|
assert _months_of_inventory(12000.0, None) is None
|
||||||
|
|
||||||
|
def test_zero_pace_is_none(self) -> None:
|
||||||
|
# темп 0 → деление невозможно → None (graceful, НЕ crash/∞).
|
||||||
|
assert _months_of_inventory(12000.0, 0.0) is None
|
||||||
|
|
||||||
|
def test_negative_pace_is_none(self) -> None:
|
||||||
|
assert _months_of_inventory(12000.0, -5.0) is None
|
||||||
|
|
||||||
|
def test_zero_supply_is_zero(self) -> None:
|
||||||
|
# пусто = поглощается мгновенно → 0.0 (ОПРЕДЕЛЁННЫЙ сигнал, НЕ None).
|
||||||
|
assert _months_of_inventory(0.0, 1000.0) == 0.0
|
||||||
|
|
||||||
|
def test_none_supply_is_none(self) -> None:
|
||||||
|
assert _months_of_inventory(None, 1000.0) is None
|
||||||
|
|
||||||
|
def test_in_as_dict_rounded(self) -> None:
|
||||||
|
# MOI присутствует в as_dict() и округлён до 1 знака.
|
||||||
|
d = _make_forecast(months_of_inventory=43.27).as_dict()
|
||||||
|
assert d["months_of_inventory"] == 43.3
|
||||||
|
|
||||||
|
|
||||||
# ── pure: _balance ────────────────────────────────────────────────────────────
|
# ── pure: _balance ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -424,6 +503,7 @@ def _make_forecast(**over: object) -> DemandSupplyForecast:
|
||||||
"balance_units": -262.13,
|
"balance_units": -262.13,
|
||||||
"balance_ratio": 0.287,
|
"balance_ratio": 0.287,
|
||||||
"deficit_index": -0.9,
|
"deficit_index": -0.9,
|
||||||
|
"months_of_inventory": 43.27,
|
||||||
"rate_future": 21.0,
|
"rate_future": 21.0,
|
||||||
"rate_sensitivity_phrase": "при росте ставки …",
|
"rate_sensitivity_phrase": "при росте ставки …",
|
||||||
"future_competitors": [{"obj_id": 1, "relevance_weight": 0.7}],
|
"future_competitors": [{"obj_id": 1, "relevance_weight": 0.7}],
|
||||||
|
|
@ -443,6 +523,7 @@ class TestAsDict:
|
||||||
assert d["projected_demand_units"] == 105.6
|
assert d["projected_demand_units"] == 105.6
|
||||||
assert d["projected_supply_units"] == 367.7
|
assert d["projected_supply_units"] == 367.7
|
||||||
assert d["deficit_index"] == -0.9
|
assert d["deficit_index"] == -0.9
|
||||||
|
assert d["months_of_inventory"] == 43.3 # round(43.27, 1)
|
||||||
assert d["advisory"] is True
|
assert d["advisory"] is True
|
||||||
assert d["confidence"] in _ALLOWED_CONFIDENCE
|
assert d["confidence"] in _ALLOWED_CONFIDENCE
|
||||||
|
|
||||||
|
|
@ -453,11 +534,13 @@ class TestAsDict:
|
||||||
balance_units=None,
|
balance_units=None,
|
||||||
balance_ratio=None,
|
balance_ratio=None,
|
||||||
deficit_index=None,
|
deficit_index=None,
|
||||||
|
months_of_inventory=None,
|
||||||
).as_dict()
|
).as_dict()
|
||||||
assert d["base_pace_units_per_mo"] is None
|
assert d["base_pace_units_per_mo"] is None
|
||||||
assert d["projected_demand_units"] is None
|
assert d["projected_demand_units"] is None
|
||||||
assert d["balance_ratio"] is None
|
assert d["balance_ratio"] is None
|
||||||
assert d["deficit_index"] is None
|
assert d["deficit_index"] is None
|
||||||
|
assert d["months_of_inventory"] is None
|
||||||
|
|
||||||
def test_competitors_passthrough(self) -> None:
|
def test_competitors_passthrough(self) -> None:
|
||||||
d = _make_forecast().as_dict()
|
d = _make_forecast().as_dict()
|
||||||
|
|
@ -793,6 +876,104 @@ class TestComputeBalance:
|
||||||
assert f.deficit_index is not None and f.deficit_index < 0.0
|
assert f.deficit_index is not None and f.deficit_index < 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ── orchestrator: months_of_inventory (ДИСКРИМИНИРУЮЩИЙ companion) ─────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestMonthsOfInventoryOrchestrator:
|
||||||
|
def test_moi_is_gross_supply_over_demand_per_mo(self) -> None:
|
||||||
|
# h=12; norm=1.0, macro=1.0, velocity=10 → demand_per_mo=10, demand_h12=120.
|
||||||
|
# gross = open 1000 + hidden 0×fraction + future 0 = 1000 → MOI = 1000/10 = 100.
|
||||||
|
with _Patches(
|
||||||
|
_METRICS=_metrics_stub(unit_velocity=10.0),
|
||||||
|
_NORM=_norm_stub(coefficient=1.0),
|
||||||
|
_MACRO_COEF=_macro_coef_stub(coefficient=1.0),
|
||||||
|
_SUPPLY=_supply_stub(open_units=1000, hidden_units=0, future_units_by_horizon=0.0),
|
||||||
|
):
|
||||||
|
res = _run(horizons=[12])
|
||||||
|
f = res[0]
|
||||||
|
assert f.projected_demand_units == pytest.approx(120.0)
|
||||||
|
assert f.months_of_inventory == pytest.approx(100.0)
|
||||||
|
|
||||||
|
def test_moi_uses_gross_not_net_supply(self) -> None:
|
||||||
|
# MOI должен делить ВАЛОВОЕ (gross), НЕ net-после-absorption. h=12, velocity=10
|
||||||
|
# → demand_h12=120 поглощает 120 из gross. net = 1000−120 = 880, gross = 1000.
|
||||||
|
# MOI(gross) = 1000/10 = 100 ≠ MOI(net) = 880/10 = 88 → проверяем именно 100.
|
||||||
|
with _Patches(
|
||||||
|
_METRICS=_metrics_stub(unit_velocity=10.0),
|
||||||
|
_NORM=_norm_stub(coefficient=1.0),
|
||||||
|
_MACRO_COEF=_macro_coef_stub(coefficient=1.0),
|
||||||
|
_SUPPLY=_supply_stub(open_units=1000, hidden_units=0, future_units_by_horizon=0.0),
|
||||||
|
):
|
||||||
|
res = _run(horizons=[12])
|
||||||
|
f = res[0]
|
||||||
|
assert f.projected_supply_units == pytest.approx(880.0) # net (после absorption)
|
||||||
|
assert f.months_of_inventory == pytest.approx(100.0) # gross/темп, НЕ net/темп (88)
|
||||||
|
|
||||||
|
def test_moi_none_when_no_demand(self) -> None:
|
||||||
|
# velocity None → projected_demand None → demand_per_mo None → MOI None
|
||||||
|
# («нет данных о спросе» ≠ «∞ запас»).
|
||||||
|
with _Patches(
|
||||||
|
_METRICS=_metrics_stub(unit_velocity=None),
|
||||||
|
_SUPPLY=_supply_stub(open_units=1000, hidden_units=0, future_units_by_horizon=0.0),
|
||||||
|
):
|
||||||
|
res = _run(horizons=[12])
|
||||||
|
f = res[0]
|
||||||
|
assert f.projected_demand_units is None
|
||||||
|
assert f.months_of_inventory is None
|
||||||
|
|
||||||
|
def test_moi_none_when_zero_demand_pace(self) -> None:
|
||||||
|
# velocity 0 → demand_per_mo 0 → MOI None (деление невозможно, graceful).
|
||||||
|
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=1000, hidden_units=0, future_units_by_horizon=0.0),
|
||||||
|
):
|
||||||
|
res = _run(horizons=[12])
|
||||||
|
assert res[0].months_of_inventory is None
|
||||||
|
|
||||||
|
def test_moi_zero_when_no_competing_supply(self) -> None:
|
||||||
|
# gross 0 (open/hidden/future все 0) при положительном спросе → MOI 0.0.
|
||||||
|
with _Patches(
|
||||||
|
_METRICS=_metrics_stub(unit_velocity=10.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])
|
||||||
|
assert res[0].months_of_inventory == 0.0
|
||||||
|
|
||||||
|
def test_moi_discriminates_where_deficit_does_not(self) -> None:
|
||||||
|
"""ГЛАВНЫЙ тест: два сегмента с ОДИНАКОВЫМ deficit_index (оба −1, ratio в
|
||||||
|
зоне clamp) но РАЗНЫМ supply/velocity → РАЗНЫЙ MOI. В этом весь смысл MOI —
|
||||||
|
дискриминировать там, где дефицит-индекс насыщается (зеркало прод-факта ЕКБ).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def run_segment(*, velocity: float, open_units: int) -> DemandSupplyForecast:
|
||||||
|
with _Patches(
|
||||||
|
_METRICS=_metrics_stub(unit_velocity=velocity),
|
||||||
|
_NORM=_norm_stub(coefficient=1.0),
|
||||||
|
_MACRO_COEF=_macro_coef_stub(coefficient=1.0),
|
||||||
|
_SUPPLY=_supply_stub(
|
||||||
|
open_units=open_units, hidden_units=0, future_units_by_horizon=0.0
|
||||||
|
),
|
||||||
|
):
|
||||||
|
return _run(horizons=[12])[0]
|
||||||
|
|
||||||
|
# Сегмент A: темп 10, gross 2400 → demand_h12 120, net 2280, ratio 0.053 → −1.
|
||||||
|
seg_a = run_segment(velocity=10.0, open_units=2400)
|
||||||
|
# Сегмент B: темп 20, gross 2400 → demand_h12 240, net 2160, ratio 0.111 → −1.
|
||||||
|
seg_b = run_segment(velocity=20.0, open_units=2400)
|
||||||
|
|
||||||
|
# Дефицит-индекс НЕ дискриминирует: оба намертво −1.0 (как на проде ЕКБ).
|
||||||
|
assert seg_a.deficit_index == pytest.approx(-1.0)
|
||||||
|
assert seg_b.deficit_index == pytest.approx(-1.0)
|
||||||
|
# MOI ДИСКРИМИНИРУЕТ: 2400/10 = 240 мес vs 2400/20 = 120 мес.
|
||||||
|
assert seg_a.months_of_inventory == pytest.approx(240.0)
|
||||||
|
assert seg_b.months_of_inventory == pytest.approx(120.0)
|
||||||
|
assert seg_a.months_of_inventory != seg_b.months_of_inventory
|
||||||
|
|
||||||
|
|
||||||
# ── orchestrator: confidence = MIN ≤ medium ────────────────────────────────────
|
# ── orchestrator: confidence = MIN ≤ medium ────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -936,6 +1117,7 @@ class TestGraceful:
|
||||||
assert f.balance_units is None # demand None → balance None
|
assert f.balance_units is None # demand None → balance None
|
||||||
assert f.balance_ratio is None
|
assert f.balance_ratio is None
|
||||||
assert f.deficit_index is None
|
assert f.deficit_index is None
|
||||||
|
assert f.months_of_inventory is None # demand None → demand_per_mo None → MOI None
|
||||||
assert f.future_competitors == []
|
assert f.future_competitors == []
|
||||||
assert f.confidence == "low"
|
assert f.confidence == "low"
|
||||||
assert f.advisory is True
|
assert f.advisory is True
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ from app.services.forecasting.report_assembler import (
|
||||||
_domrf_coverage,
|
_domrf_coverage,
|
||||||
_history_months,
|
_history_months,
|
||||||
_primary_deficit_index,
|
_primary_deficit_index,
|
||||||
|
_primary_months_of_inventory,
|
||||||
assemble_report,
|
assemble_report,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -107,6 +108,7 @@ def _sample_forecasts() -> list[dict[str, Any]]:
|
||||||
{
|
{
|
||||||
"horizon_months": 6,
|
"horizon_months": 6,
|
||||||
"deficit_index": 0.21,
|
"deficit_index": 0.21,
|
||||||
|
"months_of_inventory": 18.5,
|
||||||
"confidence": "medium",
|
"confidence": "medium",
|
||||||
"future_competitors": [],
|
"future_competitors": [],
|
||||||
"confounded": False,
|
"confounded": False,
|
||||||
|
|
@ -114,6 +116,7 @@ def _sample_forecasts() -> list[dict[str, Any]]:
|
||||||
{
|
{
|
||||||
"horizon_months": 12,
|
"horizon_months": 12,
|
||||||
"deficit_index": 0.34,
|
"deficit_index": 0.34,
|
||||||
|
"months_of_inventory": 24.2,
|
||||||
"confidence": "medium",
|
"confidence": "medium",
|
||||||
"future_competitors": [{"obj_id": 3, "comm_name": "ЖК Гамма", "relevance_weight": 0.6}],
|
"future_competitors": [{"obj_id": 3, "comm_name": "ЖК Гамма", "relevance_weight": 0.6}],
|
||||||
"confounded": False,
|
"confounded": False,
|
||||||
|
|
@ -121,6 +124,7 @@ def _sample_forecasts() -> list[dict[str, Any]]:
|
||||||
{
|
{
|
||||||
"horizon_months": 18,
|
"horizon_months": 18,
|
||||||
"deficit_index": 0.28,
|
"deficit_index": 0.28,
|
||||||
|
"months_of_inventory": 31.0,
|
||||||
"confidence": "low",
|
"confidence": "low",
|
||||||
"future_competitors": [],
|
"future_competitors": [],
|
||||||
"confounded": False,
|
"confounded": False,
|
||||||
|
|
@ -128,6 +132,7 @@ def _sample_forecasts() -> list[dict[str, Any]]:
|
||||||
{
|
{
|
||||||
"horizon_months": 24,
|
"horizon_months": 24,
|
||||||
"deficit_index": 0.19,
|
"deficit_index": 0.19,
|
||||||
|
"months_of_inventory": 40.7,
|
||||||
"confidence": "low",
|
"confidence": "low",
|
||||||
"future_competitors": [],
|
"future_competitors": [],
|
||||||
"confounded": False,
|
"confounded": False,
|
||||||
|
|
@ -326,6 +331,17 @@ class TestFutureMarket:
|
||||||
assert len(fm["forecasts_by_horizon"]) == 4
|
assert len(fm["forecasts_by_horizon"]) == 4
|
||||||
assert fm["forecasts_by_horizon"][1]["deficit_index"] == 0.34
|
assert fm["forecasts_by_horizon"][1]["deficit_index"] == 0.34
|
||||||
|
|
||||||
|
def test_months_of_inventory_flows_through(self) -> None:
|
||||||
|
# MOI прокидывается per-горизонт автоматически (passthrough as_dict()).
|
||||||
|
fm = _full_assemble().as_dict()["future_market"]
|
||||||
|
assert fm["forecasts_by_horizon"][1]["months_of_inventory"] == 24.2
|
||||||
|
|
||||||
|
def test_summary_mentions_months_of_inventory(self) -> None:
|
||||||
|
# future_market.summary несёт ДИСКРИМИНИРУЮЩИЙ MOI-headline рядом с дефицитом.
|
||||||
|
fm = _full_assemble().as_dict()["future_market"]
|
||||||
|
assert fm["summary"] is not None
|
||||||
|
assert "мес конкурирующего предложения" in fm["summary"]
|
||||||
|
|
||||||
def test_future_supply_passed_through(self) -> None:
|
def test_future_supply_passed_through(self) -> None:
|
||||||
fm = _full_assemble().as_dict()["future_market"]
|
fm = _full_assemble().as_dict()["future_market"]
|
||||||
assert fm["future_supply"]["index"] == 0.42
|
assert fm["future_supply"]["index"] == 0.42
|
||||||
|
|
@ -440,8 +456,14 @@ class TestExecSummary:
|
||||||
es = _full_assemble().as_dict()["exec_summary"]
|
es = _full_assemble().as_dict()["exec_summary"]
|
||||||
assert es["verdict"] is not None
|
assert es["verdict"] is not None
|
||||||
assert es["key_numbers"]["deficit_index"] == 0.34
|
assert es["key_numbers"]["deficit_index"] == 0.34
|
||||||
|
assert es["key_numbers"]["months_of_inventory"] == 24.2
|
||||||
assert es["key_numbers"]["overall_score"] == 0.62
|
assert es["key_numbers"]["overall_score"] == 0.62
|
||||||
|
|
||||||
|
def test_verdict_mentions_months_of_inventory(self) -> None:
|
||||||
|
# ДИСКРИМИНИРУЮЩИЙ MOI-headline присутствует в вердикте exec_summary.
|
||||||
|
es = _full_assemble().as_dict()["exec_summary"]
|
||||||
|
assert "мес конкурирующего предложения" in es["verdict"]
|
||||||
|
|
||||||
def test_overall_confidence_matches_section(self) -> None:
|
def test_overall_confidence_matches_section(self) -> None:
|
||||||
payload = _full_assemble().as_dict()
|
payload = _full_assemble().as_dict()
|
||||||
assert payload["exec_summary"]["overall_confidence"] == payload["confidence"]["level"]
|
assert payload["exec_summary"]["overall_confidence"] == payload["confidence"]["level"]
|
||||||
|
|
@ -638,3 +660,19 @@ class TestSignalExtractionHelpers:
|
||||||
forecasts = [{"horizon_months": 6, "deficit_index": 0.21}]
|
forecasts = [{"horizon_months": 6, "deficit_index": 0.21}]
|
||||||
assert _primary_deficit_index(forecasts) == 0.21
|
assert _primary_deficit_index(forecasts) == 0.21
|
||||||
assert _primary_deficit_index([]) is None
|
assert _primary_deficit_index([]) is None
|
||||||
|
|
||||||
|
def test_primary_moi_prefers_12mo(self) -> None:
|
||||||
|
forecasts = [
|
||||||
|
{"horizon_months": 6, "months_of_inventory": 18.5},
|
||||||
|
{"horizon_months": 12, "months_of_inventory": 24.2},
|
||||||
|
]
|
||||||
|
assert _primary_months_of_inventory(forecasts) == 24.2
|
||||||
|
|
||||||
|
def test_primary_moi_fallback_first(self) -> None:
|
||||||
|
forecasts = [{"horizon_months": 6, "months_of_inventory": 18.5}]
|
||||||
|
assert _primary_months_of_inventory(forecasts) == 18.5
|
||||||
|
assert _primary_months_of_inventory([]) is None
|
||||||
|
|
||||||
|
def test_primary_moi_none_when_absent(self) -> None:
|
||||||
|
# forecast без MOI-ключа → None (graceful, не KeyError).
|
||||||
|
assert _primary_months_of_inventory([{"horizon_months": 12, "deficit_index": -1.0}]) is None
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue