From f8daf50f5ffac69b16b4732f6b8681cc8404df35 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Wed, 3 Jun 2026 01:57:29 +0500 Subject: [PATCH] feat(site_finder): future-supply-pressure index (#950 Step 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add backend/app/services/site_finder/future_supply.py — deterministic, no-LLM §9.3 composite quantifying queued competing supply (hidden + future) vs market absorption, computed on-the-fly (not stored). - Reads VIEW v_supply_layers_latest (incl. non-derivable graddoc_stub/ insider_manual L3 rows), not a layer recompute. - Reuses market_metrics.compute_market_metrics for monthly absorption (available / months_of_supply == sold/mo; algebra verified). - months_of_pressure = (hidden + future_by_horizon) / absorption/mo; index = linear-clamp saturation at 18mo (1.5x EKB cycle); confidence=MIN (thin L2/L3 honestly drags composite to low). - L3 horizon-weighted (NULL expected_online_date -> neutral 0.5); open excluded from numerator (only hidden+future compete with a new object). - Graceful: empty/thin/no-absorption -> index None + low, div-by-zero guarded. - 54 unit tests (pure-helper math + mocked db/market_metrics). --- .../app/services/site_finder/future_supply.py | 398 +++++++++++++ .../site_finder/test_future_supply.py | 530 ++++++++++++++++++ 2 files changed, 928 insertions(+) create mode 100644 backend/app/services/site_finder/future_supply.py create mode 100644 backend/tests/services/site_finder/test_future_supply.py diff --git a/backend/app/services/site_finder/future_supply.py b/backend/app/services/site_finder/future_supply.py new file mode 100644 index 00000000..60e9c0d8 --- /dev/null +++ b/backend/app/services/site_finder/future_supply.py @@ -0,0 +1,398 @@ +"""Future-supply-pressure service — индекс будущего давления предложения (ТЗ §9.3). + +#950 (Site Finder v2 / GG-форсайт, EPIC 6 «3-layer склад предложения») — Step 6, +финальный compute-слой. Это **измерительный слой**: детерминированный композит, +который оценивает, насколько грядущее предложение (скрытое + будущее) будет +конкурировать с нашим перспективным объектом в районе — ОТНОСИТЕЛЬНО того, как +быстро рынок поглощает сток. Считается НА ЛЕТУ (НЕ хранится в БД — см. м.125 +docstring: «Future-supply-pressure считается на лету (#950 Step6), тут НЕ хранится»). + +Принцип: **детерминированно, без LLM** — чистый set-based SQL + арифметика +(зеркало `market_metrics.py` / `supply_layers.py`). NO LLM-импортов нигде. + +────────────────────────────────────────────────────────────────────────────── +Источники: + • Предложение по слоям — VIEW `v_supply_layers_latest` (м.125), НЕ базовая таблица + и НЕ пересчёт через supply_layers.compute_all_layers. ПОЧЕМУ именно view: + Layer3 включает строки `graddoc_stub` (#956) и `insider_manual`, которые + живут ТОЛЬКО в таблице (их нельзя вывести запросом → compute_all_layers их + НЕ вернёт). Читая view, мы честно учитываем эти невыводимые future-строки. + View отдаёт свежий снапшот на логический ключ (max snapshot_date, детерм. + tiebreak) — нам не нужно самим дедупить снапшоты. + • Поглощение рынка — reuse `market_metrics.compute_market_metrics(db, district=…)`: + берём `absorption_rate` (ед./мес ÷ доступные) и `months_of_supply` (доступные ÷ + ед./мес) и ВЫВОДИМ месячное поглощение в штуках. НЕ пересчитываем absorption + с нуля — единый источник истины по скорости рынка (ТЗ §9.2). + +Композит (ТЗ §9.3): + months_of_pressure = (hidden_units + future_units_by_horizon) / monthly_absorption + — сколько МЕСЯЦЕВ дополнительного конкурирующего предложения в очереди при + текущем темпе поглощения. Интерпретируемая «сырая» величина. + index ∈ [0,1] = насыщающее преобразование months_of_pressure (см. _saturating_index) + — выше = больше будущего давления. + +Graceful-on-thin-data (КРИТИЧНО, зеркало market_metrics / supply_layers): пустой +район / нет поглощения / нет supply-данных → index=None, confidence='low', НИКОГДА +не crash и НИКОГДА деления на ноль. Детерминированно (без рандома). + +ПРИМЕЧАНИЕ по prod-состоянию: пока worker #950 Step5 не наполнил `supply_layers` +(на prod таблица пуста до первого запуска по понедельникам 06:00), view вернёт 0 +строк → open/hidden/future = 0 → months_of_pressure=None → index=None, +confidence='low'. Это валидный graceful-результат, не ошибка. +""" + +from __future__ import annotations + +import logging +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from datetime import date +from typing import Any, Literal + +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.services.site_finder.market_metrics import compute_market_metrics + +logger = logging.getLogger(__name__) + +Confidence = Literal["high", "medium", "low"] + +# Слои склада предложения (м.125): 1=open (в продаже), 2=hidden (запас ПД), +# 3=future (ещё не на рынке). Давление формируют скрытый + будущий слои. +_LAYER_OPEN: int = 1 +_LAYER_HIDDEN: int = 2 +_LAYER_FUTURE: int = 3 + +# Насыщение индекса: months_of_pressure, при котором индекс достигает 1.0. +# 18 мес = 1.5× «здорового» цикла поглощения ЕКБ (market_metrics._OVERSTOCK_MONTHS_ +# THRESHOLD = 12): полтора цикла очереди будущего предложения сверх текущего стока — +# это уже полное насыщение давления. Выбран линейный clamp (а НЕ 1-exp): он +# интерпретируем (index=0.5 ⇔ ровно 9 мес очереди), тривиально монотонен и зеркалит +# clamp-дисциплину competitors._geo_proximity / _stage_at_horizon. Tunable. +_PRESSURE_SATURATION_MONTHS: float = 18.0 + +# Вес L3-строки с НЕИЗВЕСТНОЙ датой выхода (expected_online_date IS NULL). Тайминг +# неизвестен → не можем сказать, попадёт ли объём в горизонт. Нейтральные 0.5 (как +# competitors._NEUTRAL): не игнорируем объём целиком (это занизило бы давление) и не +# засчитываем его полностью (это завысило бы). Зеркало graceful-нейтрали ТЗ §15. +_NULL_HORIZON_WEIGHT: float = 0.5 + +# Порядок уверенности для MIN-агрегации компонентных confidence (хуже = ниже). +_CONFIDENCE_RANK: dict[Confidence, int] = {"low": 0, "medium": 1, "high": 2} +_RANK_TO_CONFIDENCE: dict[int, Confidence] = {0: "low", 1: "medium", 2: "high"} + + +@dataclass(frozen=True) +class FutureSupplyPressure: + """Индекс будущего давления предложения ТЗ §9.3 для района (считается на лету). + + Все величины детерминированы. `months_of_pressure` и `index` = None при недостатке + данных (никогда 0-как-заглушка и никогда исключение). `index` ∈ [0,1] когда задан: + выше = больше грядущего конкурирующего предложения относительно скорости рынка. + """ + + # ── Контекст ────────────────────────────────────────────────────────────── + district: str | None + horizon_months: int + premise_kind: str + confidence: Confidence # MIN компонентных confidence (тонкий L2/L3 честно тянет вниз) + + # ── Сырые входы (объёмы предложения по слоям) ───────────────────────────── + open_units: int # Σ layer1 (в продаже) — контекст, в давление НЕ входит + hidden_units: int # Σ layer2 (скрытый запас ПД) + future_units_by_horizon: float # Σ layer3, взвешенный по попаданию в горизонт + + # ── Поглощение (из market_metrics) ──────────────────────────────────────── + monthly_absorption_units: float | None # ед./мес, поглощаемых рынком + + # ── Композит ────────────────────────────────────────────────────────────── + months_of_pressure: float | None # (hidden + future) / поглощение, мес + index: float | None # насыщающее преобразование → [0,1] + + def as_dict(self) -> dict[str, Any]: + return { + "district": self.district, + "horizon_months": self.horizon_months, + "premise_kind": self.premise_kind, + "confidence": self.confidence, + "index": _round_or_none(self.index, 3), + "breakdown": self.breakdown(), + } + + def breakdown(self) -> dict[str, Any]: + """Explainability-разбивка (ТЗ §16) — зеркало competitors.relevance_breakdown. + + Все составляющие округлены. Показывает, ИЗ ЧЕГО собран index: объёмы по + слоям, месячное поглощение, сырые месяцы давления и нормированный индекс. + """ + return { + "open_units": self.open_units, + "hidden_units": self.hidden_units, + "future_units_by_horizon": _round_or_none(self.future_units_by_horizon, 1), + "monthly_absorption_units": _round_or_none(self.monthly_absorption_units, 2), + "months_of_pressure": _round_or_none(self.months_of_pressure, 1), + "index": _round_or_none(self.index, 3), + } + + +def _round_or_none(value: float | None, digits: int) -> float | None: + return round(value, digits) if value is not None else None + + +# ────────────────────────────────────────────────────────────────────────────── +# Pure-арифметика — без БД, полностью юнит-тестируемо. +# Каждая функция graceful: пустой/нулевой вход → None/нейтраль (не 0, не ZeroDivision). +# ────────────────────────────────────────────────────────────────────────────── + + +def _horizon_weight( + expected_online_date: date | None, + today: date, + horizon_months: int, +) -> float: + """Вес L3-объёма по попаданию в горизонт ∈ {0.0, _NULL_HORIZON_WEIGHT, 1.0}. + + Инверсия идеи competitors._stage_at_horizon: там — сколько ОСТАНЕТСЯ к горизонту; + здесь — сколько будущего предложения ВЫЙДЕТ онлайн в пределах горизонта и потому + будет конкурировать. + • дата ≤ today + horizon → 1.0 (выйдет в горизонте — полностью давит) + • дата > today + horizon → 0.0 (выйдет позже — в этом горизонте не давит) + • дата IS NULL → _NULL_HORIZON_WEIGHT (тайминг неизвестен → нейтраль) + horizon_months ≤ 0 трактуем как «нет окна» → только NULL-нейтраль/будущее=0. + + Реализовано на относительных месяцах (а не date-арифметике календаря) — + детерминированно и тривиально тестируемо: считаем целые месяцы между today и + датой; > horizon_months → вне окна. + """ + if expected_online_date is None: + return _NULL_HORIZON_WEIGHT + months_until = _months_between(today, expected_online_date) + # Уже онлайн (дата в прошлом) или в пределах горизонта → давит полностью. + if months_until <= horizon_months: + return 1.0 + return 0.0 + + +def _months_between(start: date, end: date) -> int: + """Целое число месяцев от start до end (может быть отрицательным, если end < start). + + Календарные месяцы (год*12 + месяц), без учёта дня — достаточно для horizon-bucket + («этот объём выйдет в пределах N месяцев?»). Детерминированно. + """ + return (end.year - start.year) * 12 + (end.month - start.month) + + +def _months_of_pressure( + hidden_units: float | None, + future_units_by_horizon: float | None, + monthly_absorption_units: float | None, +) -> float | None: + """months_of_pressure = (hidden + future) / месячное поглощение. + + Сколько месяцев дополнительного конкурирующего предложения стоит в очереди при + текущем темпе поглощения рынка. Guard: нет поглощения (None/≤0) → None (давление + неизмеримо, НЕ ∞ и НЕ 0 — иначе «нет данных о рынке» не отличить от «нулевого + давления»). Любой None-объём трактуем как 0 в сумме (отсутствие слоя ≠ нет данных + о рынке): если ОБА слоя пусты — сумма 0 → давление 0.0 (валидный «нет очереди»). + """ + if monthly_absorption_units is None or monthly_absorption_units <= 0: + return None + queued = (hidden_units or 0.0) + (future_units_by_horizon or 0.0) + return float(queued) / float(monthly_absorption_units) + + +def _saturating_index(months_of_pressure: float | None) -> float | None: + """Насыщающее преобразование months_of_pressure → index ∈ [0,1]. + + Линейный clamp: min(1.0, months / _PRESSURE_SATURATION_MONTHS). Монотонно + неубывающее по months_of_pressure, насыщается в 1.0 на _PRESSURE_SATURATION_MONTHS, + clamp снизу в 0.0 (на случай отрицательных артефактов). None на входе → None. + Детерминированно. + """ + if months_of_pressure is None: + return None + if _PRESSURE_SATURATION_MONTHS <= 0: + # Деградация без падения: нулевой масштаб → любое положительное давление = max. + return 1.0 if months_of_pressure > 0 else 0.0 + return max(0.0, min(1.0, months_of_pressure / _PRESSURE_SATURATION_MONTHS)) + + +def _monthly_absorption_units( + absorption_rate: float | None, + months_of_supply: float | None, + n_available: int | None, +) -> float | None: + """Месячное поглощение в ШТУКАХ из метрик market_metrics (без пересчёта с нуля). + + Два эквивалентных пути (берём первый доступный, оба из ТЗ §9.2): + • available / months_of_supply (months_of_supply = available ÷ ед./мес) + • absorption_rate * available (absorption_rate = ед./мес ÷ available) + Предпочитаем months_of_supply (прямое деление, меньше накопленной ошибки округл.). + Нет доступного стока или метрик → None (поглощение неизмеримо, НЕ 0). Guard всех + делений на ноль/None. + """ + if not n_available or n_available <= 0: + return None + available = float(n_available) + if months_of_supply is not None and months_of_supply > 0: + return available / months_of_supply + if absorption_rate is not None and absorption_rate > 0: + return absorption_rate * available + return None + + +def _min_confidence(values: Sequence[Confidence | None]) -> Confidence: + """Итоговая уверенность = MIN компонентных (худшая тянет вниз). Зеркало vocab. + + Тонкий L3/L2 или low-поглощение честно роняют общий confidence в 'low'. None в + списке игнорируем (компонент не дал сигнала). Пустой/весь-None вход → 'low' + (нет ни одного надёжного сигнала). Только whitelisted 'high|medium|low'. + """ + ranks = [_CONFIDENCE_RANK[v] for v in values if v is not None] + if not ranks: + return "low" + return _RANK_TO_CONFIDENCE[min(ranks)] + + +# ────────────────────────────────────────────────────────────────────────────── +# SQL — читаем VIEW v_supply_layers_latest (м.125), все bind-параметры через +# CAST(:x AS type) (psycopg v3; НИКОГДА :x::type). +# ────────────────────────────────────────────────────────────────────────────── + +# Все свежие строки склада предложения для района из VIEW (НЕ базовой таблицы): +# view уже отдаёт по одной строке на логический ключ (свежий снапшот) → дедуп не наш. +# КРИТИЧНО читать view, а не supply_layers.compute_all_layers: L3 graddoc_stub (#956) +# / insider_manual живут только в таблице и невыводимы пересчётом. Берём построчно +# (layer / units / expected_online_date / confidence) — взвешивание L3 по горизонту +# делаем в Python (per-row дата vs today+horizon), агрегацию L1/L2 — тоже в Python. +_SUPPLY_LAYERS_SQL = text( + """ + SELECT + layer, + units_estimate, + expected_online_date, + confidence + FROM v_supply_layers_latest + WHERE ( + CAST(:district AS text) IS NULL + OR district_name = CAST(:district AS text) + ) + """ +) + + +def compute_future_supply_pressure( + db: Session, + *, + district: str | None, + horizon_months: int = 12, + premise_kind: str = "квартира", +) -> FutureSupplyPressure: + """Вычислить индекс будущего давления предложения ТЗ §9.3 для района (на лету). + + Шаги: + 1. Прочитать ВСЕ свежие supply-строки района из VIEW `v_supply_layers_latest` + (включает невыводимые L3 graddoc_stub/insider_manual). + 2. Агрегировать: open=Σ L1, hidden=Σ L2, future=Σ L3·горизонт-вес (NULL-дата → + нейтраль 0.5). Собрать компонентные confidence. + 3. Поглощение — reuse compute_market_metrics(db, district=…): вывести месячное + поглощение в штуках из months_of_supply / absorption_rate (НЕ с нуля). + 4. months_of_pressure = (hidden + future) / поглощение → насыщающий index [0,1]. + 5. confidence = MIN(компонентные supply-confidence + market_metrics.confidence). + + Возвращает FutureSupplyPressure ВСЕГДА (даже на пустых данных): тогда index=None, + months_of_pressure=None, confidence='low'. Никогда не бросает, никогда деления на 0. + """ + # ── 1. Supply-строки района из VIEW (graceful: сбой/пусто → []) ──────────── + rows = _query_supply_rows(db, {"district": district}) + + # ── 2. Агрегация по слоям + сбор компонентных confidence ────────────────── + today = date.today() + open_units = 0 + hidden_units = 0 + future_units_by_horizon = 0.0 + supply_confidences: list[Confidence | None] = [] + for r in rows: + layer = int(r["layer"]) if r["layer"] is not None else 0 + units = int(r["units_estimate"]) if r["units_estimate"] is not None else 0 + conf = _coerce_confidence(r["confidence"]) + if layer == _LAYER_OPEN: + open_units += units + supply_confidences.append(conf) + elif layer == _LAYER_HIDDEN: + hidden_units += units + supply_confidences.append(conf) + elif layer == _LAYER_FUTURE: + weight = _horizon_weight(r["expected_online_date"], today, horizon_months) + future_units_by_horizon += units * weight + supply_confidences.append(conf) + + # ── 3. Поглощение — reuse market_metrics (единый источник скорости рынка) ── + metrics = compute_market_metrics( + db, district=district, window_months=6, premise_kind=premise_kind + ) + monthly_absorption = _monthly_absorption_units( + metrics.absorption_rate, metrics.months_of_supply, metrics.n_available + ) + + # ── 4. Композит: months_of_pressure → насыщающий index ──────────────────── + mop = _months_of_pressure(hidden_units, future_units_by_horizon, monthly_absorption) + index = _saturating_index(mop) + + # ── 5. confidence = MIN(supply-компоненты + market_metrics.confidence) ───── + confidence = _min_confidence([*supply_confidences, metrics.confidence]) + + logger.info( + "future_supply: district=%s horizon=%d open=%d hidden=%d future_w=%.1f " + "absorb/mo=%s months_of_pressure=%s index=%s confidence=%s", + district, + horizon_months, + open_units, + hidden_units, + future_units_by_horizon, + _round_or_none(monthly_absorption, 2), + _round_or_none(mop, 1), + _round_or_none(index, 3), + confidence, + ) + + return FutureSupplyPressure( + district=district, + horizon_months=horizon_months, + premise_kind=premise_kind, + confidence=confidence, + open_units=open_units, + hidden_units=hidden_units, + future_units_by_horizon=future_units_by_horizon, + monthly_absorption_units=monthly_absorption, + months_of_pressure=mop, + index=index, + ) + + +def _coerce_confidence(value: Any) -> Confidence | None: + """Привести значение confidence из строки view к whitelisted vocab или None. + + View-данные проходят CHECK м.125 (high|medium|low), но защищаемся от мусора/None + дёшево: неизвестное → None (компонент не учитывается в MIN, не роняет искусственно). + """ + if value in _CONFIDENCE_RANK: + return value # type: ignore[return-value] # сужено проверкой членства + return None + + +def _query_supply_rows(db: Session, params: Mapping[str, Any]) -> Sequence[Mapping[str, Any]]: + """Прочитать supply-строки района из VIEW. Graceful: сбой/пусто → [] (не бросаем). + + Зеркало market_metrics._query_* / supply_layers._safe_rows — пустой склад + (worker ещё не наполнил м.125) или сбой БД не должны валить расчёт индекса. + """ + try: + return db.execute(_SUPPLY_LAYERS_SQL, dict(params)).mappings().all() + except Exception: + logger.exception( + "future_supply: supply-layers view query failed (district=%s)", + params.get("district"), + ) + return [] diff --git a/backend/tests/services/site_finder/test_future_supply.py b/backend/tests/services/site_finder/test_future_supply.py new file mode 100644 index 00000000..00f294a7 --- /dev/null +++ b/backend/tests/services/site_finder/test_future_supply.py @@ -0,0 +1,530 @@ +"""Unit-тесты future-supply-pressure service (#950 EPIC 6 Step6, ТЗ §9.3). + +Чистые тесты (без живой БД): + • pure-арифметика: _horizon_weight (вкл. NULL-дату → нейтраль, вне/в окне), + _months_between, _months_of_pressure (guard div-by-zero → None), _saturating_index + (монотонность + clamp [0,1] + named-saturation), _monthly_absorption_units (оба + пути из market_metrics), _min_confidence (худшая тянет вниз, whitelisted). + • compute_future_supply_pressure через MagicMock-сессию + mock compute_market_metrics: + форма SQL (читает VIEW v_supply_layers_latest, НЕ базовую таблицу / recompute; + CAST(:x AS type) не :x::type), агрегация по слоям, горизонт-взвешивание L3, + graceful empty/thin → index None + confidence 'low', confidence=MIN. + +Детерминированно, без LLM. Мокаем compute_market_metrics + db (нет живой БД). +""" + +from __future__ import annotations + +import os +from datetime import date +from unittest.mock import MagicMock, patch + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import pytest + +from app.services.site_finder.future_supply import ( + _NULL_HORIZON_WEIGHT, + _PRESSURE_SATURATION_MONTHS, + FutureSupplyPressure, + _horizon_weight, + _min_confidence, + _monthly_absorption_units, + _months_between, + _months_of_pressure, + _round_or_none, + _saturating_index, + compute_future_supply_pressure, +) + +# Путь патча reused-сервиса (импортирован в модуль future_supply). +_MARKET = "app.services.site_finder.future_supply.compute_market_metrics" + +# Разрешённый vocab confidence (зеркало CHECK м.125 / market_metrics). +_ALLOWED_CONFIDENCE = {"high", "medium", "low"} + + +# ── pure: _months_between ───────────────────────────────────────────────────── + + +class TestMonthsBetween: + def test_same_month_zero(self) -> None: + assert _months_between(date(2026, 6, 1), date(2026, 6, 30)) == 0 + + def test_forward_months(self) -> None: + assert _months_between(date(2026, 6, 1), date(2026, 12, 1)) == 6 + + def test_across_year(self) -> None: + assert _months_between(date(2026, 6, 1), date(2027, 6, 1)) == 12 + + def test_past_is_negative(self) -> None: + assert _months_between(date(2026, 6, 1), date(2026, 1, 1)) == -5 + + +# ── pure: _horizon_weight (инверсия stage_at_horizon) ───────────────────────── + + +class TestHorizonWeight: + _TODAY = date(2026, 6, 1) + + def test_within_horizon_full_weight(self) -> None: + # выход через 6 мес, горизонт 12 → в окне → полностью давит (1.0). + assert _horizon_weight(date(2026, 12, 1), self._TODAY, 12) == 1.0 + + def test_exactly_at_horizon_edge_included(self) -> None: + # ровно на границе горизонта (12 мес) → включаем (≤). + assert _horizon_weight(date(2027, 6, 1), self._TODAY, 12) == 1.0 + + def test_beyond_horizon_zero_weight(self) -> None: + # выход через 24 мес, горизонт 12 → вне окна → 0.0. + assert _horizon_weight(date(2028, 6, 1), self._TODAY, 12) == 0.0 + + def test_already_online_full_weight(self) -> None: + # дата в прошлом (уже онлайн) → давит полностью. + assert _horizon_weight(date(2025, 1, 1), self._TODAY, 12) == 1.0 + + def test_null_date_neutral_weight(self) -> None: + # тайминг неизвестен → нейтральный вес (не игнор, не полный счёт). + assert _horizon_weight(None, self._TODAY, 12) == _NULL_HORIZON_WEIGHT + assert _NULL_HORIZON_WEIGHT == 0.5 + + def test_shorter_horizon_excludes_more(self) -> None: + # тот же объект (выход через 9 мес): горизонт 12 включает, горизонт 6 — нет. + d = date(2027, 3, 1) # +9 мес + assert _horizon_weight(d, self._TODAY, 12) == 1.0 + assert _horizon_weight(d, self._TODAY, 6) == 0.0 + + +# ── pure: _months_of_pressure ───────────────────────────────────────────────── + + +class TestMonthsOfPressure: + def test_basic(self) -> None: + # (200 hidden + 100 future) / 50 ед/мес = 6 мес давления. + assert _months_of_pressure(200, 100.0, 50.0) == pytest.approx(6.0) + + def test_zero_absorption_returns_none(self) -> None: + # нет поглощения → давление неизмеримо (None, не ∞ и не 0). + assert _months_of_pressure(200, 100.0, 0.0) is None + + def test_none_absorption_returns_none(self) -> None: + assert _months_of_pressure(200, 100.0, None) is None + + def test_negative_absorption_returns_none(self) -> None: + assert _months_of_pressure(200, 100.0, -5.0) is None + + def test_zero_queue_is_zero_not_none(self) -> None: + # рынок измерим, очереди нет → 0.0 мес давления (валидно, НЕ None). + assert _months_of_pressure(0, 0.0, 50.0) == 0.0 + + def test_none_layers_treated_as_zero(self) -> None: + # отсутствие слоя ≠ нет данных о рынке: None-объёмы → 0 в сумме. + assert _months_of_pressure(None, None, 50.0) == 0.0 + assert _months_of_pressure(None, 100.0, 50.0) == pytest.approx(2.0) + + +# ── pure: _saturating_index (нормировка [0,1]) ──────────────────────────────── + + +class TestSaturatingIndex: + def test_zero_pressure_zero_index(self) -> None: + assert _saturating_index(0.0) == 0.0 + + def test_at_saturation_is_one(self) -> None: + assert _saturating_index(_PRESSURE_SATURATION_MONTHS) == 1.0 + + def test_half_saturation_is_half(self) -> None: + assert _saturating_index(_PRESSURE_SATURATION_MONTHS / 2.0) == pytest.approx(0.5) + + def test_beyond_saturation_clamps_to_one(self) -> None: + # давление выше насыщения → clamp 1.0 (не >1). + assert _saturating_index(_PRESSURE_SATURATION_MONTHS * 3.0) == 1.0 + + def test_negative_clamps_to_zero(self) -> None: + # артефактно-отрицательное давление → clamp 0.0 (не negative). + assert _saturating_index(-5.0) == 0.0 + + def test_none_passthrough(self) -> None: + assert _saturating_index(None) is None + + def test_monotonic_non_decreasing(self) -> None: + # ключевое свойство: больше месяцев давления → не меньший индекс, всё в [0,1]. + prev = -1.0 + for months in [0.0, 1.0, 3.0, 6.0, 9.0, 12.0, 18.0, 30.0, 100.0]: + idx = _saturating_index(months) + assert idx is not None + assert 0.0 <= idx <= 1.0 + assert idx >= prev + prev = idx + + +# ── pure: _monthly_absorption_units (вывод из market_metrics) ───────────────── + + +class TestMonthlyAbsorptionUnits: + def test_from_months_of_supply(self) -> None: + # available 120, months_of_supply 6 → 20 ед/мес. + assert _monthly_absorption_units(None, 6.0, 120) == pytest.approx(20.0) + + def test_prefers_months_of_supply_over_rate(self) -> None: + # оба заданы → берём прямой путь available/mos (детерминированно). + # available 120, mos 6 → 20 (а не absorption_rate*available). + assert _monthly_absorption_units(0.5, 6.0, 120) == pytest.approx(20.0) + + def test_falls_back_to_absorption_rate(self) -> None: + # mos нет → absorption_rate(0.1) * available(200) = 20 ед/мес. + assert _monthly_absorption_units(0.1, None, 200) == pytest.approx(20.0) + + def test_no_available_returns_none(self) -> None: + assert _monthly_absorption_units(0.1, 6.0, 0) is None + assert _monthly_absorption_units(0.1, 6.0, None) is None + + def test_no_metrics_returns_none(self) -> None: + # есть сток, но ни mos, ни rate → поглощение неизмеримо (None). + assert _monthly_absorption_units(None, None, 120) is None + + def test_zero_metrics_returns_none(self) -> None: + assert _monthly_absorption_units(0.0, 0.0, 120) is None + + +# ── pure: _min_confidence ───────────────────────────────────────────────────── + + +class TestMinConfidence: + def test_all_high(self) -> None: + assert _min_confidence(["high", "high"]) == "high" + + def test_low_drags_down(self) -> None: + # тонкий L3 (low) тянет общий в low даже при high остальных. + assert _min_confidence(["high", "medium", "low"]) == "low" + + def test_medium_floor(self) -> None: + assert _min_confidence(["high", "medium"]) == "medium" + + def test_none_ignored(self) -> None: + # None-компонент (нет сигнала) не учитывается — берём min из остальных. + assert _min_confidence(["high", None, "medium"]) == "medium" + + def test_all_none_low(self) -> None: + assert _min_confidence([None, None]) == "low" + + def test_empty_low(self) -> None: + assert _min_confidence([]) == "low" + + def test_only_whitelisted(self) -> None: + for vals in (["high"], ["medium"], ["low"], ["high", "low"], []): + assert _min_confidence(vals) in _ALLOWED_CONFIDENCE # type: ignore[arg-type] + + +# ── pure: _round_or_none ────────────────────────────────────────────────────── + + +class TestRoundOrNone: + def test_rounds(self) -> None: + assert _round_or_none(1.23456, 3) == 1.235 + + def test_none_passthrough(self) -> None: + assert _round_or_none(None, 3) is None + + +# ── FutureSupplyPressure.as_dict / breakdown ────────────────────────────────── + + +def _make_pressure(**over: object) -> FutureSupplyPressure: + base: dict[str, object] = { + "district": "Автовокзал", + "horizon_months": 12, + "premise_kind": "квартира", + "confidence": "medium", + "open_units": 300, + "hidden_units": 200, + "future_units_by_horizon": 100.0, + "monthly_absorption_units": 50.0, + "months_of_pressure": 6.0, + "index": 0.3333, + } + base.update(over) + return FutureSupplyPressure(**base) # type: ignore[arg-type] + + +class TestAsDictBreakdown: + def test_breakdown_has_all_components_rounded(self) -> None: + d = _make_pressure().as_dict() + assert d["index"] == 0.333 + bd = d["breakdown"] + assert bd["open_units"] == 300 + assert bd["hidden_units"] == 200 + assert bd["future_units_by_horizon"] == 100.0 + assert bd["monthly_absorption_units"] == 50.0 + assert bd["months_of_pressure"] == 6.0 + assert bd["index"] == 0.333 + + def test_none_index_survives(self) -> None: + d = _make_pressure(index=None, months_of_pressure=None).as_dict() + assert d["index"] is None + assert d["breakdown"]["months_of_pressure"] is None + + def test_confidence_whitelisted(self) -> None: + assert _make_pressure().as_dict()["confidence"] in _ALLOWED_CONFIDENCE + + +# ── MagicMock-сессия helpers (зеркало test_supply_layers) ───────────────────── + + +def _mock_db(rows: list[dict]) -> MagicMock: + """Сессия с одним execute → .mappings().all() == rows.""" + db = MagicMock() + result = MagicMock() + result.mappings.return_value.all.return_value = rows + db.execute.return_value = result + return db + + +def _executed_sql(db: MagicMock, call_index: int = 0) -> str: + args, _kwargs = db.execute.call_args_list[call_index] + return str(args[0]) + + +def _executed_params(db: MagicMock, call_index: int = 0) -> dict: + args, _kwargs = db.execute.call_args_list[call_index] + return args[1] + + +def _assert_no_double_colon_cast(sql: str) -> None: + """psycopg v3: запрещён :name::type (SQLAlchemy его молча роняет).""" + import re + + assert re.search(r":[a-z_]+::[a-z]", sql) is None, f"double-colon cast in SQL:\n{sql}" + + +def _metrics_stub( + *, + absorption_rate: float | None = 0.1, + months_of_supply: float | None = 6.0, + n_available: int = 120, + confidence: str = "high", +) -> MagicMock: + """Заглушка результата compute_market_metrics (нужные поля для absorption).""" + m = MagicMock() + m.absorption_rate = absorption_rate + m.months_of_supply = months_of_supply + m.n_available = n_available + m.confidence = confidence + return m + + +# ── compute_future_supply_pressure: SQL форма (читает VIEW) ──────────────────── + + +class TestComputeReadsView: + def test_reads_latest_view_not_base_table(self) -> None: + # КРИТИЧНО (ТЗ §9.3 / м.125): читаем VIEW v_supply_layers_latest, которая + # включает невыводимые L3 graddoc_stub(#956)/insider_manual. НЕ базовую + # таблицу supply_layers напрямую и НЕ пересчёт compute_all_layers. + db = _mock_db([]) + with patch(_MARKET, return_value=_metrics_stub()): + compute_future_supply_pressure(db, district="Автовокзал") + sql = _executed_sql(db).lower() + assert "v_supply_layers_latest" in sql + # НЕ читаем источники recompute (objective_lots/domrf_kn_objects) — это была бы + # пересборка слоёв, теряющая stub/insider строки. + assert "objective_lots" not in sql + assert "domrf_kn_objects" not in sql + + def test_sql_uses_cast_not_double_colon(self) -> None: + db = _mock_db([]) + with patch(_MARKET, return_value=_metrics_stub()): + compute_future_supply_pressure(db, district="Автовокзал") + sql = _executed_sql(db) + assert "CAST(:district AS text)" in sql + _assert_no_double_colon_cast(sql) + + def test_params_pass_district(self) -> None: + db = _mock_db([]) + with patch(_MARKET, return_value=_metrics_stub()) as mm: + compute_future_supply_pressure( + db, district="Академический", horizon_months=24, premise_kind="квартира" + ) + assert _executed_params(db)["district"] == "Академический" + # district пробрасывается и в reused market_metrics (единый район). + assert mm.call_args.kwargs["district"] == "Академический" + + +# ── compute_future_supply_pressure: агрегация + композит ─────────────────────── + + +class TestComputeAggregation: + def test_aggregates_layers_and_weights_future(self) -> None: + # L1=300 open, L2=200 hidden, L3 две строки: 100 в горизонте (вес 1.0) + + # 80 за горизонтом (вес 0.0) + 40 с NULL-датой (вес 0.5) = 100+0+20 = 120. + rows = [ + {"layer": 1, "units_estimate": 300, "expected_online_date": None, "confidence": "high"}, + { + "layer": 2, + "units_estimate": 200, + "expected_online_date": None, + "confidence": "medium", + }, + { + "layer": 3, + "units_estimate": 100, + "expected_online_date": date(date.today().year + 1, 1, 1), + "confidence": "low", + }, + { + "layer": 3, + "units_estimate": 80, + "expected_online_date": date(date.today().year + 5, 1, 1), + "confidence": "low", + }, + {"layer": 3, "units_estimate": 40, "expected_online_date": None, "confidence": "low"}, + ] + db = _mock_db(rows) + # absorption: available 120, mos 6 → 20 ед/мес. + with patch(_MARKET, return_value=_metrics_stub(months_of_supply=6.0, n_available=120)): + res = compute_future_supply_pressure(db, district="X", horizon_months=12) + assert res.open_units == 300 + assert res.hidden_units == 200 + # 100*1.0 + 80*0.0 + 40*0.5 = 120.0 + assert res.future_units_by_horizon == pytest.approx(120.0) + assert res.monthly_absorption_units == pytest.approx(20.0) + # (200 + 120) / 20 = 16 мес давления. + assert res.months_of_pressure == pytest.approx(16.0) + # index = 16 / 18 saturation. + assert res.index == pytest.approx(16.0 / _PRESSURE_SATURATION_MONTHS) + assert 0.0 <= res.index <= 1.0 + # confidence = MIN(high L1, medium L2, low L3×3, high market) = low. + assert res.confidence == "low" + + def test_open_units_excluded_from_pressure(self) -> None: + # Только hidden+future формируют давление; open (L1) — лишь контекст. + rows = [ + { + "layer": 1, + "units_estimate": 9999, + "expected_online_date": None, + "confidence": "high", + }, + {"layer": 2, "units_estimate": 100, "expected_online_date": None, "confidence": "high"}, + ] + db = _mock_db(rows) + with patch(_MARKET, return_value=_metrics_stub(months_of_supply=5.0, n_available=100)): + res = compute_future_supply_pressure(db, district="X") + # absorption 100/5=20; давление только от hidden 100 → 100/20=5 мес. + assert res.monthly_absorption_units == pytest.approx(20.0) + assert res.months_of_pressure == pytest.approx(5.0) + assert res.open_units == 9999 # учтён в контексте, но не в давлении + + def test_confidence_is_min_of_components(self) -> None: + # все supply high, но market_metrics low → итог low. + rows = [ + {"layer": 2, "units_estimate": 50, "expected_online_date": None, "confidence": "high"}, + ] + db = _mock_db(rows) + with patch(_MARKET, return_value=_metrics_stub(confidence="low")): + res = compute_future_supply_pressure(db, district="X") + assert res.confidence == "low" + + def test_all_high_yields_high(self) -> None: + rows = [ + {"layer": 2, "units_estimate": 50, "expected_online_date": None, "confidence": "high"}, + ] + db = _mock_db(rows) + with patch(_MARKET, return_value=_metrics_stub(confidence="high")): + res = compute_future_supply_pressure(db, district="X") + assert res.confidence == "high" + assert res.confidence in _ALLOWED_CONFIDENCE + + +# ── compute_future_supply_pressure: graceful (empty / thin / div-by-zero) ────── + + +class TestComputeGraceful: + def test_empty_supply_and_no_absorption_none_low(self) -> None: + # Пустой склад (worker ещё не наполнил м.125 на prod) + нет поглощения → + # index None, months_of_pressure None, confidence low. НЕ crash. + db = _mock_db([]) + with patch( + _MARKET, + return_value=_metrics_stub( + absorption_rate=None, months_of_supply=None, n_available=0, confidence="low" + ), + ): + res = compute_future_supply_pressure(db, district="Пусто") + assert res.index is None + assert res.months_of_pressure is None + assert res.monthly_absorption_units is None + assert res.confidence == "low" + assert res.open_units == 0 + assert res.hidden_units == 0 + assert res.future_units_by_horizon == 0.0 + + def test_empty_supply_but_market_measurable_zero_pressure(self) -> None: + # Склад пуст, НО рынок измерим → очереди нет → давление 0.0, index 0.0 + # (валидное «нет будущего давления», НЕ None). + db = _mock_db([]) + with patch(_MARKET, return_value=_metrics_stub(months_of_supply=6.0, n_available=120)): + res = compute_future_supply_pressure(db, district="X") + assert res.months_of_pressure == 0.0 + assert res.index == 0.0 + + def test_supply_present_but_no_absorption_index_none(self) -> None: + # Есть hidden/future, но рынок неизмерим (нет поглощения) → div-by-zero + # guarded → months_of_pressure None → index None (НЕ crash, НЕ ∞). + rows = [ + { + "layer": 2, + "units_estimate": 500, + "expected_online_date": None, + "confidence": "medium", + }, + ] + db = _mock_db(rows) + with patch( + _MARKET, + return_value=_metrics_stub( + absorption_rate=None, months_of_supply=None, n_available=0, confidence="medium" + ), + ): + res = compute_future_supply_pressure(db, district="X") + assert res.hidden_units == 500 + assert res.monthly_absorption_units is None + assert res.months_of_pressure is None + assert res.index is None + # confidence всё равно whitelisted (MIN medium supply + medium market). + assert res.confidence in _ALLOWED_CONFIDENCE + + def test_view_query_exception_graceful(self) -> None: + # Сбой чтения view → [] (graceful), index по absorption (нет очереди → 0.0). + db = MagicMock() + db.execute.side_effect = RuntimeError("view gone") + with patch(_MARKET, return_value=_metrics_stub(months_of_supply=6.0, n_available=120)): + res = compute_future_supply_pressure(db, district="Сбой") + assert res.hidden_units == 0 + assert res.future_units_by_horizon == 0.0 + assert res.months_of_pressure == 0.0 + assert res.index == 0.0 + + def test_null_units_estimate_treated_as_zero(self) -> None: + # L3-строка только с датой (units_estimate None — частый случай supply_layers) → + # 0 в объёме, но строка не валит расчёт. + rows = [ + { + "layer": 3, + "units_estimate": None, + "expected_online_date": date(date.today().year + 1, 1, 1), + "confidence": "low", + }, + ] + db = _mock_db(rows) + with patch(_MARKET, return_value=_metrics_stub(months_of_supply=6.0, n_available=120)): + res = compute_future_supply_pressure(db, district="X") + assert res.future_units_by_horizon == 0.0 + assert res.months_of_pressure == 0.0 + + def test_returns_dataclass_always(self) -> None: + db = _mock_db([]) + with patch(_MARKET, return_value=_metrics_stub()): + res = compute_future_supply_pressure(db, district=None) + assert isinstance(res, FutureSupplyPressure) + assert res.district is None -- 2.45.3