feat(site_finder): market-metrics service (#949 PR A, ТЗ §9.2)
Deterministic, no-LLM market metrics from Objective data for a location (district and/or obj_ids): absorption_rate, months_of_supply, sell_through_pct, unit/area_velocity, liquidity_index per room-type, overstock_index, demand_concentration (HHI), price_sensitivity (reuses analytics_queries._elasticity_coef). Frozen MarketMetrics dataclass + as_dict, sample-size confidence (§15 spirit). Graceful on thin data (the #1 coverage risk): no data → metric None (never 0/crash), confidence='low'. Filters by district/obj_ids to sidestep the sparse domrf↔objective mapping. Read-only SELECTs, 55 unit/mock tests. Consumed by forecast (#952) + relevance (#949 PR B). No migration, no endpoint.
This commit is contained in:
parent
c78c6ec2d0
commit
c4a06813c2
2 changed files with 990 additions and 0 deletions
506
backend/app/services/site_finder/market_metrics.py
Normal file
506
backend/app/services/site_finder/market_metrics.py
Normal file
|
|
@ -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"])
|
||||||
484
backend/tests/services/site_finder/test_market_metrics.py
Normal file
484
backend/tests/services/site_finder/test_market_metrics.py
Normal file
|
|
@ -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
|
||||||
Loading…
Add table
Reference in a new issue