gendesign/backend/app/services/site_finder/macro.py
Light1YT dbae4b0bda
All checks were successful
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m20s
Deploy / build-backend (push) Successful in 2m32s
Deploy / deploy (push) Successful in 1m14s
Deploy / changes (push) Successful in 6s
feat(site-finder): macro_indicator table + backfill + reader (#945 PR A) (#963)
2026-06-02 18:41:28 +00:00

129 lines
6.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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