-- 179_mv_quarter_price_cadastral_floor.sql -- Denoise the deals price index by a cadastral-value floor. -- -- Chain (unchanged consumers): -- rosreestr_deals -- → mv_quarter_price_per_m2 (95_mv_quarter_price.sql) ← MODIFIED here -- → mv_quarter_price_index (99a_quarter_price_index.sql) ← recreated as-is -- → tradein FDW quarter_price_index (reads mv_quarter_price_index by name) -- -- What changes: mv_quarter_price_per_m2 gains a cadastral floor. Registered deals -- priced below 0.7 × the cadastral median ₽/m² for their cad-quarter are dropped -- as noise (data-entry junk / non-arm's-length deals drag medians down). -- Floor source: rosreestr_cadastral_value (schema 178, loaded MANUALLY by -- 178b_load_cadastral_value.py — region-66-scoped, apartment code 002001003000). -- -- Why this file recreates BOTH MVs: -- DROP MATERIALIZED VIEW mv_quarter_price_per_m2 CASCADE also drops the dependent -- mv_quarter_price_index. We must recreate the index MV (verbatim from 99a) to -- restore the FDW contract that CASCADE removed. -- -- *** CRITICAL SAFETY — no-op when rosreestr_cadastral_value is empty *** -- On prod this migration auto-applies (deploy.yml / _schema_migrations) BEFORE -- any cadastral data is loaded — the table exists (178) but is EMPTY. With zero -- rows, the cad_floor CTE produces zero rows → the LEFT JOIN yields -- floor_ppm2 IS NULL for EVERY quarter → the predicate -- `cf.floor_ppm2 IS NULL OR price_m2 >= cf.floor_ppm2` keeps ALL deals. The -- resulting mv_quarter_price_per_m2 is byte-identical to the pre-floor (95) -- definition. The floor only takes effect after an operator loads cadastral -- data and REFRESHes the MVs. Verified by a rolled-back dry-run (EXCEPT both -- directions = 0 rows when the cad source is empty). -- -- Idempotent: DROP ... IF EXISTS ... CASCADE + recreate. Re-apply guarded by -- _schema_migrations in prod. First REFRESH is non-concurrent (empty MV). -- Dependencies: rosreestr_deals, rosreestr_cadastral_value (178). -- Output columns / unique index names / REFRESH / COMMENT preserved exactly -- (FDW + 99a depend on the column contract). BEGIN; -- --------------------------------------------------------------------------- -- 1. Drop per_m2 MV (CASCADE also drops mv_quarter_price_index — recreated below). -- --------------------------------------------------------------------------- DROP MATERIALIZED VIEW IF EXISTS mv_quarter_price_per_m2 CASCADE; -- --------------------------------------------------------------------------- -- 2. Recreate mv_quarter_price_per_m2 = body from 95_mv_quarter_price.sql, -- plus the cadastral floor (cad_floor CTE + LEFT JOIN in `filtered`). -- --------------------------------------------------------------------------- CREATE MATERIALIZED VIEW mv_quarter_price_per_m2 AS WITH cad_floor AS ( -- Cadastral median ₽/m² per quarter × 0.7 → noise floor. -- Zero rows when rosreestr_cadastral_value is empty → floor disabled (no-op). SELECT quarter_cad_number, 0.7 * percentile_cont(0.5) WITHIN GROUP (ORDER BY cost_per_area) AS floor_ppm2 FROM rosreestr_cadastral_value WHERE realestate_type_code = '002001003000' AND cost_per_area IS NOT NULL AND cost_per_area > 0 GROUP BY quarter_cad_number ), 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. -- Cadastral-floor denoise: drop deals below 0.7 × cadastral median ₽/m². -- floor_ppm2 IS NULL (no cadastral row for the quarter) → keep the deal. SELECT quarter_cad_number, price_m2, deal_date FROM dataset LEFT JOIN cad_floor cf USING (quarter_cad_number) WHERE price_m2 BETWEEN 30000 AND 800000 AND (cf.floor_ppm2 IS NULL OR price_m2 >= cf.floor_ppm2) ), 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).'; -- --------------------------------------------------------------------------- -- 3. Recreate mv_quarter_price_index = body from 99a_quarter_price_index.sql, -- UNCHANGED (restores what the CASCADE above dropped). -- --------------------------------------------------------------------------- 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;