All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m18s
CI / backend-tests (pull_request) Successful in 6m33s
EPIC2 macro-ingest: региональный (Свердловская обл. ОКТМО 65 / ЕКБ) скрейпер Росстата в унифицированную macro_indicator (м.123). Зеркалит форму cbr_macro: pure-парсеры + тонкий httpx, Celery-таска с SAVEPOINT-per-row upsert (CAST not ::, ON CONFLICT по PK indicator_type/region/obs_date). ЕМИСС/fedstat.ru (SDMX) за WAF (hard-403 на всех путях из dev-IP) -> ИПЦ/доходы/ СМР-цены пока недоступны (документировано в коде). rosstat.gov.ru/opendata (стандарт 4.0) WAF-free -> приземляет демографию population_total (sverdl 4.32M / ekb 1.41M, §7.11). - services/scrapers/rosstat_emiss.py — fetch+parse open-data (registry->meta->data CSV) - workers/tasks/rosstat_macro_sync.py — Celery upsert (source=rosstat) - beat: rosstat-macro-sync-monthly; include в celery_app - site_finder/macro.py: population_total -> region-aware default sverdl (additive) - tests: 18 offline-тестов парсера + контракта таски Без миграции (вписано в существующую схему), без новых зависимостей. Refs #946.
179 lines
10 KiB
Python
179 lines
10 KiB
Python
"""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)
|
||
|
||
|
||
# ── Canonical region per indicator_type ───────────────────────────────────────────
|
||
# Реальная раскладка данных ЦБ в macro_indicator (проверено на prod):
|
||
# • семейство CBR-ипотеки (формы 02, ИЖК) лежит под region='sverdl' — оно
|
||
# бэкфиллится из cbr_mortgage_series, где единственный регион — 'sverdl'
|
||
# (см. 123_macro_indicator.sql §2: region берётся из источника КАК ЕСТЬ);
|
||
# • key_rate (и прочие национальные ряды) — под region='rf'.
|
||
# Это НЕ опечатка в данных, а фактический layout. Поэтому дефолтный регион ридера
|
||
# должен быть indicator-aware: иначе чтение mortgage_*-ряда без явного region молча
|
||
# вернёт пусто (footgun, #945 follow-up). Множество ипотечных типов берём как
|
||
# values() из _CBR_SERIES_MAP — единый источник правды, синхронный с миграцией.
|
||
_MORTGAGE_INDICATOR_TYPES: frozenset[str] = frozenset(
|
||
itype for itype, _unit in _CBR_SERIES_MAP.values()
|
||
)
|
||
|
||
# Региональные ряды Росстата (#946, source='rosstat') кладутся под region='sverdl'
|
||
# (субъект-агрегат, ОКТМО 65) и 'ekb' (г. Екатеринбург, ОКТМО 6570100000), НЕ под 'rf'.
|
||
# Дефолтный region таких типов = 'sverdl' (субъект — основной уровень потребления
|
||
# downstream affordability/forecast). Явный region='ekb' читатель задаёт сам.
|
||
# Источник правды по типам — DatasetSpec'и в rosstat_emiss (синхронить при добавлении).
|
||
_ROSSTAT_SVERDL_INDICATOR_TYPES: frozenset[str] = frozenset({"population_total"})
|
||
|
||
|
||
def _canonical_region(indicator_type: str) -> str:
|
||
"""Канонический region для indicator_type, когда вызыватель не задал явный.
|
||
|
||
Детерминированно, без БД:
|
||
• 6 CBR-ипотечных типов (mortgage_*) → 'sverdl' (так лежат на prod);
|
||
• региональные ряды Росстата (population_total, #946) → 'sverdl' (субъект-уровень);
|
||
• всё остальное (key_rate и пр.) → 'rf'.
|
||
Источники истины — _CBR_SERIES_MAP (зеркало миграции 123) и _ROSSTAT_SVERDL_INDICATOR_TYPES
|
||
(зеркало DatasetSpec'ов rosstat_emiss).
|
||
"""
|
||
if indicator_type in _MORTGAGE_INDICATOR_TYPES:
|
||
return "sverdl"
|
||
if indicator_type in _ROSSTAT_SVERDL_INDICATOR_TYPES:
|
||
return "sverdl"
|
||
return "rf"
|
||
|
||
|
||
# ── Reader ──────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def get_macro_series(
|
||
db: Session,
|
||
indicator_type: str,
|
||
region: str | None = None,
|
||
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'.
|
||
region: 'rf' | 'sverdl' | ... Если None — резолвится indicator-aware дефолт
|
||
через _canonical_region (CBR-ипотека → 'sverdl', остальное → 'rf'),
|
||
т.к. на prod эти семейства лежат в РАЗНЫХ регионах. Явный region
|
||
ВСЕГДА выигрывает (вызыватель может переопределить дефолт).
|
||
since: если задано — только obs_date >= since.
|
||
|
||
Returns:
|
||
Список (obs_date, value) по возрастанию даты; строки с NULL value отброшены.
|
||
"""
|
||
if region is None:
|
||
region = _canonical_region(indicator_type)
|
||
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 | None = None,
|
||
) -> tuple[date, float] | None:
|
||
"""Последняя (по obs_date) непустая точка ряда или None, если ряда нет.
|
||
|
||
Args:
|
||
db: SQLAlchemy sync Session.
|
||
indicator_type: тип показателя.
|
||
region: 'rf' | 'sverdl' | ... Если None — резолвится indicator-aware дефолт
|
||
через _canonical_region (CBR-ипотека → 'sverdl', остальное → 'rf'):
|
||
на prod эти семейства лежат в РАЗНЫХ регионах. Явный region ВСЕГДА
|
||
выигрывает (вызыватель может переопределить дефолт).
|
||
|
||
Returns:
|
||
(obs_date, value) самой свежей точки, либо None.
|
||
"""
|
||
if region is None:
|
||
region = _canonical_region(indicator_type)
|
||
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])
|