diff --git a/backend/app/services/forecasting/demand_supply_forecast.py b/backend/app/services/forecasting/demand_supply_forecast.py index 91881d57..5aec90c1 100644 --- a/backend/app/services/forecasting/demand_supply_forecast.py +++ b/backend/app/services/forecasting/demand_supply_forecast.py @@ -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: (предложения больше спроса — «риск»). Предложение исчерпано под спрос (supply≤0, demand>0) → +1.0 (пик дефицита, #980, хотя balance_ratio тогда None); 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 (β НЕ участвует в арифметике спроса дважды — он уже учтён внутри 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, diff --git a/backend/app/services/forecasting/report_assembler.py b/backend/app/services/forecasting/report_assembler.py index fcebe693..b1d2d0df 100644 --- a/backend/app/services/forecasting/report_assembler.py +++ b/backend/app/services/forecasting/report_assembler.py @@ -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: diff --git a/backend/tests/services/exporters/test_excel.py b/backend/tests/services/exporters/test_excel.py index 2c2422d3..3a668842 100644 --- a/backend/tests/services/exporters/test_excel.py +++ b/backend/tests/services/exporters/test_excel.py @@ -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=[], diff --git a/backend/tests/services/exporters/test_report_pdf.py b/backend/tests/services/exporters/test_report_pdf.py index 2eb101b2..5ef08748 100644 --- a/backend/tests/services/exporters/test_report_pdf.py +++ b/backend/tests/services/exporters/test_report_pdf.py @@ -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=[], diff --git a/backend/tests/services/forecasting/test_demand_supply_forecast.py b/backend/tests/services/forecasting/test_demand_supply_forecast.py index 83d1fe15..c4a1378e 100644 --- a/backend/tests/services/forecasting/test_demand_supply_forecast.py +++ b/backend/tests/services/forecasting/test_demand_supply_forecast.py @@ -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 = 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 ──────────────────────────────────── @@ -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 diff --git a/backend/tests/services/forecasting/test_report_assembler.py b/backend/tests/services/forecasting/test_report_assembler.py index 150a53a5..2bc4604f 100644 --- a/backend/tests/services/forecasting/test_report_assembler.py +++ b/backend/tests/services/forecasting/test_report_assembler.py @@ -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