Quarterly Rosreestr dataset_КАДАСТРСТОИМОСТЬ (cadastral value) gives a per-cad-quarter cadastral ₽/m². Registered deals priced below 0.7x that quarter's cadastral median are dropped from mv_quarter_price_per_m2 as noise (data-entry junk / non-arm's-length deals that drag medians down). - 178_schema_cadastral_value.sql: rosreestr_cadastral_value (+ staging), region-66-scoped, idempotent. - 178b_load_cadastral_value.py: psycopg v3 staging COPY -> typed region-66 INSERT, idempotent skip-if-loaded. Run MANUALLY by operator after deploy. - 179_mv_quarter_price_cadastral_floor.sql: recreates BOTH MVs (CASCADE drops the dependent index MV). Adds cad_floor CTE + LEFT JOIN guard to mv_quarter_price_per_m2; mv_quarter_price_index recreated verbatim. Safe to auto-apply on prod before any cadastral data exists: empty rosreestr_cadastral_value -> cad_floor has 0 rows -> floor_ppm2 IS NULL for every quarter -> predicate keeps ALL deals -> MV byte-identical to the pre-floor (95) definition. Proven by a rolled-back dry-run (66:% EXCEPT both directions = 0). With the experiment cad source loaded the floor moves 480 of 1972 quarters (median 66788 -> 67350). NN bumped from spec's 172/173 to 178/178b/179 (172-177 already on main).
223 lines
10 KiB
PL/PgSQL
223 lines
10 KiB
PL/PgSQL
-- 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;
|