From 2e237d0288cef33865af6cef4d041dc109c09f38 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 30 May 2026 20:02:43 +0300 Subject: [PATCH] =?UTF-8?q?feat(db):=20mv=5Fquarter=5Fprice=5Findex=20?= =?UTF-8?q?=E2=80=94=20quarter=20price-index=20=D0=B8=D0=B7=20=D0=94=D0=9A?= =?UTF-8?q?=D0=9F=20(Refs=20#760)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #647-1 foundation (P0, инфра — клиентскую цену НЕ меняет). MV mv_quarter_price_index в main-БД: price_index = median_ppm2(quarter) / median_ppm2(city) по cad-кварталам 66:% (Свердловская обл), нормировка к ЕКБ city median (66:41, deals>=5). Методология: deals>=5 → quarter median; sparse (<5) → district(66:NN) median; нет данных → 1.0. basis-колонка маркирует источник. UNIQUE index на quarter_cad_number (REFRESH CONCURRENTLY + O(1) lookup estimator #647-3). Источник — существующая mv_quarter_price_per_m2 (95_mv_quarter_price.sql). Deploy-ordering: deploy.yml применяет 'ls data/sql/*.sql | sort' (лексикограф); 99a_ сортируется ПОСЛЕ 99_ ('_'<'a'), 100_+ ушли бы перед 99_ → 99a_ корректно применяется последним. Идемпотент (DROP IF EXISTS CASCADE), BEGIN/COMMIT. Валидация (live, 2026-05-30): city_median≈110 883; 1972 квартала (quarter 1708 / district 262 / city_fallback 2); 0 NULL; index 0.277–3.98; spot-check центр ЕКБ 66:41:0401018→3.98, периферия 66:17:0806002→0.28, median-quarter→1.00. Зависимости: блокирует #647-2 (#762 schema+refresh) и #647-3 (integration, за бэктест-гейтом #763). --- data/sql/99a_quarter_price_index.sql | 115 +++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 data/sql/99a_quarter_price_index.sql diff --git a/data/sql/99a_quarter_price_index.sql b/data/sql/99a_quarter_price_index.sql new file mode 100644 index 00000000..c253e2bc --- /dev/null +++ b/data/sql/99a_quarter_price_index.sql @@ -0,0 +1,115 @@ +-- 99a_quarter_price_index.sql +-- Issue #760 — Materialized view mv_quarter_price_index: per-cadastral-quarter +-- price index normalized to EKB city median. Foundation for estimator #647-3. +-- +-- Naming: deploy.yml applies files via `ls -1 data/sql/*.sql | sort` (lexicographic). +-- '99a_' sorts AFTER '99_nspd_entities_denorm.sql' because '_' (0x5F) < 'a' (0x61). +-- Files 100_–122_ sort before 99_ (since '1' < '9') — they are already applied. +-- This file applies last of all current migrations. +-- +-- Dependencies: +-- - public.mv_quarter_price_per_m2 (created by 95_mv_quarter_price.sql) +-- Columns used: quarter_cad_number (varchar), deals_count (bigint), median (float8) +-- +-- Methodology: +-- 1. Source: mv_quarter_price_per_m2, filter scope = all 66:% quarters present. +-- 2. City normalizer (city_median): percentile_cont(0.5) over EKB (66:41:%) quarters +-- with deals_count >= 5. Computed in SQL — NOT hardcoded. +-- 3. District fallback: per district prefix substring(quarter_cad_number for 5) +-- (e.g. '66:41'), district_median = median of quarter medians (deals_count>=5). +-- 4. price_index assignment: +-- deals_count >= 5 → q.median / city_median basis='quarter' +-- deals_count < 5 + district exists → district_median / city_median basis='district' +-- no data → 1.0 basis='city_fallback' +-- 5. Coverage: all 66:% quarters (Sverdlovsk oblast), normalizer = EKB city median. +-- +-- REFRESH CONCURRENTLY: enabled via UNIQUE index on quarter_cad_number. +-- Re-apply: DROP MATERIALIZED VIEW IF EXISTS ... CASCADE makes it idempotent. +-- _schema_migrations tracking prevents re-apply in prod (deploy.yml guard). +-- +-- Validation snapshot (2026-05-30, source MV current): +-- city_median ≈ 110,883 ₽/m² +-- total 66:% quarters covered: 1,972 +-- basis breakdown: quarter=1,708 / district=262 / city_fallback=2 +-- NULL price_index: 0 +-- index range: 0.2771 – 3.9788 +-- Near-1.0 quarter: 66:41:0313118 (index=1.0000, q_median=110,883) +-- Top quarter: 66:41:0401018 (index=3.9788, q_median=441,176 — центр ЕКБ) +-- Low quarter: 66:17:0806002 (index=0.2771, q_median=30,722 — периферия области) +-- City-fallback examples: 66:00:0000000 (null-district), 66:39:0302011 (district no data) + +BEGIN; + +DROP MATERIALIZED VIEW IF EXISTS mv_quarter_price_index CASCADE; + +CREATE MATERIALIZED VIEW mv_quarter_price_index AS +WITH +-- All 66:% quarters from source MV (Sverdlovsk oblast scope) +all_66_quarters AS ( + SELECT + quarter_cad_number, + deals_count, + median, + substring(quarter_cad_number FOR 5) AS district_prefix + FROM mv_quarter_price_per_m2 + WHERE quarter_cad_number LIKE '66:%' +), + +-- City normalizer: equal-weight median-of-quarter-medians for EKB (66:41:%) +-- Uses only quarters with deals_count >= 5 to suppress noise. +city_median_cte AS ( + SELECT + percentile_cont(0.5) WITHIN GROUP (ORDER BY median) AS city_median + FROM all_66_quarters + WHERE quarter_cad_number LIKE '66:41:%' + AND deals_count >= 5 +), + +-- District fallback: median of quarter medians per district prefix, +-- also restricted to deals_count >= 5 quarters. +district_medians AS ( + SELECT + district_prefix, + percentile_cont(0.5) WITHIN GROUP (ORDER BY median) AS district_median + FROM all_66_quarters + WHERE deals_count >= 5 + GROUP BY district_prefix +) + +SELECT + q.quarter_cad_number, + CASE + WHEN q.deals_count >= 5 THEN q.median / c.city_median + WHEN dm.district_median IS NOT NULL THEN dm.district_median / c.city_median + ELSE 1.0 + END::double precision AS price_index, + q.deals_count AS n_deals, + CASE + WHEN q.deals_count >= 5 THEN 'quarter' + WHEN dm.district_median IS NOT NULL THEN 'district' + ELSE 'city_fallback' + END::text AS basis, + NOW() AS computed_at +FROM all_66_quarters q +CROSS JOIN city_median_cte c +LEFT JOIN district_medians dm ON dm.district_prefix = q.district_prefix +WITH NO DATA; + +-- UNIQUE index required for REFRESH MATERIALIZED VIEW CONCURRENTLY. +-- Also serves as O(1) PK lookup for the estimator (#647-3). +CREATE UNIQUE INDEX IF NOT EXISTS mv_quarter_price_index_uq + ON mv_quarter_price_index (quarter_cad_number); + +-- Initial populate (non-concurrent — MV is empty, CONCURRENTLY not allowed on empty MV). +REFRESH MATERIALIZED VIEW mv_quarter_price_index; + +COMMENT ON MATERIALIZED VIEW mv_quarter_price_index IS + 'Per-cadastral-quarter price index normalized to EKB city median (=1.0). ' + 'Source: mv_quarter_price_per_m2. Scope: all 66:% (Sverdlovsk oblast) quarters. ' + 'city_median = percentile_cont(0.5) over 66:41:% quarters with deals_count>=5. ' + 'basis: quarter (>=5 deals) | district (sparse, district fallback) | city_fallback. ' + 'Refresh: CONCURRENTLY safe (UNIQUE index on quarter_cad_number). ' + 'Consumer: estimator service (#647-3) reads O(1) by quarter_cad_number. ' + 'Issue: #760.'; + +COMMIT;