feat(forecasting): months-of-inventory (MOI) — discriminating §9.8 metric #1055

Merged
bot-backend merged 1 commit from feat/demand-supply-months-of-inventory into main 2026-06-05 02:37:28 +00:00
6 changed files with 393 additions and 21 deletions

View file

@ -11,7 +11,16 @@
горизонте поглощённое спросом (§9.3 compute_future_supply_pressure).
БАЛАНС / индекс дефицита = знаковое насыщающее преобразование отношения
спрос/предложение в [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 на горизонт.
Всё ДЕТЕРМИНИРОВАННО, БЕЗ LLM (чистая арифметика + reuse §9.x; своего SQL НЕТ).
@ -124,6 +133,15 @@ class DemandSupplyForecast:
(предложения больше спроса «риск»). Предложение исчерпано под спрос (supply0,
demand>0) +1.0 (пик дефицита, #980, хотя balance_ratio тогда None); None только
при отсутствии сигнала (supply0 И demand0/None).
`deficit_index` АБСОЛЮТНЫЙ сигнал (1 = реально затоварено; ЕКБ-новостройка
широко затоварена при текущих ставках 1 встречается часто и это честно). Он
насыщается уже при двукратном перекосе, поэтому слабо ДИСКРИМИНИРУЕТ среди
сильно-затоваренных сегментов. `months_of_inventory` (MOI) ДИСКРИМИНИРУЮЩИЙ
companion: сколько месяцев нужно поглотить ВАЛОВОЕ конкурирующее предложение при
текущем нормализованном темпе спроса (= gross_supply / demand_per_mo). Не
насыщается различает «30 мес» от «100 мес» там, где дефицит у обоих = 1.
`rate_sensitivity_phrase` ТОЛЬКО explainability (β НЕ участвует в арифметике
спроса дважды он уже учтён внутри demand_norm_coefficient §9.4).
"""
@ -144,10 +162,11 @@ class DemandSupplyForecast:
future_online_units: float # Σ Layer3, взвешенный по попаданию в горизонт
projected_supply_units: float # open + hidden_release + future absorbed, ≥0
# ── Баланс / индекс дефицита ───────────────────────────────────────────────
# ── Баланс / индекс дефицита / months-of-inventory ─────────────────────────
balance_units: float | None # demand supply (>0 дефицит / <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 ─────────────────────────────────────────────────────────
rate_future: float | None # ставка сценария на горизонте (вход §9.4)
@ -172,6 +191,7 @@ class DemandSupplyForecast:
"balance_units": _round_or_none(self.balance_units, 1),
"balance_ratio": _round_or_none(self.balance_ratio, 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_sensitivity_phrase": self.rate_sensitivity_phrase,
"future_competitors": list(self.future_competitors),
@ -246,6 +266,19 @@ def _project_demand(
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(
open_units: float,
hidden_release_units: float,
@ -254,12 +287,13 @@ def _project_supply(
) -> float:
"""Чистое конкурирующее предложение = валовое поглощённое спросом, clamp ≥0.
Валовое = open + hidden_release + future_online (всё, что выйдет/доступно на
горизонте). Поглощённое (absorbed) = min(валовое, projected_demand) рынок
«съест» столько, сколько есть спроса, но не больше доступного объёма. Чистое =
валовое absorbed (остаток, который РЕАЛЬНО конкурирует с нашим объектом).
Спрос None трактуем как 0 поглощения (нет данных о спросе ничего не вычитаем
консервативно показываем всё валовое как конкурирующее). clamp 0. PURE.
Валовое (см. `_gross_supply`) = open + hidden_release + future_online (всё, что
выйдет/доступно на горизонте). Поглощённое (absorbed) = min(валовое,
projected_demand) рынок «съест» столько, сколько есть спроса, но не больше
доступного объёма. Чистое = валовое absorbed (остаток, который РЕАЛЬНО
конкурирует с нашим объектом). Спрос None трактуем как 0 поглощения (нет данных
о спросе ничего не вычитаем консервативно показываем всё валовое как
конкурирующее). clamp 0. PURE.
Args:
open_units: Σ Layer1 (в продаже).
@ -270,7 +304,7 @@ def _project_supply(
Returns:
Чистое конкурирующее предложение (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
absorbed = min(gross, max(0.0, demand))
return max(0.0, gross - absorbed)
@ -310,6 +344,14 @@ def _deficit_index(
) -> float | None:
"""Знаковое насыщение balance_ratio → индекс дефицита ∈ [1,+1]. PURE.
АБСОЛЮТНЫЙ сигнал, НЕ относительный рейтинг: 1 = рынок реально затоварен
(ЕКБ-новостройка широко затоварена при текущих ставках 1 встречается часто и
это честно). Насыщается уже при ДВУКРАТНОМ перекосе, поэтому слабо
ДИСКРИМИНИРУЕТ среди сильно-затоваренных сегментов (все липнут к 1) для
различения пользуйтесь companion-метрикой months_of_inventory (gross-предложение
/ помесячный темп спроса), которая не насыщается. Намеренно НЕ перекалибруем
шкалу под ЕКБ: индекс честен как абсолют.
Лог-шкала (отношение мультипликативно ×2 и ÷2 симметричны вокруг 1.0):
index = clamp(log(ratio) / log(saturation), 1, +1).
ratio = 1.0 (спрос = предложение) log(1)=0 index 0 (баланс).
@ -381,6 +423,63 @@ def _deficit_index_from_balance(
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:
"""Итоговая уверенность = MIN компонентных (худшая тянет вниз). Зеркало vocab.
@ -461,7 +560,8 @@ def compute_demand_supply_forecast(
§9.3 compute_future_supply_pressure(horizon_months=h) open/hidden/future;
hidden_release = hidden × _hidden_release_fraction(h); чистое предложение =
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).
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_release = fsp.hidden_units * hidden_fraction
future_online = fsp.future_units_by_horizon
gross_supply = _gross_supply(fsp.open_units, hidden_release, future_online)
projected_supply = _project_supply(
fsp.open_units, hidden_release, future_online, projected_demand
)
# ── БАЛАНС / индекс дефицита ────────────────────────────────────────────────
# ── БАЛАНС / индекс дефицита / months-of-inventory ──────────────────────────
# balance_ratio честно None при исчерпанном предложении (спрос/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_ratio = _balance_ratio(projected_demand, projected_supply)
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) ────────────────────
future_competitors = _future_competitors(db, cad_num=cad_num, horizon=horizon)
@ -577,7 +682,7 @@ def _forecast_for_horizon(
logger.info(
"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,
horizon,
_round_or_none(base_pace, 2),
@ -588,6 +693,7 @@ def _forecast_for_horizon(
_round_or_none(balance_units, 1),
_round_or_none(balance_ratio, 3),
_round_or_none(deficit_index, 3),
_round_or_none(months_of_inventory, 1),
confidence,
)
@ -605,6 +711,7 @@ def _forecast_for_horizon(
balance_units=balance_units,
balance_ratio=balance_ratio,
deficit_index=deficit_index,
months_of_inventory=months_of_inventory,
rate_future=rate_future,
rate_sensitivity_phrase=sensitivity_phrase,
future_competitors=future_competitors,

View file

@ -297,6 +297,27 @@ def _primary_deficit_index(forecasts: Sequence[dict[str, Any]]) -> float | 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:
"""Рекомендованный класс из product_tz-вывода (overlay class_reco / obj_class). PURE."""
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:
"""Короткий RU-текст про будущий рынок (дефицит/затоварка осн. горизонта). PURE."""
"""Короткий RU-текст про будущий рынок (дефицит/затоварка осн. горизонта). PURE.
Индекс дефицита АБСОЛЮТНЫЙ вердикт; months_of_inventory (если есть)
ДИСКРИМИНИРУЮЩИЙ headline «N мес конкурирующего предложения» (различает
затоваренные сегменты, где дефицит у всех липнет к 1).
"""
di = _primary_deficit_index(forecasts)
if di is None:
return None
@ -412,7 +438,9 @@ def _future_market_summary(forecasts: Sequence[dict[str, Any]]) -> str | None:
verdict = "баланс спроса и предложения"
else:
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:
@ -600,21 +628,25 @@ def _build_exec_summary(
ДЕТЕРМИНИРОВАННЫЙ шаблонный синтез (НЕ LLM):
headline одна фраза «что строить здесь» из рекомендованного класса +
силы дефицита (или честный «данных недостаточно»).
verdict абзац: дефицит осн. горизонта + overall-скор + уровень уверенности.
key_numbers плоские опорные числа (deficit_index / overall_score / confidence).
verdict абзац: дефицит осн. горизонта (АБСОЛЮТ) + months_of_inventory
(ДИСКРИМИНИРУЮЩИЙ headline «N мес конкурирующего предложения») + overall-скор
+ уровень уверенности.
key_numbers плоские опорные числа (deficit_index / months_of_inventory /
overall_score / confidence).
overall_confidence уровень из §15-секции (#990).
Любого сигнала нет соответствующая часть опускается; всё пусто честный headline
«недостаточно данных» (секция всё равно валидна).
"""
di = _primary_deficit_index(forecasts)
moi = _primary_months_of_inventory(forecasts)
obj_class = _recommended_class(product_tz)
overall = _overall_score(product_scores)
level: ReportConfidenceLevel | None = confidence.level
headline = _exec_headline(obj_class, di)
verdict = _exec_verdict(di, overall, level)
key_numbers = _exec_key_numbers(di, overall, level)
verdict = _exec_verdict(di, moi, overall, level)
key_numbers = _exec_key_numbers(di, moi, overall, level)
return ReportExecSummary(
headline=headline,
@ -647,16 +679,21 @@ def _exec_headline(obj_class: str | None, deficit_index: float | None) -> str:
def _exec_verdict(
deficit_index: float | None,
months_of_inventory: float | None,
overall: float | None,
level: ReportConfidenceLevel | None,
) -> str | None:
"""Развёрнутый абзац-резюме из дефицита, overall-скора и уверенности. PURE.
"""Развёрнутый абзац-резюме из дефицита, MOI, overall-скора и уверенности. PURE.
Шаблонная RU-фраза; ни одного сигнала None (graceful вердикт опционален).
months_of_inventory ДИСКРИМИНИРУЮЩИЙ headline (интерпретируемо «N мес
конкурирующего предложения»), рядом с АБСОЛЮТНЫМ индексом дефицита.
"""
parts: list[str] = []
if deficit_index is not None:
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:
parts.append(f"итоговый продуктовый скор {round(overall, 2)}")
if level is not None:
@ -672,16 +709,20 @@ def _exec_verdict(
def _exec_key_numbers(
deficit_index: float | None,
months_of_inventory: float | None,
overall: float | None,
level: ReportConfidenceLevel | None,
) -> dict[str, Any]:
"""Плоские опорные числа exec_summary (JSON-safe). PURE.
Кладём только заданные сигналы (None опускаем) частичный набор валиден.
months_of_inventory ДИСКРИМИНИРУЮЩИЙ headline рядом с АБСОЛЮТНЫМ дефицитом.
"""
numbers: dict[str, Any] = {}
if deficit_index is not None:
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:
numbers["overall_score"] = round(overall, 3)
if level is not None:

View file

@ -70,6 +70,7 @@ def _forecast(horizon: int, demand: float, supply: float, deficit: float) -> dic
balance_units=demand - supply,
balance_ratio=(demand / supply if supply else None),
deficit_index=deficit,
months_of_inventory=(supply / (demand / horizon) if demand else None),
rate_future=18.0,
rate_sensitivity_phrase="спрос эластичен к ставке",
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_ratio=1.314,
deficit_index=deficit_12mo,
months_of_inventory=9.1,
rate_future=18.0,
rate_sensitivity_phrase=None,
future_competitors=[],

View file

@ -72,6 +72,7 @@ def _forecast(horizon: int, demand: float, supply: float, deficit: float) -> dic
balance_units=demand - supply,
balance_ratio=(demand / supply if supply else None),
deficit_index=deficit,
months_of_inventory=(supply / (demand / horizon) if demand else None),
rate_future=18.0,
rate_sensitivity_phrase="спрос эластичен к ставке",
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_ratio=1.314,
deficit_index=deficit_12mo,
months_of_inventory=9.1,
rate_future=18.0,
rate_sensitivity_phrase=None,
future_competitors=[],

View file

@ -40,8 +40,11 @@ from app.services.forecasting.demand_supply_forecast import (
_cap_confidence,
_deficit_index,
_deficit_index_from_balance,
_demand_per_mo,
_gross_supply,
_hidden_release_fraction,
_min_confidence,
_months_of_inventory,
_project_demand,
_project_supply,
_round_or_none,
@ -166,6 +169,82 @@ class TestProjectSupply:
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 ────────────────────────────────────────────────────────────
@ -424,6 +503,7 @@ def _make_forecast(**over: object) -> DemandSupplyForecast:
"balance_units": -262.13,
"balance_ratio": 0.287,
"deficit_index": -0.9,
"months_of_inventory": 43.27,
"rate_future": 21.0,
"rate_sensitivity_phrase": "при росте ставки …",
"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_supply_units"] == 367.7
assert d["deficit_index"] == -0.9
assert d["months_of_inventory"] == 43.3 # round(43.27, 1)
assert d["advisory"] is True
assert d["confidence"] in _ALLOWED_CONFIDENCE
@ -453,11 +534,13 @@ class TestAsDict:
balance_units=None,
balance_ratio=None,
deficit_index=None,
months_of_inventory=None,
).as_dict()
assert d["base_pace_units_per_mo"] is None
assert d["projected_demand_units"] is None
assert d["balance_ratio"] is None
assert d["deficit_index"] is None
assert d["months_of_inventory"] is None
def test_competitors_passthrough(self) -> None:
d = _make_forecast().as_dict()
@ -793,6 +876,104 @@ class TestComputeBalance:
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 = 1000120 = 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 ────────────────────────────────────
@ -936,6 +1117,7 @@ class TestGraceful:
assert f.balance_units is None # demand None → balance None
assert f.balance_ratio 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.confidence == "low"
assert f.advisory is True

View file

@ -36,6 +36,7 @@ from app.services.forecasting.report_assembler import (
_domrf_coverage,
_history_months,
_primary_deficit_index,
_primary_months_of_inventory,
assemble_report,
)
@ -107,6 +108,7 @@ def _sample_forecasts() -> list[dict[str, Any]]:
{
"horizon_months": 6,
"deficit_index": 0.21,
"months_of_inventory": 18.5,
"confidence": "medium",
"future_competitors": [],
"confounded": False,
@ -114,6 +116,7 @@ def _sample_forecasts() -> list[dict[str, Any]]:
{
"horizon_months": 12,
"deficit_index": 0.34,
"months_of_inventory": 24.2,
"confidence": "medium",
"future_competitors": [{"obj_id": 3, "comm_name": "ЖК Гамма", "relevance_weight": 0.6}],
"confounded": False,
@ -121,6 +124,7 @@ def _sample_forecasts() -> list[dict[str, Any]]:
{
"horizon_months": 18,
"deficit_index": 0.28,
"months_of_inventory": 31.0,
"confidence": "low",
"future_competitors": [],
"confounded": False,
@ -128,6 +132,7 @@ def _sample_forecasts() -> list[dict[str, Any]]:
{
"horizon_months": 24,
"deficit_index": 0.19,
"months_of_inventory": 40.7,
"confidence": "low",
"future_competitors": [],
"confounded": False,
@ -326,6 +331,17 @@ class TestFutureMarket:
assert len(fm["forecasts_by_horizon"]) == 4
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:
fm = _full_assemble().as_dict()["future_market"]
assert fm["future_supply"]["index"] == 0.42
@ -440,8 +456,14 @@ class TestExecSummary:
es = _full_assemble().as_dict()["exec_summary"]
assert es["verdict"] is not None
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
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:
payload = _full_assemble().as_dict()
assert payload["exec_summary"]["overall_confidence"] == payload["confidence"]["level"]
@ -638,3 +660,19 @@ class TestSignalExtractionHelpers:
forecasts = [{"horizon_months": 6, "deficit_index": 0.21}]
assert _primary_deficit_index(forecasts) == 0.21
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