feat(site_finder): future-supply-pressure index (#950 Step 6)

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).
This commit is contained in:
Light1YT 2026-06-03 01:57:29 +05:00
parent ae85f19993
commit f8daf50f5f
2 changed files with 928 additions and 0 deletions

View file

@ -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 []

View file

@ -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