From 45d61ecff01b5368805af0d625773b1a7ab05034 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Tue, 2 Jun 2026 19:26:26 +0000 Subject: [PATCH] =?UTF-8?q?feat(site=5Ffinder):=20market-metrics=20service?= =?UTF-8?q?=20(#949=20PR=20A,=20=C2=A79.2)=20(#997)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/site_finder/market_metrics.py | 506 ++++++++++++++++++ .../site_finder/test_market_metrics.py | 484 +++++++++++++++++ 2 files changed, 990 insertions(+) create mode 100644 backend/app/services/site_finder/market_metrics.py create mode 100644 backend/tests/services/site_finder/test_market_metrics.py diff --git a/backend/app/services/site_finder/market_metrics.py b/backend/app/services/site_finder/market_metrics.py new file mode 100644 index 00000000..9a58c5f7 --- /dev/null +++ b/backend/app/services/site_finder/market_metrics.py @@ -0,0 +1,506 @@ +"""Market-metrics service — детерминированные рыночные метрики из данных Объектива. + +#949 PR A (Site Finder v2 / GG-форсайт, EPIC «релевантность конкурентов + +рыночные метрики»). Это **измерительный слой** (ТЗ §9.2), который потребляют +forecasting-эпики (#950/#952) и relevance-модель (#949 PR B). + +Принцип: **детерминированно, без LLM** — чистый set-based SQL + арифметика. + +Источники (см. `data/sql/68_schema_objective.sql`): + - `objective_lots` — per-flat текущее состояние (status, is_sold, area_pd, + rooms_int, district, premise_kind, sales_start_date). + - `objective_lots_history` — weekly-снапшоты per-flat: is_sold, contract_date, + area_pd — time-series для velocity/sell-through. + - elasticity (price_sensitivity) — переиспользуем + `analytics_queries._elasticity_coef` (objective_corpus_room_month, log-log регрессия). + +Фильтрация по `district` / `obj_ids` (а НЕ по domrf↔objective маппингу): маппинг +покрывает ~2.5% объектов, тогда как `district` заполнен у большинства лотов. Это +обходит mapping-gap — главный риск проекта (sparse coverage). + +Graceful-on-thin-data (КРИТИЧНО): любая метрика при отсутствии данных = `None` +(НЕ 0, НЕ crash), `confidence='low'`, результат всё равно возвращается. Каждый +helper защищён от деления на ноль и пустых выборок. +""" + +from __future__ import annotations + +import logging +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from typing import Any, Literal + +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.services.analytics_queries import _elasticity_coef + +logger = logging.getLogger(__name__) + +Confidence = Literal["high", "medium", "low"] + +# Лот считается «зависшим» (overstock), если он в продаже дольше этого числа +# месяцев и до сих пор не продан. ЕКБ-эмпирика: здоровый цикл поглощения ~ 12 мес. +_OVERSTOCK_MONTHS_THRESHOLD: int = 12 + +# Пороги уверенности по размеру выборки (зеркало духа ТЗ §15: мало лотов / 1 ЖК → low). +_CONF_HIGH_MIN_LOTS: int = 200 +_CONF_HIGH_MIN_OBJ: int = 3 +_CONF_MEDIUM_MIN_LOTS: int = 50 +_CONF_MEDIUM_MIN_OBJ: int = 2 + +# Регион данных Объектива — ЕКБ (Свердловская обл.). Передаётся в elasticity-reuse, +# где параметр сохранён для обратной совместимости (objective покрывает только ЕКБ). +_EKB_REGION_CODE: int = 66 + + +@dataclass(frozen=True) +class MarketMetrics: + """Рыночные метрики ТЗ §9.2 для локации (район и/или набор obj_ids). + + Все метрики — детерминированные. Любая метрика = None при недостатке данных + (никогда 0-как-заглушка и никогда исключение). + """ + + # ── Контекст выборки ────────────────────────────────────────────────────── + district: str | None + obj_count: int # сколько отдельных ЖК (project_name) попало в выборку + n_lots: int # всего лотов (квартир) в выборке + n_sold: int # из них проданных + n_available: int # из них доступных (в продаже) + window_months: int + premise_kind: str + confidence: Confidence + + # ── §9.2 named-метрики ──────────────────────────────────────────────────── + absorption_rate: float | None # ед./мес ÷ доступные ед. (доля поглощения в мес) + months_of_supply: float | None # доступные ед. ÷ месячное поглощение (мес до распродажи) + sell_through_pct: float | None # проданные ÷ (проданные + доступные), % + unit_velocity: float | None # ед. продано в месяц (за window_months) + area_velocity: float | None # м² продано в месяц (за window_months) + liquidity_index: dict[str, float] | None # {rooms_bucket: индекс относит. скорости} + overstock_index: float | None # доля долго-экспонируемого непроданного стока + demand_concentration: float | None # Херфиндаль продаж по комнатности (0..1) + price_sensitivity: float | None # эластичность цена↔спрос (slope, обычно < 0) + price_sensitivity_source: str | None # 'regression' | 'fallback' | None + + def as_dict(self) -> dict[str, Any]: + return { + "district": self.district, + "obj_count": self.obj_count, + "n_lots": self.n_lots, + "n_sold": self.n_sold, + "n_available": self.n_available, + "window_months": self.window_months, + "premise_kind": self.premise_kind, + "confidence": self.confidence, + "absorption_rate": _round_or_none(self.absorption_rate, 4), + "months_of_supply": _round_or_none(self.months_of_supply, 1), + "sell_through_pct": _round_or_none(self.sell_through_pct, 1), + "unit_velocity": _round_or_none(self.unit_velocity, 2), + "area_velocity": _round_or_none(self.area_velocity, 1), + "liquidity_index": ( + {k: round(v, 3) for k, v in self.liquidity_index.items()} + if self.liquidity_index is not None + else None + ), + "overstock_index": _round_or_none(self.overstock_index, 3), + "demand_concentration": _round_or_none(self.demand_concentration, 3), + "price_sensitivity": _round_or_none(self.price_sensitivity, 4), + "price_sensitivity_source": self.price_sensitivity_source, + } + + +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, не ZeroDivisionError). +# ────────────────────────────────────────────────────────────────────────────── + + +def _monthly_rate(count: float | int | None, months: int) -> float | None: + """count за окно → count в месяц. None/нет окна → None.""" + if count is None or months <= 0: + return None + return float(count) / float(months) + + +def _absorption_rate(sold: int | None, available: int | None, months: int) -> float | None: + """absorption_rate = (проданных в месяц) / доступных. + + Доля текущего стока, поглощаемая рынком за месяц. Если нет доступных лотов + или нет окна — None (распродано / неизмеримо, НЕ 0). + """ + monthly_sold = _monthly_rate(sold, months) + if monthly_sold is None or not available or available <= 0: + return None + return monthly_sold / float(available) + + +def _months_of_supply(available: int | None, sold: int | None, months: int) -> float | None: + """months_of_supply = доступные / (проданных в месяц). + + Сколько месяцев нужно, чтобы распродать текущий сток при текущем темпе. + Нет продаж за окно (темп 0) → None (бесконечность неизмерима, НЕ 0). + """ + monthly_sold = _monthly_rate(sold, months) + if available is None or monthly_sold is None or monthly_sold <= 0: + return None + return float(available) / monthly_sold + + +def _sell_through_pct(sold: int | None, available: int | None) -> float | None: + """sell_through_pct = sold / (sold + available) * 100. + + Доля реализованного от всего выведенного на рынок. Пустая выборка → None. + """ + if sold is None or available is None: + return None + denom = sold + available + if denom <= 0: + return None + return float(sold) / float(denom) * 100.0 + + +def _liquidity_index(sold_by_room: Mapping[str, int]) -> dict[str, float] | None: + """liquidity_index per комнатность — относительная скорость продаж. + + Нормируем долю продаж бакета на среднюю долю (1/n_buckets): индекс 1.0 = + «продаётся со средней по выборке скоростью», >1 = быстрее, <1 = медленнее. + Нет продаж ни в одном бакете → None. + """ + buckets = {k: int(v) for k, v in sold_by_room.items() if v is not None} + total = sum(buckets.values()) + n = len(buckets) + if total <= 0 or n == 0: + return None + fair_share = 1.0 / n + return {bucket: (cnt / total) / fair_share for bucket, cnt in buckets.items()} + + +def _overstock_index(n_long_unsold: int | None, n_available: int | None) -> float | None: + """overstock_index = долго-экспонируемые непроданные / все доступные. + + Доля «зависшего» стока (в продаже > N месяцев без сделки). Нет доступных + лотов → None (неизмеримо, НЕ 0). + """ + if n_long_unsold is None or not n_available or n_available <= 0: + return None + return float(n_long_unsold) / float(n_available) + + +def _demand_concentration(sold_by_room: Mapping[str, int]) -> float | None: + """demand_concentration — индекс Херфиндаля (HHI) долей продаж по комнатности. + + Sum( share_i^2 ) ∈ (0..1]: 1.0 = весь спрос в одном формате, → 0 = + равномерно размазан. Нет продаж → None. + """ + counts = [int(v) for v in sold_by_room.values() if v is not None and v > 0] + total = sum(counts) + if total <= 0: + return None + return sum((c / total) ** 2 for c in counts) + + +def _confidence(n_lots: int, obj_count: int, n_sold: int) -> Confidence: + """Уверенность по размеру выборки (ТЗ §15 spirit). + + 'low' если мало лотов / 1 ЖК / нет проданной истории — тогда метрики + скорости/поглощения статистически ненадёжны. + """ + if n_sold <= 0: + return "low" + if n_lots >= _CONF_HIGH_MIN_LOTS and obj_count >= _CONF_HIGH_MIN_OBJ: + return "high" + if n_lots >= _CONF_MEDIUM_MIN_LOTS and obj_count >= _CONF_MEDIUM_MIN_OBJ: + return "medium" + return "low" + + +def _room_bucket(rooms_int: int | None) -> str: + """Нормализуем rooms_int (objective: 0=студия) в стабильный bucket-ключ.""" + if rooms_int is None: + return "unknown" + if rooms_int <= 0: + return "студия" + if rooms_int >= 5: + return "5+" + return str(rooms_int) + + +# ────────────────────────────────────────────────────────────────────────────── +# SQL aggregation +# ────────────────────────────────────────────────────────────────────────────── + +# Текущий сток per-flat. Считаем по objective_lots (последний UPSERT-снапшот). +# is_sold распознаём И через флаг is_sold, И через наличие contract_date / статус +# 'продан' — Объектив заполняет их неконсистентно. n_long_unsold: непродан и +# в продаже > N мес (sales_start_date — самый надёжный «когда вышел на рынок»). +_STOCK_SQL = text( + """ + WITH lots AS ( + SELECT + ol.objective_lot_id, + ol.project_name, + ol.rooms_int, + ol.area_pd, + ol.sales_start_date, + ( + ol.is_sold IS TRUE + OR ol.contract_date IS NOT NULL + OR LOWER(COALESCE(ol.status, '')) = 'продан' + ) AS sold_now + FROM objective_lots ol + WHERE ol.premise_kind = :premise_kind + AND ( + CAST(:district AS text) IS NULL + OR ol.district = CAST(:district AS text) + ) + AND ( + CAST(:has_obj_ids AS boolean) IS FALSE + OR ol.objective_lot_id = ANY(CAST(:obj_ids AS bigint[])) + ) + ) + SELECT + COUNT(*) AS n_lots, + COUNT(*) FILTER (WHERE sold_now) AS n_sold, + COUNT(*) FILTER (WHERE NOT sold_now) AS n_available, + COUNT(DISTINCT project_name) AS obj_count, + COUNT(*) FILTER ( + WHERE NOT sold_now + AND sales_start_date IS NOT NULL + AND sales_start_date + <= CURRENT_DATE - CAST(:overstock_interval AS interval) + ) AS n_long_unsold + FROM lots + """ +) + +# Продажи за окно — из objective_lots_history (weekly timeline). «Продано в окне» +# = лот, у которого в окне появилась contract_date ИЛИ is_sold стал TRUE. Берём +# по одному событию на лот (MIN snapshot где он впервые помечен проданным), +# чтобы не считать один и тот же лот многократно из-за повторных снапшотов. +# area_pd берём из того же history-снапшота (последний known area для лота). +_SALES_WINDOW_SQL = text( + """ + WITH hist AS ( + SELECT + h.objective_lot_id, + h.snapshot_date, + h.area_pd, + (h.is_sold IS TRUE OR h.contract_date IS NOT NULL) AS sold_flag + FROM objective_lots_history h + JOIN objective_lots ol + ON ol.objective_lot_id = h.objective_lot_id + WHERE ol.premise_kind = :premise_kind + AND ( + CAST(:district AS text) IS NULL + OR ol.district = CAST(:district AS text) + ) + AND ( + CAST(:has_obj_ids AS boolean) IS FALSE + OR ol.objective_lot_id = ANY(CAST(:obj_ids AS bigint[])) + ) + AND h.snapshot_date >= CURRENT_DATE - CAST(:window_interval AS interval) + ), + first_sold AS ( + SELECT DISTINCT ON (objective_lot_id) + objective_lot_id, + ol2.rooms_int, + hist.area_pd + FROM hist + JOIN objective_lots ol2 USING (objective_lot_id) + WHERE hist.sold_flag + ORDER BY objective_lot_id, hist.snapshot_date ASC + ) + SELECT + COUNT(*) AS units_sold_window, + COALESCE(SUM(area_pd), 0) AS area_sold_window, + rooms_int + FROM first_sold + GROUP BY ROLLUP (rooms_int) + """ +) + + +def compute_market_metrics( + db: Session, + *, + district: str | None = None, + obj_ids: Sequence[int] | None = None, + window_months: int = 6, + premise_kind: str = "квартира", +) -> MarketMetrics: + """Вычислить рыночные метрики ТЗ §9.2 для локации. + + Фильтрация по `district` и/или `obj_ids` (объединяются по AND, если оба + заданы). Если ни один не задан — считается по всей выборке premise_kind + (имеет смысл для ЕКБ-wide baseline). + + Возвращает MarketMetrics ВСЕГДА (даже на пустых данных): тогда метрики = + None, confidence='low'. Никогда не бросает на отсутствии данных. + """ + obj_id_list: list[int] = [int(x) for x in obj_ids] if obj_ids else [] + has_obj_ids = bool(obj_id_list) + params: dict[str, Any] = { + "premise_kind": premise_kind, + "district": district, + "has_obj_ids": has_obj_ids, + # ANY(NULL::bigint[]) валиден; передаём пустой список когда фильтра нет. + "obj_ids": obj_id_list, + "overstock_interval": f"{_OVERSTOCK_MONTHS_THRESHOLD} months", + } + + # ── Текущий сток ────────────────────────────────────────────────────────── + stock = _query_stock(db, params) + n_lots = stock["n_lots"] + n_sold_total = stock["n_sold"] + n_available = stock["n_available"] + obj_count = stock["obj_count"] + n_long_unsold = stock["n_long_unsold"] + + # ── Продажи за окно (для velocity / absorption / liquidity / concentration) ─ + window_params = {**params, "window_interval": f"{window_months} months"} + units_sold_window, area_sold_window, sold_by_room = _query_sales_window(db, window_params) + + # ── Pure-метрики ────────────────────────────────────────────────────────── + # n_lots == 0 → выборка пуста, мерить нечего: velocity/absorption = None + # (НЕ 0 — иначе «нет данных» не отличить от «честно продали 0»). При n_lots>0 + # и нуле продаж в окне velocity=0.0 — это валидное измерение «0 ед./мес». + has_sample = n_lots > 0 + units_window: int | None = units_sold_window if has_sample else None + area_window: float | None = area_sold_window if has_sample else None + absorption = _absorption_rate(units_window, n_available, window_months) + mos = _months_of_supply(n_available, units_window, window_months) + sell_through = _sell_through_pct(n_sold_total, n_available) + unit_velocity = _monthly_rate(units_window, window_months) + area_velocity = _monthly_rate(area_window, window_months) + liquidity = _liquidity_index(sold_by_room) + overstock = _overstock_index(n_long_unsold, n_available) + demand_conc = _demand_concentration(sold_by_room) + + # ── price_sensitivity — reuse analytics_queries._elasticity_coef ─────────── + price_sensitivity, price_sensitivity_source = _price_sensitivity( + db, district=district, window_months=window_months + ) + + confidence = _confidence(n_lots=n_lots, obj_count=obj_count, n_sold=n_sold_total) + + logger.info( + "market_metrics: district=%s obj_ids=%d n_lots=%d n_sold=%d n_available=%d " + "obj_count=%d units_sold_window=%d confidence=%s", + district, + len(obj_id_list), + n_lots, + n_sold_total, + n_available, + obj_count, + units_sold_window, + confidence, + ) + + return MarketMetrics( + district=district, + obj_count=obj_count, + n_lots=n_lots, + n_sold=n_sold_total, + n_available=n_available, + window_months=window_months, + premise_kind=premise_kind, + confidence=confidence, + absorption_rate=absorption, + months_of_supply=mos, + sell_through_pct=sell_through, + unit_velocity=unit_velocity, + area_velocity=area_velocity, + liquidity_index=liquidity, + overstock_index=overstock, + demand_concentration=demand_conc, + price_sensitivity=price_sensitivity, + price_sensitivity_source=price_sensitivity_source, + ) + + +def _query_stock(db: Session, params: Mapping[str, Any]) -> dict[str, int]: + """Текущий сток. На ошибке/пустых данных → все счётчики 0 (graceful).""" + try: + row = db.execute(_STOCK_SQL, dict(params)).mappings().first() + except Exception: + logger.exception("market_metrics: stock query failed (district=%s)", params.get("district")) + row = None + if row is None: + return { + "n_lots": 0, + "n_sold": 0, + "n_available": 0, + "obj_count": 0, + "n_long_unsold": 0, + } + return { + "n_lots": int(row["n_lots"] or 0), + "n_sold": int(row["n_sold"] or 0), + "n_available": int(row["n_available"] or 0), + "obj_count": int(row["obj_count"] or 0), + "n_long_unsold": int(row["n_long_unsold"] or 0), + } + + +def _query_sales_window( + db: Session, params: Mapping[str, Any] +) -> tuple[int, float, dict[str, int]]: + """Продажи за окно из history. Возвращает (units, area_m2, {bucket: units}). + + GROUP BY ROLLUP: строка с rooms_int IS NULL — это grand-total (берём как + units/area), остальные строки — разбивка по комнатности (для liquidity / + demand_concentration). На ошибке/пусто → (0, 0.0, {}). + """ + try: + rows = db.execute(_SALES_WINDOW_SQL, dict(params)).mappings().all() + except Exception: + logger.exception( + "market_metrics: sales-window query failed (district=%s)", params.get("district") + ) + rows = [] + + units_total = 0 + area_total = 0.0 + by_room: dict[str, int] = {} + for r in rows: + cnt = int(r["units_sold_window"] or 0) + area = float(r["area_sold_window"] or 0.0) + if r["rooms_int"] is None: + # ROLLUP grand-total. + units_total = cnt + area_total = area + else: + by_room[_room_bucket(int(r["rooms_int"]))] = cnt + return units_total, area_total, by_room + + +def _price_sensitivity( + db: Session, *, district: str | None, window_months: int +) -> tuple[float | None, str | None]: + """Эластичность цена↔спрос — reuse analytics_queries._elasticity_coef. + + Требует district (регрессия по району). Без district → None (нечего фитить). + elasticity-окно отдельно от velocity-окна: регрессии нужно больше истории, + поэтому минимум 24 мес (как в recommend_mix). + """ + if not district: + return None, None + elasticity_window = max(window_months, 24) + try: + elast = _elasticity_coef( + db, + region_code=_EKB_REGION_CODE, + district_name=district, + target_class=None, + elasticity_window_months=elasticity_window, + ) + except Exception: + logger.exception("market_metrics: elasticity reuse failed (district=%s)", district) + return None, None + return float(elast["elasticity"]), str(elast["source"]) diff --git a/backend/tests/services/site_finder/test_market_metrics.py b/backend/tests/services/site_finder/test_market_metrics.py new file mode 100644 index 00000000..b16ee75c --- /dev/null +++ b/backend/tests/services/site_finder/test_market_metrics.py @@ -0,0 +1,484 @@ +"""Unit-тесты market-metrics service (#949 PR A, ТЗ §9.2). + +Чистые тесты (без живой БД): + • pure-арифметика метрик (_absorption_rate, _months_of_supply, _sell_through_pct, + _monthly_rate, _liquidity_index, _overstock_index, _demand_concentration, + _confidence, _room_bucket) — hand-built фикстуры, включая thin-data / + empty / zero-division кейсы → None (не 0, не crash) + confidence='low'. + • compute_market_metrics через MagicMock-сессию — форма SQL (CAST(:x AS type), + не :x::type), params, и graceful-on-empty (всё None, low confidence, no crash). + +psycopg v3 правило проверяется явно: bind-параметры — CAST(:x AS type). +""" + +from __future__ import annotations + +import os +from unittest.mock import MagicMock, patch + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.services.site_finder.market_metrics import ( + MarketMetrics, + _absorption_rate, + _confidence, + _demand_concentration, + _liquidity_index, + _monthly_rate, + _months_of_supply, + _overstock_index, + _room_bucket, + _sell_through_pct, + compute_market_metrics, +) + +_ELAST = "app.services.site_finder.market_metrics._elasticity_coef" + + +# ── pure: _monthly_rate ─────────────────────────────────────────────────────── + + +class TestMonthlyRate: + def test_basic(self) -> None: + assert _monthly_rate(12, 6) == 2.0 + + def test_zero_count(self) -> None: + assert _monthly_rate(0, 6) == 0.0 + + def test_none_count(self) -> None: + assert _monthly_rate(None, 6) is None + + def test_zero_months_no_crash(self) -> None: + assert _monthly_rate(10, 0) is None + + def test_negative_months(self) -> None: + assert _monthly_rate(10, -3) is None + + +# ── pure: _absorption_rate ──────────────────────────────────────────────────── + + +class TestAbsorptionRate: + def test_basic(self) -> None: + # 12 sold over 6 mo = 2/mo; / 40 available = 0.05 + assert _absorption_rate(12, 40, 6) == 0.05 + + def test_no_available_returns_none(self) -> None: + # распродано — не 0, а None (неизмеримо) + assert _absorption_rate(12, 0, 6) is None + + def test_none_available(self) -> None: + assert _absorption_rate(12, None, 6) is None + + def test_no_sales_is_zero_rate(self) -> None: + # есть сток, продаж 0 → поглощение 0/мес = 0.0 (валидно, не None) + assert _absorption_rate(0, 40, 6) == 0.0 + + def test_none_sold(self) -> None: + assert _absorption_rate(None, 40, 6) is None + + def test_zero_months_no_crash(self) -> None: + assert _absorption_rate(12, 40, 0) is None + + +# ── pure: _months_of_supply ─────────────────────────────────────────────────── + + +class TestMonthsOfSupply: + def test_basic(self) -> None: + # 40 available / (12 sold/6mo = 2/mo) = 20 мес + assert _months_of_supply(40, 12, 6) == 20.0 + + def test_no_sales_returns_none(self) -> None: + # темп 0 → бесконечность неизмерима → None (не 0) + assert _months_of_supply(40, 0, 6) is None + + def test_none_available(self) -> None: + assert _months_of_supply(None, 12, 6) is None + + def test_zero_available(self) -> None: + # сток распродан → 0 месяцев до распродажи + assert _months_of_supply(0, 12, 6) == 0.0 + + def test_zero_months_no_crash(self) -> None: + assert _months_of_supply(40, 12, 0) is None + + +# ── pure: _sell_through_pct ─────────────────────────────────────────────────── + + +class TestSellThroughPct: + def test_basic(self) -> None: + # 30 sold / (30 + 70) = 30% + assert _sell_through_pct(30, 70) == 30.0 + + def test_all_sold(self) -> None: + assert _sell_through_pct(50, 0) == 100.0 + + def test_empty_returns_none(self) -> None: + assert _sell_through_pct(0, 0) is None + + def test_none_inputs(self) -> None: + assert _sell_through_pct(None, 70) is None + assert _sell_through_pct(30, None) is None + + +# ── pure: _liquidity_index ──────────────────────────────────────────────────── + + +class TestLiquidityIndex: + def test_relative_speed(self) -> None: + # 3 бакета, fair_share = 1/3. {"1":50,"2":25,"3":25} total=100 + idx = _liquidity_index({"1": 50, "2": 25, "3": 25}) + assert idx is not None + # 1-к: (50/100)/(1/3) = 1.5; остальные (0.25)/(1/3) = 0.75 + assert idx["1"] == 1.5 + assert idx["2"] == 0.75 + assert idx["3"] == 0.75 + + def test_single_bucket_index_one(self) -> None: + idx = _liquidity_index({"студия": 10}) + assert idx == {"студия": 1.0} + + def test_empty_returns_none(self) -> None: + assert _liquidity_index({}) is None + + def test_all_zero_returns_none(self) -> None: + assert _liquidity_index({"1": 0, "2": 0}) is None + + def test_none_values_skipped(self) -> None: + idx = _liquidity_index({"1": 10, "2": None}) # type: ignore[dict-item] + assert idx == {"1": 1.0} + + +# ── pure: _overstock_index ──────────────────────────────────────────────────── + + +class TestOverstockIndex: + def test_basic(self) -> None: + # 15 long-unsold / 60 available = 0.25 + assert _overstock_index(15, 60) == 0.25 + + def test_no_available_returns_none(self) -> None: + assert _overstock_index(0, 0) is None + + def test_none_long_unsold(self) -> None: + assert _overstock_index(None, 60) is None + + def test_zero_long_unsold(self) -> None: + assert _overstock_index(0, 60) == 0.0 + + +# ── pure: _demand_concentration ─────────────────────────────────────────────── + + +class TestDemandConcentration: + def test_single_format_hhi_one(self) -> None: + assert _demand_concentration({"1": 100}) == 1.0 + + def test_even_split_low(self) -> None: + # 4 равных бакета → HHI = 4 * 0.25^2 = 0.25 + assert _demand_concentration({"1": 25, "2": 25, "3": 25, "4": 25}) == 0.25 + + def test_empty_returns_none(self) -> None: + assert _demand_concentration({}) is None + + def test_all_zero_returns_none(self) -> None: + assert _demand_concentration({"1": 0, "2": 0}) is None + + def test_zeros_ignored(self) -> None: + # нулевые бакеты не считаются за «формат» + assert _demand_concentration({"1": 100, "2": 0}) == 1.0 + + +# ── pure: _confidence ───────────────────────────────────────────────────────── + + +class TestConfidence: + def test_high(self) -> None: + assert _confidence(n_lots=300, obj_count=4, n_sold=80) == "high" + + def test_medium(self) -> None: + assert _confidence(n_lots=80, obj_count=2, n_sold=20) == "medium" + + def test_low_few_lots(self) -> None: + assert _confidence(n_lots=10, obj_count=1, n_sold=3) == "low" + + def test_low_no_sales_history_even_if_many_lots(self) -> None: + # ТЗ §15 spirit: нет проданной истории → скорости ненадёжны → low + assert _confidence(n_lots=500, obj_count=5, n_sold=0) == "low" + + def test_low_single_complex(self) -> None: + # много лотов но 1 ЖК → не high (obj_count gate) + assert _confidence(n_lots=500, obj_count=1, n_sold=80) == "low" + + +# ── pure: _room_bucket ──────────────────────────────────────────────────────── + + +class TestRoomBucket: + def test_studio(self) -> None: + assert _room_bucket(0) == "студия" + + def test_one_room(self) -> None: + assert _room_bucket(1) == "1" + + def test_five_plus(self) -> None: + assert _room_bucket(5) == "5+" + assert _room_bucket(7) == "5+" + + def test_none(self) -> None: + assert _room_bucket(None) == "unknown" + + +# ── MarketMetrics.as_dict ───────────────────────────────────────────────────── + + +class TestAsDict: + def test_rounds_and_serialises(self) -> None: + m = MarketMetrics( + district="Автовокзал", + obj_count=3, + n_lots=300, + n_sold=90, + n_available=210, + window_months=6, + premise_kind="квартира", + confidence="high", + absorption_rate=0.0476190476, + months_of_supply=21.0, + sell_through_pct=30.0, + unit_velocity=2.0, + area_velocity=95.5, + liquidity_index={"1": 1.5, "2": 0.75}, + overstock_index=0.25, + demand_concentration=0.3333333, + price_sensitivity=-1.234567, + price_sensitivity_source="regression", + ) + d = m.as_dict() + assert d["absorption_rate"] == 0.0476 + assert d["months_of_supply"] == 21.0 + assert d["demand_concentration"] == 0.333 + assert d["price_sensitivity"] == -1.2346 + assert d["liquidity_index"] == {"1": 1.5, "2": 0.75} + assert d["confidence"] == "high" + + def test_none_metrics_survive(self) -> None: + m = MarketMetrics( + district=None, + obj_count=0, + n_lots=0, + n_sold=0, + n_available=0, + window_months=6, + premise_kind="квартира", + confidence="low", + absorption_rate=None, + months_of_supply=None, + sell_through_pct=None, + unit_velocity=None, + area_velocity=None, + liquidity_index=None, + overstock_index=None, + demand_concentration=None, + price_sensitivity=None, + price_sensitivity_source=None, + ) + d = m.as_dict() + assert d["absorption_rate"] is None + assert d["liquidity_index"] is None + assert d["confidence"] == "low" + + +# ── compute_market_metrics: MagicMock-сессия (форма SQL + graceful) ─────────── + + +def _mock_db(stock_row: dict | None, sales_rows: list[dict]) -> MagicMock: + """Сессия с двумя последовательными execute: stock (.first), sales (.all).""" + db = MagicMock() + stock_result = MagicMock() + stock_result.mappings.return_value.first.return_value = stock_row + sales_result = MagicMock() + sales_result.mappings.return_value.all.return_value = sales_rows + db.execute.side_effect = [stock_result, sales_result] + return db + + +def _executed_sql(db: MagicMock, call_index: int) -> str: + args, _kwargs = db.execute.call_args_list[call_index] + return str(args[0]) + + +def _executed_params(db: MagicMock, call_index: int) -> dict: + args, _kwargs = db.execute.call_args_list[call_index] + return args[1] + + +_FULL_STOCK = { + "n_lots": 300, + "n_sold": 90, + "n_available": 210, + "obj_count": 3, + "n_long_unsold": 42, +} +# ROLLUP: grand-total (rooms_int=None) + per-room buckets. +_FULL_SALES = [ + {"units_sold_window": 60, "area_sold_window": 2880.0, "rooms_int": None}, + {"units_sold_window": 30, "area_sold_window": 900.0, "rooms_int": 1}, + {"units_sold_window": 20, "area_sold_window": 1200.0, "rooms_int": 2}, + {"units_sold_window": 10, "area_sold_window": 780.0, "rooms_int": 0}, +] + + +class TestComputeMarketMetricsShape: + def test_sql_uses_cast_not_double_colon(self) -> None: + db = _mock_db(_FULL_STOCK, _FULL_SALES) + with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}): + compute_market_metrics(db, district="Автовокзал") + stock_sql = _executed_sql(db, 0) + sales_sql = _executed_sql(db, 1) + # psycopg v3: CAST(:x AS type), НЕ :x::type + assert "CAST(:district AS text)" in stock_sql + assert "CAST(:obj_ids AS bigint[])" in stock_sql + assert "CAST(:overstock_interval AS interval)" in stock_sql + assert "CAST(:window_interval AS interval)" in sales_sql + for sql in (stock_sql, sales_sql): + assert ":district::" not in sql + assert ":obj_ids::" not in sql + assert ":window_interval::" not in sql + assert ":overstock_interval::" not in sql + + def test_params_district_and_premise(self) -> None: + db = _mock_db(_FULL_STOCK, _FULL_SALES) + with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}): + compute_market_metrics(db, district="Автовокзал", premise_kind="квартира") + p = _executed_params(db, 0) + assert p["district"] == "Автовокзал" + assert p["premise_kind"] == "квартира" + assert p["has_obj_ids"] is False + assert p["obj_ids"] == [] + + def test_params_obj_ids_filter(self) -> None: + db = _mock_db(_FULL_STOCK, _FULL_SALES) + with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}): + compute_market_metrics(db, obj_ids=[101, 202, 303]) + p = _executed_params(db, 0) + assert p["has_obj_ids"] is True + assert p["obj_ids"] == [101, 202, 303] + + def test_full_metrics_computed(self) -> None: + db = _mock_db(_FULL_STOCK, _FULL_SALES) + with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}): + m = compute_market_metrics(db, district="Автовокзал", window_months=6) + assert m.n_lots == 300 + assert m.n_sold == 90 + assert m.n_available == 210 + assert m.obj_count == 3 + # unit_velocity: 60 / 6 = 10/mo + assert m.unit_velocity == 10.0 + # area_velocity: 2880 / 6 = 480/mo + assert m.area_velocity == 480.0 + # absorption: 10/mo / 210 ≈ 0.0476 + assert m.absorption_rate is not None and round(m.absorption_rate, 4) == 0.0476 + # months_of_supply: 210 / 10 = 21 + assert m.months_of_supply == 21.0 + # sell_through: 90 / 300 = 30% + assert m.sell_through_pct == 30.0 + # overstock: 42 / 210 = 0.2 + assert m.overstock_index == 0.2 + # liquidity / demand_concentration computed from per-room buckets + assert m.liquidity_index is not None + assert set(m.liquidity_index.keys()) == {"1", "2", "студия"} + assert m.demand_concentration is not None + # price_sensitivity from reused elasticity + assert m.price_sensitivity == -1.4 + assert m.price_sensitivity_source == "regression" + assert m.confidence == "high" + + def test_elasticity_reuse_invoked_with_district(self) -> None: + db = _mock_db(_FULL_STOCK, _FULL_SALES) + with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}) as elast: + compute_market_metrics(db, district="Автовокзал", window_months=6) + assert elast.called + _args, kwargs = elast.call_args + assert kwargs["district_name"] == "Автовокзал" + # elasticity-окно минимум 24 мес даже при коротком window_months + assert kwargs["elasticity_window_months"] == 24 + + def test_no_district_skips_elasticity(self) -> None: + db = _mock_db(_FULL_STOCK, _FULL_SALES) + with patch(_ELAST) as elast: + m = compute_market_metrics(db, obj_ids=[1, 2]) + assert not elast.called + assert m.price_sensitivity is None + assert m.price_sensitivity_source is None + + +class TestComputeMarketMetricsThinData: + """Graceful-on-thin-data: empty / zero → None metrics + low confidence, no crash.""" + + def test_empty_stock_all_none(self) -> None: + empty_stock = { + "n_lots": 0, + "n_sold": 0, + "n_available": 0, + "obj_count": 0, + "n_long_unsold": 0, + } + db = _mock_db(empty_stock, []) + with patch(_ELAST, return_value={"elasticity": -1.5, "source": "fallback"}): + m = compute_market_metrics(db, district="ПустойРайон") + assert m.n_lots == 0 + assert m.absorption_rate is None + assert m.months_of_supply is None + assert m.sell_through_pct is None + assert m.unit_velocity is None + assert m.area_velocity is None + assert m.liquidity_index is None + assert m.overstock_index is None + assert m.demand_concentration is None + assert m.confidence == "low" + + def test_stock_none_row_no_crash(self) -> None: + # .first() вернул None — не падаем, всё 0/None. + db = _mock_db(None, []) + with patch(_ELAST, return_value={"elasticity": -1.5, "source": "fallback"}): + m = compute_market_metrics(db, district="Х") + assert m.n_lots == 0 + assert m.unit_velocity is None + assert m.confidence == "low" + + def test_available_stock_but_no_sales(self) -> None: + # Есть сток, но никто не продал за окно → velocity 0, MoS None, absorption 0. + stock = { + "n_lots": 50, + "n_sold": 0, + "n_available": 50, + "obj_count": 1, + "n_long_unsold": 5, + } + # только grand-total строка с 0 продаж + sales = [{"units_sold_window": 0, "area_sold_window": 0.0, "rooms_int": None}] + db = _mock_db(stock, sales) + with patch(_ELAST, return_value={"elasticity": -1.5, "source": "fallback"}): + m = compute_market_metrics(db, district="Тихий") + assert m.unit_velocity == 0.0 + assert m.absorption_rate == 0.0 # 0 продаж при наличии стока = 0/мес (валидно) + assert m.months_of_supply is None # темп 0 → распродажа неизмерима + assert m.sell_through_pct == 0.0 # 0 / 50 = 0% + assert m.liquidity_index is None # нет проданных по бакетам + assert m.demand_concentration is None + # overstock считается (есть доступные): 5 / 50 = 0.1 + assert m.overstock_index == 0.1 + assert m.confidence == "low" # n_sold=0 → low + + def test_stock_query_exception_graceful(self) -> None: + db = MagicMock() + db.execute.side_effect = RuntimeError("db down") + with patch(_ELAST, return_value={"elasticity": -1.5, "source": "fallback"}): + m = compute_market_metrics(db, district="Сбой") + # exception проглочен (logged), вернулся валидный пустой результат + assert m.n_lots == 0 + assert m.confidence == "low" + assert m.as_dict()["unit_velocity"] is None