gendesign/backend/app/services/forecasting/macro_series.py
bot-backend 3a13bce468
All checks were successful
CI / changes (pull_request) Successful in 11s
CI Trade-In / changes (pull_request) Successful in 11s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 3m1s
CI / backend-tests (pull_request) Successful in 16m4s
fix(forecasting): savepoint db.execute failures to stop §22 session poisoning (#2464)
One layer's caught DB error left the shared §22 report Session in Postgres's
"aborted transaction" state with no rollback, silently emptying every later
layer on the same report. Wrap each failure point in db.begin_nested()
(SAVEPOINT): orchestrator._safe_call, macro_series _query_key_rate_monthly /
_query_inflation_monthly / _query_mortgage_monthly (per indicator),
special_indices._run (per index), and sales_series _query_source_a /
_query_source_b — so a sibling builder's failure no longer cascades.

Also close the RELEASE-SAVEPOINT trap: inner helpers that swallowed a
db.execute failure without their own savepoint left the tx aborted, so the
outer builder's savepoint failed at RELEASE (illegal in an aborted tx) and
poisoning still cascaded. Add savepoints at the inner catch sites —
special_indices._query_parcel_centroid and district_resolver._admin_names /
resolve_objective_districts.

Refs #2464 (cluster A session-poisoning).
2026-07-07 17:50:17 +05:00

438 lines
24 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.

"""Monthly макро-ряд + детерминированный классификатор режима ключевой ставки.
#951 (Site Finder v2 / «GG-форсайт» ТЗ §9.5/§9.6), EPIC 7 «Чувствительность к
ключевой ставке», PR B (#951b). Это **data-independent фундамент**: monthly
макро-таймсерия, классификатор тренда ставки (down/up/stable), лаговые помощники
и маркер «зашумлённого» окна. Всё ДЕТЕРМИНИРОВАННО, БЕЗ LLM.
Потребители (later PRs, НЕ этот): §9.6 key-rate sensitivity, §9.5 макро-коэффициент.
Источник данных — таблица macro_indicator (миграция data/sql/123_macro_indicator.sql),
читается через тонкий reader app.services.site_finder.macro (переиспользуем):
• key_rate — region 'rf', ДНЕВНОЙ ряд, source 'cbr' (backfill PR B
#945, 2019-01-01 → сегодня). Ресэмплим daily → monthly.
• inflation_yoy — region 'rf', УЖЕ месячный (obs_date = 1-е число), source
'cbr' (ИПЦ YoY, #946 — залит cbr_macro_sync через CBR
UniDbQuery). Берём как есть. Потребитель — §9.5 канал
inflation (macro_coefficient), теперь BACKED, не degraded.
• mortgage_* — region 'sverdl', УЖЕ месячные (obs_date нормализован к
1-му числу через make_date(...,1)). Берём как есть.
(income НЕ backfilled — на него не опираемся.)
Соглашение по дате: каждый MonthlyMacro.month — ПЕРВОЕ число месяца (YYYY-MM-01).
Это зеркалит нормализацию mortgage-рядов в backfill (make_date(year, month, 1)),
поэтому join «месяц-в-месяц» сводится к равенству obs_date без date_trunc.
Graceful-on-thin-data (КРИТИЧНО, дух market_metrics.py): пустая таблица / сбой БД
→ [] (НЕ crash). Месяцы без точки key_rate → carry-forward последнего известного
значения; до самой первой известной точки carry невозможен → key_rate=None.
psycopg v3 / SQLAlchemy text: bind-параметры ВСЕГДА через CAST(:x AS type) —
никогда :x::type (парсер psycopg3 даёт SyntaxError на :name::type).
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import date
from typing import Any, Literal
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.services.forecast_request_cache import cached
from app.services.site_finder.macro import get_macro_series
logger = logging.getLogger(__name__)
Regime = Literal["down", "up", "stable"]
# ── Named-константы ───────────────────────────────────────────────────────────
# Глубина окна по умолчанию для get_monthly_macro (месяцев назад от текущего).
# 48 мес ≈ 4 года: покрывает полный цикл ставки ЦБ (мягкая 2020 → жёсткая 2022 →
# смягчение → новый подъём 2023-2024) — достаточно для лаговых регрессий §9.6.
_DEFAULT_MONTHS_BACK: int = 48
# Порог режима ставки: изменение key_rate ≥ этого числа п.п. за окно считается
# трендом (down/up); меньше по модулю → 'stable' (шум/удержание). 0.25 п.п. —
# минимальный «значимый» шаг решения ЦБ (совет директоров двигает ставку шагами
# кратными 0.25-0.50 п.п.; <0.25 п.п. за месяц = фактически плато).
_REGIME_EPS_PP: float = 0.25
# Окно (в месяцах) для оценки локального тренда ставки в classify_regime: сравниваем
# текущую ставку со ставкой _REGIME_WINDOW_MONTHS назад. 1 = «месяц-к-месяцу».
# Берём 1: решения ЦБ дискретны и крупны, лишнее сглаживание размывает точку разворота.
_REGIME_WINDOW_MONTHS: int = 1
# ── Confounder-даты (hardcoded — с обоснованием КАЖДОЙ, см. sql.md «нужен why») ──
# Окна, ПЕРЕСЕКАЮЩИЕ любую из этих дат, зашумлены: продажи в них двигала не ставка,
# а разовый политический/эпид-шок → downstream (§9.6) понижает по ним confidence.
# Формат: (date, краткое «почему этот разрыв конфаундит rate↔sales»).
_CONFOUNDER_DATES: tuple[tuple[date, str], ...] = (
(
date(2020, 3, 1),
"COVID-19 lockdown + запуск льготной ипотеки 6.5%: обвал, затем всплеск "
"спроса, не связанный с динамикой ключевой ставки",
),
(
date(2022, 2, 1),
"Геополитический шок 02.2022 + экстренный подъём ставки ЦБ до 20%: "
"паническая динамика сделок, разрыв обычной связи rate↔sales",
),
(
date(2024, 7, 1),
"Сворачивание массовой льготной ипотеки с 01.07.2024: ажиотажный «забег» "
"в июне + провал спроса после — продажи двигала отмена льготы, не ставка",
),
)
@dataclass(frozen=True)
class MonthlyMacro:
"""Одна месячная точка макро-ряда (ТЗ §9.5/§9.6).
month — ПЕРВОЕ число месяца (YYYY-MM-01). Любое поле = None при отсутствии
данных за этот месяц (никогда 0-как-заглушка).
"""
month: date
key_rate: float | None # ставка ЦБ, действовавшая на конец месяца (% годовых)
mortgage_rate_weighted: float | None # средневзвешенная ИЖК-ставка, %
mortgage_issued_count: float | None # выдано ИЖК-кредитов, шт
mortgage_issued_volume: float | None # объём выданных ИЖК, млн руб
mortgage_debt: float | None # задолженность по ИЖК, млн руб
mortgage_overdue: float | None # просроченная задолженность по ИЖК, млн руб
# #946: инфляция «% г/г» (ИПЦ YoY), region 'rf'. Поле с дефолтом None и в КОНЦЕ —
# чтобы существующие kwargs-конструкторы (тесты/демо), не знающие о поле, не падали.
inflation_yoy: float | None = None
def as_dict(self) -> dict[str, Any]:
return {
"month": self.month.isoformat(),
"key_rate": _round_or_none(self.key_rate, 2),
"inflation_yoy": _round_or_none(self.inflation_yoy, 2),
"mortgage_rate_weighted": _round_or_none(self.mortgage_rate_weighted, 2),
"mortgage_issued_count": _round_or_none(self.mortgage_issued_count, 0),
"mortgage_issued_volume": _round_or_none(self.mortgage_issued_volume, 1),
"mortgage_debt": _round_or_none(self.mortgage_debt, 1),
"mortgage_overdue": _round_or_none(self.mortgage_overdue, 1),
}
def _round_or_none(value: float | None, digits: int) -> float | None:
return round(value, digits) if value is not None else None
# ──────────────────────────────────────────────────────────────────────────────
# Pure-логика — без БД, полностью юнит-тестируемо.
# ──────────────────────────────────────────────────────────────────────────────
# Месячные mortgage-поля, которые join-им из macro_indicator (region 'sverdl').
# indicator_type → имя поля MonthlyMacro. mortgage_term намеренно НЕ тянем
# (downstream §9.6/§9.5 он не нужен; держим MonthlyMacro узким).
_MORTGAGE_FIELDS: tuple[tuple[str, str], ...] = (
("mortgage_rate_weighted", "mortgage_rate_weighted"),
("mortgage_issued_count", "mortgage_issued_count"),
("mortgage_issued_volume", "mortgage_issued_volume"),
("mortgage_debt", "mortgage_debt"),
("mortgage_overdue", "mortgage_overdue"),
)
def _month_start(d: date) -> date:
"""Нормализовать дату к 1-му числу её месяца (YYYY-MM-01)."""
return date(d.year, d.month, 1)
def _carry_forward(values: list[float | None]) -> list[float | None]:
"""Заполнить None-дыры последним известным значением (LOCF).
Дыры ДО первого известного значения остаются None (carry невозможен).
Чистая функция: не мутирует вход.
"""
out: list[float | None] = []
last: float | None = None
for v in values:
if v is not None:
last = v
out.append(last)
return out
def classify_regime(
rates: list[float | None],
*,
eps_pp: float = _REGIME_EPS_PP,
window_months: int = _REGIME_WINDOW_MONTHS,
) -> list[Regime]:
"""Классифицировать локальный тренд ключевой ставки по месяцам.
Для каждого месяца i сравниваем ставку с ней же window_months назад:
• Δ ≤ -eps_pp → 'down' (смягчение ДКП)
• Δ ≥ +eps_pp → 'up' (ужесточение ДКП)
• |Δ| < eps_pp → 'stable' (плато/удержание; шум < шага решения ЦБ)
Δ берётся на carry-forward версии ряда (LOCF), поэтому одиночные None-дыры
не рвут тренд. Если на момент i нет ни текущего значения, ни базы для
сравнения (оба None — типично в самом начале до первой точки) → 'stable'
(нейтрально, не выдумываем тренд). PURE, без БД.
Args:
rates: ставка по месяцам (хронологически ASC), None = нет данных.
eps_pp: порог значимого изменения в п.п. (по умолчанию _REGIME_EPS_PP).
window_months: за сколько месяцев назад берём базу сравнения (≥1).
Returns:
Список того же размера, что rates: 'down' | 'up' | 'stable' на месяц.
"""
w = max(1, window_months)
filled = _carry_forward(rates)
out: list[Regime] = []
for i in range(len(filled)):
cur = filled[i]
prev = filled[i - w] if i - w >= 0 else None
if cur is None or prev is None:
out.append("stable")
continue
delta = cur - prev
if delta <= -eps_pp:
out.append("down")
elif delta >= eps_pp:
out.append("up")
else:
out.append("stable")
return out
def macro_at_lag(
series: list[MonthlyMacro], target_month: date, lag_months: int
) -> MonthlyMacro | None:
"""Вернуть точку макро-ряда на (target_month lag_months) или None.
Лаги, используемые downstream §9.6: 0/1/2/3/6. target_month нормализуется к
1-му числу. Если такого месяца нет в series (дыра / выход за диапазон) → None.
PURE, без БД.
Args:
series: список MonthlyMacro (порядок не важен — ищем по равенству month).
target_month: целевой месяц (нормализуется к YYYY-MM-01).
lag_months: на сколько месяцев назад сместиться (≥0).
Returns:
MonthlyMacro искомого месяца либо None.
"""
anchor = _month_start(target_month)
wanted = _shift_months(anchor, -lag_months)
for m in series:
if m.month == wanted:
return m
return None
def _shift_months(d: date, delta: int) -> date:
"""Сдвинуть дату-1-е-число на delta месяцев (delta может быть отрицательным)."""
base = _month_start(d)
total = (base.year * 12 + (base.month - 1)) + delta
year, month0 = divmod(total, 12)
return date(year, month0 + 1, 1)
def is_confounded_window(start: date, end: date) -> bool:
"""True, если окно [start, end] пересекает известную дату-конфаундер.
Эти даты (см. _CONFOUNDER_DATES) — разовые шоки, разрывающие обычную связь
«ключевая ставка ↔ продажи»; downstream §9.6 по таким окнам понижает
confidence. Сравнение по 1-му числу месяца (даты-конфаундеры — начала
месяцев). start>end → нормализуем (меняем местами). PURE, без БД.
Args:
start: начало окна.
end: конец окна (включительно).
Returns:
True если хотя бы одна confounder-дата попадает в [start, end].
"""
lo, hi = (start, end) if start <= end else (end, start)
lo_m = _month_start(lo)
hi_m = _month_start(hi)
return any(lo_m <= cdate <= hi_m for cdate, _why in _CONFOUNDER_DATES)
# ──────────────────────────────────────────────────────────────────────────────
# DB-reader — тонкий, graceful. Pure-логика выше тестируется без него.
# ──────────────────────────────────────────────────────────────────────────────
# Ресэмпл ДНЕВНОГО key_rate (region 'rf') → monthly методом last-known-value
# (ставка, действовавшая на КОНЕЦ месяца). DISTINCT ON (date_trunc('month', ...))
# + ORDER BY ... obs_date DESC оставляет по одной — самой поздней — точке месяца.
# Делаем в SQL, а не в Python: иначе пришлось бы тянуть ~1.9k дневных строк в
# процесс; DISTINCT ON решает last-value за один set-based проход. Месяц,
# нормализованный к 1-му числу, сразу join-ится к mortgage-рядам.
_KEY_RATE_MONTHLY_SQL = text(
"""
SELECT DISTINCT ON (date_trunc('month', obs_date))
CAST(date_trunc('month', obs_date) AS date) AS month,
value
FROM macro_indicator
WHERE indicator_type = CAST(:itype AS text)
AND region = CAST(:region AS text)
AND value IS NOT NULL
AND obs_date >= CAST(:since AS date)
ORDER BY date_trunc('month', obs_date), obs_date DESC
"""
)
@cached(lambda db, *, months_back=_DEFAULT_MONTHS_BACK: months_back, label="get_monthly_macro")
def get_monthly_macro(
db: Session, *, months_back: int = _DEFAULT_MONTHS_BACK
) -> list[MonthlyMacro]:
"""Собрать monthly макро-ряд за последние months_back месяцев.
key_rate (region 'rf', дневной) ресэмплится daily→monthly как last-known-value
(ставка на конец месяца). Месячные mortgage-ряды (region 'sverdl') берутся как
есть и join-ятся по году-месяцу. Дыры key_rate между известными точками
заполняются carry-forward (LOCF); до первой известной точки key_rate=None.
Ряд строится по непрерывной сетке месяцев [start .. текущий] (а НЕ только по
месяцам, где есть данные), чтобы лаги/режим имели регулярный шаг. Месяц без
ЛЮБЫХ данных всё равно присутствует (все поля None для него — кроме carry key_rate).
Graceful: при сбое БД или пустой таблице key_rate сетка месяцев всё равно
возвращается, но с None-полями (НЕ crash). Пустой список [] — только если
сама сетка пуста (months_back < 0).
Args:
db: SQLAlchemy sync Session.
months_back: глубина ряда в месяцах (по умолчанию _DEFAULT_MONTHS_BACK).
Returns:
Список MonthlyMacro по возрастанию month (по непрерывной сетке);
[] только при пустой сетке (months_back < 0).
"""
# month-bucketing в локальной tz сервера (single-region, как и весь codebase)
today = date.today()
start = _shift_months(today, -max(0, months_back))
grid = _month_grid(start, _month_start(today))
if not grid:
return []
key_rate_by_month = _query_key_rate_monthly(db, since=start)
inflation_by_month = _query_inflation_monthly(db, since=start)
mortgage_by_field = _query_mortgage_monthly(db, since=start)
# key_rate: разворачиваем на сетку + carry-forward по сетке (LOCF).
raw_rates: list[float | None] = [key_rate_by_month.get(m) for m in grid]
filled_rates = _carry_forward(raw_rates)
# inflation_yoy: тоже carry-forward (медленный месячный ряд; последняя известная
# точка = текущий режим, как и у ставки). До первой точки → None.
raw_infl: list[float | None] = [inflation_by_month.get(m) for m in grid]
filled_infl = _carry_forward(raw_infl)
rows: list[MonthlyMacro] = []
for idx, month in enumerate(grid):
kwargs: dict[str, float | None] = {
field: mortgage_by_field.get(field, {}).get(month) for _itype, field in _MORTGAGE_FIELDS
}
rows.append(
MonthlyMacro(
month=month,
key_rate=filled_rates[idx],
inflation_yoy=filled_infl[idx],
**kwargs,
)
)
logger.info(
"get_monthly_macro: months_back=%d grid=%d key_rate_pts=%d inflation_pts=%d",
months_back,
len(grid),
len(key_rate_by_month),
len(inflation_by_month),
)
return rows
def _month_grid(start: date, end: date) -> list[date]:
"""Непрерывный список 1-х чисел месяцев от start до end включительно (ASC)."""
lo = _month_start(start)
hi = _month_start(end)
if lo > hi:
return []
out: list[date] = []
cur = lo
while cur <= hi:
out.append(cur)
cur = _shift_months(cur, 1)
return out
def _query_key_rate_monthly(db: Session, *, since: date) -> dict[date, float]:
"""Ресэмпл дневного key_rate (region 'rf') → {month1st: value}. Graceful → {}.
SAVEPOINT (#2464 cluster A finding #2): `db` — общая §22-сессия отчёта; при сбое
этого запроса БЕЗ SAVEPOINT транзакция Postgres остаётся aborted и следующие
запросы get_monthly_macro (inflation, mortgage) + все ПОСЛЕДУЮЩИЕ §9.x-слои на
той же сессии тоже падают. `with db.begin_nested():` откатывает ТОЛЬКО этот
SAVEPOINT (ROLLBACK TO SAVEPOINT), внешняя транзакция остаётся рабочей.
"""
try:
with db.begin_nested():
rows = db.execute(
_KEY_RATE_MONTHLY_SQL,
{"itype": "key_rate", "region": "rf", "since": since},
).all()
except Exception:
logger.exception("get_monthly_macro: key_rate query failed")
return {}
return {r[0]: float(r[1]) for r in rows if r[0] is not None and r[1] is not None}
def _query_inflation_monthly(db: Session, *, since: date) -> dict[date, float]:
"""Месячный inflation_yoy (region 'rf', ИПЦ YoY) → {month1st: value}. Graceful → {}.
Ряд УЖЕ месячный (obs_date = 1-е число, залит cbr_macro_sync) — берём как есть
через reuse get_macro_series (свой SQL не пишем). _month_start — страховка.
Сбой/пустой ряд → {} (НЕ crash), inflation_yoy тогда None по всей сетке.
SAVEPOINT (#2464 cluster A finding #2): см. `_query_key_rate_monthly` — та же
общая §22-сессия, тот же риск отравления транзакции для последующих запросов
(mortgage-поля + §9.x-слои). `with db.begin_nested():` изолирует сбой в SAVEPOINT.
"""
try:
with db.begin_nested():
series = get_macro_series(db, "inflation_yoy", region="rf", since=since)
except Exception:
logger.exception("get_monthly_macro: inflation_yoy query failed")
return {}
return {_month_start(d): float(v) for d, v in series}
def _query_mortgage_monthly(db: Session, *, since: date) -> dict[str, dict[date, float]]:
"""Месячные mortgage-ряды (region 'sverdl') через reuse get_macro_series.
Возвращает {field: {month1st: value}}. obs_date уже нормализован к 1-му числу
в backfill, но _month_start применяем повторно (страховка). Сбой одного ряда
не валит остальные (graceful: пустой подсловарь).
SAVEPOINT (#2464 cluster A finding #2): все 5 полей читаются на ОДНОЙ `db`-Session
(get_monthly_macro вызывается внутри общей §22-сессии отчёта). Без SAVEPOINT сбой
ОДНОГО поля оставляет транзакцию Postgres aborted — каждое СЛЕДУЮЩЕЕ поле в этом
же цикле тоже падает (хотя его данные были бы доступны), а `except` здесь молча
отдаёт [] по каждому, маскируя каскад под «нормальную» построчную деградацию.
`with db.begin_nested():` — SAVEPOINT на КАЖДОЕ поле: сбой откатывает только его
SAVEPOINT (ROLLBACK TO SAVEPOINT), сессия остаётся рабочей для следующего поля.
"""
out: dict[str, dict[date, float]] = {}
for indicator_type, field in _MORTGAGE_FIELDS:
try:
with db.begin_nested():
series = get_macro_series(db, indicator_type, region="sverdl", since=since)
except Exception:
logger.exception("get_monthly_macro: mortgage series %s failed", indicator_type)
series = []
out[field] = {_month_start(d): float(v) for d, v in series}
return out