All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m47s
Deploy Trade-In / build-backend (push) Successful in 1m8s
Deploy Trade-In / deploy (push) Successful in 7m51s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m42s
989 lines
65 KiB
Python
989 lines
65 KiB
Python
"""Daily recompute of the asking→sold correction ratios (#648 Stage 4).
|
||
|
||
ПРОБЛЕМА: asking_to_sold_ratios (migration 080) засеяна один раз derivation-CTE
|
||
(медиана SOLD ДКП за 12 мес vs медиана ASKING активных listings, per-rooms + global -1
|
||
fallback). Оценщик (estimator.py, Stage 3) читает её per-estimate (cache 300s) и домножает
|
||
asking-median прогноз на sold/asking. По мере ночного импорта новых ДКП-сделок
|
||
(rosreestr_dkp_import) ratio устаревает — нужен периодический пересчёт по тому же derivation.
|
||
|
||
TRUE-MIRROR REFRESH (флаг database-expert): 080-seed использовал ON CONFLICT DO UPDATE,
|
||
который при повторном прогоне ОБНОВЛЯЕТ существующие строки, но НЕ удаляет per-rooms строки
|
||
бакетов, упавших ниже порога 30/30 на более позднем прогоне (StaLE rows). Поэтому refresh
|
||
сначала DELETE FROM asking_to_sold_ratios WHERE district = '' (все строки #648), затем
|
||
заново гоняет ту же 080-derivation INSERT...SELECT. DELETE+INSERT в ОДНОЙ транзакции
|
||
(атомарно — таблица никогда не пуста mid-refresh). После commit таблица == свежий re-seed.
|
||
|
||
Задача синхронная (DB-only, никаких внешних HTTP-вызовов) — запускается kit-scheduler'ом
|
||
через product_handlers._job_asking_to_sold_ratio (run_in_executor), по образцу
|
||
snapshot_listing_sources / import_rosreestr_dkp.
|
||
|
||
Окно расписания 06:00-07:00 UTC — ПОСЛЕ rosreestr_dkp_import (04:00-06:00 UTC), чтобы
|
||
refresh потреблял свежие ДКП-сделки того же дня.
|
||
|
||
SQL derivation ниже повторяет seed в data/sql/080_asking_to_sold_ratios.sql (deal_side /
|
||
ask_side / per_bucket + deal_global / ask_global / global_row: трейлинг-12мес окно, ppm²-полоса
|
||
[_PPM2_MIN, settings.asking_ratio_ppm2_max] (default [30000,1200000]), порог n_deals>=30 AND
|
||
n_listings>=30 для per_rooms, global -1 строка всегда). ON CONFLICT убран — DELETE идёт первым,
|
||
конфликтов нет (повторный прогон в одной tx невозможен, refresh = re-seed по семантике).
|
||
|
||
#2656 — ВТОРОЕ ПРЕДНАМЕРЕННОЕ РАСХОЖДЕНИЕ с 080 (первое — #2620 ниже): ask_side/ask_global
|
||
несут фильтр свежести `scraped_at > NOW() - LISTINGS_FRESH_DAYS дней` — тот же, что эстиматор
|
||
применяет к ЧИСЛИТЕЛЮ (_COMMON_WHERE). Без него знаменатель считался по бессрочной популяции
|
||
объявлений, а числитель — по 14-дневной, т.е. коэффициент калибровался на одном рынке, а
|
||
применялся к другому. Замер на проде (2026-08, #2656): глобальный коэффициент −0.49%, бакеты
|
||
44-62 +2.43% и 62-85 +4.58%; NULL-сегмент в знаменателе схлопывается с 674 строк до 20.
|
||
|
||
#2620 — ПЕРВОЕ ПРЕДНАМЕРЕННОЕ РАСХОЖДЕНИЕ с 080: deal_side бакетится по
|
||
LEAST(GREATEST(rooms,0),4), а ask_side — по _AREA_ROOMS_BUCKET_SQL (площадь, та же формула,
|
||
что deals.rooms получает при импорте). Причина — deals.rooms НЕ настоящая комнатность
|
||
(Росреестр её не отдаёт), это синтетика из площади; сравнивать её с РЕАЛЬНЫМИ комнатами
|
||
listings значило сравнивать разные классификации. Замер на проде (2026-08, #2620) показал
|
||
миграцию 23-55% объявлений между бакетами при таком сравнении — не только в бакете «4+»
|
||
(который к тому же обрезан обрезкой ELSE 4, тогда как listings.rooms доходит до 10) — и
|
||
это и была причина ratio>1 в бакете 4+ (см. _AREA_ROOMS_BUCKET_SQL ниже).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
|
||
from sqlalchemy import text
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.config import LISTINGS_FRESH_DAYS, settings
|
||
from app.services import regions as regions_mod
|
||
from app.services import scrape_runs as runs_mod
|
||
|
||
# Нижняя граница ppm² — отсекает нежилые/технические сделки; не меняется.
|
||
_PPM2_MIN: int = 30_000
|
||
|
||
# #C2 — исторически asking-сторона (listings) была покрыта скрейпом ТОЛЬКО по ЕКБ, а
|
||
# миграция 177 залила ДКП-сделки по всей обл.66 (368 городов) → sold-медиана смешивала
|
||
# дешёвую область с ЕКБ-asking и обваливала ratio (0.877→0.62, «выкупная» −29% системно).
|
||
# Скоупили SOLD-сторону (deal_side/deal_global) на ЕКБ, чтобы sold и asking считались по
|
||
# ОДНОМУ рынку.
|
||
#
|
||
# #2583 H2 (аудит, 2026-08): oblast-развёртки заработали 12 июля — областные объявления
|
||
# попали в знаменатель (ask_side/ask_global) без городского скоупа, а sold-сторона
|
||
# осталась скоуплена на ЕКБ → асимметрия вернулась с другой стороны (дешёвая область
|
||
# занижает ask-медиану → ratio завышен на 2.5-5.3% по всем бакетам, выкупные цены
|
||
# системно переплачены). Теперь ask_side/ask_global ТОЖЕ скоупятся этим паттерном
|
||
# (предикат `city IS NULL OR city ILIKE :asking_city` — см. комментарий на месте в CTE
|
||
# ниже) — симметрично deal-стороне.
|
||
#
|
||
# #3512 (per-region ratio, migration 304): эта городская квота — костыль ИМЕННО региона
|
||
# 66 (asking-скрейп исторически покрывал только сам ЕКБ, см. #C2 выше), и трогать её
|
||
# нельзя — числа региона 66 обязаны остаться byte-for-byte прежними. Для ЛЮБОГО другого
|
||
# региона (deals.region_code / listings.region_code — обе таблицы несут колонку) городской
|
||
# квоты не было и не нужно: там достаточно `region_code = :region_code` симметрично на
|
||
# обеих сторонах (см. _REDERIVE_SQL_REGION ниже) — колонка `district` остаётся
|
||
# зарезервированной под #647 (гео-районы ВНУТРИ региона), per-region разрез теперь
|
||
# несёт `region_code`, а не `district`.
|
||
_ASKING_CITY_PATTERN: str = "%Екатеринбург%"
|
||
# Верхняя граница берётся из settings.asking_ratio_ppm2_max (default 1_200_000).
|
||
# QA-note: точное значение сверить с `SELECT max(price_per_m2) FROM deals
|
||
# WHERE source='rosreestr'` на проде — ceiling должен быть > max(ppm²) premium-сделок.
|
||
|
||
# #3512: регионы, для которых считается ОТДЕЛЬНАЯ (не-ЕКБ) деривация ниже —
|
||
# весь реестр покрытия (app.services.regions — ЕДИНСТВЕННЫЙ источник правды про регионы)
|
||
# минус 66 (у него своя историческая деривация выше). Появление нового региона в реестре
|
||
# автоматически включает его в пересчёт ratio, без правки этого файла.
|
||
_OTHER_REGION_CODES: tuple[int, ...] = tuple(
|
||
sorted(code for code in regions_mod.REGIONS if code != 66)
|
||
)
|
||
|
||
# #2620 — синтетический "бакет комнат по площади", ИСТОЧНИК ИСТИНЫ:
|
||
# tradein-mvp/deploy/import-rosreestr.sh (Росреестр не отдаёт комнатность — deals.rooms
|
||
# синтезируется из area_m2 при импорте ровно этим CASE). Три представления ОДНОЙ формулы —
|
||
# держи границы (30/44/62/85) в синхроне при правке: shell (import-rosreestr.sh) → SQL
|
||
# (эта константа, ask_side ниже) → Python (area_bucket() ниже, estimator.py rekey #2620-2).
|
||
_AREA_ROOMS_BUCKET_SQL = (
|
||
"CASE WHEN area_m2 < 30 THEN 0 WHEN area_m2 < 44 THEN 1 "
|
||
"WHEN area_m2 < 62 THEN 2 WHEN area_m2 < 85 THEN 3 ELSE 4 END"
|
||
)
|
||
|
||
|
||
# ── #time-adjust: приведение SOLD-стороны к сегодняшнему дню (rollback за settings) ──
|
||
# ПРОБЛЕМА: sold-медиана (deal_side/deal_global/deal_geo) — сырые цены сделок за
|
||
# трейлинг-12 мес (на проде фактическое окно сентябрь 2025 - апрель 2026, медиана
|
||
# ~декабрь 2025), ask-медиана — объявления за LISTINGS_FRESH_DAYS (сегодня). На растущем
|
||
# рынке это занижает ratio (числитель отстаёт от знаменателя во времени). Фикс — тот же
|
||
# приём, что estimator._sber_time_factor применяет к ДКП-коридору (#794): каждая сделка
|
||
# домножается на factor = idx[последний доступный месяц серии] / idx[месяц сделки]
|
||
# (с clamp SBER_TIME_FACTOR_MIN/MAX). Серия и её имя — ТА ЖЕ карта sber_region_series_name
|
||
# (app.services.estimator), НЕ вторая карта — импортируется ЛЕНИВО внутри
|
||
# recompute_asking_to_sold_ratios(), т.к. estimator.py импортирует area_bucket ИЗ этого
|
||
# модуля на верхнем уровне (см. выше) — top-level импорт в обратную сторону дал бы цикл.
|
||
#
|
||
# Двойной учёт (проверено, см. PR-описание/vault): estimator._fetch_dkp_corridor тоже
|
||
# зовёт _sber_time_factor, но на ЖИВОЙ per-request ДКП-коридор (отдельный SQL, отдельная
|
||
# цель — condition-guard corridor-clamp поверх ASKING-медианы, estimator.py:3989-4002),
|
||
# а не на ratio из asking_to_sold_ratios. Эта таблица и коридор — независимые артефакты
|
||
# по одному сырому источнику (deals); один и тот же множитель здесь и там НЕ перемножается
|
||
# на одно и то же число дважды.
|
||
#
|
||
# Дашборд выбирается В SQL по приоритету SBER_COEFF_DASHBOARDS (bind-массив
|
||
# :sber_dashboards, array_position — первый непустой побеждает, тот же порядок, что
|
||
# estimator._load_sber_index_series перебирает по одному дашборду). Приведение
|
||
# управляется флагом settings.asking_ratio_time_adjust_enabled (:time_adjust_enabled) —
|
||
# False даёт factor=1.0 для КАЖДОЙ сделки (байт-в-байт прежнее поведение, откат без
|
||
# деплоя). Если серии для города вообще нет (sber_bounds пуст) — тоже factor=1.0; это
|
||
# НЕ ошибка (сделка не выбрасывается), но recompute_asking_to_sold_ratios логирует
|
||
# факт счётчиком, а не молча (см. _sber_series_missing ниже). Если для конкретного
|
||
# месяца сделки нет точки — берётся ближайший БОЛЕЕ РАННИЙ месяц серии (или самый
|
||
# ранний, если сделка старше начала серии) — то же правило, что estimator._sber_time_factor.
|
||
_SBER_FACTOR_CTES = """
|
||
sber_series_raw AS (
|
||
SELECT period_month, index_value_rub_m2, dashboard,
|
||
array_position(CAST(:sber_dashboards AS text[]), dashboard) AS dash_priority
|
||
FROM sber_price_index
|
||
WHERE city = CAST(:sber_city AS text)
|
||
AND (segment IS NULL OR segment ILIKE '%вторичн%')
|
||
AND dashboard = ANY(CAST(:sber_dashboards AS text[]))
|
||
),
|
||
sber_best_dashboard AS (
|
||
SELECT dashboard FROM sber_series_raw
|
||
WHERE dash_priority IS NOT NULL
|
||
ORDER BY dash_priority
|
||
LIMIT 1
|
||
),
|
||
sber_series AS (
|
||
SELECT r.period_month, r.index_value_rub_m2
|
||
FROM sber_series_raw r
|
||
JOIN sber_best_dashboard b ON r.dashboard = b.dashboard
|
||
),
|
||
sber_bounds AS (
|
||
SELECT MAX(period_month) AS latest_month, MIN(period_month) AS earliest_month
|
||
FROM sber_series
|
||
),
|
||
sber_latest_value AS (
|
||
SELECT s.index_value_rub_m2 AS latest_value
|
||
FROM sber_series s JOIN sber_bounds b ON s.period_month = b.latest_month
|
||
),
|
||
sber_earliest_value AS (
|
||
SELECT s.index_value_rub_m2 AS earliest_value
|
||
FROM sber_series s JOIN sber_bounds b ON s.period_month = b.earliest_month
|
||
)"""
|
||
|
||
# LEFT JOIN'ы к sber_* CTE выше — вставляются в `FROM deals` ДО `WHERE` (JOIN не может
|
||
# идти после WHERE). ON TRUE — CTE содержат максимум одну строку (не декартово произведение).
|
||
# snb — LATERAL «ближайший месяц серии <= месяца сделки», коррелирован по bare deal_date
|
||
# (единственная таблица в запросе с этой колонкой — алиас не нужен).
|
||
_SBER_FACTOR_JOINS = """
|
||
LEFT JOIN sber_bounds sb ON TRUE
|
||
LEFT JOIN sber_latest_value slv ON TRUE
|
||
LEFT JOIN sber_earliest_value sev ON TRUE
|
||
LEFT JOIN LATERAL (
|
||
SELECT s.index_value_rub_m2 AS base_value
|
||
FROM sber_series s
|
||
WHERE s.period_month <= date_trunc('month', deal_date)::date
|
||
ORDER BY s.period_month DESC
|
||
LIMIT 1
|
||
) snb ON TRUE
|
||
"""
|
||
|
||
# Сам фактор — 1.0 при выключенном флаге, при пустой серии, при сделке новее последнего
|
||
# месяца серии (без экстраполяции вперёд — симметрично estimator._sber_time_factor).
|
||
# GREATEST/LEAST — те же клампы SBER_TIME_FACTOR_MIN/MAX, что estimator применяет к
|
||
# ДКП-коридору (bind :factor_min/:factor_max — значения ОТТУДА, не второй набор констант).
|
||
_SBER_FACTOR_EXPR = """CASE
|
||
WHEN NOT CAST(:time_adjust_enabled AS boolean) THEN 1.0
|
||
WHEN sb.latest_month IS NULL THEN 1.0
|
||
WHEN date_trunc('month', deal_date)::date >= sb.latest_month THEN 1.0
|
||
ELSE GREATEST(
|
||
CAST(:factor_min AS double precision),
|
||
LEAST(
|
||
CAST(:factor_max AS double precision),
|
||
slv.latest_value / NULLIF(COALESCE(snb.base_value, sev.earliest_value), 0)
|
||
)
|
||
)
|
||
END"""
|
||
|
||
|
||
def area_bucket(area_m2: float) -> int:
|
||
"""Python-двойник _AREA_ROOMS_BUCKET_SQL (границы ИДЕНТИЧНЫ, #2620).
|
||
|
||
Используется estimator.py при ПРИМЕНЕНИИ ratio (не только при расчёте здесь) —
|
||
ratio_resolver должен ключевать по ТОМУ ЖЕ area-бакету, что и ask_side при
|
||
деривации, иначе mismatch просто переезжает из расчёта в применение (прод-замер
|
||
ревьюера #2620: 310/1038 = 29.9% исторических запросов легли бы в другой бакет
|
||
при rooms-ключе vs area-ключе).
|
||
"""
|
||
if area_m2 < 30:
|
||
return 0
|
||
if area_m2 < 44:
|
||
return 1
|
||
if area_m2 < 62:
|
||
return 2
|
||
if area_m2 < 85:
|
||
return 3
|
||
return 4
|
||
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# ── True-mirror cleanup: drop all #648 EKB rows before re-derivation ──────────
|
||
# district = '' — все строки #648 (district зарезервирован под #647, пока всегда '').
|
||
# #3512: явный region_code=66 — эта DELETE трогает ТОЛЬКО ЕКБ-строки; остальные регионы
|
||
# чистит своя _DELETE_SQL_REGION в цикле ниже (иначе один бланкет-DELETE стирал бы
|
||
# только что вставленные строки другого региона на повторном прогоне того же transaction).
|
||
# Удаляем ПЕРЕД re-derive, чтобы бакеты, упавшие ниже порога 30/30, не оставались
|
||
# stale (ON CONFLICT DO UPDATE такие строки бы не тронул). В одной транзакции с INSERT.
|
||
_DELETE_SQL = text(
|
||
"""
|
||
DELETE FROM asking_to_sold_ratios
|
||
WHERE region_code = 66 AND district = ''
|
||
"""
|
||
)
|
||
|
||
# #3512: та же true-mirror очистка, но per-region (используется в цикле для
|
||
# _OTHER_REGION_CODES) — CAST(:region_code AS int), никогда :region_code::int (psycopg v3).
|
||
_DELETE_SQL_REGION = text(
|
||
"""
|
||
DELETE FROM asking_to_sold_ratios
|
||
WHERE region_code = CAST(:region_code AS int) AND district = ''
|
||
"""
|
||
)
|
||
|
||
# ── Derivation + re-seed (БАЙТ-В-БАЙТ из 080, ON CONFLICT убран — DELETE идёт первым) ──
|
||
# deal_side / ask_side / per_bucket + deal_global / ask_global / global_row:
|
||
# sold_median = percentile_cont(0.5) по deals.price_per_m2 (source='rosreestr',
|
||
# ppm² ∈ [_PPM2_MIN, settings.asking_ratio_ppm2_max], deal_date >= CURRENT_DATE − 12 months),
|
||
# бакет LEAST(GREATEST(rooms,0),4) (rooms уже синтетика-из-площади при импорте, см. #2620
|
||
# комментарий у _AREA_ROOMS_BUCKET_SQL выше).
|
||
# ask_median = percentile_cont(0.5) по listings.price_per_m2
|
||
# (is_active + свежесть scraped_at ≤ LISTINGS_FRESH_DAYS (#2656), та же ppm²-полоса
|
||
# [_PPM2_MIN, asking_ratio_ppm2_max], тот же город что
|
||
# SOLD-сторона — city IS NULL OR city ILIKE :asking_city, #2583 H2). Бакет —
|
||
# _AREA_ROOMS_BUCKET_SQL (площадь, #2620), НЕ listings.rooms — см. комментарий там.
|
||
# per_rooms строки — только при n_deals>=30 AND n_listings>=30 AND ask>0 AND sold>0.
|
||
# global -1 строка (basis='global_fallback') — всегда (если ask>0 AND sold>0). window_months=12.
|
||
# Порог/окно — литералы; ppm²-полоса передаётся bind-параметрами :ppm2_min/:ppm2_max
|
||
# (безопасно от SQL-инъекций; CAST не нужен — psycopg v3 передаёт int напрямую).
|
||
_REDERIVE_SQL = text(
|
||
f"""
|
||
WITH{_SBER_FACTOR_CTES},
|
||
-- SOLD медианы по бакетам комнат за трейлинг-12мес (ДКП Росреестра). #time-adjust:
|
||
-- price_per_m2 домножен на sber-фактор приведения к последнему месяцу серии.
|
||
deal_side AS (
|
||
SELECT
|
||
LEAST(GREATEST(rooms, 0), 4) AS rooms_bucket,
|
||
percentile_cont(0.5) WITHIN GROUP (
|
||
ORDER BY price_per_m2 * ({_SBER_FACTOR_EXPR})
|
||
) AS sold_median,
|
||
COUNT(*) AS n_deals
|
||
FROM deals
|
||
{_SBER_FACTOR_JOINS}
|
||
WHERE source = 'rosreestr'
|
||
AND rooms IS NOT NULL
|
||
AND city ILIKE :asking_city -- #C2 SOLD-сторона на ЕКБ (match asking-рынок)
|
||
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
|
||
AND deal_date >= CURRENT_DATE - INTERVAL '12 months'
|
||
GROUP BY LEAST(GREATEST(rooms, 0), 4)
|
||
),
|
||
-- ASKING медианы по ТОМУ ЖЕ area-бакету, что deal_side (#2620) — НЕ по listings.rooms.
|
||
-- deals.rooms — синтетика из площади (Росреестр её не отдаёт), listings.rooms — реальная
|
||
-- комнатность; сравнение area-бакета с area-бакетом (не area-бакета с real-rooms-бакетом)
|
||
-- убирает миграцию объявлений между бакетами (23-55% строк на проде, 2026-08, #2620) —
|
||
-- включая инверсию ratio>1 в бакете «4+» (deals.rooms обрезан ELSE 4, а listings.rooms
|
||
-- нет: 110/782 пяти- и более комнатных объявлений раньше схлопывались в бакет 4).
|
||
ask_side AS (
|
||
SELECT
|
||
{_AREA_ROOMS_BUCKET_SQL} AS rooms_bucket,
|
||
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS ask_median,
|
||
COUNT(*) AS n_listings
|
||
FROM listings
|
||
WHERE is_active
|
||
-- #2656: окно свежести — то же самое, что эстиматор применяет к ЧИСЛИТЕЛЮ
|
||
-- (_COMMON_WHERE, LISTINGS_FRESH_DAYS). Без него числитель оценки считался
|
||
-- по 14-дневной популяции, а знаменатель коэффициента — по бессрочной:
|
||
-- калибровка и применение по разным рынкам. `is_active` для этого не годится
|
||
-- — он означает разное у разных источников (TTL деактивации 30д, NULL-сегмент
|
||
-- не деактивируется никогда: 97.4% таких строк протухшие и при этом дорогие).
|
||
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
|
||
AND rooms IS NOT NULL
|
||
-- #2620 hardening: area_m2 IS NULL falls into the CASE ELSE branch (bucket 4)
|
||
-- of _AREA_ROOMS_BUCKET_SQL — a latent "everything unmeasured looks like a big
|
||
-- flat" trap. Excluded explicitly instead of relying on ELSE-as-junk-drawer.
|
||
AND area_m2 IS NOT NULL
|
||
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
|
||
-- novostroyki guard (#1186): NULL = legacy вторичка до м.011
|
||
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
|
||
-- #2583 H2: скоупим ASKING-сторону на тот же город, что и SOLD-сторона
|
||
-- (симметрично deal_side выше) — иначе дешёвые oblast-объявления (развёртки
|
||
-- с 12 июля) занижают ask-медиану и завышают ratio. city IS NULL считается
|
||
-- "своим" (не отбрасывается) НАМЕРЕННО: listings.city заполнена пока только у
|
||
-- Авито (Циан/Домклик/Яндекс — NULL, #2598/#2606), симметричный
|
||
-- `city ILIKE :asking_city` без IS NULL выбросил бы ~70% выборки. По мере
|
||
-- роста покрытия колонки этот предикат сам ужесточается без правок кода; когда
|
||
-- покрытие станет полным — заменить на строго симметричный `city ILIKE :asking_city`.
|
||
AND (city IS NULL OR city ILIKE :asking_city)
|
||
GROUP BY {_AREA_ROOMS_BUCKET_SQL}
|
||
),
|
||
-- Per-rooms строки: только бакеты с обеими сторонами, прошедшие порог 30/30 и ask>0.
|
||
-- Тонкие бакеты (n<30) сюда НЕ попадают → estimator делает fallback на -1.
|
||
per_bucket AS (
|
||
SELECT
|
||
d.rooms_bucket,
|
||
''::text AS district,
|
||
(d.sold_median / a.ask_median)::numeric AS ratio,
|
||
round(d.sold_median)::bigint AS sold_median,
|
||
round(a.ask_median)::bigint AS ask_median,
|
||
d.n_deals::int AS n_deals,
|
||
a.n_listings::int AS n_listings,
|
||
12 AS window_months,
|
||
'per_rooms'::text AS basis
|
||
FROM deal_side d
|
||
JOIN ask_side a USING (rooms_bucket)
|
||
WHERE d.n_deals >= 30
|
||
AND a.n_listings >= 30
|
||
AND a.ask_median IS NOT NULL
|
||
AND a.ask_median > 0
|
||
AND d.sold_median IS NOT NULL -- divide-safety (порог n_deals>=30 гарантирует)
|
||
AND d.sold_median > 0
|
||
),
|
||
-- SOLD медиана по ВСЕМ комнатам (без бакет-фильтра) за трейлинг-12мес — для global row.
|
||
deal_global AS (
|
||
SELECT
|
||
percentile_cont(0.5) WITHIN GROUP (
|
||
ORDER BY price_per_m2 * ({_SBER_FACTOR_EXPR})
|
||
) AS sold_median,
|
||
COUNT(*) AS n_deals
|
||
FROM deals
|
||
{_SBER_FACTOR_JOINS}
|
||
WHERE source = 'rosreestr'
|
||
AND rooms IS NOT NULL
|
||
AND city ILIKE :asking_city -- #C2 SOLD-сторона на ЕКБ (match asking-рынок)
|
||
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
|
||
AND deal_date >= CURRENT_DATE - INTERVAL '12 months'
|
||
),
|
||
-- ASKING медиана по ВСЕМ активным listings (без бакет-фильтра) — для global row.
|
||
ask_global AS (
|
||
SELECT
|
||
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS ask_median,
|
||
COUNT(*) AS n_listings
|
||
FROM listings
|
||
WHERE is_active
|
||
-- #2656: то же окно свежести, что и в ask_side выше (см. комментарий там)
|
||
-- — global-строка должна считаться по той же популяции, что per-bucket.
|
||
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
|
||
AND rooms IS NOT NULL
|
||
-- #2620 hardening: same area_m2 IS NOT NULL as ask_side — keeps the global-row
|
||
-- population consistent with the per-bucket rows it's a fallback for.
|
||
AND area_m2 IS NOT NULL
|
||
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
|
||
-- novostroyki guard (#1186): NULL = legacy вторичка до м.011
|
||
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
|
||
-- #2583 H2: тот же городской скоуп, что и ask_side выше (см. комментарий там
|
||
-- про причину city IS NULL == "свой" и #2598/#2606).
|
||
AND (city IS NULL OR city ILIKE :asking_city)
|
||
),
|
||
-- Global fallback строка rooms_bucket=-1 (пишется всегда, если ask>0).
|
||
global_row AS (
|
||
SELECT
|
||
-1 AS rooms_bucket,
|
||
''::text AS district,
|
||
(d.sold_median / a.ask_median)::numeric AS ratio,
|
||
round(d.sold_median)::bigint AS sold_median,
|
||
round(a.ask_median)::bigint AS ask_median,
|
||
d.n_deals::int AS n_deals,
|
||
a.n_listings::int AS n_listings,
|
||
12 AS window_months,
|
||
'global_fallback'::text AS basis
|
||
FROM deal_global d
|
||
CROSS JOIN ask_global a
|
||
WHERE a.ask_median IS NOT NULL
|
||
AND a.ask_median > 0
|
||
AND d.sold_median IS NOT NULL -- защита от пустого окна сделок (иначе ratio=NULL)
|
||
AND d.sold_median > 0
|
||
)
|
||
INSERT INTO asking_to_sold_ratios (
|
||
rooms_bucket, district, ratio, sold_median, ask_median,
|
||
n_deals, n_listings, window_months, basis, region_code
|
||
)
|
||
SELECT rooms_bucket, district, ratio, sold_median, ask_median,
|
||
n_deals, n_listings, window_months, basis, 66 FROM global_row
|
||
UNION ALL
|
||
SELECT rooms_bucket, district, ratio, sold_median, ask_median,
|
||
n_deals, n_listings, window_months, basis, 66 FROM per_bucket
|
||
"""
|
||
)
|
||
|
||
# ── Geography-matched per-region derivation (#3529) ───────────────────────────
|
||
# ПРОБЛЕМА (#3512-путь, прод-замер 2026-09 по региону 50): обе стороны фильтровались
|
||
# ТОЛЬКО по region_code и соединялись ТОЛЬКО по бакету комнат — т.е. sold-медиана и
|
||
# ask-медиана считались по РАЗНЫМ географическим популяциям одного региона.
|
||
# Разложение обл.50 по кольцам 10 км от центра Москвы (сделки 12 мес vs активные объявления):
|
||
# 20-30 км: 12 189 сделок / 23 292 объявления → 0.808
|
||
# 30-40 км: 4 132 / 10 206 → 0.847
|
||
# 50-60 км: 1 432 / 5 243 → 0.951
|
||
# 70-80 км: 150 / 2 294 → 0.688
|
||
# ВНУТРИ колец отношение 0.69-0.95, ближние кольца (76% сделок) — 0.81-0.85, а общий пул
|
||
# давал 0.891: объявления смещены к дальней дешёвой периферии СИЛЬНЕЕ, чем сделки. Это
|
||
# перекос СОСТАВА выборки, а не свойство рынка: выкупная цена по области системно завышена.
|
||
#
|
||
# РЕШЕНИЕ: считать коэффициент на СОГЛАСОВАННОЙ географии — обе стороны раскладываются
|
||
# по одним и тем же пространственным ячейкам, медианы берутся ВНУТРИ ячейки, и в итог
|
||
# идут только ячейки, где есть ОБЕ стороны, с весами по числу СДЕЛОК. Т.е. ask-сторона
|
||
# перевзвешивается на географию сделок (индекс Ласпейреса): ratio = Σ(w·sold) / Σ(w·ask),
|
||
# w = n_deals ячейки. Строка остаётся внутренне согласованной: ratio == sold_median/ask_median,
|
||
# где оба медианных столбца — взвешенные средние ячеечных медиан с ОДНИМИ весами.
|
||
#
|
||
# ПУТЬ ЕКБ (66) НЕ ТРОГАЕМ — там своя историческая калибровка городской квотой (#C2/#2583),
|
||
# числа региона 66 обязаны остаться byte-for-byte прежними (_REDERIVE_SQL выше).
|
||
|
||
# РАЗМЕР ЯЧЕЙКИ — регулярная сетка 0.1° широты × 0.2° долготы ≈ 11 км × 12-16 км на
|
||
# широтах 45-60°N (0.2° долготы × cos(lat): 15.7 км на 45°, 12.5 км на 55.7°, 11.1 км на 60°).
|
||
# Почему именно так:
|
||
# • Масштаб взят от замера выше: именно на ~10-км разрешении отношение перестаёт
|
||
# гулять от состава (внутри кольца 0.69-0.95 вместо 0.891 по пулу), при этом ячейка
|
||
# ещё достаточно крупная, чтобы набрать десятки сделок и объявлений.
|
||
# • Сетка, а НЕ кольца от центра: кольцам нужен центр, а у региона 50 своего
|
||
# города-центра нет (его фактический центр — Москва, т.е. ДРУГОЙ регион), и каждый
|
||
# следующий регион реестра потребовал бы своего анкора и своего шага. Сетке анкор не нужен.
|
||
# • Совмещение по НАЗВАНИЮ муниципалитета НЕВОЗМОЖНО: listings.city у региона 50
|
||
# пуста (3 строки из 70 996). geom есть с обеих сторон (объявления 70 996/70 996,
|
||
# сделки 87 562/113 351) — выравниваем ПРОСТРАНСТВЕННО.
|
||
# • FLOOR по градусам — чистая арифметика по ST_X/ST_Y, без репроекций и без стыковки
|
||
# с админграницами, которых в БД нет. Точность границ ячейки здесь не важна — важно,
|
||
# что ОБЕ стороны режутся ОДИНАКОВО.
|
||
_CELL_LAT_DEG: float = 0.1
|
||
_CELL_LON_DEG: float = 0.2
|
||
|
||
# Порог НА ЯЧЕЙКУ (все комнатности вместе) — сколько нужно, чтобы ячейка считалась
|
||
# покрытой ОБЕИМИ сторонами. Ниже глобального 30/30 НАМЕРЕННО: ячеечная медиана не
|
||
# публикуется сама по себе — она входит во взвешенную сумму, а публикуемый барьер
|
||
# остаётся прежним 30/30, но уже на СУММЕ по удержанным ячейкам (HAVING ниже).
|
||
_CELL_MIN_DEALS: int = 10
|
||
_CELL_MIN_LISTINGS: int = 10
|
||
|
||
# Порог на пару (ячейка, бакет комнат) — ещё мягче: внутри уже отобранной ячейки
|
||
# комнатность дробит выборку ещё на 5 частей. Меньше 5 наблюдений на сторону — медиана
|
||
# шум, и при большом весе этот шум попадёт в итоговую строку.
|
||
_CELL_BUCKET_MIN_DEALS: int = 5
|
||
_CELL_BUCKET_MIN_LISTINGS: int = 5
|
||
|
||
# ГАРДЫ ДЕГРАДАЦИИ (пункт 6 задачи): если согласованной географии по факту нет —
|
||
# лучше НЕ писать строку вообще (эстиматор деградирует явно, без коэффициента),
|
||
# чем посчитать неверно и выглядеть уверенно.
|
||
_MIN_MATCHED_CELLS: int = 3
|
||
# Доля СДЕЛОК (с geom), попавших в пересечение ячеек. Именно сделки — целевая популяция
|
||
# (на их географию перевзвешивается ask-сторона); объявления за пределами пересечения
|
||
# отбрасываются НАМЕРЕННО (это и есть фикс), поэтому гарда на них нет — только счётчик.
|
||
_MIN_DEAL_CELL_COVERAGE: float = 0.5
|
||
# Доля строк с geom на КАЖДОЙ стороне: если большая часть стороны без координат,
|
||
# выравнивать пространственно нечего — получился бы коэффициент по неслучайному остатку.
|
||
_MIN_GEOM_COVERAGE: float = 0.5
|
||
# Сигнальный (не блокирующий) порог: выше него пишется WARNING. 0.25 выбран чуть выше
|
||
# текущего прод-состояния региона 50 (25 789/113 351 = 22.8% сделок ждут геокодера),
|
||
# чтобы лог не шумел на норме, но ухудшение было видно сразу. Доля попадает в счётчики
|
||
# ВСЕГДА, независимо от порога — строки без geom не выпадают молча (пункт 3 задачи).
|
||
_GEOM_WARN_SHARE: float = 0.25
|
||
|
||
# ОБЩИЕ ФИЛЬТРЫ сторон — один источник правды для stats- и insert-запросов (иначе счётчики
|
||
# и деривация разъехались бы при первой же правке одного из них). Состав гардов тот же,
|
||
# что у ЕКБ-деривации (12-мес окно, ppm²-полоса, свежесть #2656, novostroyki #1186,
|
||
# area_m2 IS NOT NULL #2620) — меняется ТОЛЬКО гео-согласование.
|
||
_DEAL_FROM_REGION = """
|
||
FROM deals
|
||
"""
|
||
|
||
_DEAL_WHERE_REGION = """
|
||
WHERE source = 'rosreestr'
|
||
AND rooms IS NOT NULL
|
||
AND region_code = CAST(:region_code AS int)
|
||
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
|
||
AND deal_date >= CURRENT_DATE - INTERVAL '12 months'
|
||
"""
|
||
|
||
# #time-adjust: FROM и WHERE разведены — deal_geo (ниже) вставляет sber-JOIN'ы МЕЖДУ
|
||
# ними (JOIN обязан стоять до WHERE), deal_all (stats, без time-adjust) склеивает как раньше.
|
||
_DEAL_FROM_WHERE_REGION = _DEAL_FROM_REGION + _DEAL_WHERE_REGION
|
||
|
||
_ASK_FROM_WHERE_REGION = """
|
||
FROM listings
|
||
WHERE is_active
|
||
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
|
||
AND rooms IS NOT NULL
|
||
AND area_m2 IS NOT NULL
|
||
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
|
||
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
|
||
AND region_code = CAST(:region_code AS int)
|
||
"""
|
||
|
||
# Ячеечные CTE — ОБЩИЕ для stats-запроса (счётчики + гард) и для самой деривации,
|
||
# чтобы решение «писать / не писать» принималось РОВНО по тем ячейкам, которые потом считаются.
|
||
_CELL_CTES_REGION = f"""{_SBER_FACTOR_CTES},
|
||
-- #time-adjust: price_per_m2 домножен на sber-фактор ДО попадания в deal_cell/
|
||
-- deal_cell_bucket — приведение проезжает через весь взвешенный (Ласпейрес) расчёт
|
||
-- региона автоматически, отдельно трогать deal_cell/per_bucket не нужно.
|
||
deal_geo AS (
|
||
SELECT
|
||
FLOOR(ST_Y(geom) / {_CELL_LAT_DEG}) AS cell_lat,
|
||
FLOOR(ST_X(geom) / {_CELL_LON_DEG}) AS cell_lon,
|
||
LEAST(GREATEST(rooms, 0), 4) AS rooms_bucket,
|
||
price_per_m2 * ({_SBER_FACTOR_EXPR}) AS price_per_m2
|
||
{_DEAL_FROM_REGION}
|
||
{_SBER_FACTOR_JOINS}
|
||
{_DEAL_WHERE_REGION} AND geom IS NOT NULL
|
||
),
|
||
ask_geo AS (
|
||
SELECT
|
||
FLOOR(ST_Y(geom) / {_CELL_LAT_DEG}) AS cell_lat,
|
||
FLOOR(ST_X(geom) / {_CELL_LON_DEG}) AS cell_lon,
|
||
{_AREA_ROOMS_BUCKET_SQL} AS rooms_bucket,
|
||
price_per_m2
|
||
{_ASK_FROM_WHERE_REGION} AND geom IS NOT NULL
|
||
),
|
||
deal_cell AS (
|
||
SELECT
|
||
cell_lat, cell_lon,
|
||
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS sold_median,
|
||
COUNT(*) AS n_deals
|
||
FROM deal_geo
|
||
GROUP BY cell_lat, cell_lon
|
||
),
|
||
ask_cell AS (
|
||
SELECT
|
||
cell_lat, cell_lon,
|
||
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS ask_median,
|
||
COUNT(*) AS n_listings
|
||
FROM ask_geo
|
||
GROUP BY cell_lat, cell_lon
|
||
),
|
||
-- СОГЛАСОВАННАЯ ГЕОГРАФИЯ: ячейки, где ОБЕ стороны имеют свою массу.
|
||
matched_cell AS (
|
||
SELECT
|
||
d.cell_lat, d.cell_lon,
|
||
d.sold_median, d.n_deals,
|
||
a.ask_median, a.n_listings
|
||
FROM deal_cell d
|
||
JOIN ask_cell a USING (cell_lat, cell_lon)
|
||
WHERE d.n_deals >= {_CELL_MIN_DEALS}
|
||
AND a.n_listings >= {_CELL_MIN_LISTINGS}
|
||
AND d.sold_median IS NOT NULL AND d.sold_median > 0
|
||
AND a.ask_median IS NOT NULL AND a.ask_median > 0
|
||
)"""
|
||
|
||
# Статистика СОСТАВА выборки — считается ДО деривации и решает, писать ли регион вообще.
|
||
# Строки БЕЗ geom тоже считаются (n_all vs n_geo) — они выпадают из деривации, и это
|
||
# должно быть видно в счётчиках, а не тихо (пункт 3 задачи).
|
||
_GEO_STATS_SQL_REGION = text(
|
||
f"""
|
||
WITH{_CELL_CTES_REGION},
|
||
deal_all AS (
|
||
SELECT
|
||
COUNT(*) AS n_all,
|
||
COUNT(*) FILTER (WHERE geom IS NOT NULL) AS n_geo
|
||
{_DEAL_FROM_WHERE_REGION} ),
|
||
ask_all AS (
|
||
SELECT
|
||
COUNT(*) AS n_all,
|
||
COUNT(*) FILTER (WHERE geom IS NOT NULL) AS n_geo
|
||
{_ASK_FROM_WHERE_REGION} )
|
||
SELECT
|
||
da.n_all AS deals_total,
|
||
da.n_geo AS deals_geo,
|
||
aa.n_all AS listings_total,
|
||
aa.n_geo AS listings_geo,
|
||
(SELECT COUNT(*) FROM deal_cell) AS cells_deal,
|
||
(SELECT COUNT(*) FROM ask_cell) AS cells_ask,
|
||
(SELECT COUNT(*) FROM deal_cell d
|
||
JOIN ask_cell a USING (cell_lat, cell_lon)) AS cells_both_sides,
|
||
(SELECT COUNT(*) FROM matched_cell) AS cells_matched,
|
||
(SELECT COALESCE(SUM(n_deals), 0) FROM matched_cell) AS deals_in_cells,
|
||
(SELECT COALESCE(SUM(n_listings), 0) FROM matched_cell) AS listings_in_cells
|
||
FROM deal_all da CROSS JOIN ask_all aa
|
||
"""
|
||
)
|
||
|
||
# Деривация на согласованной географии. Отличий от ЕКБ-пути (_REDERIVE_SQL) ровно два:
|
||
# 1. городская квота ЕКБ → симметричный region_code на обеих сторонах (#3512);
|
||
# 2. медианы считаются ВНУТРИ ячейки и агрегируются с весами по числу сделок (#3529).
|
||
# Окно 12 мес, ppm²-полоса, area-бакет ask-стороны, порог 30/30 на публикуемую строку — прежние.
|
||
_REDERIVE_SQL_REGION = text(
|
||
f"""
|
||
WITH{_CELL_CTES_REGION},
|
||
-- Внутри УЖЕ отобранных ячеек — разрез по бакету комнат (обе стороны — тот же набор
|
||
-- ячеек, т.е. гео-ключ есть И в фильтре, И в соединении — в отличие от старого
|
||
-- `JOIN ... USING (rooms_bucket)`, где географии в соединении не было вообще).
|
||
deal_cell_bucket AS (
|
||
SELECT
|
||
g.cell_lat, g.cell_lon, g.rooms_bucket,
|
||
percentile_cont(0.5) WITHIN GROUP (ORDER BY g.price_per_m2) AS sold_median,
|
||
COUNT(*) AS n_deals
|
||
FROM deal_geo g
|
||
JOIN matched_cell m USING (cell_lat, cell_lon)
|
||
GROUP BY g.cell_lat, g.cell_lon, g.rooms_bucket
|
||
),
|
||
ask_cell_bucket AS (
|
||
SELECT
|
||
g.cell_lat, g.cell_lon, g.rooms_bucket,
|
||
percentile_cont(0.5) WITHIN GROUP (ORDER BY g.price_per_m2) AS ask_median,
|
||
COUNT(*) AS n_listings
|
||
FROM ask_geo g
|
||
JOIN matched_cell m USING (cell_lat, cell_lon)
|
||
GROUP BY g.cell_lat, g.cell_lon, g.rooms_bucket
|
||
),
|
||
bucket_cell AS (
|
||
SELECT
|
||
d.rooms_bucket,
|
||
d.sold_median, d.n_deals,
|
||
a.ask_median, a.n_listings
|
||
FROM deal_cell_bucket d
|
||
JOIN ask_cell_bucket a USING (cell_lat, cell_lon, rooms_bucket)
|
||
WHERE d.n_deals >= {_CELL_BUCKET_MIN_DEALS}
|
||
AND a.n_listings >= {_CELL_BUCKET_MIN_LISTINGS}
|
||
AND d.sold_median IS NOT NULL AND d.sold_median > 0
|
||
AND a.ask_median IS NOT NULL AND a.ask_median > 0
|
||
),
|
||
-- Взвешивание по числу СДЕЛОК: ask-сторона приводится к географии сделок.
|
||
-- ratio == sold_median/ask_median построчно (оба — взвешенные средние с ОДНИМИ весами),
|
||
-- так что публикуемые столбцы остаются взаимно согласованными.
|
||
per_bucket AS (
|
||
SELECT
|
||
rooms_bucket,
|
||
''::text AS district,
|
||
CAST(SUM(sold_median * n_deals) / SUM(ask_median * n_deals)
|
||
AS numeric) AS ratio,
|
||
round(SUM(sold_median * n_deals) / SUM(n_deals))::bigint AS sold_median,
|
||
round(SUM(ask_median * n_deals) / SUM(n_deals))::bigint AS ask_median,
|
||
SUM(n_deals)::int AS n_deals,
|
||
SUM(n_listings)::int AS n_listings,
|
||
12 AS window_months,
|
||
'per_rooms'::text AS basis
|
||
FROM bucket_cell
|
||
GROUP BY rooms_bucket
|
||
-- ТОТ ЖЕ публикуемый барьер 30/30, что и раньше — теперь на сумме по ячейкам.
|
||
HAVING SUM(n_deals) >= 30
|
||
AND SUM(n_listings) >= 30
|
||
AND SUM(ask_median * n_deals) > 0
|
||
),
|
||
-- Global -1 fallback — те же ячейки, но без разреза по комнатности.
|
||
global_row AS (
|
||
SELECT
|
||
-1 AS rooms_bucket,
|
||
''::text AS district,
|
||
CAST(SUM(sold_median * n_deals) / SUM(ask_median * n_deals)
|
||
AS numeric) AS ratio,
|
||
round(SUM(sold_median * n_deals) / SUM(n_deals))::bigint AS sold_median,
|
||
round(SUM(ask_median * n_deals) / SUM(n_deals))::bigint AS ask_median,
|
||
SUM(n_deals)::int AS n_deals,
|
||
SUM(n_listings)::int AS n_listings,
|
||
12 AS window_months,
|
||
'global_fallback'::text AS basis
|
||
FROM matched_cell
|
||
HAVING SUM(n_deals) > 0
|
||
AND SUM(ask_median * n_deals) > 0
|
||
)
|
||
INSERT INTO asking_to_sold_ratios (
|
||
rooms_bucket, district, ratio, sold_median, ask_median,
|
||
n_deals, n_listings, window_months, basis, region_code
|
||
)
|
||
SELECT rooms_bucket, district, ratio, sold_median, ask_median,
|
||
n_deals, n_listings, window_months, basis, CAST(:region_code AS int) FROM global_row
|
||
UNION ALL
|
||
SELECT rooms_bucket, district, ratio, sold_median, ask_median,
|
||
n_deals, n_listings, window_months, basis, CAST(:region_code AS int) FROM per_bucket
|
||
"""
|
||
)
|
||
|
||
# КОЛОНКА district (#647-слот) ОСТАЁТСЯ ПУСТОЙ и здесь. Идентификатор ячейки в неё не
|
||
# ложится: ячейка — ПРОМЕЖУТОЧНАЯ единица расчёта, а не единица публикации. На выходе
|
||
# по-прежнему одна строка на (регион, бакет) — агрегат по всем ячейкам; записать в
|
||
# district «какую-то одну» ячейку было бы враньём, а писать строку НА ЯЧЕЙКУ нельзя:
|
||
# потребитель (estimator._get_asking_sold_ratio) читает строго `district = ''` и
|
||
# ключевать оценку по гео-ячейке пока не умеет — это отдельная задача #647.
|
||
|
||
|
||
def _pct(part: float, whole: float) -> int:
|
||
"""Доля part/whole в ЦЕЛЫХ процентах (счётчики scrape_runs — dict[str, int])."""
|
||
if whole <= 0:
|
||
return 0
|
||
return round(100.0 * part / whole)
|
||
|
||
|
||
def geo_region_verdict(region_code: int, stats: dict[str, int] | None) -> tuple[bool, str]:
|
||
"""Писать ли строки региона по согласованной географии (пункт 6 — явная деградация).
|
||
|
||
Возвращает (ok, reason). ok=False — регион НЕ получает НИ ОДНОЙ строки (старые всё
|
||
равно удалены), и эстиматор честно остаётся без коэффициента вместо неверного.
|
||
Чистая функция от строки stats — тестируется без базы.
|
||
"""
|
||
if not stats:
|
||
return False, "geo-stats не вернулись"
|
||
deals_total = int(stats.get("deals_total") or 0)
|
||
deals_geo = int(stats.get("deals_geo") or 0)
|
||
listings_total = int(stats.get("listings_total") or 0)
|
||
listings_geo = int(stats.get("listings_geo") or 0)
|
||
cells_matched = int(stats.get("cells_matched") or 0)
|
||
deals_in_cells = int(stats.get("deals_in_cells") or 0)
|
||
|
||
if deals_total == 0 or listings_total == 0:
|
||
return False, f"нет данных: deals={deals_total} listings={listings_total}"
|
||
if deals_geo / deals_total < _MIN_GEOM_COVERAGE:
|
||
return False, (
|
||
f"сделки без geom: {_pct(deals_total - deals_geo, deals_total)}% "
|
||
f"(порог покрытия {_MIN_GEOM_COVERAGE:.0%})"
|
||
)
|
||
if listings_geo / listings_total < _MIN_GEOM_COVERAGE:
|
||
return False, (
|
||
f"объявления без geom: {_pct(listings_total - listings_geo, listings_total)}% "
|
||
f"(порог покрытия {_MIN_GEOM_COVERAGE:.0%})"
|
||
)
|
||
if cells_matched < _MIN_MATCHED_CELLS:
|
||
return False, (
|
||
f"ячеек с обеими сторонами {cells_matched} < {_MIN_MATCHED_CELLS} "
|
||
f"(географии сделок и объявлений практически не пересекаются)"
|
||
)
|
||
if deals_geo > 0 and deals_in_cells / deals_geo < _MIN_DEAL_CELL_COVERAGE:
|
||
return False, (
|
||
f"в пересечение ячеек попало {_pct(deals_in_cells, deals_geo)}% сделок "
|
||
f"(порог {_MIN_DEAL_CELL_COVERAGE:.0%})"
|
||
)
|
||
_ = region_code
|
||
return True, "ok"
|
||
|
||
|
||
def _geo_region_counters(region_code: int, stats: dict[str, int] | None) -> dict[str, int]:
|
||
"""Порегионные счётчики состава выборки (пункты 3 и 5 задачи), всё — int."""
|
||
s = stats or {}
|
||
deals_total = int(s.get("deals_total") or 0)
|
||
deals_geo = int(s.get("deals_geo") or 0)
|
||
listings_total = int(s.get("listings_total") or 0)
|
||
listings_geo = int(s.get("listings_geo") or 0)
|
||
deals_in_cells = int(s.get("deals_in_cells") or 0)
|
||
listings_in_cells = int(s.get("listings_in_cells") or 0)
|
||
cells_matched = int(s.get("cells_matched") or 0)
|
||
cells_both = int(s.get("cells_both_sides") or 0)
|
||
p = f"geo_r{region_code}_"
|
||
return {
|
||
p + "cells_deal": int(s.get("cells_deal") or 0),
|
||
p + "cells_ask": int(s.get("cells_ask") or 0),
|
||
p + "cells_matched": cells_matched,
|
||
# Ячейки, где есть обе стороны, но одна из них тоньше порога ячейки.
|
||
p + "cells_dropped": max(cells_both - cells_matched, 0),
|
||
# Сколько массы осталось ЗА пределами пересечения (от строк с geom).
|
||
p + "deals_outside_pct": _pct(deals_geo - deals_in_cells, deals_geo),
|
||
p + "listings_outside_pct": _pct(listings_geo - listings_in_cells, listings_geo),
|
||
# Строки без координат — не выпадают молча (пункт 3).
|
||
p + "deals_no_geom_pct": _pct(deals_total - deals_geo, deals_total),
|
||
p + "listings_no_geom_pct": _pct(listings_total - listings_geo, listings_total),
|
||
}
|
||
|
||
|
||
# ── Post-insert counters ──────────────────────────────────────────────────────
|
||
# Считываем итог из таблицы (всё ещё в той же транзакции — до commit): сколько строк
|
||
# записано всего, сколько per_rooms, был ли использован global -1 fallback.
|
||
# #3512: считается ОДИН раз в самом конце, ПОСЛЕ всех регионов (ЕКБ + цикл по
|
||
# _OTHER_REGION_CODES) — district='' покрывает все регионы разом, счётчики суммарные.
|
||
_COUNTERS_SQL = text(
|
||
"""
|
||
SELECT
|
||
COUNT(*) AS rows_written,
|
||
COUNT(*) FILTER (WHERE basis = 'per_rooms') AS per_rooms_rows,
|
||
COUNT(*) FILTER (WHERE rooms_bucket = -1) AS used_global_fallback
|
||
FROM asking_to_sold_ratios
|
||
WHERE district = ''
|
||
"""
|
||
)
|
||
|
||
|
||
def recompute_asking_to_sold_ratios(db: Session, run_id: int) -> dict[str, int]:
|
||
"""Пересчитать asking_to_sold_ratios per region (#3512, TRUE-MIRROR refresh #648 Stage 4).
|
||
|
||
Sync (вызывается scheduler-триггером в executor, как snapshot_listing_sources).
|
||
В ОДНОЙ транзакции (атомарно — таблица никогда не пуста mid-refresh для уже
|
||
посчитанных регионов):
|
||
1. Регион 66 (ЕКБ): DELETE (region_code=66, district='') → 080-derivation
|
||
INSERT...SELECT (byte-for-byte прежняя логика — городская квота ЕКБ, #C2/#2583).
|
||
2. Каждый прочий регион реестра app.services.regions (минус 66): тот же
|
||
DELETE/INSERT цикл, но derivation скоупится по `region_code` симметрично
|
||
на sold- и asking-стороне (без городской квоты — она не применима вне
|
||
ЕКБ, см. комментарий у generic-derivation SQL ниже).
|
||
Регион без своих ДКП-сделок ИЛИ без активных listings просто не получает строк
|
||
(global_row/per_bucket WHERE-гарды отфильтровывают NULL-медианы) — это НЕ
|
||
ошибка, а честный признак «данных пока недостаточно», ловится потребителем
|
||
(estimator._get_asking_sold_ratio) через отсутствие строки → явная деградация.
|
||
Затем ОДИН общий counters-запрос по всей таблице, commit, mark_done.
|
||
|
||
Финализирует scrape_runs (mark_done / mark_failed) и пишет counters.
|
||
|
||
LIMITATION (#2620, честно задокументировано — не гард, а факт данных): sold-сторона
|
||
(deals) НЕ имеет маркера новостройка/вторичка — Росреестр таким свойством ДКП не
|
||
делится, а listing_segment (гард #1186) существует только у listings. ask_side/ask_global
|
||
отфильтрованы на вторичку, deal_side/deal_global — нет. Замер на проде (2026-08, #2620):
|
||
доля сделок с year_built >= 2020 (грубый прокси новостройки) — 44.1% в бакете «4+» против
|
||
23.1% в бакетах 1-3 — заметный перекос, но year_built НЕ идентифицирует первичку/вторичку
|
||
(продажа квартиры 2021 года постройки в 2026м — легитимная вторичка), поэтому фильтр по
|
||
году НЕ добавлен (создал бы новую, столь же спекулятивную асимметрию). Area-бакет-фикс
|
||
ниже (см. _AREA_ROOMS_BUCKET_SQL) сам по себе убрал инверсию ratio>1 в бакете «4+»
|
||
(0.8315 на замере прод-данных 2026-08, было 1.0257) — снятие миграции между бакетами было
|
||
root cause, а не новостройки.
|
||
|
||
Returns {"rows_written": N, "per_rooms_rows": M, "used_global_fallback": 0|1}.
|
||
"""
|
||
counters: dict[str, int] = {
|
||
"rows_written": 0,
|
||
"per_rooms_rows": 0,
|
||
"used_global_fallback": 0,
|
||
# #3529: сколько регионов посчитано по согласованной географии, а сколько
|
||
# деградировало явно (строк нет → эстиматор без коэффициента).
|
||
"geo_regions_written": 0,
|
||
"geo_regions_skipped": 0,
|
||
# #time-adjust: сколько регионов реально получили sber-приведение SOLD-стороны
|
||
# (серия найдена) vs посчитаны с factor=1.0 (флаг выключен ИЛИ серии нет вовсе).
|
||
"sber_time_adjust_regions_applied": 0,
|
||
"sber_time_adjust_regions_missing_series": 0,
|
||
}
|
||
# #time-adjust: ленивый импорт — estimator.py импортирует area_bucket ИЗ этого модуля
|
||
# на верхнем уровне, top-level импорт в обратную сторону дал бы цикл (см. комментарий
|
||
# у _SBER_FACTOR_CTES выше).
|
||
from app.services.estimator import (
|
||
SBER_COEFF_DASHBOARDS,
|
||
SBER_TIME_FACTOR_MAX,
|
||
SBER_TIME_FACTOR_MIN,
|
||
sber_region_series_name,
|
||
)
|
||
|
||
sber_dashboards = list(SBER_COEFF_DASHBOARDS)
|
||
time_adjust_enabled = bool(settings.asking_ratio_time_adjust_enabled)
|
||
|
||
def _sber_params(sber_city: str) -> dict[str, object]:
|
||
return {
|
||
"sber_city": sber_city,
|
||
"sber_dashboards": sber_dashboards,
|
||
"time_adjust_enabled": time_adjust_enabled,
|
||
"factor_min": SBER_TIME_FACTOR_MIN,
|
||
"factor_max": SBER_TIME_FACTOR_MAX,
|
||
}
|
||
|
||
def _check_sber_series(region_code: int, sber_city: str) -> None:
|
||
"""Пункт 3 задачи: если серии для города вообще нет — не молча, счётчик+лог."""
|
||
if not time_adjust_enabled:
|
||
return
|
||
try:
|
||
row = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
SELECT COUNT(*) AS n FROM sber_price_index
|
||
WHERE city = CAST(:sber_city AS text)
|
||
AND dashboard = ANY(CAST(:sber_dashboards AS text[]))
|
||
"""
|
||
),
|
||
{"sber_city": sber_city, "sber_dashboards": sber_dashboards},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
except Exception as exc: # pragma: no cover — defensive, graceful
|
||
logger.warning("sber series presence-check failed (graceful): %s", exc)
|
||
row = None
|
||
n = int(row["n"]) if row and row.get("n") is not None else 0
|
||
if n:
|
||
counters["sber_time_adjust_regions_applied"] += 1
|
||
else:
|
||
counters["sber_time_adjust_regions_missing_series"] += 1
|
||
logger.warning(
|
||
"asking_to_sold_ratio region_code=%d: sber_price_index серии нет "
|
||
"(city=%s) — SOLD-сторона считается БЕЗ time-adjust (factor=1.0)",
|
||
region_code,
|
||
sber_city,
|
||
)
|
||
|
||
try:
|
||
# DELETE + re-derive INSERT в одной транзакции (НЕ коммитим между ними —
|
||
# таблица не должна остаться пустой, если INSERT упадёт). Регион 66 —
|
||
# прежняя ЕКБ-деривация байт-в-байт; остальные регионы — цикл ниже (#3512).
|
||
ekb_sber_city = sber_region_series_name(66)
|
||
_check_sber_series(66, ekb_sber_city)
|
||
db.execute(_DELETE_SQL)
|
||
db.execute(
|
||
_REDERIVE_SQL,
|
||
{
|
||
"ppm2_min": _PPM2_MIN,
|
||
"ppm2_max": settings.asking_ratio_ppm2_max,
|
||
"asking_city": _ASKING_CITY_PATTERN,
|
||
"fresh_days": LISTINGS_FRESH_DAYS,
|
||
**_sber_params(ekb_sber_city),
|
||
},
|
||
)
|
||
for region_code in _OTHER_REGION_CODES:
|
||
region_sber_city = sber_region_series_name(region_code)
|
||
_check_sber_series(region_code, region_sber_city)
|
||
params = {
|
||
"region_code": region_code,
|
||
"ppm2_min": _PPM2_MIN,
|
||
"ppm2_max": settings.asking_ratio_ppm2_max,
|
||
"fresh_days": LISTINGS_FRESH_DAYS,
|
||
**_sber_params(region_sber_city),
|
||
}
|
||
# Сначала состав выборки (#3529) — он же решает, писать ли регион вообще.
|
||
stats_row = db.execute(_GEO_STATS_SQL_REGION, params).mappings().first()
|
||
stats = dict(stats_row) if stats_row is not None else None
|
||
counters.update(_geo_region_counters(region_code, stats))
|
||
ok, reason = geo_region_verdict(region_code, stats)
|
||
|
||
# DELETE идёт В ЛЮБОМ случае: если согласованной географии больше нет, старый
|
||
# (считанный по пулу) коэффициент тем более не должен оставаться в таблице.
|
||
db.execute(_DELETE_SQL_REGION, {"region_code": region_code})
|
||
|
||
no_geom_deals = counters.get(f"geo_r{region_code}_deals_no_geom_pct", 0)
|
||
no_geom_listings = counters.get(f"geo_r{region_code}_listings_no_geom_pct", 0)
|
||
if max(no_geom_deals, no_geom_listings) >= int(_GEOM_WARN_SHARE * 100):
|
||
# Пункт 3: строки без координат не выпадают молча — это сигнал.
|
||
logger.warning(
|
||
"asking_to_sold_ratio region_code=%d: без geom сделок %d%%, "
|
||
"объявлений %d%% — гео-согласование считается по остатку",
|
||
region_code,
|
||
no_geom_deals,
|
||
no_geom_listings,
|
||
)
|
||
|
||
if not ok:
|
||
counters["geo_regions_skipped"] += 1
|
||
counters[f"geo_r{region_code}_skipped"] = 1
|
||
logger.warning(
|
||
"asking_to_sold_ratio region_code=%d: СТРОКИ НЕ ПИШУТСЯ — %s. "
|
||
"Оценка останется без коэффициента (явная деградация)",
|
||
region_code,
|
||
reason,
|
||
)
|
||
continue
|
||
|
||
counters[f"geo_r{region_code}_skipped"] = 0
|
||
counters["geo_regions_written"] += 1
|
||
db.execute(_REDERIVE_SQL_REGION, params)
|
||
logger.info(
|
||
"asking_to_sold_ratio region_code=%d: ячеек с обеими сторонами %d "
|
||
"(отброшено по порогу %d), вне пересечения: сделок %d%%, объявлений %d%%",
|
||
region_code,
|
||
counters.get(f"geo_r{region_code}_cells_matched", 0),
|
||
counters.get(f"geo_r{region_code}_cells_dropped", 0),
|
||
counters.get(f"geo_r{region_code}_deals_outside_pct", 0),
|
||
counters.get(f"geo_r{region_code}_listings_outside_pct", 0),
|
||
)
|
||
|
||
row = db.execute(_COUNTERS_SQL).mappings().first()
|
||
if row is not None:
|
||
counters["rows_written"] = int(row["rows_written"] or 0)
|
||
counters["per_rooms_rows"] = int(row["per_rooms_rows"] or 0)
|
||
counters["used_global_fallback"] = int(row["used_global_fallback"] or 0)
|
||
|
||
db.commit()
|
||
runs_mod.mark_done(db, run_id, counters)
|
||
logger.info(
|
||
"recompute_asking_to_sold_ratios run_id=%d done: "
|
||
"rows_written=%d per_rooms_rows=%d used_global_fallback=%d",
|
||
run_id,
|
||
counters["rows_written"],
|
||
counters["per_rooms_rows"],
|
||
counters["used_global_fallback"],
|
||
)
|
||
return counters
|
||
except Exception as exc:
|
||
logger.exception("recompute_asking_to_sold_ratios run_id=%d failed", run_id)
|
||
db.rollback()
|
||
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters)
|
||
raise
|