feat(site-finder): macro_indicator table + backfill + reader (#945 PR A) #963

Merged
Light1YT merged 1 commit from feat/945-macro-indicator into main 2026-06-02 18:41:28 +00:00
3 changed files with 549 additions and 0 deletions
Showing only changes of commit 77b5fb800e - Show all commits

View file

@ -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])

View file

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

View file

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