gendesign/backend/app/services/site_finder/macro.py
Light1YT 77b5fb800e feat(site-finder): unified macro_indicator table + backfill + reader (#945 PR A)
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.
2026-06-02 23:41:16 +05: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])