gendesign/backend/app/services/site_finder/market_metrics.py
bot-backend 73739671a9
All checks were successful
CI / changes (pull_request) Successful in 9s
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m41s
CI / backend-tests (pull_request) Successful in 15m25s
fix(site_finder): SAVEPOINT-isolate supply/market db.execute + kill 2 bare rollbacks (#2464)
Convert two bare db.rollback() calls on the shared per-request session
(best_layouts _fetch_supply_only_layouts + Step 2b core-attribution) to
db.begin_nested() — a bare rollback there discards ALL uncommitted work of
the request and orphans the outer SessionTransaction. Also wrap the swallow
sites in market_metrics (_query_stock, _query_sales_window, _price_sensitivity,
compute_offer_price_trend), best_layouts step-5 supply, future_supply
_query_supply_rows, own_portfolio _query_current/_query_future, and
custom_pois get_overlaps_for_scoring so a failed statement no longer poisons
the shared tx for later steps. Per-site regression tests assert fallback
preserved, begin_nested used, bare rollback NOT called, and session usable
after failure.

Refs #2464 (cluster A session-poisoning, Wave 2).
EOF2
echo "HANDLED"
2026-07-08 11:29:31 +05:00

961 lines
52 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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) +
`contract_date` («Дата договора») — реальная дата сделки,
заполнена у 100% проданных лотов. Используем её и для
кумулятивного стока, и для продаж-в-окне (velocity /
absorption / sell-through). `objective_lots_history`
(weekly-снапшоты) НЕ используем: глубина ~17 дней не даёт
корректного окна продаж (см. #949).
- 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
from app.services.forecast_request_cache import cached
from app.services.site_finder.district_resolver import resolve_objective_districts
logger = logging.getLogger(__name__)
def _market_metrics_key(
db: Session,
*,
district: str | None = None,
obj_ids: Sequence[int] | None = None,
window_months: int = 6,
premise_kind: str = "квартира",
obj_class: str | None = None,
room_bucket: str | None = None,
) -> tuple[Any, ...]:
"""Ключ кэша §22-форсайта для compute_market_metrics (#1129).
Все входы, влияющие на SQL-фильтр и метрики: district, набор obj_ids (tuple для
hashability), окно, premise_kind, + сегмент-оси obj_class/room_bucket (#1959 —
посегментный дефицит). В форсайт-пути obj_ids всегда None → tuple() устойчив.
`db` не в ключе (одна сессия на отчёт).
"""
return (
district,
tuple(obj_ids) if obj_ids is not None else None,
window_months,
premise_kind,
obj_class,
room_bucket,
)
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
# #1593: ед./мес per room-bucket (ключи — вокабуляр _room_bucket():
# "студия","1","2","3","4","5+"). None если нет данных в окне.
velocity_by_room: dict[str, float] | 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,
# velocity_by_room намеренно не сериализуется: это внутренний
# pipeline-атрибут (потребляется recommendation.py напрямую).
}
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. ДЕДУП до ПОСЛЕДНЕГО снапшота на физический лот (#1959):
# objective_lots — append-per-snapshot (~2.9× инфляция строк), без дедупа сток
# раздут и симметрично раздувает спрос И предложение → деградирует посегментный
# дефицит. Ключ физлота = (project_name, corpus_name, section, floor, lot_number);
# берём строку с MAX(snapshot_date), tie-break MAX(id). Поддерживается индексом
# objective_lots_physflat_latest_idx (создаётся в миграции 173) — он совпадает с
# ключом и направлениями этого DISTINCT ON → Index Only Scan + Unique без Sort.
# project_name NOT NULL; corpus/section/floor/lot_number могут быть NULL (~2.5%
# лотов) — для таких лотов DISTINCT ON схлопывает NULL=NULL в одну группу
# (неидентифицируемые физлоты, в дефицит-расчёте безопасно), для остального —
# точный per-flat дедуп.
#
# ⚠️ ДЕДУП БЕЗУСЛОВНЫЙ — применяется ко ВСЕМ вызовам compute_market_metrics, не
# только к форсайт-пути. Deep-review (#1959) подтвердил: безопасно для остальных
# потребителей (absorption/MoS/sell_through — отношения; velocity — saturated),
# т.к. inflation симметрична в числителе/знаменателе и съедается шкалой. Дедуп
# здесь касается ТОЛЬКО выборки внутри метрик; #1964 ввёл общий view
# v_objective_lots_latest для ОСТАЛЬНЫХ current-state консьюмеров — он использует
# ТОТ ЖЕ physflat-ключ/направления (project,corpus,section,floor,lot_number;
# snapshot_date DESC, id DESC), что и этот inline DISTINCT ON (инвариант общий).
# Этот блок НЕ рефакторим на view (zero behavior change, лишний риск).
#
# Сегмент-фильтры (#1959): obj_class (class в objective_lots — lowercase, матчим
# LOWER=LOWER) + room_bucket. ROOM-bucket — Source-B room_area-вокабуляр
# ("Студии 15-30"/"1-к 30-45"/"2-к 45-60"/"3-к 60-80"/"80+ м²"), ТОЧНОЕ зеркало
# sales_series.room_area_bucket_of (и _SOURCE_B_SQL): площадь ≥ :large_area → LARGE,
# иначе по rooms_int; так what_to_build (Source-B сетка) фильтрует БЕЗ перевода
# вокабуляра. Применяются ДО DISTINCT ON: сегментация объективна по физлоту
# (класс/комнатность/площадь не меняются между снапшотами).
#
# is_sold распознаём И через флаг is_sold, И через наличие contract_date / статус
# 'продан' — Объектив заполняет их неконсистентно. n_long_unsold: непродан и
# в продаже > N мес (sales_start_date — самый надёжный «когда вышел на рынок»).
_STOCK_SQL = text(
"""
WITH lots AS (
SELECT DISTINCT ON (
ol.project_name, ol.corpus_name, ol.section, ol.floor, ol.lot_number
)
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(:has_district AS boolean) IS FALSE
OR ol.district = ANY(CAST(:districts AS text[]))
)
AND (
CAST(:has_obj_ids AS boolean) IS FALSE
OR ol.objective_lot_id = ANY(CAST(:obj_ids AS bigint[]))
)
AND (
CAST(:cls AS text) IS NULL
OR LOWER(ol.class) = LOWER(CAST(:cls AS text))
)
AND (
CAST(:room_bucket AS text) IS NULL
OR (CASE
WHEN ol.area_pd >= CAST(:large_area AS numeric)
THEN CAST(:b_large AS text)
WHEN ol.rooms_int IS NULL THEN CAST(:b_unknown AS text)
WHEN ol.rooms_int <= 0 THEN CAST(:b_studio AS text)
WHEN ol.rooms_int = 1 THEN CAST(:b_1k AS text)
WHEN ol.rooms_int = 2 THEN CAST(:b_2k AS text)
WHEN ol.rooms_int = 3 THEN CAST(:b_3k AS text)
ELSE CAST(:b_large AS text)
END) = CAST(:room_bucket AS text)
)
ORDER BY
ol.project_name, ol.corpus_name, ol.section, ol.floor, ol.lot_number,
ol.snapshot_date DESC, ol.id DESC
)
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 по contract_date («Дата договора»).
# «Продано в окне» = лот с contract_date внутри окна. contract_date — реальная дата
# сделки и заполнен у 100% проданных лотов, поэтому источник надёжен и не требует
# истории. (Раньше считали через objective_lots_history-снапшоты, но history глубиной
# ~17 дней: любой сейчас-проданный лот имел sold-снапшот в окне → «продажи в окне»
# схлопывались в весь кумулятивный проданный сток, завышая absorption/velocity/MoS.
# Bug #949: Автовокзал 6mo давал ~33 245 ед. вместо реальных ~2 308.)
#
# ДЕДУП до ПОСЛЕДНЕГО снапшота на физический лот (#1959, симметрично _STOCK_SQL):
# без дедупа один проданный физлот, присутствующий в N снапшотах, считается N раз
# → velocity/absorption раздуты ~2.9× (раздувают спрос симметрично раздутому
# предложению → дефицит вырождается в 1.0). Дедупим в CTE `latest`, окно по
# contract_date и сегмент-фильтры применяем уже к дедуплицированному набору.
# Ключ/направления дедупа — зеркало _STOCK_SQL (оба поддержаны индексом
# objective_lots_physflat_latest_idx из миграции 173). contract_date / rooms_int /
# area_pd берём из последнего снапшота физлота.
# Сегмент-фильтры (#1959): obj_class (lowercase, LOWER=LOWER) + room_bucket
# (Source-B room_area-вокабуляр "Студии 15-30"/"1-к 30-45"/"2-к 45-60"/"3-к 60-80"/
# "80+ м²" — ТОЧНОЕ зеркало sales_series.room_area_bucket_of, как в _STOCK_SQL) —
# применяются ПОСЛЕ дедупа (объективны по физлоту: класс/комнатность/площадь не
# меняются между снапшотами). ROLLUP остаётся: при room_bucket-фильтре отдаёт
# {bucket, total}.
_SALES_WINDOW_SQL = text(
"""
WITH latest AS (
SELECT DISTINCT ON (
ol.project_name, ol.corpus_name, ol.section, ol.floor, ol.lot_number
)
ol.rooms_int,
ol.area_pd,
ol.contract_date,
ol.class
FROM objective_lots ol
WHERE ol.premise_kind = :premise_kind
AND (
CAST(:has_district AS boolean) IS FALSE
OR ol.district = ANY(CAST(:districts AS text[]))
)
AND (
CAST(:has_obj_ids AS boolean) IS FALSE
OR ol.objective_lot_id = ANY(CAST(:obj_ids AS bigint[]))
)
ORDER BY
ol.project_name, ol.corpus_name, ol.section, ol.floor, ol.lot_number,
ol.snapshot_date DESC, ol.id DESC
)
SELECT
COUNT(*) AS units_sold_window,
COALESCE(SUM(area_pd), 0) AS area_sold_window,
rooms_int,
-- #1214: ROLLUP grand-total и NULL-группа дают rooms_int IS NULL обе.
-- Различаем через GROUPING(): =1 для grand-total, =0 для NULL-комнатной
-- группы. Без этого один проданный лот с rooms_int IS NULL даёт ДВЕ
-- строки rooms_int IS NULL (NULL-группа + итог), и MixedAggregate-план
-- эмитит итог ПЕРВЫМ → NULL-группа затирает units_total частичным
-- счётом → unit_velocity/absorption занижены, MoS завышен.
GROUPING(rooms_int) AS is_total
FROM latest
WHERE contract_date IS NOT NULL
AND contract_date >= CURRENT_DATE - CAST(:window_interval AS interval)
AND (
CAST(:cls AS text) IS NULL
OR LOWER(class) = LOWER(CAST(:cls AS text))
)
AND (
CAST(:room_bucket AS text) IS NULL
OR (CASE
WHEN area_pd >= CAST(:large_area AS numeric)
THEN CAST(:b_large AS text)
WHEN rooms_int IS NULL THEN CAST(:b_unknown AS text)
WHEN rooms_int <= 0 THEN CAST(:b_studio AS text)
WHEN rooms_int = 1 THEN CAST(:b_1k AS text)
WHEN rooms_int = 2 THEN CAST(:b_2k AS text)
WHEN rooms_int = 3 THEN CAST(:b_3k AS text)
ELSE CAST(:b_large AS text)
END) = CAST(:room_bucket AS text)
)
GROUP BY ROLLUP (rooms_int)
"""
)
@cached(_market_metrics_key, label="compute_market_metrics")
def compute_market_metrics(
db: Session,
*,
district: str | None = None,
obj_ids: Sequence[int] | None = None,
window_months: int = 6,
premise_kind: str = "квартира",
obj_class: str | None = None,
room_bucket: str | None = None,
) -> MarketMetrics:
"""Вычислить рыночные метрики ТЗ §9.2 для локации.
Фильтрация по `district` и/или `obj_ids` (объединяются по AND, если оба
заданы). Если ни один не задан — считается по всей выборке premise_kind
(имеет смысл для ЕКБ-wide baseline).
#1959 (посегментный дефицит): `obj_class` (lowercase в objective_lots,
матчим регистронезависимо) и `room_bucket` (Source-B room_area-вокабуляр —
"Студии 15-30"/"1-к 30-45"/"2-к 45-60"/"3-к 60-80"/"80+ м²", зеркало
`sales_series.room_area_bucket_of` → what_to_build фильтрует БЕЗ перевода)
сужают выборку до одного сегмента. Так base_pace (unit_velocity) и доступный
сток становятся ПОСЕГМЕНТНЫМИ, а не district-wide (иначе все ячейки
what_to_build делят один темп → дефицит вырождается в 1.0). None по обеим осям
→ прежнее district-wide поведение. ВАЖНО: SQL дедуплицирует objective_lots до
последнего снапшота на физлот → counts (а значит и confidence) считаются на
дедуплицированном наборе.
Возвращает MarketMetrics ВСЕГДА (даже на пустых данных): тогда метрики =
None, confidence='low'. Никогда не бросает на отсутствии данных.
"""
# #1959 room-bucket пороги/метки — зеркало sales_series.room_area_bucket_of /
# _SOURCE_B_SQL (Source-B вокабуляр), чтобы what_to_build фильтровал без перевода.
# Локальный (lazy) импорт: модульный импорт sales_series тянет forecasting/__init__
# → demand_supply_forecast → future_supply → market_metrics (циклический импорт).
from app.services.forecasting.sales_series import (
_LARGE_AREA_THRESHOLD_M2,
ROOM_AREA_BUCKET_1K,
ROOM_AREA_BUCKET_2K,
ROOM_AREA_BUCKET_3K,
ROOM_AREA_BUCKET_LARGE,
ROOM_AREA_BUCKET_STUDIO,
ROOM_AREA_BUCKET_UNKNOWN,
)
obj_id_list: list[int] = [int(x) for x in obj_ids] if obj_ids else []
has_obj_ids = bool(obj_id_list)
# Резолвим district (админ-имя ЕКБ) → набор informal микро-районов, по которым
# реально фильтруется objective_lots. None → EKB-wide (без district-фильтра); это
# лечит баг «админ-имя → 0 строк → пустой прогноз» (см. district_resolver).
micros = resolve_objective_districts(db, district)
has_district = micros is not None
params: dict[str, Any] = {
"premise_kind": premise_kind,
# has_district=False → district-ветка фильтра отключена (EKB-wide).
"has_district": has_district,
# ANY(NULL::text[]) валиден; пустой список когда фильтра нет (has_district=False).
"districts": micros if micros is not None else [],
"has_obj_ids": has_obj_ids,
# ANY(NULL::bigint[]) валиден; передаём пустой список когда фильтра нет.
"obj_ids": obj_id_list,
"overstock_interval": f"{_OVERSTOCK_MONTHS_THRESHOLD} months",
# #1959 сегмент-фильтры (None → ось не сужается).
"cls": obj_class,
"room_bucket": room_bucket,
# #1959 room-bucket пороги/метки — зеркало sales_series.room_area_bucket_of
# / _SOURCE_B_SQL (Source-B вокабуляр), чтобы what_to_build фильтровал без
# перевода. Меняешь bucketing — правь в ОБОИХ местах (тут + sales_series).
"large_area": _LARGE_AREA_THRESHOLD_M2,
"b_studio": ROOM_AREA_BUCKET_STUDIO,
"b_1k": ROOM_AREA_BUCKET_1K,
"b_2k": ROOM_AREA_BUCKET_2K,
"b_3k": ROOM_AREA_BUCKET_3K,
"b_large": ROOM_AREA_BUCKET_LARGE,
"b_unknown": ROOM_AREA_BUCKET_UNKNOWN,
}
# ── Текущий сток ──────────────────────────────────────────────────────────
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)
# #1593: per-bucket velocity — ед./мес по каждой комнатности. Ключи зеркалят
# _room_bucket() ("студия","1","2","3","4","5+"). При has_sample=False нет
# смысла делить 0 лотов → None (graceful, зеркало unit_velocity поведения).
# window_months > 0 защищает от ZeroDivisionError (зеркало _monthly_rate()).
vel_by_room: dict[str, float] | None = (
{bkt: float(cnt) / float(window_months) for bkt, cnt in sold_by_room.items()}
if has_sample and sold_by_room and window_months > 0
else None
)
# ── 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 micros=%s class=%s room=%s obj_ids=%d n_lots=%d "
"n_sold=%d n_available=%d obj_count=%d units_sold_window=%d confidence=%s",
district,
micros,
obj_class,
room_bucket,
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,
velocity_by_room=vel_by_room,
)
def _query_stock(db: Session, params: Mapping[str, Any]) -> dict[str, int]:
"""Текущий сток. На ошибке/пустых данных → все счётчики 0 (graceful)."""
try:
# SAVEPOINT — db shared с caller (compute_market_metrics в цепочке §22-форсайта
# / analyze). Голый db.rollback() orphan'ит outer SessionTransaction (см.
# velocity.py:170) — begin_nested откатывает только эту savepoint, outer tx
# остаётся usable для последующих db.execute (_query_sales_window/_price_sensitivity)
# на этой же сессии.
with db.begin_nested():
row = db.execute(_STOCK_SQL, dict(params)).mappings().first()
except Exception:
logger.exception(
"market_metrics: stock query failed (districts=%s)", params.get("districts")
)
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]]:
"""Продажи за окно по contract_date. Возвращает (units, area_m2, {bucket: units}).
GROUP BY ROLLUP с GROUPING(rooms_int) AS is_total (#1214):
• is_total=1 → grand-total (units/area за все комнаты);
• is_total=0 и rooms_int IS NULL → разбивка для лотов БЕЗ rooms — кладём
в by_room['unknown'] (а не путаем с total);
• is_total=0 и rooms_int не NULL → разбивка по конкретной комнатности.
by_room аккумулирует через += чтобы при будущих доп.NULL-вариантах не
затирать прежние счётчики. На ошибке/пусто → (0, 0.0, {}).
"""
try:
# SAVEPOINT — db shared с caller (см. _query_stock выше). CRITICAL: без него
# сбой здесь оставляет транзакцию aborted, и следующий db.execute в этой же
# цепочке (_price_sensitivity → _elasticity_coef) падает "current transaction
# is aborted" вместо честного graceful-fallback.
with db.begin_nested():
rows = db.execute(_SALES_WINDOW_SQL, dict(params)).mappings().all()
except Exception:
logger.exception(
"market_metrics: sales-window query failed (districts=%s)", params.get("districts")
)
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 int(r["is_total"]) == 1:
# ROLLUP grand-total — единственная строка с GROUPING=1.
units_total = cnt
area_total = area
elif r["rooms_int"] is None:
# Лоты с rooms_int IS NULL (ETL пишет NULL для «неопределённого типа»)
# — отдельный бакет, не путаем с total.
by_room["unknown"] = by_room.get("unknown", 0) + cnt
else:
bucket = _room_bucket(int(r["rooms_int"]))
by_room[bucket] = by_room.get(bucket, 0) + 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).
#1211 fix: District-вход — admin-имя ЕКБ (из /analyze), но
``objective_corpus_room_month.district`` — МИКРО-вокабуляр (как и
``objective_lots.district``). Сырое admin-имя в фильтр давало 0 точек
регрессии → всегда FALLBACK_ELASTICITY (silent-correctness). Резолвим
admin → набор микро через ``resolve_objective_districts`` и передаём
списком в ``_elasticity_coef(..., districts=...)``. Резолвер None
(нет чистых алиасов / 'не определён') → пустой список → EKB-wide
регрессия (лучше, чем 0 точек: получаем агрегированный сигнал).
"""
if not district:
return None, None
micros = resolve_objective_districts(db, district)
elasticity_window = max(window_months, 24)
try:
# SAVEPOINT — db shared с caller (compute_market_metrics в цепочке §22-форсайта
# / analyze). _elasticity_coef делает свой db.execute внутри без try/except —
# оборачиваем ВЕСЬ вызов, чтобы сбой откатывал только эту savepoint (не outer
# SessionTransaction, см. velocity.py:170), и db оставалась usable дальше.
with db.begin_nested():
elast = _elasticity_coef(
db,
region_code=_EKB_REGION_CODE,
district_name=district,
target_class=None,
elasticity_window_months=elasticity_window,
districts=micros if micros is not None else [],
)
except Exception:
logger.exception(
"market_metrics: elasticity reuse failed (district=%s micros=%s)",
district,
micros,
)
return None, None
return float(elast["elasticity"]), str(elast["source"])
# ──────────────────────────────────────────────────────────────────────────────
# Offer-price trend (#2178) — динамика цен ПРЕДЛОЖЕНИЯ из objective_lots_history.
#
# Мотивация: §4.3 «Тренд рынка» строится на rosreestr_deals, чей поток встал
# (max дата 2026-01-01) → recent-окно пусто → status='source_stale' и пустая
# карточка. objective_lots_history даёт weekly-снапшоты цен предложения активных
# лотов — это ВТОРОЙ источник, живой (снимки до 2026-06-30), которым мы заполняем
# карточку, когда сделки устарели.
#
# СВЯЗКА С ЖК РАДИУСА — тот же путь, что best_layouts/competitors (маппинг, НЕ
# ненадёжный complex_id-FK, который джойнит лишь ~28% и мультигородской, см.
# 131_district_vocab_map.sql):
# radius (ST_DWithin по domrf_kn_objects)
# → domrf_obj_id
# → objective_complex_mapping.domrf_obj_id → objective_complex_name
# → objective_lots.project_name → objective_lot_id-набор
# → objective_lots_history (per-snapshot цены этих лотов).
# objective_lots_history НЕ содержит project_name — поэтому набор objective_lot_id
# фиксируем из objective_lots (текущее состояние), а историю берём по нему.
#
# ДЕДУП задвоенного снапшота (КРИТИЧНО, #2178): 2026-06-22 был просвипан дважды
# (~621k строк = два свипа в один день). На (objective_lot_id, snapshot_date) в
# истории стоит UNIQUE-констрейнт → физически дубля быть не должно, НО на проде
# он есть (два ETL-пути до починки триггера). Безусловно дедупим до одной строки
# на (objective_lot_id, snapshot_date): DISTINCT ON (...) ORDER BY fetched_at DESC
# — берём последний свип дня. Медиану считаем УЖЕ по дедуплицированному набору,
# иначе задвоенный день весит ×2 и смещает медиану.
#
# ФИЛЬТР «в продаже» (честный, по реальным status в objective_lots_history —
# "резерв"|"свободна"|"продан"|"бронь", см. 68_schema_objective.sql): лот активен
# = is_sold IS NOT TRUE И LOWER(status) не равен 'продан'. Бронь/резерв считаем
# «в продаже» (цена ещё котируется рынку). contract_date-признак здесь не
# используем: в истории он часто NULL у активных лотов, а нам важна котировка.
#
# ФИЛЬТР ВМЕНЯЕМОСТИ ЦЕНЫ: price_per_m2_rub BETWEEN 10000 AND 1000000 — отсекает
# ETL-мусор (0 / гросс-суммы, ошибочно попавшие в per-m2). Границы шире, чем у
# сделок-тренда (30k500k), т.к. предложение включает премиум/апартаменты.
_OFFER_TREND_SQL = text(
"""
WITH radius_obj AS (
SELECT DISTINCT ON (o.obj_id) o.obj_id
FROM domrf_kn_objects o
WHERE o.latitude IS NOT NULL AND o.longitude IS NOT NULL
AND ST_DWithin(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_SetSRID(
ST_MakePoint(
CAST(:center_lon AS float), CAST(:center_lat AS float)
),
4326
)::geography,
CAST(:radius_m AS float)
)
ORDER BY o.obj_id, o.snapshot_date DESC NULLS LAST
),
radius_projects AS (
SELECT DISTINCT cm.objective_complex_name AS project_name
FROM objective_complex_mapping cm
WHERE cm.domrf_obj_id IN (SELECT obj_id FROM radius_obj)
),
radius_lots AS (
SELECT DISTINCT ol.objective_lot_id
FROM objective_lots ol
JOIN radius_projects rp ON rp.project_name = ol.project_name
WHERE ol.premise_kind = CAST(:premise_kind AS text)
),
hist AS (
-- Дедуп задвоенного снапшота: одна строка на (лот, день), последний свип.
SELECT DISTINCT ON (h.objective_lot_id, h.snapshot_date)
h.snapshot_date,
h.price_per_m2_rub
FROM objective_lots_history h
JOIN radius_lots rl ON rl.objective_lot_id = h.objective_lot_id
WHERE h.price_per_m2_rub BETWEEN CAST(:price_min AS numeric)
AND CAST(:price_max AS numeric)
AND (h.is_sold IS NOT TRUE)
AND LOWER(COALESCE(h.status, '')) <> 'продан'
ORDER BY h.objective_lot_id, h.snapshot_date, h.fetched_at DESC
)
SELECT
snapshot_date AS snapshot_date,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price_per_m2_rub) AS median_price_m2,
COUNT(*) AS lots_count,
(SELECT COUNT(*) FROM radius_projects) AS complexes_covered
FROM hist
GROUP BY snapshot_date
ORDER BY snapshot_date
"""
)
# Границы вменяемости цены предложения (руб/м²). Шире, чем сделки-тренд (30k500k):
# предложение включает премиум/апартаменты; отсекаем только явный ETL-мусор.
# ⚠ Значения зеркалированы ЛИТЕРАЛАМИ в partial-предикате покрывающего индекса
# data/sql/186_objective_lots_history_offer_trend_covering_idx.sql — при изменении
# констант индекс надо пересоздать, иначе offer-trend молча теряет Index-Only Scan.
_OFFER_PRICE_MIN: int = 10000
_OFFER_PRICE_MAX: int = 1000000
# Радиус ЖК-подбора — зеркало market_trend-хендлера parcels.py (3 км от центроида).
_OFFER_TREND_RADIUS_M: int = 3000
@dataclass(frozen=True)
class OfferPriceTrend:
"""Динамика медианной цены предложения (руб/м²) по снапшотам Объектива (#2178).
Источник — objective_lots_history (weekly-снапшоты активных лотов ЖК в радиусе).
Всегда конструируется через ``from_points``; пустой набор точек → None-поля,
``points=[]`` (никогда не бросает на отсутствии данных).
"""
points: list[dict[str, Any]] # [{date: 'YYYY-MM-DD', median_price_m2, lots_count}]
delta_pct: float | None # (последняя медиана первая) / первая × 100
lots_latest: int | None # lots_count последнего снапшота
complexes_covered: int # сколько ЖК (project_name) попало в радиус-выборку
def as_dict(self) -> dict[str, Any]:
return {
"points": self.points,
"delta_pct": _round_or_none(self.delta_pct, 1),
"lots_latest": self.lots_latest,
"complexes_covered": self.complexes_covered,
}
@property
def has_data(self) -> bool:
"""True если есть ≥1 снапшот с медианой — тогда карточку можно показать."""
return len(self.points) > 0
def compute_offer_price_trend(
db: Session,
*,
center_lat: float,
center_lon: float,
radius_m: int = _OFFER_TREND_RADIUS_M,
premise_kind: str = "квартира",
) -> OfferPriceTrend:
"""Медианная цена предложения по снапшотам Объектива для ЖК в радиусе (#2178).
ЖК определяются тем же путём, что market_trend-хендлер / best_layouts: ЖК в
радиусе ``radius_m`` от (``center_lat``, ``center_lon``) по domrf_kn_objects →
objective_complex_mapping → objective_lots. По их лотам считается per-snapshot
медиана price_per_m2_rub активных лотов (дедуп задвоенного дня, фильтр
вменяемости цены).
Возвращает OfferPriceTrend ВСЕГДА (даже на пустых данных: points=[], поля None,
complexes_covered=0). Никогда не бросает на отсутствии данных — на ошибке SQL
логирует и возвращает пустой результат (graceful, зеркало _query_stock).
"""
params: dict[str, Any] = {
"center_lat": float(center_lat),
"center_lon": float(center_lon),
"radius_m": float(radius_m),
"premise_kind": premise_kind,
"price_min": _OFFER_PRICE_MIN,
"price_max": _OFFER_PRICE_MAX,
}
try:
# SAVEPOINT — db shared с caller (POST .../analyze / §22-отчёт, Depends(get_db)).
# 8с-гвард (parcels.py) может отменить этот запрос (QueryCanceled) → без savepoint
# транзакция остаётся aborted и следующий db.execute падает "current transaction
# is aborted". begin_nested откатывает только этот savepoint, db остаётся usable.
with db.begin_nested():
rows = db.execute(_OFFER_TREND_SQL, params).mappings().all()
except Exception as exc:
# Ожидаемый путь: 8с-гвард (parcels.py) отменяет запрос на холодном кэше
# (~2/сутки, профилирование 2026-07-04) — блок аддитивный, analyze успешен.
# WARNING, не exception: ERROR-уровень поднимал ложные GlitchTip-события.
logger.warning(
"market_metrics: offer-trend query failed (lat=%s lon=%s radius=%s): %s",
center_lat,
center_lon,
radius_m,
exc,
)
rows = []
points: list[dict[str, Any]] = []
complexes_covered = 0
for r in rows:
# complexes_covered — константа по всем строкам (scalar-subquery), берём
# из первой встреченной строки. При пустом hist rows=[] → остаётся 0.
complexes_covered = int(r["complexes_covered"] or 0)
median = r["median_price_m2"]
if median is None:
continue
snap = r["snapshot_date"]
# snapshot_date — DATE; нормализуем (isoformat) вне зависимости от того,
# date или datetime отдал драйвер.
date_str = snap.isoformat() if hasattr(snap, "isoformat") else str(snap)
points.append(
{
"date": date_str,
"median_price_m2": round(float(median)),
"lots_count": int(r["lots_count"] or 0),
}
)
delta_pct: float | None = None
lots_latest: int | None = None
if points:
first_median = points[0]["median_price_m2"]
last_median = points[-1]["median_price_m2"]
lots_latest = points[-1]["lots_count"]
if first_median and first_median > 0:
delta_pct = (last_median - first_median) / first_median * 100.0
logger.info(
"offer_price_trend: lat=%.5f lon=%.5f radius=%d snapshots=%d "
"lots_latest=%s delta_pct=%s",
center_lat,
center_lon,
radius_m,
len(points),
lots_latest,
_round_or_none(delta_pct, 1),
)
return OfferPriceTrend(
points=points,
delta_pct=delta_pct,
lots_latest=lots_latest,
complexes_covered=complexes_covered,
)