-- 152_mv_ddu_price_indicator.sql -- Issue #99 — «Индикатор цен ДДУ» (ARN-framework applied to primary market). -- -- ARN (НСПД /api/market-analytics) считает индикатор цен ВТОРИЧНОГО рынка. -- У нас в rosreestr_deals — ПЕРВИЧКА (ДДУ). Это дополнение их фреймворка: -- тот же «индикатор = median(price/m²) тек.периода / median базисного периода», -- но по новостройкам. -- -- ── MVP SCOPE / честные упрощения (см. issue #99 «Risks / open») ─────────── -- Полный ARN: 4 indicator-типа (M/Q/H/Y) × 6 area buckets × 3 calc methods × -- 4 уровня гранулярности (Округ→Субъект→Нас.пункт→Cad_quarter). Реализовано -- НЕ всё — данных по первичке хватает только на часть матрицы: -- -- 1. ГРАНУЛЯРНОСТЬ = СУБЪЕКТ (region_code=66), НЕ cad_quarter. -- Per-cad_quarter × area_bucket для ДДУ безнадёжно разрежено: после смены -- агрегации rosreestr (2025Q1) на квартал приходится ~190-220 строк ИТОГО -- по всей области; на отдельный cad_quarter — единицы. Субъект-уровень — -- единственный статистически устойчивый уровень для первички. -- (Per-quarter price ОТДЕЛЬНО уже есть в mv_quarter_price_index — там -- кросс-секционный индекс «квартал vs город», другая метрика.) -- -- 2. PERIOD_TYPE = 'Q' (квартал). M/H/Y не делаем: -- - M (месяц): rosreestr публикует данные поквартально (period_start_date — -- начало квартала), месячной гранулярности в источнике НЕТ. -- - H/Y: см. п.3 — слишком мало сопоставимых периодов. -- -- 3. ОКНО = 2025-Q2 .. now. ЖЁСТКИЙ обрыв на 2025-Q1: -- rosreestr с 2025Q1 резко увеличил агрегацию (1 строка = пакет из N ДДУ, -- area=SUM, deal_count=N). Сырых строк до 2025Q1: 700-2400/кв; с 2025Q2: -- ~190-220/кв. Медиана price/m² скачет 66K→154K на границе 2024Q4→2025Q2 -- ЧИСТО из-за смены формата, не из-за инфляции. Поэтому периоды до 2025Q2 -- исключены — иначе basis/prev-индикатор даёт фейковые 1.4-1.6. -- => calc_method 3 (год назад) НЕ реализуем: нет сопоставимого 2025Q1-. -- -- 4. CALC METHODS: 1 = basis (к первому периоду окна = 2025-Q2), -- 2 = previous (к предыдущему ПРИСУТСТВУЮЩЕМУ периоду). -- method 3 (год назад) — BLOCKED (нет данных, см. п.3). -- -- 5. AREA_BUCKET: 1..6 по ARN (0-25/25-40/40-60/60-80/80-100/100+ м²) -- на ПОКВАРТИРНОЙ площади (area/deal_count — пакетные ДДУ!), плюс -- bucket 0 = ALL (headline, все площади вместе). -- -- 6. MIN_DEALS = 10 на (bucket, quarter): ниже порога медиана шумная → -- строка не создаётся (HAVING). prev/basis сравнивают только присутствующие -- периоды → prev_period_value хранится явно (может быть не «−1 квартал»). -- -- ── VALIDATION (prod, 2026-06, source rosreestr_deals) ───────────────────── -- ALL-buckets (bucket=0), method=previous: -- 2025-Q2 (basis) → 2025-Q3 1.0026 → 2025-Q4 1.0501 → 2026-Q1 1.0185 -- ARN benchmark Екатеринбург Q1 2026 (вторичка) = 1.03. -- Наш Q1 2026 (первичка) = 1.0185 → within ±5% (issue acceptance). ✓ -- Расхождение ожидаемо: primary vs secondary (объяснять в UI tooltip). -- -- Источник: rosreestr_deals (партиционированная, фильтр realestate_type_code= -- '002001003000' новостройки + doc_type='ДДУ'). Прямой запрос к таблице -- (НЕ через mv_quarter_price_per_m2: тому MV нужна area_bucket-разбивка, -- которой там нет — он агрегирует по cad_quarter без поквартирной площади). -- -- REFRESH CONCURRENTLY: UNIQUE индекс создаётся сразу после CREATE WITH NO DATA. -- Первый populate — non-concurrent (MV пуст). Re-apply idempotent: DROP ... CASCADE. -- _schema_migrations (deploy.yml) предотвращает повторный apply на prod. -- Refresh helper: backend/app/services/analytics/ddu_price_indicator.py (не в beat). BEGIN; DROP MATERIALIZED VIEW IF EXISTS mv_ddu_price_indicator CASCADE; CREATE MATERIALIZED VIEW mv_ddu_price_indicator AS WITH per_unit AS ( -- Поквартирная площадь и цена/м². Пакетные ДДУ: area=SUM, deal_count=N, -- поэтому area_per_unit = area/deal_count (иначе пакет из 5 студий -- попадает в bucket «80+»). price_per_sqm pre-computed; fallback deal_price/area. SELECT date_trunc('quarter', period_start_date)::date AS quarter_start, (area / deal_count) AS area_per_unit, CASE WHEN price_per_sqm IS NOT NULL THEN price_per_sqm WHEN deal_price IS NOT NULL AND area > 0 THEN deal_price / area ELSE NULL END AS price_m2 FROM rosreestr_deals WHERE region_code = 66 AND realestate_type_code = '002001003000' -- квартиры в новостройках AND doc_type = 'ДДУ' AND deal_count > 0 AND area > 0 -- Жёсткий обрыв формата (см. шапку п.3): окно строго с 2025-Q2. AND period_start_date >= DATE '2025-04-01' ), filtered AS ( SELECT quarter_start, area_per_unit, price_m2 FROM per_unit WHERE area_per_unit BETWEEN 10 AND 300 -- реалистичная 1-квартира AND price_m2 BETWEEN 30000 AND 800000 -- срез выбросов (как в #33 MV) ), bucketed AS ( -- Каждую сделку дублируем в её bucket (1..6) И в bucket 0 (ALL/headline) -- через UNION ALL, чтобы один GROUP BY посчитал и разбивку, и агрегат. SELECT quarter_start, price_m2, CASE WHEN area_per_unit < 25 THEN 1 WHEN area_per_unit < 40 THEN 2 WHEN area_per_unit < 60 THEN 3 WHEN area_per_unit < 80 THEN 4 WHEN area_per_unit < 100 THEN 5 ELSE 6 END AS area_bucket FROM filtered UNION ALL SELECT quarter_start, price_m2, 0 AS area_bucket FROM filtered ), agg AS ( SELECT area_bucket, quarter_start, COUNT(*) AS deals_count, PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price_m2) AS median_price_per_m2 FROM bucketed GROUP BY area_bucket, quarter_start HAVING COUNT(*) >= 10 -- min_deals: ниже порога медиана шумная ), indexed AS ( SELECT area_bucket, quarter_start, deals_count, median_price_per_m2, -- basis = первый присутствующий квартал окна для этого bucket FIRST_VALUE(median_price_per_m2) OVER w_asc AS basis_median, -- previous = предыдущий ПРИСУТСТВУЮЩИЙ квартал (после HAVING-фильтра) LAG(median_price_per_m2) OVER w_asc AS prev_median, LAG(quarter_start) OVER w_asc AS prev_quarter_start FROM agg WINDOW w_asc AS (PARTITION BY area_bucket ORDER BY quarter_start) ) SELECT area_bucket, -- period_value в ARN-стиле: '2026-Q1' (EXTRACT(YEAR FROM quarter_start)::int::text || '-Q' || EXTRACT(QUARTER FROM quarter_start)::int::text) AS period_value, quarter_start, deals_count, median_price_per_m2::numeric(12, 2) AS median_price_per_m2, -- calc_method 1: basis (к первому периоду окна) (median_price_per_m2 / NULLIF(basis_median, 0))::numeric(8, 4) AS index_basis, -- calc_method 2: previous (к предыдущему присутствующему периоду) (median_price_per_m2 / NULLIF(prev_median, 0))::numeric(8, 4) AS index_previous, -- какой период реально сравнивается в index_previous (может быть не −1 кв, -- если промежуточный quarter не прошёл HAVING) — честность для UI tooltip CASE WHEN prev_quarter_start IS NOT NULL THEN (EXTRACT(YEAR FROM prev_quarter_start)::int::text || '-Q' || EXTRACT(QUARTER FROM prev_quarter_start)::int::text) END AS prev_period_value, 'Q'::text AS period_type, NOW() AS computed_at FROM indexed WITH NO DATA; -- UNIQUE индекс для REFRESH CONCURRENTLY + O(1) lookup эндпойнтом. CREATE UNIQUE INDEX IF NOT EXISTS mv_ddu_price_indicator_pk ON mv_ddu_price_indicator (area_bucket, period_value); -- Первый populate (non-concurrent — MV пуст, CONCURRENTLY на пустом запрещён). REFRESH MATERIALIZED VIEW mv_ddu_price_indicator; COMMENT ON MATERIALIZED VIEW mv_ddu_price_indicator IS 'ARN-style ценовой индикатор ПЕРВИЧНОГО рынка (ДДУ) по кварталам × area_bucket. ' 'Source: rosreestr_deals (region 66, realestate_type_code=002001003000, doc_type=ДДУ). ' 'MVP scope (issue #99): granularity=субъект (НЕ cad_quarter — первичка разрежена), ' 'period_type=Q, окно с 2025-Q2 (обрыв формата rosreestr на 2025-Q1), ' 'calc methods 1=basis / 2=previous (method 3 год-назад BLOCKED: нет данных). ' 'area_bucket 0=ALL(headline)/1..6=ARN-площади на поквартирной area/deal_count. ' 'min_deals=10/строка. Q1-2026 headline=1.0185 vs ARN-вторичка 1.03 (±5%, primary vs secondary). ' 'Refresh: ddu_price_indicator.refresh_ddu_price_indicator() (не в beat). Issue #99.'; COMMIT;