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:
parent
ae85f19993
commit
f8daf50f5f
2 changed files with 928 additions and 0 deletions
398
backend/app/services/site_finder/future_supply.py
Normal file
398
backend/app/services/site_finder/future_supply.py
Normal 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 []
|
||||
530
backend/tests/services/site_finder/test_future_supply.py
Normal file
530
backend/tests/services/site_finder/test_future_supply.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue