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
#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
177 lines
11 KiB
PL/PgSQL
177 lines
11 KiB
PL/PgSQL
-- 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;
|