gendesign/data/sql/152_mv_ddu_price_indicator.sql
Light1YT 658d724075
Some checks failed
CI / changes (push) Successful in 8s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 8s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 7m37s
CI / backend-tests (pull_request) Successful in 7m42s
Deploy / deploy (push) Blocked by required conditions
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / build-frontend (push) Blocked by required conditions
Deploy / changes (push) Has been cancelled
feat(analytics): velocity-anomaly alerts (#17) + ARN ДДУ price indicator (#99)
#17: detect_velocity_anomalies + GET /analytics/velocity-alerts — z-score drop-detection
на domrf_kn_sale_graph (double-gate z≤-2 AND drop≤-30%, starvation-guards). Snapshot=richest
(не MAX — prod новейшие частичные), lookback anchored на latest report_month (scrape лаг ~4мес).
Prod: ЖК Центральный Парк -69%, ~14ms.

#99: mv_ddu_price_indicator (миграция 152) + POST /market/ddu-indicator — ARN-mirror
ценовой индекс per quarter×area_bucket из rosreestr_deals (ДДУ регион 66). MVP: subject-level,
period Q, window 2025-Q2+, methods 1/2 (basis/previous, prev_period_value honesty). Q1-2026
headline 1.0185 vs ARN 1.03 (±5%). Method 3 blocked (нет pre-2025-Q2 данных) — задокументировано.

Closes #17
Closes #99
2026-06-13 22:04:49 +05:00

177 lines
11 KiB
PL/PgSQL
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- 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;