From 77b5fb800ec3dcaff7df8e00a4e99d815511ff16 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Tue, 2 Jun 2026 23:41:16 +0500 Subject: [PATCH] feat(site-finder): unified macro_indicator table + backfill + reader (#945 PR A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Site Finder v2 / GG-форсайт foundation (deterministic, no LLM). New typed macro_indicator series table (§8.8: indicator_type/region/obs_date/value/ source/frequency/unit) consolidating scattered macro data. Backfill (idempotent, ON CONFLICT DO UPDATE): - cbr_mortgage_series → 4 ИЖК series by exact series_id (rate_weighted/ issued_count/issued_volume/term), Russian-month period 'Август 2019' parsed via 12-month Cyrillic CASE → make_date (guard ^[А-Яа-яЁё]+ [0-9]{4}$ skips junk periods). region=sverdl. - domrf_mortgage_rates → median primary_rate per snapshot_date (mortgage_rate_primary_domrf). Reader services/site_finder/macro.py: get_macro_series/get_latest_macro (psycopg-v3 CAST), pure classify_cbr_series mirror (test-enforced consistency). Prod dry-run (rollback): 345 rows, sane values (rate avg 8.39%, domrf 19.13%). key_rate left for PR B (live CBR scraper). 25 tests, no LLM, additive. --- backend/app/services/site_finder/macro.py | 129 ++++++++++ .../tests/services/site_finder/test_macro.py | 226 ++++++++++++++++++ data/sql/123_macro_indicator.sql | 194 +++++++++++++++ 3 files changed, 549 insertions(+) create mode 100644 backend/app/services/site_finder/macro.py create mode 100644 backend/tests/services/site_finder/test_macro.py create mode 100644 data/sql/123_macro_indicator.sql diff --git a/backend/app/services/site_finder/macro.py b/backend/app/services/site_finder/macro.py new file mode 100644 index 00000000..dde3ab37 --- /dev/null +++ b/backend/app/services/site_finder/macro.py @@ -0,0 +1,129 @@ +"""Thin deterministic reader for the unified macro_indicator series table. + +ТЗ §8.8 / #945 (EPIC «Унифицированная сущность Макропоказатель»), PR A. + +Архитектурный принцип: ДЕТЕРМИНИРОВАННО, БЕЗ LLM, без внешних вызовов — это просто +типизированное чтение временных рядов из таблицы macro_indicator (создаётся и +заполняется миграцией data/sql/123_macro_indicator.sql). + +Это то, что будут потреблять последующие эпики: Mortgage Affordability Index, +макро-коэффициент, key-rate sensitivity. Держим helper МИНИМАЛЬНЫМ. + +psycopg v3 / SQLAlchemy text: bind-параметры ВСЕГДА через CAST(:x AS type) — +никогда :x::type (парсер psycopg3 игнорирует :name::type → SyntaxError). + +NB: analytics_queries._current_mortgage_rate НЕ трогаем в этом PR (отдельная задача). +""" + +from __future__ import annotations + +from datetime import date + +from sqlalchemy import text +from sqlalchemy.orm import Session + +# ── Маппинг cbr_mortgage_series.series_id → indicator_type (ЗЕРКАЛО SQL-миграции) ─ +# Это pure-Python зеркало CASE series_id-выражения из 123_macro_indicator.sql. Сам +# backfill делается set-based в SQL (миграция не импортирует Python) — здесь дублируем +# логику ТОЛЬКО для unit-тестируемости и для возможного переиспользования из Python. +# При изменении маппинга в миграции — синхронно правьте здесь (и наоборот). +# +# Ключ — устойчивый series_id (НЕ title!): в prod строка «в рублях» живёт именно в +# series_id, а title общий и её не содержит, поэтому матчим series_id точным равенством. +# Берём ТОЛЬКО _в_рублях-варианты семейства _mortgage_ (ИЖК, форма 02): маппинг и +# _в_рублях, и _итого на один indicator_type коллиднул бы по PK (type,region,obs_date). +# НЕ включены: _итого-варианты, _housing_ (02_06/07/08), _scpa_mortgage_ (02_15-18), +# _Share_ (02_44) — все они дают None и в backfill не попадают. +_CBR_SERIES_MAP: dict[str, tuple[str, str]] = { + "02_13_Rates_mortgage_ставка_в_рублях": ("mortgage_rate_weighted", "%"), + "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", "млн руб"), +} + + +def classify_cbr_series(series_id: str | None) -> tuple[str, str] | None: + """Pure helper: cbr_mortgage_series.series_id → (indicator_type, unit) | None. + + Детерминированно, без БД. Ключуем по УСТОЙЧИВОМУ series_id (не по title — title + в prod общий и не содержит «в рублях»). Возвращает None для рядов, которые НЕ + сопоставляются ни одному чистому макротипу (они сознательно пропускаются в backfill: + все _итого-варианты, _housing_, _scpa_mortgage_, _Share_ и любой неизвестный ключ). + + Зеркалит CASE series_id-логику миграции 123_macro_indicator.sql (держать в синхроне). + """ + if not series_id: + return None + return _CBR_SERIES_MAP.get(series_id) + + +# ── Reader ────────────────────────────────────────────────────────────────────── + +def get_macro_series( + db: Session, + indicator_type: str, + region: str = "rf", + since: date | None = None, +) -> list[tuple[date, float]]: + """Весь ряд (obs_date, value) для (indicator_type, region), ASC по obs_date. + + Args: + db: SQLAlchemy sync Session. + indicator_type: напр. 'mortgage_rate_weighted', 'key_rate' (key_rate заполнит PR B). + region: 'rf' | 'sverdl' | ... (по умолчанию 'rf'). + since: если задано — только obs_date >= since. + + Returns: + Список (obs_date, value) по возрастанию даты; строки с NULL value отброшены. + """ + rows = db.execute( + text( + """ + SELECT obs_date, value + FROM macro_indicator + WHERE indicator_type = CAST(:itype AS text) + AND region = CAST(:region AS text) + AND value IS NOT NULL + AND (CAST(:since AS date) IS NULL OR obs_date >= CAST(:since AS date)) + ORDER BY obs_date ASC + """ + ), + {"itype": indicator_type, "region": region, "since": since}, + ).all() + return [(r[0], float(r[1])) for r in rows] + + +def get_latest_macro( + db: Session, + indicator_type: str, + region: str = "rf", +) -> tuple[date, float] | None: + """Последняя (по obs_date) непустая точка ряда или None, если ряда нет. + + Args: + db: SQLAlchemy sync Session. + indicator_type: тип показателя. + region: регион (по умолчанию 'rf'). + + Returns: + (obs_date, value) самой свежей точки, либо None. + """ + row = db.execute( + text( + """ + SELECT obs_date, value + FROM macro_indicator + WHERE indicator_type = CAST(:itype AS text) + AND region = CAST(:region AS text) + AND value IS NOT NULL + ORDER BY obs_date DESC + LIMIT 1 + """ + ), + {"itype": indicator_type, "region": region}, + ).first() + if row is None: + return None + return row[0], float(row[1]) diff --git a/backend/tests/services/site_finder/test_macro.py b/backend/tests/services/site_finder/test_macro.py new file mode 100644 index 00000000..1c3801e3 --- /dev/null +++ b/backend/tests/services/site_finder/test_macro.py @@ -0,0 +1,226 @@ +"""Unit-тесты для reader макропоказателей (#945 PR A, ТЗ §8.8). + +Чистые тесты (без живой БД): + • classify_cbr_series — pure mapping cbr series_id → indicator_type (+ skip unmapped). + • Консистентность Python-зеркала с SQL-миграцией 123_macro_indicator.sql. + • Форма SQL reader-а (CAST(:x AS type), ORDER BY, params) через MagicMock-сессию, + как в test_best_layouts.py — без подключения к Postgres. + +psycopg v3 правило проверяется явно: bind-параметры должны быть CAST(:x AS type), +а не :x::type. +""" + +from __future__ import annotations + +import datetime as dt +import pathlib +from unittest.mock import MagicMock + +from app.services.site_finder.macro import ( + _CBR_SERIES_MAP, + classify_cbr_series, + get_latest_macro, + get_macro_series, +) + +# Путь к миграции — для теста-консистентности зеркала. +_MIGRATION = ( + pathlib.Path(__file__).resolve().parents[4] + / "data" + / "sql" + / "123_macro_indicator.sql" +) + + +# ── pure: classify_cbr_series ───────────────────────────────────────────────── + + +class TestClassifyCbrSeries: + def test_weighted_rate(self) -> None: + assert classify_cbr_series("02_13_Rates_mortgage_ставка_в_рублях") == ( + "mortgage_rate_weighted", + "%", + ) + + def test_term(self) -> None: + assert classify_cbr_series("02_13_Rates_mortgage_срок_в_руб") == ( + "mortgage_term", + "мес", + ) + + def test_issued_count(self) -> None: + assert classify_cbr_series("02_10_Quantity_mortgage_в_рублях") == ( + "mortgage_issued_count", + "шт", + ) + + def test_issued_volume(self) -> None: + assert classify_cbr_series("02_11_New_loans_mortgage_в_рублях") == ( + "mortgage_issued_volume", + "млн руб", + ) + + def test_debt(self) -> None: + assert classify_cbr_series("02_14_Debt_mortgage_в_рублях") == ( + "mortgage_debt", + "млн руб", + ) + + def test_overdue(self) -> None: + assert classify_cbr_series("02_14_Debt_mortgage_в_т.ч._просроч._в_рублях") == ( + "mortgage_overdue", + "млн руб", + ) + + def test_none_and_empty(self) -> None: + assert classify_cbr_series(None) is None + assert classify_cbr_series("") is None + + def test_itogo_variants_excluded(self) -> None: + """_итого-варианты НЕ маппятся (иначе коллизия по PK с _в_рублях).""" + assert classify_cbr_series("02_13_Rates_mortgage_ставка_итого") is None + assert classify_cbr_series("02_10_Quantity_mortgage_итого") is None + assert classify_cbr_series("02_14_Debt_mortgage_итого") is None + + def test_other_families_excluded(self) -> None: + """_housing_ / _scpa_mortgage_ / _Share_ — шире/иное, чем _mortgage_ ИЖК-семья.""" + assert classify_cbr_series("02_06_Quantity_housing_в_рублях") is None + assert classify_cbr_series("02_15_Rates_scpa_mortgage_ставка_в_рублях") is None + assert classify_cbr_series("02_44_Share_mortgage_в_рублях") is None + + def test_unknown_series_id_skipped(self) -> None: + """Неизвестный / посторонний series_id → None (в backfill не попадает).""" + assert classify_cbr_series("Доля ипотечных кредитов на ИЖС, %") is None + assert classify_cbr_series("02_99_unknown_series") is None + + +# ── консистентность Python-зеркала с SQL-миграцией ──────────────────────────── + + +class TestMirrorConsistency: + def test_migration_file_exists(self) -> None: + assert _MIGRATION.is_file(), f"migration not found: {_MIGRATION}" + + def test_all_indicator_types_present_in_migration(self) -> None: + """Каждый indicator_type из Python-зеркала встречается в тексте миграции.""" + sql = _MIGRATION.read_text(encoding="utf-8") + for indicator_type, _unit in _CBR_SERIES_MAP.values(): + assert indicator_type in sql, ( + f"indicator_type {indicator_type!r} есть в Python-зеркале, " + f"но отсутствует в миграции 123_macro_indicator.sql" + ) + + def test_all_series_ids_present_in_migration(self) -> None: + """Каждый series_id-ключ Python-зеркала присутствует в CASE миграции.""" + sql = _MIGRATION.read_text(encoding="utf-8") + for series_id in _CBR_SERIES_MAP: + assert series_id in sql, ( + f"series_id {series_id!r} есть в Python-зеркале, " + f"но отсутствует в миграции 123_macro_indicator.sql" + ) + + def test_domrf_primary_type_present(self) -> None: + sql = _MIGRATION.read_text(encoding="utf-8") + assert "mortgage_rate_primary_domrf" in sql + + def test_migration_balanced_transaction(self) -> None: + sql = _MIGRATION.read_text(encoding="utf-8") + assert sql.count("BEGIN;") == 1 + assert sql.count("COMMIT;") == 1 + assert "ON CONFLICT" in sql + assert "macro_indicator" in sql + + +# ── reader SQL shape (mock Session, без БД) ─────────────────────────────────── + + +def _mock_db(rows: list) -> MagicMock: + """MagicMock-сессия: db.execute(...).all() и .first() возвращают подготовленное.""" + db = MagicMock() + result = MagicMock() + result.all.return_value = rows + result.first.return_value = rows[0] if rows else None + db.execute.return_value = result + return db + + +def _executed_sql(db: MagicMock) -> str: + """Текст SQL первого db.execute (TextClause → str).""" + args, _kwargs = db.execute.call_args + return str(args[0]) + + +class TestGetMacroSeries: + def test_returns_ordered_float_tuples(self) -> None: + rows = [(dt.date(2019, 2, 1), 9.5), (dt.date(2019, 3, 1), 9.4)] + db = _mock_db(rows) + out = get_macro_series(db, "mortgage_rate_weighted") + assert out == [(dt.date(2019, 2, 1), 9.5), (dt.date(2019, 3, 1), 9.4)] + assert all(isinstance(v, float) for _d, v in out) + + def test_empty(self) -> None: + db = _mock_db([]) + assert get_macro_series(db, "key_rate") == [] + + def test_default_region_and_params(self) -> None: + db = _mock_db([]) + get_macro_series(db, "mortgage_debt") + _args, kwargs = db.execute.call_args + params = _args[1] if len(_args) > 1 else kwargs + assert params["itype"] == "mortgage_debt" + assert params["region"] == "rf" + assert params["since"] is None + + def test_since_passed_through(self) -> None: + db = _mock_db([]) + since = dt.date(2024, 1, 1) + get_macro_series(db, "mortgage_debt", region="sverdl", since=since) + _args, _kwargs = db.execute.call_args + params = _args[1] + assert params["region"] == "sverdl" + assert params["since"] == since + + def test_sql_uses_cast_not_double_colon(self) -> None: + db = _mock_db([]) + get_macro_series(db, "mortgage_debt") + sql = _executed_sql(db) + assert "CAST(:itype AS text)" in sql + assert "CAST(:region AS text)" in sql + assert "CAST(:since AS date)" in sql + # psycopg v3 trap: никаких :name::type + assert ":itype::" not in sql + assert ":region::" not in sql + assert ":since::" not in sql + + def test_sql_orders_ascending(self) -> None: + db = _mock_db([]) + get_macro_series(db, "mortgage_debt") + assert "ORDER BY obs_date ASC" in _executed_sql(db) + + +class TestGetLatestMacro: + def test_returns_tuple(self) -> None: + db = _mock_db([(dt.date(2026, 2, 1), 11.0)]) + assert get_latest_macro(db, "mortgage_rate_weighted") == (dt.date(2026, 2, 1), 11.0) + + def test_none_when_empty(self) -> None: + db = _mock_db([]) + assert get_latest_macro(db, "key_rate") is None + + def test_value_coerced_to_float(self) -> None: + from decimal import Decimal + + db = _mock_db([(dt.date(2026, 2, 1), Decimal("11.0"))]) + result = get_latest_macro(db, "mortgage_rate_weighted") + assert result is not None + _d, v = result + assert isinstance(v, float) + assert v == 11.0 + + def test_sql_orders_desc_limit_1(self) -> None: + db = _mock_db([]) + get_latest_macro(db, "mortgage_debt") + sql = _executed_sql(db) + assert "ORDER BY obs_date DESC" in sql + assert "LIMIT 1" in sql + assert "CAST(:itype AS text)" in sql diff --git a/data/sql/123_macro_indicator.sql b/data/sql/123_macro_indicator.sql new file mode 100644 index 00000000..2b12401b --- /dev/null +++ b/data/sql/123_macro_indicator.sql @@ -0,0 +1,194 @@ +-- 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;