gendesign/backend/tests/services/forecasting/test_macro_coefficient.py
Light1YT a5e887ae32 feat(forecasting): §9.5 macro coefficient (#951e, advisory)
Deterministic composite demand multiplier (centered 1.0, clamped [0.6,1.3]),
corrects a forecast for the macro regime, directional by market segment.
Heuristic named-constant weights over 4 backed sub-factors (rate / mortgage
rate / issuance / overdue) with weight renormalization over available inputs
(degraded gov/income/cpi/confidence drop out of num AND denom, not silently 0).
Segment-steepness modifier on rate-driven channels (expensive/large/investment
steeper-negative on rate↑; family/compact/liquid steeper-positive on rate↓).
Graceful empty -> 1.0/low. Pure, no LLM, no new SQL (reuses PR2 get_monthly_macro).
ADVISORY: not wired into any endpoint. 50 unit tests (forecasting/ total 191).

PR6/wiring follow-ups: gate lone-survivor renorm leverage under low confidence;
reconsider asymmetric favored-segment steepness (resilient on rate↑).
2026-06-03 11:19:59 +05:00

548 lines
25 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.

"""Unit-тесты §9.5 макроэкономического коэффициента (#951e, ADVISORY).
Чистые тесты — БЕЗ живой БД (арифметика на синтетике + мок PR2 get_monthly_macro):
• pure sub-factors (f_rate / f_mortgage_rate / f_issuance / f_overdue) — знак +
границы [-1,1] + None-вход → None (недоступен).
• renormalize_contributions — деградированные входы выпадают из числителя И суммы
весов; coef НЕ тянется к 1.0 искусственно; все-None → renorm None.
• segment_steepness — large/expensive/investment → круче (>1.0); favored → >1.0;
нейтральный/неизвестный → 1.0; клэмп.
• assemble_coefficient — клэмп на MIN/MAX; None-вклады пропускаются.
• compute_macro_coefficient (мок PR2): rate↑+issuance↓ → coef<1; rate↓+gov-favored
→ coef>1 для favored-сегмента; graceful пусто → 1.0/low.
ADVISORY-статус (веса — эвристика) проверяется на уровне поведения (центр 1.0,
направленность, renorm, graceful).
"""
from __future__ import annotations
import datetime as dt
import math
import os
from unittest.mock import MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services.forecasting.macro_coefficient import (
_DEGRADED_FACTORS,
_F_ISSUANCE,
_F_MORTG_RATE,
_F_OVERDUE,
_F_RATE,
_MACRO_COEF_MAX,
_MACRO_COEF_MIN,
_MACRO_COEF_NEUTRAL,
_STEEP_BASE,
_STEEP_MAX,
_STEEP_MIN,
_WEIGHTS,
MacroCoefficient,
assemble_coefficient,
compute_macro_coefficient,
f_issuance,
f_mortgage_rate,
f_overdue,
f_rate,
renormalize_contributions,
segment_steepness,
)
from app.services.forecasting.macro_series import MonthlyMacro
_MACRO = "app.services.forecasting.macro_coefficient.get_monthly_macro"
def _months(n: int, *, end: dt.date | None = None) -> list[dt.date]:
"""n подряд идущих 1-х чисел месяцев, заканчивая end (по умолчанию 2023-12)."""
end = end or dt.date(2023, 12, 1)
out: list[dt.date] = []
y, m = end.year, end.month
for _ in range(n):
out.append(dt.date(y, m, 1))
m -= 1
if m == 0:
m = 12
y -= 1
return list(reversed(out))
def _macro(
months: list[dt.date],
*,
key_rate: list[float | None] | None = None,
mortgage_rate: list[float | None] | None = None,
issued_count: list[float | None] | None = None,
issued_volume: list[float | None] | None = None,
debt: list[float | None] | None = None,
overdue: list[float | None] | None = None,
) -> list[MonthlyMacro]:
"""Список MonthlyMacro; невыставленные ряды → все None (degraded-вход)."""
n = len(months)
none_n: list[float | None] = [None] * n
kr = key_rate if key_rate is not None else none_n
mr = mortgage_rate if mortgage_rate is not None else none_n
ic = issued_count if issued_count is not None else none_n
iv = issued_volume if issued_volume is not None else none_n
db_ = debt if debt is not None else none_n
od = overdue if overdue is not None else none_n
out: list[MonthlyMacro] = []
for i, month in enumerate(months):
out.append(
MonthlyMacro(
month=month,
key_rate=kr[i],
mortgage_rate_weighted=mr[i],
mortgage_issued_count=ic[i],
mortgage_issued_volume=iv[i],
mortgage_debt=db_[i],
mortgage_overdue=od[i],
)
)
return out
# ── pure: f_rate ──────────────────────────────────────────────────────────────
class TestFRate:
def test_rate_up_is_negative(self) -> None:
assert f_rate(4.0) is not None
assert f_rate(4.0) < 0 # ставка ↑ → давит спрос
def test_rate_down_is_positive(self) -> None:
v = f_rate(-4.0)
assert v is not None and v > 0 # ставка ↓ → поддержит спрос
def test_zero_trend_is_zero(self) -> None:
assert f_rate(0.0) == 0.0
def test_bounded_below_minus_one(self) -> None:
# Экстремальный рост (12 п.п. > full scale 8) → клэмп в 1.
assert f_rate(100.0) == -1.0
def test_bounded_above_plus_one(self) -> None:
assert f_rate(-100.0) == 1.0
def test_none_is_unavailable(self) -> None:
assert f_rate(None) is None
# ── pure: f_mortgage_rate ─────────────────────────────────────────────────────
class TestFMortgageRate:
def test_up_negative_down_positive(self) -> None:
up = f_mortgage_rate(3.0)
down = f_mortgage_rate(-3.0)
assert up is not None and up < 0
assert down is not None and down > 0
def test_bounds(self) -> None:
assert f_mortgage_rate(50.0) == -1.0
assert f_mortgage_rate(-50.0) == 1.0
def test_none_unavailable(self) -> None:
assert f_mortgage_rate(None) is None
# ── pure: f_issuance ──────────────────────────────────────────────────────────
class TestFIssuance:
def test_drop_is_negative(self) -> None:
v = f_issuance(-0.3, -0.3) # выдачи упали → негатив
assert v is not None and v < 0
def test_growth_is_positive(self) -> None:
v = f_issuance(0.3, 0.3)
assert v is not None and v > 0
def test_averages_two_inputs(self) -> None:
# count +0.5, volume 0.5 → среднее 0 → нудж 0.
assert f_issuance(0.5, -0.5) == 0.0
def test_single_available_input(self) -> None:
# Доступен только volume → берём его (count None не обнуляет канал).
v = f_issuance(None, -0.25)
assert v is not None and v < 0
def test_bounds(self) -> None:
assert f_issuance(-5.0, -5.0) == -1.0
assert f_issuance(5.0, 5.0) == 1.0
def test_both_none_unavailable(self) -> None:
assert f_issuance(None, None) is None
# ── pure: f_overdue ───────────────────────────────────────────────────────────
class TestFOverdue:
def test_high_ratio_is_negative(self) -> None:
# overdue/debt = 30/1000 = 3% > neutral 1% → негатив.
v = f_overdue(30.0, 1000.0)
assert v is not None and v < 0
def test_healthy_portfolio_is_neutral_zero(self) -> None:
# 0.5% < neutral 1% → канал доступен, но нудж 0 (не давит).
assert f_overdue(5.0, 1000.0) == 0.0
def test_only_non_positive(self) -> None:
# Просрочка не «помогает» спросу: нудж всегда ≤ 0.
v = f_overdue(80.0, 1000.0) # 8% > full 5% → клэмп 1
assert v == -1.0
def test_none_or_zero_debt_unavailable(self) -> None:
assert f_overdue(None, 1000.0) is None
assert f_overdue(10.0, None) is None
assert f_overdue(10.0, 0.0) is None # нулевой портфель → нет базы
# ── pure: segment_steepness ───────────────────────────────────────────────────
class TestSegmentSteepness:
def test_neutral_profile_is_base(self) -> None:
assert segment_steepness({}) == _STEEP_BASE
def test_unknown_fields_ignored(self) -> None:
assert segment_steepness({"foo": "bar", "obj_class": None}) == _STEEP_BASE
def test_expensive_class_steeper(self) -> None:
assert segment_steepness({"obj_class": "Бизнес"}) > _STEEP_BASE
def test_premium_tier_steeper(self) -> None:
assert segment_steepness({"price_tier": "premium"}) > _STEEP_BASE
def test_large_room_steeper(self) -> None:
assert segment_steepness({"room_bucket": "3-к 60-80"}) > _STEEP_BASE
assert segment_steepness({"room_bucket": "4"}) > _STEEP_BASE # Source A ключ
def test_investment_steeper(self) -> None:
assert segment_steepness({"is_investment": True}) > _STEEP_BASE
def test_favored_family_compact_steeper(self) -> None:
assert segment_steepness({"obj_class": "комфорт"}) > _STEEP_BASE
assert segment_steepness({"is_family": True}) > _STEEP_BASE
assert segment_steepness({"room_bucket": "студия"}) > _STEEP_BASE
def test_compound_profile_clamped_to_max(self) -> None:
# Дорогой + крупный + инвестиционный — перемножение крутизн упёрлось бы за
# потолок; клэмп держит в _STEEP_MAX.
steep = segment_steepness(
{"obj_class": "премиум", "room_bucket": "80+ м²", "is_investment": True}
)
assert steep == _STEEP_MAX
def test_within_bounds(self) -> None:
for prof in ({}, {"obj_class": "бизнес"}, {"is_investment": True}):
s = segment_steepness(prof)
assert _STEEP_MIN <= s <= _STEEP_MAX
# ── pure: renormalize_contributions (РЕНОРМАЛИЗАЦИЯ) ───────────────────────────
class TestRenormalize:
def test_unavailable_dropped_to_none(self) -> None:
nudges: dict[str, float | None] = {"a": -0.5, "b": None, "c": 0.2}
weights = {"a": 0.2, "b": 0.3, "c": 0.1}
contribs, renorm = renormalize_contributions(nudges, weights)
assert contribs["b"] is None # недоступный → None, НЕ 0
assert contribs["a"] is not None and contribs["c"] is not None
assert renorm is not None
def test_renorm_factor_scales_by_dropped_weight(self) -> None:
# Доступны a(0.2)+c(0.1)=0.3 из total 0.6 → renorm = 0.6/0.3 = 2.0.
nudges: dict[str, float | None] = {"a": -0.5, "b": None, "c": 0.2}
weights = {"a": 0.2, "b": 0.3, "c": 0.1}
contribs, renorm = renormalize_contributions(nudges, weights)
assert renorm is not None and math.isclose(renorm, 2.0, rel_tol=1e-9)
# Вклад a = renorm·вес·нудж = 2.0·0.2·(0.5) = 0.2.
assert contribs["a"] is not None
assert math.isclose(contribs["a"], renorm * 0.2 * -0.5, rel_tol=1e-9)
def test_not_artificially_shrunk_to_neutral(self) -> None:
# Один и тот же сигнал не должен ослабевать оттого, что соседние каналы
# деградировали: renorm возвращает «потерянный» вес оставшимся.
weights = {"a": 0.2, "b": 0.2, "c": 0.2}
# Случай 1: все доступны, все нуджи 1.
all_av: dict[str, float | None] = {"a": -1.0, "b": -1.0, "c": -1.0}
c_all, _ = renormalize_contributions(all_av, weights)
coef_all = assemble_coefficient(c_all)
# Случай 2: только a доступен (нудж 1), b/c деградировали.
one_av: dict[str, float | None] = {"a": -1.0, "b": None, "c": None}
c_one, _ = renormalize_contributions(one_av, weights)
coef_one = assemble_coefficient(c_one)
# Единственный доступный канал с тем же нуджем даёт ТОТ ЖЕ итог (renorm
# компенсирует выпавшие): сигнал не размазан к нейтрали.
assert coef_one == coef_all
def test_all_unavailable_renorm_none(self) -> None:
nudges: dict[str, float | None] = {"a": None, "b": None}
contribs, renorm = renormalize_contributions(nudges, {"a": 0.2, "b": 0.3})
assert renorm is None
assert all(v is None for v in contribs.values())
# Сборка из всех-None вкладов → нейтраль 1.0.
assert assemble_coefficient(contribs) == _MACRO_COEF_NEUTRAL
def test_available_weights_sum_back_to_total(self) -> None:
# Сумма ЭФФЕКТИВНЫХ весов доступных каналов (renorm·вес) = полной сумме весов
# (это и есть «не сжимать к 1.0»): проверяем на единичных нуджах.
weights = {"a": 0.18, "b": 0.12, "c": 0.10, "d": 0.20}
nudges: dict[str, float | None] = {"a": 1.0, "b": 1.0, "c": None, "d": None}
contribs, renorm = renormalize_contributions(nudges, weights)
eff_sum = sum(v for v in contribs.values() if v is not None)
assert renorm is not None
# eff_sum = renorm·(0.18+0.12)·1.0 = total(0.60). Доступные «забрали» c+d.
assert abs(eff_sum - sum(weights.values())) < 1e-12
# ── pure: assemble_coefficient (клэмп) ────────────────────────────────────────
class TestAssemble:
def test_centered_at_one(self) -> None:
assert assemble_coefficient({}) == _MACRO_COEF_NEUTRAL
assert assemble_coefficient({"a": None}) == _MACRO_COEF_NEUTRAL
def test_positive_contributions_raise(self) -> None:
c = assemble_coefficient({"a": 0.1, "b": 0.05})
assert c == _MACRO_COEF_NEUTRAL + 0.15
def test_clamped_at_min(self) -> None:
assert assemble_coefficient({"a": -5.0}) == _MACRO_COEF_MIN
def test_clamped_at_max(self) -> None:
assert assemble_coefficient({"a": 5.0}) == _MACRO_COEF_MAX
def test_none_contributions_skipped(self) -> None:
assert assemble_coefficient({"a": 0.1, "b": None, "c": -0.05}) == (
_MACRO_COEF_NEUTRAL + 0.05
)
# ── compute_macro_coefficient (мок PR2) ───────────────────────────────────────
class TestComputeMacroCoefficient:
def test_rate_up_issuance_down_coef_below_one(self) -> None:
# Ставка растёт + выдачи падают → давящий режим → coef < 1.
n = 12
months = _months(n)
key_rate = [10.0 + i * 0.5 for i in range(n)] # тренд +5.5 п.п. за окно
issued_count = [10000.0 - i * 400 for i in range(n)] # выдачи падают
issued_volume = [50000.0 - i * 2000 for i in range(n)]
macro = _macro(
months,
key_rate=key_rate,
issued_count=issued_count,
issued_volume=issued_volume,
)
with patch(_MACRO, return_value=macro):
out = compute_macro_coefficient(MagicMock(), segment_profile={})
assert isinstance(out, MacroCoefficient)
assert out.coefficient < _MACRO_COEF_NEUTRAL
assert _F_RATE in out.available_inputs
assert _F_ISSUANCE in out.available_inputs
# Backed-каналы без данных (mortgage_rate / overdue) → unavailable, не 0.
assert _F_MORTG_RATE in out.unavailable_inputs
assert out.breakdown[_F_MORTG_RATE] is None
def test_rate_down_favored_segment_coef_above_one(self) -> None:
# Ставка падает + выдачи растут → поддерживающий режим → coef > 1, и для
# favored-сегмента (семейный/компакт) подъём КРУЧЕ (сегмент-модификатор).
n = 12
months = _months(n)
key_rate = [18.0 - i * 0.5 for i in range(n)] # тренд 5.5 п.п.
issued_count = [8000.0 + i * 400 for i in range(n)] # выдачи растут
issued_volume = [40000.0 + i * 2000 for i in range(n)]
macro = _macro(
months,
key_rate=key_rate,
issued_count=issued_count,
issued_volume=issued_volume,
)
with patch(_MACRO, return_value=macro):
neutral = compute_macro_coefficient(MagicMock(), segment_profile={})
with patch(_MACRO, return_value=macro):
favored = compute_macro_coefficient(
MagicMock(), segment_profile={"is_family": True, "room_bucket": "1-к 30-45"}
)
assert neutral.coefficient > _MACRO_COEF_NEUTRAL
assert favored.coefficient > _MACRO_COEF_NEUTRAL
# Favored реагирует круче на rate↓ → coef ВЫШЕ нейтрального (но клэмп может
# уравнять, если оба упёрлись в MAX — допускаем ≥).
assert favored.coefficient >= neutral.coefficient
def test_expensive_segment_steeper_negative_on_rate_up(self) -> None:
# Ставка растёт: дорогой/крупный сегмент должен дать coef НИЖЕ нейтрального
# (круче негатив на rate↑).
n = 12
months = _months(n)
key_rate = [9.0 + i * 0.4 for i in range(n)]
macro = _macro(months, key_rate=key_rate)
with patch(_MACRO, return_value=macro):
neutral = compute_macro_coefficient(MagicMock(), segment_profile={})
with patch(_MACRO, return_value=macro):
expensive = compute_macro_coefficient(
MagicMock(),
segment_profile={"obj_class": "премиум", "is_investment": True},
)
assert expensive.coefficient < neutral.coefficient
def test_all_backed_available_with_clean_window_high(self) -> None:
# Все 4 backed-канала есть + окно без шок-дат → confidence='high'.
n = 12
months = _months(n, end=dt.date(2023, 12, 1)) # 2023 без шок-дат PR2
key_rate = [9.0 + i * 0.2 for i in range(n)]
mortgage_rate = [11.0 + i * 0.15 for i in range(n)]
issued_count = [9000.0 - i * 100 for i in range(n)]
issued_volume = [45000.0 - i * 500 for i in range(n)]
debt = [2_000_000.0 + i * 1000 for i in range(n)]
overdue = [25_000.0 + i * 200 for i in range(n)]
macro = _macro(
months,
key_rate=key_rate,
mortgage_rate=mortgage_rate,
issued_count=issued_count,
issued_volume=issued_volume,
debt=debt,
overdue=overdue,
)
with patch(_MACRO, return_value=macro):
out = compute_macro_coefficient(MagicMock(), segment_profile={})
assert out.confidence == "high"
assert out.confounded is False
# Все backed доступны; все degraded — нет.
for name in (_F_RATE, _F_MORTG_RATE, _F_ISSUANCE, _F_OVERDUE):
assert name in out.available_inputs
for name in _DEGRADED_FACTORS:
assert name in out.unavailable_inputs
def test_confounded_window_caps_confidence(self) -> None:
# Окно пересекает шок 2022-02 → confounded=True; даже все backed → не 'high'.
n = 12
months = _months(n, end=dt.date(2022, 6, 1)) # охватывает 2022-02
assert any(m == dt.date(2022, 2, 1) for m in months)
key_rate = [9.0 + i * 0.5 for i in range(n)]
mortgage_rate = [11.0 + i * 0.3 for i in range(n)]
issued_count = [9000.0 - i * 100 for i in range(n)]
issued_volume = [45000.0 - i * 500 for i in range(n)]
debt = [2_000_000.0 for _ in range(n)]
overdue = [25_000.0 + i * 300 for i in range(n)]
macro = _macro(
months,
key_rate=key_rate,
mortgage_rate=mortgage_rate,
issued_count=issued_count,
issued_volume=issued_volume,
debt=debt,
overdue=overdue,
)
with patch(_MACRO, return_value=macro):
out = compute_macro_coefficient(MagicMock(), segment_profile={})
assert out.confounded is True
assert out.confidence != "high"
def test_partial_backed_is_medium(self) -> None:
# Доступны 2 backed-канала (rate + issuance) → medium (частичный сигнал).
n = 12
months = _months(n)
key_rate = [9.0 + i * 0.3 for i in range(n)]
issued_count = [9000.0 - i * 100 for i in range(n)]
issued_volume = [45000.0 - i * 500 for i in range(n)]
macro = _macro(
months,
key_rate=key_rate,
issued_count=issued_count,
issued_volume=issued_volume,
)
with patch(_MACRO, return_value=macro):
out = compute_macro_coefficient(MagicMock(), segment_profile={})
assert out.confidence == "medium"
def test_graceful_empty_is_neutral_low(self) -> None:
# Пустой макро-ряд → coef=1.0 (нейтрально), confidence='low', не crash.
with patch(_MACRO, return_value=[]):
out = compute_macro_coefficient(MagicMock(), segment_profile={})
assert out.coefficient == _MACRO_COEF_NEUTRAL
assert out.confidence == "low"
assert out.confounded is False
assert out.weight_renorm_factor is None
# Все каналы недоступны → breakdown целиком None.
assert all(v is None for v in out.breakdown.values())
def test_all_none_series_is_neutral_low(self) -> None:
# Сетка месяцев есть, но все ряды None → coef=1.0, low (как degraded).
n = 12
months = _months(n)
macro = _macro(months) # все ряды None
with patch(_MACRO, return_value=macro):
out = compute_macro_coefficient(MagicMock(), segment_profile={})
assert out.coefficient == _MACRO_COEF_NEUTRAL
assert out.confidence == "low"
def test_none_segment_profile_defaults_neutral(self) -> None:
# segment_profile=None → нейтральный профиль, не crash.
n = 12
months = _months(n)
key_rate = [9.0 + i * 0.3 for i in range(n)]
macro = _macro(months, key_rate=key_rate)
with patch(_MACRO, return_value=macro):
out = compute_macro_coefficient(MagicMock(), segment_profile=None)
assert isinstance(out, MacroCoefficient)
assert out.segment_profile == {}
def test_coefficient_always_within_band(self) -> None:
# Любой режим → coef в [MIN, MAX] (клэмп).
n = 12
months = _months(n)
# Жёсткий шок: ставка +6 п.п., выдачи рухнули, высокая просрочка.
macro = _macro(
months,
key_rate=[9.0 + i * 0.6 for i in range(n)],
mortgage_rate=[10.0 + i * 0.5 for i in range(n)],
issued_count=[10000.0 - i * 700 for i in range(n)],
issued_volume=[50000.0 - i * 3500 for i in range(n)],
debt=[1_000_000.0 for _ in range(n)],
overdue=[80_000.0 + i * 1000 for i in range(n)],
)
with patch(_MACRO, return_value=macro):
out = compute_macro_coefficient(
MagicMock(),
segment_profile={"obj_class": "премиум", "is_investment": True},
)
assert _MACRO_COEF_MIN <= out.coefficient <= _MACRO_COEF_MAX
# ── as_dict ───────────────────────────────────────────────────────────────────
class TestMacroCoefficientAsDict:
def test_serialises_and_rounds(self) -> None:
mc = MacroCoefficient(
coefficient=0.876543,
breakdown={"rate": -0.123456, "income": None},
available_inputs=["rate"],
unavailable_inputs=["income"],
segment_profile={"obj_class": "бизнес"},
confidence="medium",
confounded=False,
weight_renorm_factor=1.234567,
)
d = mc.as_dict()
assert d["coefficient"] == 0.8765
assert d["breakdown"]["rate"] == -0.1235
assert d["breakdown"]["income"] is None # None-вклад сохраняется
assert d["weight_renorm_factor"] == 1.2346
assert d["available_inputs"] == ["rate"]
assert d["confidence"] == "medium"
def test_weights_schema_covers_all_factors(self) -> None:
# Каждый sub-factor имеет вес; degraded-набор ⊂ схеме весов.
assert _F_RATE in _WEIGHTS
assert _F_MORTG_RATE in _WEIGHTS
assert _F_ISSUANCE in _WEIGHTS
assert _F_OVERDUE in _WEIGHTS
assert _DEGRADED_FACTORS.issubset(set(_WEIGHTS))