-- 95_mv_quarter_price.sql -- Issue #33 D1 — Per-cad_quarter price aggregation из 6.83M rosreestr_deals. -- -- Real schema (verified via information_schema): -- quarter_cad_number varchar — cadastral quarter key (e.g. '66:41:0301036') -- price_per_sqm numeric — pre-computed руб/м²; filled for 6.82M / 6.83M rows -- deal_price numeric — total deal price (fallback if price_per_sqm IS NULL) -- area numeric — area m² (fallback denominator) -- period_start_date date — quarter start (2024-01-01 .. 2026-01-01) -- realestate_type_code text — '002001003000' = новостройки (ДДУ) -- -- Filter scope: -- realestate_type_code = '002001003000' → 3 143 004 rows (45.9% of total 6.83M) -- price_per_sqm BETWEEN 30000 AND 800000 → outlier removal -- period_start_date >= NOW() - INTERVAL '24 months' → rolling window -- HAVING COUNT(*) >= 3 → suppress quarters with too few deals -- -- Expected rows: ~52 492 distinct quarters pass HAVING >= 3 (verified via SELECT COUNT). -- Dataset is all-Russia (rosreestr_deals is not region-filtered at table level). -- -- Sub-window medians (6m / 12m): -- PERCENTILE_CONT does not support FILTER clause in PG16. -- Workaround: pre-aggregate per quarter with separate FILTER masks in a lateral -- subquery. Implemented as a second CTE (agg_windows) joined to the main agg. -- -- REFRESH CONCURRENTLY: safe after this migration because a UNIQUE INDEX on -- quarter_cad_number is created immediately after CREATE WITH NO DATA. -- First populate is non-concurrent (MV is empty, CONCURRENTLY not allowed). -- -- Deploy: auto-applied by deploy.yml via _schema_migrations tracking. -- Dependencies: none (no existing views depend on this MV at creation time). -- -- WARNING: Re-apply drops MV + CASCADE; after re-apply first REFRESH must use -- concurrently=False. _schema_migrations tracking prevents re-apply in prod. BEGIN; DROP MATERIALIZED VIEW IF EXISTS mv_quarter_price_per_m2 CASCADE; CREATE MATERIALIZED VIEW mv_quarter_price_per_m2 AS WITH dataset AS ( -- Resolve price_m2: use pre-computed field, fall back to deal_price / area SELECT quarter_cad_number, 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, period_start_date AS deal_date FROM rosreestr_deals WHERE realestate_type_code = '002001003000' AND period_start_date >= NOW() - INTERVAL '24 months' ), filtered AS ( -- Apply outlier filter once; reused by both aggregate CTEs SELECT quarter_cad_number, price_m2, deal_date FROM dataset WHERE price_m2 BETWEEN 30000 AND 800000 ), agg_main AS ( -- Primary aggregation: full 24-month window SELECT quarter_cad_number, COUNT(*) AS deals_count, PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY price_m2) AS p25, PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY price_m2) AS median, PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY price_m2) AS p75, AVG(price_m2)::numeric(12, 2) AS mean, MAX(deal_date) AS last_deal_date FROM filtered GROUP BY quarter_cad_number HAVING COUNT(*) >= 3 ), agg_windows AS ( -- Sub-window medians per quarter. -- PERCENTILE_CONT does not accept a FILTER clause in PG16, so we pre-filter -- per window inside separate conditional aggregations using a CASE that -- returns NULL for out-of-window rows (NULLs are excluded from PERCENTILE_CONT). SELECT quarter_cad_number, PERCENTILE_CONT(0.50) WITHIN GROUP ( ORDER BY CASE WHEN deal_date > NOW() - INTERVAL '6 months' THEN price_m2 ELSE NULL END ) AS median_6m, PERCENTILE_CONT(0.50) WITHIN GROUP ( ORDER BY CASE WHEN deal_date > NOW() - INTERVAL '12 months' THEN price_m2 ELSE NULL END ) AS median_12m FROM filtered GROUP BY quarter_cad_number ) SELECT m.quarter_cad_number, m.deals_count, m.p25, m.median, m.p75, m.mean, m.last_deal_date, w.median_6m, w.median_12m, -- median_24m == median (window is already 24mo); kept for API symmetry m.median AS median_24m FROM agg_main m JOIN agg_windows w USING (quarter_cad_number) WITH NO DATA; -- UNIQUE index required for REFRESH MATERIALIZED VIEW CONCURRENTLY. -- Created on empty MV → instant (no data lock). CREATE UNIQUE INDEX mv_quarter_price_pk ON mv_quarter_price_per_m2 (quarter_cad_number); -- Initial populate (non-concurrent — CONCURRENTLY not allowed on empty MV). REFRESH MATERIALIZED VIEW mv_quarter_price_per_m2; COMMENT ON MATERIALIZED VIEW mv_quarter_price_per_m2 IS 'Per-cad_quarter (quarter_cad_number) price aggregation из rosreestr_deals. ' 'Filter: realestate_type_code=002001003000 (новостройки/ДДУ), rolling 24 months, ' 'outlier filter 30K–800K руб/м², HAVING >= 3 deals. ' 'All-Russia scope (~52K rows expected). ' 'Refresh: weekly via celery beat (TBD, Issue #33 PR C).'; COMMIT;