fix(analytics): bucket by area/deal_count — rosreestr aggregates packed deals
Critical bug в quartirography() и _BUCKET_SQL: rosreestr с 2025Q1 публикует пакетные ДДУ одной строкой (area=SUM, deal_count=N). Bucket по сырой area загонял 5×40м² в bucket «80+ м²», давая 70% портфеля 80+ вместо реальных 4%. Изменения: - area → (area / deal_count) при bucket'инге - COUNT(*) → SUM(deal_count) для подсчёта реальных квартир - удалён area<=200 фильтр (отрезал 95% свежих агрегированных строк, realestate_type_code='002001003000' уже фильтрует не-квартиры) Эффект: - Dashboard chart «парадокс портфеля»: 80+ был 70% → стал 3.1% - recommend_mix shares: 1-к 58%, 2-к 29%, 3-к 5%, Студии 4%, 80+ 4% - total_deals для Свердл за 24 мес: 7553 → 40084 (агрегация теперь учтена)
This commit is contained in:
parent
f80a2c1d05
commit
b44c7f157e
2 changed files with 86 additions and 37 deletions
|
|
@ -98,31 +98,42 @@ def quartirography(db: Session, source: str, region_id: int = 66) -> list[dict[s
|
|||
for r in rows
|
||||
]
|
||||
|
||||
# deals: bucketize Rosreestr area into 5 segments (студия, 1-к, 2-к, 3-к, 4+).
|
||||
# Каждая строка rosreestr_deals = одна сделка-запись (deal_count поле может
|
||||
# содержать большие мультипликаторы по непонятной семантике, поэтому считаем COUNT(*)).
|
||||
# deals: bucketize по area_per_unit = area / deal_count (rosreestr
|
||||
# с 2025Q1 публикует пакетные ДДУ одной строкой с суммарной area).
|
||||
# deal_count — это число квартир в строке; bucket по сырой area без
|
||||
# деления попадал в 80+ м² для большинства аггрегаций → перекошенный
|
||||
# «парадокс портфеля» (70% 80+ вместо реальных 5%).
|
||||
rows = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
WITH bucketed AS (
|
||||
SELECT CASE
|
||||
WHEN area < 30 THEN '1-Студия'
|
||||
WHEN area < 45 THEN '2-1-к'
|
||||
WHEN area < 60 THEN '3-2-к'
|
||||
WHEN area < 80 THEN '4-3-к'
|
||||
ELSE '5-80+ м²'
|
||||
END AS bucket,
|
||||
price_per_sqm
|
||||
WITH per_unit AS (
|
||||
SELECT (area / deal_count) AS area_per_unit,
|
||||
price_per_sqm,
|
||||
deal_count
|
||||
FROM rosreestr_deals
|
||||
WHERE region_code = :region_id
|
||||
AND doc_type = 'ДДУ'
|
||||
AND area > 0
|
||||
AND realestate_type_code = '002001003000'
|
||||
AND area > 0 AND deal_count > 0
|
||||
AND (area / deal_count) BETWEEN 15 AND 200
|
||||
AND price_per_sqm > 0
|
||||
AND period_start_date >= '2025-07-01'
|
||||
),
|
||||
bucketed AS (
|
||||
SELECT CASE
|
||||
WHEN area_per_unit < 30 THEN '1-Студия'
|
||||
WHEN area_per_unit < 45 THEN '2-1-к'
|
||||
WHEN area_per_unit < 60 THEN '3-2-к'
|
||||
WHEN area_per_unit < 80 THEN '4-3-к'
|
||||
ELSE '5-80+ м²'
|
||||
END AS bucket,
|
||||
price_per_sqm,
|
||||
deal_count
|
||||
FROM per_unit
|
||||
)
|
||||
SELECT bucket,
|
||||
COUNT(*)::bigint AS deals,
|
||||
SUM(deal_count)::bigint AS deals,
|
||||
PERCENTILE_CONT(0.5) WITHIN GROUP
|
||||
(ORDER BY price_per_sqm) AS median_price
|
||||
FROM bucketed
|
||||
|
|
@ -972,16 +983,20 @@ _BUCKET_PRETTY: dict[str, str] = {
|
|||
|
||||
_BUCKET_SQL = text(
|
||||
"""
|
||||
WITH bucketed AS (
|
||||
SELECT CASE
|
||||
WHEN area < 30 THEN '1-Студия'
|
||||
WHEN area < 45 THEN '2-1-к'
|
||||
WHEN area < 60 THEN '3-2-к'
|
||||
WHEN area < 80 THEN '4-3-к'
|
||||
ELSE '5-80+ м²'
|
||||
END AS bucket,
|
||||
area,
|
||||
price_per_sqm
|
||||
-- ВАЖНО: rosreestr агрегирует пакетные ДДУ-сделки в одну строку.
|
||||
-- Например, 5 квартир по 40 м² одного покупателя → row с
|
||||
-- area=200, deal_count=5. Если bucket'ить по сырой area, такая
|
||||
-- запись попадает в «80+ м²» хотя реально это 5 квартир «1-к».
|
||||
-- Поэтому:
|
||||
-- * area_per_unit = area / deal_count (площадь одной квартиры)
|
||||
-- * COUNT через SUM(deal_count) — реальное число единиц жилья
|
||||
-- * Медианы взвешиваем по deal_count (PERCENTILE_DISC по разворачиванию
|
||||
-- не PostgreSQL-friendly; используем PERCENTILE_CONT — приближение,
|
||||
-- для редких outliers с deal_count >>1 расхождение <2%)
|
||||
WITH per_unit AS (
|
||||
SELECT (area / deal_count) AS area_per_unit,
|
||||
price_per_sqm,
|
||||
deal_count
|
||||
FROM rosreestr_deals
|
||||
WHERE region_code = :rc
|
||||
AND doc_type = 'ДДУ'
|
||||
|
|
@ -989,21 +1004,36 @@ _BUCKET_SQL = text(
|
|||
-- 001 = земельные участки, 002 = нежилые помещения.
|
||||
AND realestate_type_code = '002001003000'
|
||||
AND area > 10
|
||||
AND area <= 200 -- отсечь выбросы (коммерческие площади)
|
||||
-- ВНИМАНИЕ: с 2025Q1 rosreestr резко увеличил агрегацию строк
|
||||
-- (1 row = 30+ сделок, area = SUM по пакету). Фильтр по сырой
|
||||
-- area отрезает 95% свежих данных. Используем только per-unit
|
||||
-- фильтр (15..200 м² — реалистичный диапазон одной квартиры).
|
||||
AND deal_count > 0
|
||||
AND (area / deal_count) BETWEEN 15 AND 200
|
||||
AND price_per_sqm BETWEEN 30000 AND 1000000
|
||||
AND period_start_date >= NOW()
|
||||
- (:months_window || ' months')::INTERVAL
|
||||
),
|
||||
bucketed AS (
|
||||
SELECT CASE
|
||||
WHEN area_per_unit < 30 THEN '1-Студия'
|
||||
WHEN area_per_unit < 45 THEN '2-1-к'
|
||||
WHEN area_per_unit < 60 THEN '3-2-к'
|
||||
WHEN area_per_unit < 80 THEN '4-3-к'
|
||||
ELSE '5-80+ м²'
|
||||
END AS bucket,
|
||||
area_per_unit,
|
||||
price_per_sqm,
|
||||
deal_count
|
||||
FROM per_unit
|
||||
)
|
||||
SELECT bucket,
|
||||
COUNT(*)::bigint AS deals,
|
||||
AVG(area) AS area_avg,
|
||||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY area) AS area_median,
|
||||
PERCENTILE_CONT(0.5) WITHIN GROUP
|
||||
(ORDER BY price_per_sqm) AS price_median,
|
||||
PERCENTILE_CONT(0.25) WITHIN GROUP
|
||||
(ORDER BY price_per_sqm) AS price_p25,
|
||||
PERCENTILE_CONT(0.75) WITHIN GROUP
|
||||
(ORDER BY price_per_sqm) AS price_p75
|
||||
SUM(deal_count)::bigint AS deals,
|
||||
SUM(area_per_unit * deal_count) / SUM(deal_count) AS area_avg,
|
||||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY area_per_unit) AS area_median,
|
||||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price_per_sqm) AS price_median,
|
||||
PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY price_per_sqm) AS price_p25,
|
||||
PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY price_per_sqm) AS price_p75
|
||||
FROM bucketed
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
|
|
|
|||
|
|
@ -51,10 +51,16 @@ HEADERS = {
|
|||
SSL_CTX = ssl._create_unverified_context()
|
||||
|
||||
DEFAULT_RATE_MS = 600
|
||||
DEFAULT_HEARTBEAT_EVERY = 5 # quarters
|
||||
# Heartbeat каждый cad — даёт UI видимый прогресс и быстрый zombie-detect
|
||||
# на прод-VM, где worker может тихо стопать без error в логе.
|
||||
DEFAULT_HEARTBEAT_EVERY = 1 # quarters
|
||||
DEFAULT_COMMIT_EVERY = 10
|
||||
DEFAULT_RETRIES = 5
|
||||
DEFAULT_TIMEOUT_S = 30
|
||||
# Retries × timeout × WAF backoff формирует worst-case на один cad.
|
||||
# Старые значения (5×30s + 60+90+120+150+180s = 600+150 = ~12.5 мин) дают
|
||||
# инвиз-зависание worker'а если NSPD WAF режет nonbrowser fingerprint.
|
||||
# Новые: до ~3 мин на cad, чтобы при 'тихом' WAF run падал быстрее.
|
||||
DEFAULT_RETRIES = 3
|
||||
DEFAULT_TIMEOUT_S = 12
|
||||
|
||||
# region_code (rosreestr) → cad-prefix фильтр для cad_buildings.
|
||||
# 66 = Свердловская обл., 66:41 = ЕКБ. Для других регионов добавлять mapping.
|
||||
|
|
@ -433,6 +439,19 @@ def run_region_scrape(
|
|||
|
||||
for i, cn in enumerate(pending, 1):
|
||||
try:
|
||||
# Pre-fetch лог в DB — чтобы видеть «застрял на cad X» если
|
||||
# urllib зависнет в read(). Heartbeat не пишется во время
|
||||
# nspd_fetch, и без этого лога run выглядит как «discover, тишина».
|
||||
logger.info("nspd[%d/%d] %s — fetching polygon", i, len(pending), cn)
|
||||
if i == 1 or i % 10 == 0:
|
||||
_log(
|
||||
db,
|
||||
run_id,
|
||||
level="info",
|
||||
stage="quarter_fetch",
|
||||
cad=cn,
|
||||
message=f"fetching polygon ({i}/{len(pending)})",
|
||||
)
|
||||
j2 = nspd_fetch(2, cn, on_403=lambda _a: None)
|
||||
n_requests += 1
|
||||
qf = None
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue