194 lines
15 KiB
PL/PgSQL
194 lines
15 KiB
PL/PgSQL
-- 123_macro_indicator.sql
|
||
-- #945 (Site Finder v2 / «GG-форсайт» ТЗ §8.8) — EPIC «Унифицированная сущность Макропоказатель».
|
||
-- PR A: создать единую типизированную таблицу временных рядов макропоказателей
|
||
-- и сделать backfill из уже существующих разрозненных macro-таблиц.
|
||
--
|
||
-- Архитектурный принцип (фиксирован): ДЕТЕРМИНИРОВАННО, БЕЗ LLM — это чистый SQL
|
||
-- + тонкий reader (backend/app/services/site_finder/macro.py). Никаких внешних вызовов.
|
||
--
|
||
-- Это фундамент для (последующие эпики, НЕ этот PR):
|
||
-- • Mortgage Affordability Index, макро-коэффициент, key-rate sensitivity.
|
||
--
|
||
-- Зависимости (источники backfill, НЕ дропаются — продолжают писать своими импортёрами):
|
||
-- • cbr_mortgage_series (43_anton_import.sql) — period — РУССКАЯ строка
|
||
-- 'Месяц YYYY' (напр. 'Август 2019'), ключ ряда —
|
||
-- устойчивый series_id (region в prod = 'sverdl').
|
||
-- • domrf_mortgage_rates (20_schema_domrf_extras.sql) — per-bank, агрегируем в median.
|
||
--
|
||
-- indicator_type 'key_rate' НАМЕРЕННО остаётся НЕзаполненным в этом PR —
|
||
-- LIVE-скрапер ключевой ставки ЦБ это отдельный follow-up (PR B), он и заполнит 'key_rate'.
|
||
--
|
||
-- Deploy: auto-applied by deploy.yml через _schema_migrations tracking (ровно один раз).
|
||
-- Idempotent: CREATE TABLE/INDEX IF NOT EXISTS + INSERT ... ON CONFLICT DO UPDATE —
|
||
-- повторный прогон (или re-apply после refresh источников) безопасен и обновит value.
|
||
-- Migration order: эта SQL-миграция применяется ПЕРВОЙ, backend macro.py — следом.
|
||
|
||
BEGIN;
|
||
|
||
-- ── 1. Единая таблица типизированных рядов макропоказателей (ТЗ §8.8) ───────────
|
||
CREATE TABLE IF NOT EXISTS macro_indicator (
|
||
indicator_type TEXT NOT NULL, -- 'key_rate','mortgage_rate_primary','mortgage_rate_secondary',
|
||
-- 'mortgage_rate_weighted','mortgage_issued_count','mortgage_issued_volume',
|
||
-- 'mortgage_debt','mortgage_overdue_share','income_per_capita','cpi',
|
||
-- 'housing_input','construction_price_index', ...
|
||
region TEXT NOT NULL DEFAULT 'rf', -- 'rf' | 'sverdl' | ...
|
||
obs_date DATE NOT NULL, -- нормализовано к 1-му числу месяца для monthly-рядов
|
||
value NUMERIC,
|
||
source TEXT NOT NULL, -- 'cbr' | 'domrf' | 'rosstat' | ...
|
||
frequency TEXT NOT NULL DEFAULT 'monthly', -- 'daily'|'monthly'|'quarterly'|'yearly'
|
||
unit TEXT, -- '%','шт','млрд руб','руб', ...
|
||
comment TEXT,
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
PRIMARY KEY (indicator_type, region, obs_date)
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_macro_type_region_date
|
||
ON macro_indicator (indicator_type, region, obs_date DESC);
|
||
|
||
COMMENT ON TABLE macro_indicator IS
|
||
'Унифицированный типизированный временной ряд макропоказателей (ТЗ §8.8, #945). '
|
||
'Long-формат: (indicator_type, region, obs_date) → value. Backfill из cbr_mortgage_series '
|
||
'+ domrf_mortgage_rates (источники не дропаются). indicator_type=key_rate заполняет PR B '
|
||
'(LIVE-скрапер ставки ЦБ). Детерминированно, без LLM. Потребители: affordability/'
|
||
'sensitivity-эпики через backend/app/services/site_finder/macro.py.';
|
||
|
||
-- ── 2. Backfill из cbr_mortgage_series ─────────────────────────────────────────
|
||
-- period — РУССКАЯ строка 'Месяц YYYY' (напр. 'Август 2019', 'Апрель 2020').
|
||
-- to_date(period,'Mon YYYY') здесь НЕ работает: на русском месяце Postgres падает
|
||
-- с «invalid value "Август" for "Mon"». Поэтому парсим руками:
|
||
-- make_date(YYYY, <номер месяца>, 1), где номер месяца — CASE по русскому названию
|
||
-- (split_part(period,' ',1)). 1-е число месяца зашито в make_date — date_trunc не нужен.
|
||
-- Маппинг по УСТОЙЧИВОМУ КЛЮЧУ series_id (НЕ по title!). В prod строка «в рублях»
|
||
-- живёт в series_id, а title — общий («Средневзвешенная ставка по ипотечным жилищным
|
||
-- кредитам, предоставленным физическим лицам-резидентам») и НЕ содержит «в рублях»,
|
||
-- поэтому прежний title-ILIKE матчил 0 строк. series_id-значения чистые/стабильные,
|
||
-- поэтому сопоставляем ТОЧНЫМ равенством series_id = '...'. Всё прочее НЕ матчится
|
||
-- (детерминированно, без побочных эффектов). Скипнутые ряды задокументированы ниже.
|
||
--
|
||
-- Маппинг (series_id ТОЧНО → indicator_type, unit) — семейство _mortgage_ (ИЖК), форма 02.
|
||
-- У каждого показателя есть варианты _в_рублях и _итого; берём ТОЛЬКО _в_рублях —
|
||
-- маппинг обоих на один indicator_type коллиднул бы по PK (type,region,obs_date):
|
||
-- '02_13_Rates_mortgage_ставка_в_рублях' → mortgage_rate_weighted (%)
|
||
-- [льготно-средневзвешенная ИЖК-ставка ~7-8% в рублях; та же, что читает
|
||
-- analytics_queries._current_mortgage_rate; РЫНОЧНОЙ ставки без льгот в БД нет]
|
||
-- '02_13_Rates_mortgage_срок_в_руб' → mortgage_term (мес)
|
||
-- '02_10_Quantity_mortgage_в_рублях' → mortgage_issued_count (шт)
|
||
-- '02_11_New_loans_mortgage_в_рублях' → mortgage_issued_volume (млн руб)
|
||
-- '02_14_Debt_mortgage_в_рублях' → mortgage_debt (млн руб)
|
||
-- '02_14_Debt_mortgage_в_т.ч._просроч._в_рублях' → mortgage_overdue (млн руб)
|
||
-- НЕ берём: _housing_ (02_06/07/08 — все жилищные, шире ИЖК), _scpa_mortgage_
|
||
-- (02_15-18), _Share_ (02_44 — доли), и все _итого-варианты.
|
||
--
|
||
-- Guard: пропускаем строки с NULL value; строки с непарсящимся period (в prod в
|
||
-- period встречается мусор вроде '10054588.0') — отсекаем регуляркой
|
||
-- '^[А-Яа-яЁё]+ [0-9]{4}$' (русское слово + 4-значный год) ДО парсинга, плюс
|
||
-- требуем, чтобы CASE по названию месяца дал не-NULL номер (неизвестное слово →
|
||
-- month_num IS NULL → строка скипается, make_date никогда не получает NULL);
|
||
-- и строки, чей series_id не входит в маппинг (indicator_type IS NULL).
|
||
-- region берётся из источника КАК ЕСТЬ (в prod это 'sverdl' — единственный регион в
|
||
-- cbr_mortgage_series; 'rf'-рядов там нет).
|
||
--
|
||
-- Замечание про единицы: фактические единицы (млн/млрд руб, шт/единиц) зависят от
|
||
-- конкретного XLSX-листа ЦБ; ставим наиболее вероятную единицу формы 02. Это поле
|
||
-- справочное (не участвует в PK) и легко правится отдельным UPDATE при необходимости.
|
||
|
||
INSERT INTO macro_indicator
|
||
(indicator_type, region, obs_date, value, source, frequency, unit, comment)
|
||
SELECT
|
||
m.indicator_type,
|
||
s.region,
|
||
make_date(split_part(s.period, ' ', 2)::int, m.month_num, 1) AS obs_date,
|
||
s.value,
|
||
'cbr' AS source,
|
||
'monthly' AS frequency,
|
||
m.unit,
|
||
'backfill #945 from cbr_mortgage_series; series_id=' || s.series_id AS comment
|
||
FROM cbr_mortgage_series AS s
|
||
CROSS JOIN LATERAL (
|
||
SELECT
|
||
CASE s.series_id
|
||
WHEN '02_13_Rates_mortgage_ставка_в_рублях' THEN 'mortgage_rate_weighted'
|
||
WHEN '02_13_Rates_mortgage_срок_в_руб' THEN 'mortgage_term'
|
||
WHEN '02_10_Quantity_mortgage_в_рублях' THEN 'mortgage_issued_count'
|
||
WHEN '02_11_New_loans_mortgage_в_рублях' THEN 'mortgage_issued_volume'
|
||
WHEN '02_14_Debt_mortgage_в_рублях' THEN 'mortgage_debt'
|
||
WHEN '02_14_Debt_mortgage_в_т.ч._просроч._в_рублях' THEN 'mortgage_overdue'
|
||
ELSE NULL
|
||
END AS indicator_type,
|
||
CASE s.series_id
|
||
WHEN '02_13_Rates_mortgage_ставка_в_рублях' THEN '%'
|
||
WHEN '02_13_Rates_mortgage_срок_в_руб' THEN 'мес'
|
||
WHEN '02_10_Quantity_mortgage_в_рублях' THEN 'шт'
|
||
WHEN '02_11_New_loans_mortgage_в_рублях' THEN 'млн руб'
|
||
WHEN '02_14_Debt_mortgage_в_рублях' THEN 'млн руб'
|
||
WHEN '02_14_Debt_mortgage_в_т.ч._просроч._в_рублях' THEN 'млн руб'
|
||
ELSE NULL
|
||
END AS unit,
|
||
-- русское название месяца → номер (1..12); неизвестное слово → NULL (скип ниже)
|
||
CASE split_part(s.period, ' ', 1)
|
||
WHEN 'Январь' THEN 1 WHEN 'Февраль' THEN 2 WHEN 'Март' THEN 3
|
||
WHEN 'Апрель' THEN 4 WHEN 'Май' THEN 5 WHEN 'Июнь' THEN 6
|
||
WHEN 'Июль' THEN 7 WHEN 'Август' THEN 8 WHEN 'Сентябрь' THEN 9
|
||
WHEN 'Октябрь' THEN 10 WHEN 'Ноябрь' THEN 11 WHEN 'Декабрь' THEN 12
|
||
ELSE NULL
|
||
END AS month_num
|
||
) AS m
|
||
WHERE s.value IS NOT NULL
|
||
AND m.indicator_type IS NOT NULL
|
||
-- period — русская строка 'Месяц YYYY'; отсекаем мусор (напр. '10054588.0')
|
||
AND s.period ~ '^[А-Яа-яЁё]+ [0-9]{4}$'
|
||
-- неизвестное название месяца → month_num NULL → скип (make_date не получит NULL)
|
||
AND m.month_num IS NOT NULL
|
||
ON CONFLICT (indicator_type, region, obs_date) DO UPDATE
|
||
SET value = EXCLUDED.value,
|
||
source = EXCLUDED.source,
|
||
frequency = EXCLUDED.frequency,
|
||
unit = EXCLUDED.unit,
|
||
comment = EXCLUDED.comment,
|
||
updated_at = now();
|
||
|
||
-- ── 3. Backfill из domrf_mortgage_rates (per-bank → market median) ─────────────
|
||
-- Источник — ставки топ-банков по дате снапшота. Агрегируем к рыночному ряду:
|
||
-- median(primary_rate) по snapshot_date (set-based, NULL игнорируются percentile_cont).
|
||
-- ДОМ.РФ-ставка по первичке хранится под ОТДЕЛЬНЫМ indicator_type, чтобы не
|
||
-- коллидировать по PK с возможной cbr-ставкой на ту же дату (PK = type+region+obs_date).
|
||
-- ЦБ-ряд выше = mortgage_rate_weighted (льготная, ~7-8%); это иное измерение, поэтому
|
||
-- их сосуществование корректно и без disambiguation, но рыночную первичку ДОМ.РФ всё
|
||
-- равно держим отдельным типом 'mortgage_rate_primary_domrf' для прозрачности источника.
|
||
-- snapshot_date берём как obs_date КАК ЕСТЬ (это не строго monthly-ряд — это снапшоты);
|
||
-- frequency помечаем 'daily' (нерегулярные снапшоты), НЕ нормализуем к 1-му числу,
|
||
-- чтобы не схлопнуть два снапшота одного месяца в одну строку.
|
||
|
||
INSERT INTO macro_indicator
|
||
(indicator_type, region, obs_date, value, source, frequency, unit, comment)
|
||
SELECT
|
||
'mortgage_rate_primary_domrf' AS indicator_type,
|
||
'rf' AS region,
|
||
r.snapshot_date AS obs_date,
|
||
percentile_cont(0.5) WITHIN GROUP (ORDER BY r.primary_rate) AS value,
|
||
'domrf' AS source,
|
||
'daily' AS frequency,
|
||
'%' AS unit,
|
||
'backfill #945 median(primary_rate) per snapshot from domrf_mortgage_rates' AS comment
|
||
FROM domrf_mortgage_rates AS r
|
||
WHERE r.primary_rate IS NOT NULL
|
||
GROUP BY r.snapshot_date
|
||
HAVING percentile_cont(0.5) WITHIN GROUP (ORDER BY r.primary_rate) IS NOT NULL
|
||
ON CONFLICT (indicator_type, region, obs_date) DO UPDATE
|
||
SET value = EXCLUDED.value,
|
||
source = EXCLUDED.source,
|
||
frequency = EXCLUDED.frequency,
|
||
unit = EXCLUDED.unit,
|
||
comment = EXCLUDED.comment,
|
||
updated_at = now();
|
||
|
||
-- ── Skipped / не сопоставлено (детерминированно, документировано) ──────────────
|
||
-- • sber_ddf_metrics — пропущено намеренно (формат «2 точки value_2019/value_2025»
|
||
-- без месячной шкалы; не временной ряд, ТЗ допускает пропуск).
|
||
-- • Любые cbr_mortgage_series, чей series_id не входит в маппинг выше (все _итого-
|
||
-- варианты, _housing_ 02_06/07/08, _scpa_mortgage_ 02_15-18, _Share_ 02_44 и др.) —
|
||
-- НЕ вставлены. Это видно по COUNT источника vs вставленных строк; маппинг
|
||
-- расширяется отдельной миграцией добавлением series_id в оба CASE.
|
||
-- • indicator_type='key_rate' — пусто (заполнит PR B, LIVE-скрапер ставки ЦБ).
|
||
|
||
COMMIT;
|