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