feat(tradein): второй % «за 45 дней» + zhkh-знаменатель slot (146) #2071
5 changed files with 324 additions and 45 deletions
|
|
@ -51,11 +51,14 @@ async def list_sale_share(
|
|||
house_type: Annotated[str | None, Query(max_length=40)] = None,
|
||||
sort: Annotated[
|
||||
str,
|
||||
Query(pattern="^(share_desc|active_desc|exposure_desc|price_asc|price_desc)$"),
|
||||
Query(
|
||||
pattern="^(share_desc|share_45d_desc|active_desc|count_45d_desc"
|
||||
"|exposure_desc|price_asc|price_desc)$"
|
||||
),
|
||||
] = "share_desc",
|
||||
limit: Annotated[int, Query(ge=1, le=1000)] = 200,
|
||||
) -> list[BuildingSaleShare]:
|
||||
"""Дома вторички с долей квартир в продаже >= min_pct (+ фильтры)."""
|
||||
"""Дома вторички, где ЛЮБОЙ из двух % (now / 45д) >= min_pct (+ фильтры)."""
|
||||
sql, args = build_sale_share_query(
|
||||
min_pct=min_pct,
|
||||
max_pct=max_pct,
|
||||
|
|
@ -87,18 +90,25 @@ async def list_building_listings(
|
|||
async def sale_share_summary(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> SaleShareSummary:
|
||||
"""Сводка распределения sale_share_pct: coverage, max, p95, гистограмма."""
|
||||
"""Сводка распределения обоих % (now + 45д): coverage, max, p95, две гистограммы."""
|
||||
scalars = db.execute(text(build_summary_scalars_query())).mappings().one()
|
||||
|
||||
hist_rows = db.execute(text(build_summary_histogram_query())).mappings().all()
|
||||
counts = {str(r["bucket"]): int(r["count"]) for r in hist_rows}
|
||||
histogram = [HistogramBucket(bucket=b, count=counts.get(b, 0)) for b in HISTOGRAM_BUCKETS]
|
||||
def _histogram(column: str) -> list[HistogramBucket]:
|
||||
rows = db.execute(text(build_summary_histogram_query(column))).mappings().all()
|
||||
counts = {str(r["bucket"]): int(r["count"]) for r in rows}
|
||||
return [HistogramBucket(bucket=b, count=counts.get(b, 0)) for b in HISTOGRAM_BUCKETS]
|
||||
|
||||
def _opt_float(key: str) -> float | None:
|
||||
return float(scalars[key]) if scalars[key] is not None else None
|
||||
|
||||
return SaleShareSummary(
|
||||
total_secondary_buildings=int(scalars["total_secondary_buildings"]),
|
||||
buildings_with_denominator=int(scalars["buildings_with_denominator"]),
|
||||
coverage_pct=float(scalars["coverage_pct"]),
|
||||
max_pct=(float(scalars["max_pct"]) if scalars["max_pct"] is not None else None),
|
||||
p95_pct=(float(scalars["p95_pct"]) if scalars["p95_pct"] is not None else None),
|
||||
histogram=histogram,
|
||||
max_pct=_opt_float("max_pct"),
|
||||
p95_pct=_opt_float("p95_pct"),
|
||||
max_pct_45d=_opt_float("max_pct_45d"),
|
||||
p95_pct_45d=_opt_float("p95_pct_45d"),
|
||||
histogram=_histogram("sale_share_pct"),
|
||||
histogram_45d=_histogram("sale_share_pct_45d"),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -22,11 +22,16 @@ class BuildingSaleShare(BaseModel):
|
|||
lon: float | None = None
|
||||
|
||||
sale_share_pct: float | None = None
|
||||
# implausible-матч (active_secondary > знаменателя) — скорее всего коллизия
|
||||
# адреса при ГАР-матче; не прячем, а маркируем для UI.
|
||||
# 2-й процент: distinct vtorichka за последние 45д (активные ИЛИ недавно
|
||||
# закрытые) / знаменатель. Дом проходит фильтр по ЛЮБОМУ из двух процентов.
|
||||
sale_share_pct_45d: float | None = None
|
||||
# implausible-матч (числитель > знаменателя по любому из %) — скорее всего
|
||||
# коллизия адреса при ГАР-матче; не прячем, а маркируем для UI.
|
||||
over_100: bool = False
|
||||
|
||||
active_secondary: int | None = None
|
||||
# distinct vtorichka-листинги (активные ИЛИ закрытые) за окно 45д.
|
||||
listings_45d: int | None = None
|
||||
flat_count_effective: int | None = None
|
||||
gar_match_method: str | None = None
|
||||
|
||||
|
|
@ -68,11 +73,18 @@ class HistogramBucket(BaseModel):
|
|||
|
||||
|
||||
class SaleShareSummary(BaseModel):
|
||||
"""Сводка для слайдера % + баннера покрытия (/buildings/sale-share/summary)."""
|
||||
"""Сводка для слайдера % + баннера покрытия (/buildings/sale-share/summary).
|
||||
|
||||
buildings_with_denominator/coverage считают дома с непустым ЛЮБЫМ из двух %.
|
||||
max/p95 и histogram — отдельно для каждого процента (now + 45д).
|
||||
"""
|
||||
|
||||
total_secondary_buildings: int
|
||||
buildings_with_denominator: int
|
||||
coverage_pct: float
|
||||
max_pct: float | None = None
|
||||
p95_pct: float | None = None
|
||||
max_pct_45d: float | None = None
|
||||
p95_pct_45d: float | None = None
|
||||
histogram: list[HistogramBucket]
|
||||
histogram_45d: list[HistogramBucket]
|
||||
|
|
|
|||
|
|
@ -1,25 +1,34 @@
|
|||
"""SQL-билдеры страницы «доля квартир дома в продаже» (мигр. 143).
|
||||
"""SQL-билдеры страницы «доля квартир дома в продаже» (мигр. 143; окно 45д мигр. 146).
|
||||
|
||||
Читаем готовый view v_building_sale_share — логику доли НЕ пересчитываем.
|
||||
Два процента на дом:
|
||||
- sale_share_pct — active_secondary / flat_count_effective (только активные);
|
||||
- sale_share_pct_45d — distinct vtorichka за последние 45д (активные ИЛИ недавно
|
||||
закрытые) / flat_count_effective.
|
||||
Дом проходит фильтр, если ЛЮБОЙ из двух процентов в диапазоне [min_pct, max_pct].
|
||||
|
||||
Все запросы параметризованы (SQLAlchemy text + bind params, CAST(:x AS type) —
|
||||
psycopg v3). Сортировка — строго по whitelist-колонкам, user-ввод в SQL не
|
||||
интерполируется.
|
||||
psycopg v3). Сортировка/колонка гистограммы — строго по whitelist, user-ввод в SQL
|
||||
не интерполируется.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# share_desc / active_desc / exposure_desc / price_asc / price_desc → безопасный ORDER BY.
|
||||
# house_id как стабильный tiebreaker для детерминированной пагинации.
|
||||
# share_desc / share_45d_desc / active_desc / count_45d_desc / exposure_desc /
|
||||
# price_asc / price_desc → безопасный ORDER BY. house_id как стабильный tiebreaker
|
||||
# для детерминированной пагинации.
|
||||
_SORT_SQL: dict[str, str] = {
|
||||
"share_desc": "sale_share_pct DESC NULLS LAST, house_id",
|
||||
"share_45d_desc": "sale_share_pct_45d DESC NULLS LAST, house_id",
|
||||
"active_desc": "active_secondary DESC NULLS LAST, house_id",
|
||||
"count_45d_desc": "listings_45d DESC NULLS LAST, house_id",
|
||||
"exposure_desc": "avg_days_on_market DESC NULLS LAST, house_id",
|
||||
"price_asc": "median_price_rub ASC NULLS LAST, house_id",
|
||||
"price_desc": "median_price_rub DESC NULLS LAST, house_id",
|
||||
}
|
||||
ALLOWED_SORTS: frozenset[str] = frozenset(_SORT_SQL)
|
||||
|
||||
# Гистограмма распределения sale_share_pct — фиксированный порядок корзин.
|
||||
# Гистограмма распределения процента — фиксированный порядок корзин.
|
||||
HISTOGRAM_BUCKETS: tuple[str, ...] = (
|
||||
"0-5",
|
||||
"5-10",
|
||||
|
|
@ -30,16 +39,21 @@ HISTOGRAM_BUCKETS: tuple[str, ...] = (
|
|||
"100+",
|
||||
)
|
||||
|
||||
# Колонки view, по которым можно строить гистограмму (whitelist против инъекции).
|
||||
_HISTOGRAM_COLUMNS: frozenset[str] = frozenset({"sale_share_pct", "sale_share_pct_45d"})
|
||||
|
||||
# Колонки view, отдаваемые в список (в порядке SELECT). median_* округляем до
|
||||
# bigint — percentile_cont отдаёт double precision (может быть x.5), а Pydantic
|
||||
# int-поле на дробном float падает.
|
||||
# int-поле на дробном float падает. over_100 — implausible-флаг по любому из %.
|
||||
_LIST_COLUMNS = (
|
||||
"house_id, "
|
||||
"COALESCE(short_address, full_address, address) AS address, "
|
||||
"lat, lon, "
|
||||
"sale_share_pct, "
|
||||
"(sale_share_pct > 100) AS over_100, "
|
||||
"sale_share_pct_45d, "
|
||||
"(sale_share_pct > 100 OR sale_share_pct_45d > 100) AS over_100, "
|
||||
"active_secondary, "
|
||||
"listings_45d, "
|
||||
"flat_count_effective, "
|
||||
"gar_match_method, "
|
||||
"CAST(round(median_price_rub) AS bigint) AS median_price_rub, "
|
||||
|
|
@ -64,18 +78,23 @@ def build_sale_share_query(
|
|||
) -> tuple[str, dict[str, object]]:
|
||||
"""SELECT домов из v_building_sale_share по фильтрам.
|
||||
|
||||
Всегда требует sale_share_pct IS NOT NULL (строки без знаменателя — только
|
||||
для coverage в summary, не в списке).
|
||||
Дом проходит, если ЛЮБОЙ из двух процентов (now / 45д) попадает в диапазон
|
||||
[min_pct, max_pct]. NULL-процент (неправдоподобный знаменатель) не проходит
|
||||
сравнение, поэтому отдельный `IS NOT NULL` не нужен — рядки без обоих % выпадают.
|
||||
"""
|
||||
where: list[str] = ["sale_share_pct IS NOT NULL"]
|
||||
where: list[str] = []
|
||||
args: dict[str, object] = {}
|
||||
|
||||
where.append("sale_share_pct >= CAST(:min_pct AS numeric)")
|
||||
# Каждая ветка требует, чтобы ИМЕННО её % был в диапазоне; building qualifies
|
||||
# on either. Без max_pct сводится к `(pct_now>=min OR pct_45d>=min)`.
|
||||
args["min_pct"] = min_pct
|
||||
|
||||
qual_now = "sale_share_pct >= CAST(:min_pct AS numeric)"
|
||||
qual_45d = "sale_share_pct_45d >= CAST(:min_pct AS numeric)"
|
||||
if max_pct is not None:
|
||||
where.append("sale_share_pct <= CAST(:max_pct AS numeric)")
|
||||
args["max_pct"] = max_pct
|
||||
qual_now += " AND sale_share_pct <= CAST(:max_pct AS numeric)"
|
||||
qual_45d += " AND sale_share_pct_45d <= CAST(:max_pct AS numeric)"
|
||||
where.append(f"(({qual_now}) OR ({qual_45d}))")
|
||||
|
||||
if city:
|
||||
# case-insensitive substring по всем адресным полям (город может быть в любом).
|
||||
|
|
@ -131,36 +150,44 @@ def build_listings_query(house_id: int) -> tuple[str, dict[str, object]]:
|
|||
|
||||
|
||||
def build_summary_scalars_query() -> str:
|
||||
"""Скаляры сводки: всего домов, с знаменателем, coverage, max, p95."""
|
||||
"""Скаляры сводки: всего домов, с знаменателем (любой %), coverage, max/p95 обоих %."""
|
||||
return (
|
||||
"SELECT "
|
||||
"count(*) AS total_secondary_buildings, "
|
||||
"count(*) FILTER (WHERE sale_share_pct IS NOT NULL) "
|
||||
"count(*) FILTER (WHERE sale_share_pct IS NOT NULL OR sale_share_pct_45d IS NOT NULL) "
|
||||
"AS buildings_with_denominator, "
|
||||
"COALESCE(round("
|
||||
"100.0 * count(*) FILTER (WHERE sale_share_pct IS NOT NULL) "
|
||||
"100.0 * count(*) FILTER "
|
||||
"(WHERE sale_share_pct IS NOT NULL OR sale_share_pct_45d IS NOT NULL) "
|
||||
"/ NULLIF(count(*), 0), 1), 0) AS coverage_pct, "
|
||||
"max(sale_share_pct) AS max_pct, "
|
||||
"percentile_cont(0.95) WITHIN GROUP (ORDER BY sale_share_pct) AS p95_pct "
|
||||
"percentile_cont(0.95) WITHIN GROUP (ORDER BY sale_share_pct) AS p95_pct, "
|
||||
"max(sale_share_pct_45d) AS max_pct_45d, "
|
||||
"percentile_cont(0.95) WITHIN GROUP (ORDER BY sale_share_pct_45d) AS p95_pct_45d "
|
||||
"FROM v_building_sale_share"
|
||||
)
|
||||
|
||||
|
||||
def build_summary_histogram_query() -> str:
|
||||
"""Гистограмма sale_share_pct по фиксированным корзинам (только non-null)."""
|
||||
def build_summary_histogram_query(column: str = "sale_share_pct") -> str:
|
||||
"""Гистограмма по фиксированным корзинам (только non-null) для одного из двух %.
|
||||
|
||||
column — whitelist {sale_share_pct, sale_share_pct_45d}; чужое → fallback на
|
||||
sale_share_pct (user-ввод в SQL не интерполируется).
|
||||
"""
|
||||
col = column if column in _HISTOGRAM_COLUMNS else "sale_share_pct"
|
||||
return (
|
||||
"SELECT "
|
||||
"CASE "
|
||||
"WHEN sale_share_pct < 5 THEN '0-5' "
|
||||
"WHEN sale_share_pct < 10 THEN '5-10' "
|
||||
"WHEN sale_share_pct < 20 THEN '10-20' "
|
||||
"WHEN sale_share_pct < 30 THEN '20-30' "
|
||||
"WHEN sale_share_pct < 50 THEN '30-50' "
|
||||
"WHEN sale_share_pct <= 100 THEN '50-100' "
|
||||
f"WHEN {col} < 5 THEN '0-5' "
|
||||
f"WHEN {col} < 10 THEN '5-10' "
|
||||
f"WHEN {col} < 20 THEN '10-20' "
|
||||
f"WHEN {col} < 30 THEN '20-30' "
|
||||
f"WHEN {col} < 50 THEN '30-50' "
|
||||
f"WHEN {col} <= 100 THEN '50-100' "
|
||||
"ELSE '100+' "
|
||||
"END AS bucket, "
|
||||
"count(*) AS count "
|
||||
"FROM v_building_sale_share "
|
||||
"WHERE sale_share_pct IS NOT NULL "
|
||||
f"WHERE {col} IS NOT NULL "
|
||||
"GROUP BY bucket"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,132 @@
|
|||
-- 146_sale_share_45d_and_zhkh_denom.sql
|
||||
--
|
||||
-- CONTEXT: страница «доля квартир дома в продаже» получает ВТОРОЙ процент —
|
||||
-- trailing-45-дневное окно — и второй источник знаменателя (ГИС ЖКХ).
|
||||
--
|
||||
-- Было (мигр. 143/145): один метрик sale_share_pct =
|
||||
-- active_vtorichka_listings / flat_count_effective (только АКТИВНЫЕ объявления).
|
||||
-- Стало: добавляем sale_share_pct_45d =
|
||||
-- distinct vtorichka-листинги (активные ИЛИ недавно закрытые), увиденные за
|
||||
-- последние 45 дней / flat_count_effective. Дом попадает в выборку, если ЛЮБОЙ
|
||||
-- из двух процентов проходит порог (фильтрация — на стороне API).
|
||||
--
|
||||
-- Мы храним ~27k неактивных листингов с last_seen_at (история скрейпа ~36 дней),
|
||||
-- поэтому окно 45д сейчас ≈ «всё, что когда-либо видели» — это ожидаемо и нормально
|
||||
-- (по мере накопления истории окно станет действительно скользящим).
|
||||
--
|
||||
-- Второй источник знаменателя: houses.zhkh_flat_count (ГИС ЖКХ) — заполняется
|
||||
-- ОТДЕЛЬНЫМ loader'ом позже; здесь только schema-slot + приоритет в COALESCE
|
||||
-- (после ГАР, перед total_units/flat_count).
|
||||
--
|
||||
-- WHAT:
|
||||
-- 1. houses.zhkh_* — provenance-колонки матча ГИС ЖКХ → houses
|
||||
-- (zhkh_flat_count, zhkh_house_guid, zhkh_matched_at). Partial-индекс на
|
||||
-- zhkh_flat_count WHERE NOT NULL.
|
||||
-- 2. v_building_sale_share — добавляем listings_45d + sale_share_pct_45d,
|
||||
-- zhkh как 2-й приоритет знаменателя, WHERE расширяем на churn-only дома
|
||||
-- (0 активных, но есть листинги за 45д).
|
||||
--
|
||||
-- DEPENDENCIES: 143 (view + houses.gar_*), 144 (canon match populates gar_flat_count),
|
||||
-- 145 (плаузибилити-гейт знаменателя). listings.dedup_hash / listings.last_seen_at —
|
||||
-- существующие колонки (dedup-merge мигр. 135 + scrape-tracking).
|
||||
--
|
||||
-- SAFETY / IDEMPOTENCY: только ADD COLUMN IF NOT EXISTS, CREATE INDEX IF NOT EXISTS,
|
||||
-- CREATE OR REPLACE VIEW. Повторный прогон — no-op. Деплой-раннер гонит файл через
|
||||
-- psql -v ON_ERROR_STOP=on БЕЗ --single-transaction → транзакцию открывает САМ файл
|
||||
-- (BEGIN/COMMIT ниже), как 143/145.
|
||||
--
|
||||
-- NB по нумерации: 143–145 заняты building_sale_share_*; следующий свободный
|
||||
-- sequential = 146 (проверено `ls tradein-mvp/backend/data/sql | grep '^14'`).
|
||||
--
|
||||
-- Deploy order: после 145_building_sale_share_plausible_denom.sql.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────
|
||||
-- 1. houses — provenance-колонки 2-го источника знаменателя (ГИС ЖКХ)
|
||||
-- ─────────────────────────────────────────────────────────────────────
|
||||
ALTER TABLE houses ADD COLUMN IF NOT EXISTS zhkh_flat_count int;
|
||||
ALTER TABLE houses ADD COLUMN IF NOT EXISTS zhkh_house_guid text;
|
||||
ALTER TABLE houses ADD COLUMN IF NOT EXISTS zhkh_matched_at timestamptz;
|
||||
|
||||
-- partial: индексируем только реально заматченные дома (zhkh_flat_count populated).
|
||||
CREATE INDEX IF NOT EXISTS houses_zhkh_flat_count_idx
|
||||
ON houses (zhkh_flat_count)
|
||||
WHERE zhkh_flat_count IS NOT NULL;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────
|
||||
-- 2. v_building_sale_share — два процента (now + 45д) + zhkh-знаменатель
|
||||
-- flat_count_effective = COALESCE(ГАР, ЖКХ, total_units, flat_count).
|
||||
-- sale_share_pct = active_secondary / denom (как было).
|
||||
-- sale_share_pct_45d = distinct vtorichka за 45д / denom (новое).
|
||||
-- Оба процента — под одним плаузибилити-гейтом (denom>=GREATEST(floors,8) AND
|
||||
-- числитель<=denom), иначе NULL. WHERE расширен на churn-only дома.
|
||||
-- ─────────────────────────────────────────────────────────────────────
|
||||
CREATE OR REPLACE VIEW v_building_sale_share AS
|
||||
WITH listing_agg AS (
|
||||
SELECT l.house_id_fk AS house_id,
|
||||
count(*) FILTER (WHERE l.is_active AND l.listing_segment = 'vtorichka'::text) AS active_secondary,
|
||||
count(DISTINCT l.dedup_hash) FILTER (
|
||||
WHERE l.listing_segment = 'vtorichka'::text
|
||||
AND l.last_seen_at >= (now() - interval '45 days')
|
||||
) AS listings_45d,
|
||||
percentile_cont(0.5::double precision) WITHIN GROUP (ORDER BY (l.price_rub::double precision))
|
||||
FILTER (WHERE l.is_active AND l.listing_segment = 'vtorichka'::text AND l.price_rub IS NOT NULL) AS median_price_rub,
|
||||
percentile_cont(0.5::double precision) WITHIN GROUP (ORDER BY (l.price_per_m2::double precision))
|
||||
FILTER (WHERE l.is_active AND l.listing_segment = 'vtorichka'::text AND l.price_per_m2 IS NOT NULL) AS median_price_per_m2,
|
||||
avg(l.days_on_market)
|
||||
FILTER (WHERE l.is_active AND l.listing_segment = 'vtorichka'::text AND l.days_on_market IS NOT NULL) AS avg_days_on_market
|
||||
FROM listings l
|
||||
WHERE l.house_id_fk IS NOT NULL
|
||||
GROUP BY l.house_id_fk
|
||||
)
|
||||
SELECT h.id AS house_id,
|
||||
h.short_address,
|
||||
h.full_address,
|
||||
h.address,
|
||||
h.lat,
|
||||
h.lon,
|
||||
h.year_built,
|
||||
h.house_type,
|
||||
h.total_floors,
|
||||
h.series_name,
|
||||
h.is_emergency,
|
||||
COALESCE(h.gar_flat_count, h.zhkh_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0)) AS flat_count_effective,
|
||||
h.gar_flat_count,
|
||||
h.gar_match_method,
|
||||
la.active_secondary,
|
||||
la.median_price_rub,
|
||||
la.median_price_per_m2,
|
||||
la.avg_days_on_market,
|
||||
CASE
|
||||
WHEN COALESCE(h.gar_flat_count, h.zhkh_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0))
|
||||
>= GREATEST(COALESCE(h.total_floors, 0), 8)
|
||||
AND la.active_secondary <= COALESCE(h.gar_flat_count, h.zhkh_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0))
|
||||
THEN round(100.0 * la.active_secondary::numeric
|
||||
/ COALESCE(h.gar_flat_count, h.zhkh_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0))::numeric, 1)
|
||||
ELSE NULL::numeric
|
||||
END AS sale_share_pct,
|
||||
la.listings_45d,
|
||||
CASE
|
||||
WHEN COALESCE(h.gar_flat_count, h.zhkh_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0))
|
||||
>= GREATEST(COALESCE(h.total_floors, 0), 8)
|
||||
AND la.listings_45d <= COALESCE(h.gar_flat_count, h.zhkh_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0))
|
||||
THEN round(100.0 * la.listings_45d::numeric
|
||||
/ COALESCE(h.gar_flat_count, h.zhkh_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0))::numeric, 1)
|
||||
ELSE NULL::numeric
|
||||
END AS sale_share_pct_45d
|
||||
FROM houses h
|
||||
JOIN listing_agg la ON la.house_id = h.id
|
||||
WHERE h.geom IS NOT NULL AND (la.active_secondary > 0 OR la.listings_45d > 0);
|
||||
|
||||
COMMENT ON VIEW v_building_sale_share IS
|
||||
'Per-building rollup вторички для «доли квартир дома в продаже» (мигр. 143; знаменатель — '
|
||||
'ГАР canon-match мигр. 144; 2-й источник ЖКХ + окно 45д мигр. 146). flat_count_effective = '
|
||||
'COALESCE(gar_flat_count, zhkh_flat_count, NULLIF(total_units,0), NULLIF(flat_count,0)). '
|
||||
'sale_share_pct = active_secondary/denom; sale_share_pct_45d = count(DISTINCT dedup_hash) '
|
||||
'vtorichka за last_seen_at>=now()-45d / denom (активные ИЛИ недавно закрытые). Оба под '
|
||||
'плаузибилити-гейтом (denom>=GREATEST(total_floors,8) AND числитель<=denom; мигр. 145), '
|
||||
'иначе NULL. Фильтр: geom NOT NULL AND (active_secondary>0 OR listings_45d>0) — churn-only '
|
||||
'дома (0 активных, но есть листинги за 45д) тоже видны.';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -34,9 +34,14 @@ from app.services.buildings_query import (
|
|||
def test_sale_share_minimal_defaults():
|
||||
sql, args = build_sale_share_query()
|
||||
assert "FROM v_building_sale_share" in sql
|
||||
assert "sale_share_pct IS NOT NULL" in sql
|
||||
# building qualifies on EITHER % — OR-фильтр, без жёсткого IS NOT NULL.
|
||||
assert "sale_share_pct >= CAST(:min_pct AS numeric)" in sql
|
||||
assert "(sale_share_pct > 100) AS over_100" in sql
|
||||
assert "sale_share_pct_45d >= CAST(:min_pct AS numeric)" in sql
|
||||
assert "OR (sale_share_pct_45d >= CAST(:min_pct AS numeric))" in sql
|
||||
# оба % выставлены в SELECT; over_100 — по любому из них.
|
||||
assert "sale_share_pct_45d, " in sql
|
||||
assert "listings_45d, " in sql
|
||||
assert "(sale_share_pct > 100 OR sale_share_pct_45d > 100) AS over_100" in sql
|
||||
assert "COALESCE(short_address, full_address, address) AS address" in sql
|
||||
assert args["min_pct"] == 5.0
|
||||
assert args["limit"] == 200
|
||||
|
|
@ -44,6 +49,24 @@ def test_sale_share_minimal_defaults():
|
|||
assert "ORDER BY sale_share_pct DESC NULLS LAST" in sql
|
||||
|
||||
|
||||
def test_sale_share_or_filter_includes_churn_only():
|
||||
"""45d-only дом (0 активных, но sale_share_pct_45d>=min) проходит через OR-ветку."""
|
||||
sql, _ = build_sale_share_query(min_pct=10)
|
||||
# обе ветки порога присутствуют, объединены через OR в одной скобке
|
||||
assert "((sale_share_pct >= CAST(:min_pct AS numeric))" in sql
|
||||
assert "(sale_share_pct_45d >= CAST(:min_pct AS numeric)))" in sql
|
||||
# жёсткого `sale_share_pct IS NOT NULL` больше нет — он отрезал бы 45d-only дома
|
||||
assert "sale_share_pct IS NOT NULL" not in sql
|
||||
|
||||
|
||||
def test_sale_share_max_pct_bounds_both_metrics():
|
||||
"""max_pct ограничивает КАЖДУЮ ветку отдельно (per-metric [min,max])."""
|
||||
sql, args = build_sale_share_query(min_pct=5, max_pct=40)
|
||||
assert "sale_share_pct <= CAST(:max_pct AS numeric)" in sql
|
||||
assert "sale_share_pct_45d <= CAST(:max_pct AS numeric)" in sql
|
||||
assert args["max_pct"] == 40
|
||||
|
||||
|
||||
def test_sale_share_all_filters():
|
||||
sql, args = build_sale_share_query(
|
||||
min_pct=10,
|
||||
|
|
@ -113,9 +136,36 @@ def test_listings_query_shape():
|
|||
def test_summary_scalars_query_shape():
|
||||
sql = build_summary_scalars_query()
|
||||
assert "total_secondary_buildings" in sql
|
||||
assert "FILTER (WHERE sale_share_pct IS NOT NULL)" in sql
|
||||
# знаменатель/coverage — по ЛЮБОМУ из двух %
|
||||
assert "FILTER (WHERE sale_share_pct IS NOT NULL OR sale_share_pct_45d IS NOT NULL)" in sql
|
||||
assert "NULLIF(count(*), 0)" in sql
|
||||
assert "percentile_cont(0.95)" in sql
|
||||
# max/p95 для обоих процентов
|
||||
assert "max(sale_share_pct_45d) AS max_pct_45d" in sql
|
||||
assert "AS p95_pct_45d" in sql
|
||||
|
||||
|
||||
def test_summary_histogram_column_whitelisted():
|
||||
"""build_summary_histogram_query: только whitelist-колонка, чужое → fallback."""
|
||||
sql_now = build_summary_histogram_query("sale_share_pct")
|
||||
assert "WHERE sale_share_pct IS NOT NULL" in sql_now
|
||||
sql_45d = build_summary_histogram_query("sale_share_pct_45d")
|
||||
assert "WHEN sale_share_pct_45d < 5 THEN '0-5'" in sql_45d
|
||||
assert "WHERE sale_share_pct_45d IS NOT NULL" in sql_45d
|
||||
# инъекция в имя колонки не проходит — fallback на sale_share_pct
|
||||
sql_bad = build_summary_histogram_query("sale_share_pct; DROP TABLE houses")
|
||||
assert "DROP TABLE" not in sql_bad
|
||||
assert "WHERE sale_share_pct IS NOT NULL" in sql_bad
|
||||
|
||||
|
||||
def test_new_sorts_whitelisted():
|
||||
"""share_45d_desc / count_45d_desc добавлены в whitelist и дают корректный ORDER BY."""
|
||||
assert "share_45d_desc" in ALLOWED_SORTS
|
||||
assert "count_45d_desc" in ALLOWED_SORTS
|
||||
sql_share_45d, _ = build_sale_share_query(sort="share_45d_desc")
|
||||
assert "ORDER BY sale_share_pct_45d DESC NULLS LAST" in sql_share_45d
|
||||
sql_count_45d, _ = build_sale_share_query(sort="count_45d_desc")
|
||||
assert "ORDER BY listings_45d DESC NULLS LAST" in sql_count_45d
|
||||
|
||||
|
||||
def test_summary_histogram_buckets_cover_spec():
|
||||
|
|
@ -169,8 +219,10 @@ def test_get_sale_share_list_200(buildings_app: FastAPI):
|
|||
"lat": 56.8,
|
||||
"lon": 60.6,
|
||||
"sale_share_pct": 12.5,
|
||||
"sale_share_pct_45d": 16.7,
|
||||
"over_100": False,
|
||||
"active_secondary": 3,
|
||||
"listings_45d": 4,
|
||||
"flat_count_effective": 24,
|
||||
"gar_match_method": None,
|
||||
"median_price_rub": 5_000_000,
|
||||
|
|
@ -190,6 +242,41 @@ def test_get_sale_share_list_200(buildings_app: FastAPI):
|
|||
assert body[0]["house_id"] == 7
|
||||
assert body[0]["over_100"] is False
|
||||
assert body[0]["sale_share_pct"] == 12.5
|
||||
assert body[0]["sale_share_pct_45d"] == 16.7
|
||||
assert body[0]["listings_45d"] == 4
|
||||
|
||||
|
||||
def test_get_sale_share_churn_only_row(buildings_app: FastAPI):
|
||||
"""Дом с 0 активных, но 45d-листингами проходит OR-фильтр и несёт оба поля."""
|
||||
row = {
|
||||
"house_id": 99,
|
||||
"address": "Удельная, 1/1",
|
||||
"lat": 56.9,
|
||||
"lon": 60.7,
|
||||
"sale_share_pct": 0.0,
|
||||
"sale_share_pct_45d": 22.7,
|
||||
"over_100": False,
|
||||
"active_secondary": 0,
|
||||
"listings_45d": 5,
|
||||
"flat_count_effective": 22,
|
||||
"gar_match_method": "canon",
|
||||
"median_price_rub": None,
|
||||
"median_price_per_m2": None,
|
||||
"avg_days_on_market": None,
|
||||
"year_built": 1975,
|
||||
"house_type": "brick",
|
||||
"total_floors": 9,
|
||||
"series_name": None,
|
||||
"is_emergency": False,
|
||||
}
|
||||
client = _client_with_rows(buildings_app, _mapped([row]))
|
||||
resp = client.get("/api/v1/buildings/sale-share?min_pct=10&sort=share_45d_desc")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body[0]["house_id"] == 99
|
||||
assert body[0]["active_secondary"] == 0
|
||||
assert body[0]["sale_share_pct_45d"] == 22.7
|
||||
assert body[0]["listings_45d"] == 5
|
||||
|
||||
|
||||
def test_get_sale_share_empty_when_no_gar(buildings_app: FastAPI):
|
||||
|
|
@ -229,21 +316,32 @@ def test_get_summary_zero_fills_histogram(buildings_app: FastAPI):
|
|||
"total_secondary_buildings": 10,
|
||||
"buildings_with_denominator": 4,
|
||||
"coverage_pct": 40.0,
|
||||
"max_pct": 250.0,
|
||||
"max_pct": 95.0,
|
||||
"p95_pct": 33.3,
|
||||
"max_pct_45d": 98.0,
|
||||
"p95_pct_45d": 38.1,
|
||||
}
|
||||
)
|
||||
# handler делает 3 запроса: scalars, histogram(now), histogram(45d).
|
||||
hist = _mapped([{"bucket": "0-5", "count": 2}, {"bucket": "100+", "count": 1}])
|
||||
client = _client_with_rows(buildings_app, scalars, hist)
|
||||
hist_45d = _mapped([{"bucket": "5-10", "count": 3}, {"bucket": "50-100", "count": 1}])
|
||||
client = _client_with_rows(buildings_app, scalars, hist, hist_45d)
|
||||
resp = client.get("/api/v1/buildings/sale-share/summary")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["total_secondary_buildings"] == 10
|
||||
assert body["buildings_with_denominator"] == 4
|
||||
assert body["coverage_pct"] == 40.0
|
||||
# все 7 корзин присутствуют, недостающие — count 0, порядок фиксирован
|
||||
assert body["max_pct_45d"] == 98.0
|
||||
assert body["p95_pct_45d"] == 38.1
|
||||
# обе гистограммы: все 7 корзин, недостающие — count 0, порядок фиксирован
|
||||
assert [b["bucket"] for b in body["histogram"]] == list(HISTOGRAM_BUCKETS)
|
||||
assert [b["bucket"] for b in body["histogram_45d"]] == list(HISTOGRAM_BUCKETS)
|
||||
counts = {b["bucket"]: b["count"] for b in body["histogram"]}
|
||||
assert counts["0-5"] == 2
|
||||
assert counts["100+"] == 1
|
||||
assert counts["20-30"] == 0
|
||||
counts_45d = {b["bucket"]: b["count"] for b in body["histogram_45d"]}
|
||||
assert counts_45d["5-10"] == 3
|
||||
assert counts_45d["50-100"] == 1
|
||||
assert counts_45d["0-5"] == 0
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue