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
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"
961 lines
52 KiB
Python
961 lines
52 KiB
Python
"""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). Границы шире, чем у
|
||
# сделок-тренда (30k–500k), т.к. предложение включает премиум/апартаменты.
|
||
_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
|
||
"""
|
||
)
|
||
|
||
# Границы вменяемости цены предложения (руб/м²). Шире, чем сделки-тренд (30k–500k):
|
||
# предложение включает премиум/апартаменты; отсекаем только явный 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,
|
||
)
|