gendesign/backend/tests/services/forecasting/test_regression.py
Light1YT 9cc65ea838 feat(forecasting): wire Almon-ADL rate estimator into §9.6 consumers (#978)
Backtest (OOS directional hit-rate): single-best-lag compute_rate_sensitivity
is directionally noise (0.148 Source B EKB-wide, lag-unstable); the Almon
distributed-lag estimator (compute_district_rate_regression) is strictly less
noisy on every tier (0.407 Source B / 0.60 survivorship-free Source A,
lag-stable). Add a thin adapter compute_rate_regime_sensitivity mapping
DistributedLagFit onto the existing RateSensitivity contract (beta=long-run
sum-beta, confidence regression->medium / fallback->low, district=None->low and
no call) and repoint the three consumers (demand_normalization, product_scoring,
demand_supply_forecast). Magnitude bounded by the existing [0.5,1.2] clamp.
Reversible; compute_rate_sensitivity kept for the backtest. Consumer tests
repointed to the real Almon path (mutation-verified genuine) + adapter unit
tests + end-to-end fallback degradation. Forecasting suite 840 passed; ruff clean.
2026-06-04 15:32:51 +05:00

686 lines
29 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 tests for §9.6 Almon distributed-lag regression (Forgejo #978 Part B).
Covers the PURE numpy logic on SYNTHETIC series with a KNOWN injected lag effect:
- _build_lag_matrix — full-row-only lag profile, drops incomplete/None rows
- _almon_basis — W[j,p] = j^p (constrains 7 lags to degree+1 params)
- newey_west_bandwidth — floor(4·(n/100)^(2/9)) rule, ≥1 floor
- newey_west_cov — HAC covariance differs from naive OLS; PSD; manual NW
- fit_almon_dl — recovers the injected best_lag + sign + long-run; R²;
per-lag reconstruction; HAC SEs computed
- build_fit_result — gate (n≥30 ∧ R²≥0.1 ∧ Σβ<0) → regression vs fallback;
fallback on thin n / weak R² / wrong sign (no crash)
- _build_phrase — §9.6 text from the lag shape; insufficient on no-gate
- compute_district_rate_regression — DB orchestrator wiring (mocked session)
NO live DB: the orchestrator test injects a fake session + monkeypatched data
loaders. Set a dummy DATABASE_URL BEFORE importing so app.core.config.Settings
fail-fast doesn't trip (same pattern as test_rate_sensitivity.py).
"""
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")
import numpy as np
import pytest
from app.services.forecasting import regression as reg
from app.services.forecasting.rate_sensitivity import RateSensitivity
from app.services.forecasting.sales_series import SegmentSpec
# --------------------------------------------------------------------------- #
# Synthetic-series helpers — inject a KNOWN distributed-lag effect
# --------------------------------------------------------------------------- #
def _aperiodic_rate_deltas(n: int, *, seed: int = 13) -> list[float]:
"""Δrate series with APERIODIC (LCG) jitter → low autocorrelation across lags.
A periodic regressor would let false lags compete with the injected one; an
LCG jitter keeps successive Δ weakly correlated so the true lag shape wins.
Finite from index 0 (the DL matrix builder drops incomplete leading rows).
"""
lvl = 10.0
state = seed
levels: list[float] = []
for _ in range(n):
state = (state * 1103515245 + 12345) % 2147483648
lvl += 0.3 + (state / 2147483648.0 - 0.5) * 0.8
levels.append(lvl)
return [0.0] + [levels[i] - levels[i - 1] for i in range(1, n)]
def _hump_beta(max_lag: int, *, peak: int, scale: float = 0.06) -> np.ndarray:
"""A negative 'hump' lag shape peaking (in magnitude) at ``peak``.
|β_j| = scale 0.012·(jpeak)² (floored at 0.005), all signs negative — the
economically expected shape (rate ↑ → demand ↓, response builds then fades).
Representable approximately by an Almon deg-2 polynomial, so the fit recovers
the peak and long-run.
"""
betas: list[float] = []
for j in range(max_lag + 1):
mag = scale - 0.012 * (j - peak) ** 2
betas.append(-max(0.005, mag))
return np.asarray(betas, dtype=float)
def _y_from_lag_shape(
x: list[float], beta: np.ndarray, *, max_lag: int, noise: float = 0.0, seed: int = 0
) -> list[float | None]:
"""y[t] = Σ_j β_j·x[tj] (+ optional gaussian noise); y[t<max_lag] = None.
Builds the regressand carrying the injected distributed-lag effect exactly
(plus noise). Leading months without a full lag profile → None (the builder
drops them anyway).
"""
rng = np.random.default_rng(seed)
y: list[float | None] = [None] * len(x)
for t in range(max_lag, len(x)):
val = float(sum(beta[j] * x[t - j] for j in range(max_lag + 1)))
if noise > 0.0:
val += float(rng.normal(0.0, noise))
y[t] = val
return y
# --------------------------------------------------------------------------- #
# _build_lag_matrix
# --------------------------------------------------------------------------- #
class TestBuildLagMatrix:
def test_shapes_and_full_rows_only(self) -> None:
x = [float(i) for i in range(10)]
y = [float(i) * 0.1 for i in range(10)]
built = reg._build_lag_matrix(x, y, max_lag=2)
assert built is not None
xm, yv = built
# First usable row is t=max_lag=2 → 102 = 8 rows, 3 lag columns.
assert xm.shape == (8, 3)
assert yv.shape == (8,)
# Row 0 corresponds to t=2: [x[2], x[1], x[0]] = [2,1,0].
assert list(xm[0]) == [2.0, 1.0, 0.0]
def test_drops_rows_with_none_in_any_lag(self) -> None:
x: list[float | None] = [0.0, 1.0, None, 3.0, 4.0, 5.0]
y: list[float | None] = [0.0, 0.1, 0.2, 0.3, 0.4, 0.5]
built = reg._build_lag_matrix(x, y, max_lag=2)
assert built is not None
xm, _yv = built
# t=2 reads x[0..2] (has None) → dropped; t=3 reads x[1..3] (has None) →
# dropped; t=4 reads x[2..4] (has None) → dropped; t=5 reads x[3..5] OK.
assert xm.shape == (1, 3)
assert list(xm[0]) == [5.0, 4.0, 3.0]
def test_drops_rows_with_none_y(self) -> None:
x = [float(i) for i in range(6)]
y: list[float | None] = [0.0, 0.1, None, 0.3, 0.4, 0.5]
built = reg._build_lag_matrix(x, y, max_lag=1)
assert built is not None
xm, yv = built
# t=2 has y=None → dropped. Usable t ∈ {1,3,4,5} → 4 rows.
assert xm.shape == (4, 2)
assert yv.shape == (4,)
def test_returns_none_when_no_full_row(self) -> None:
x: list[float | None] = [None, None, None]
y: list[float | None] = [1.0, 2.0, 3.0]
assert reg._build_lag_matrix(x, y, max_lag=1) is None
# --------------------------------------------------------------------------- #
# _almon_basis
# --------------------------------------------------------------------------- #
class TestAlmonBasis:
def test_j_to_the_p(self) -> None:
w = reg._almon_basis(3, 2) # lags 0..3, degree 2
assert w.shape == (4, 3)
# Column p = j^p: col0 = ones, col1 = j, col2 = j².
assert list(w[:, 0]) == [1.0, 1.0, 1.0, 1.0]
assert list(w[:, 1]) == [0.0, 1.0, 2.0, 3.0]
assert list(w[:, 2]) == [0.0, 1.0, 4.0, 9.0]
def test_reconstruct_quadratic_beta_exactly(self) -> None:
# β_j = 2 0.5j + 0.1j² is degree-2 → W @ γ reproduces it for γ=[2,0.5,0.1].
w = reg._almon_basis(6, 2)
gamma = np.array([2.0, -0.5, 0.1])
beta = w @ gamma
expected = np.array([2.0 - 0.5 * j + 0.1 * j**2 for j in range(7)])
assert np.allclose(beta, expected)
# --------------------------------------------------------------------------- #
# newey_west_bandwidth / newey_west_cov
# --------------------------------------------------------------------------- #
class TestNeweyWestBandwidth:
def test_rule_values(self) -> None:
# floor(4·(n/100)^(2/9)).
assert reg.newey_west_bandwidth(100) == 4
assert reg.newey_west_bandwidth(41) == 3
assert reg.newey_west_bandwidth(50) == 3
def test_small_n_values(self) -> None:
# floor(4·(10/100)^(2/9)) = floor(2.398) = 2; n=20 → floor(2.40)·… = 2.
assert reg.newey_west_bandwidth(10) == 2
assert reg.newey_west_bandwidth(20) == 2
def test_floor_at_one_for_tiny_n(self) -> None:
# n=2 → floor(4·0.02^(2/9)) ≈ floor(1.06) = 1, but the ≥1 floor guarantees
# at least a lag-1 autocovariance whenever n>1.
assert reg.newey_west_bandwidth(2) == 1
assert reg.newey_west_bandwidth(3) == 1
def test_zero_for_degenerate(self) -> None:
assert reg.newey_west_bandwidth(1) == 0
assert reg.newey_west_bandwidth(0) == 0
class TestNeweyWestCov:
def test_psd_and_symmetric(self) -> None:
rng = np.random.default_rng(1)
n = 50
design = np.column_stack([np.ones(n), rng.normal(size=(n, 2))])
resid = rng.normal(size=n)
cov = reg.newey_west_cov(design, resid, bandwidth=4)
# Symmetric and positive semi-definite (Bartlett weights guarantee PSD).
assert np.allclose(cov, cov.T, atol=1e-10)
eig = np.linalg.eigvalsh(cov)
assert float(eig.min()) >= -1e-8
def test_bandwidth_zero_equals_white_hc0(self) -> None:
rng = np.random.default_rng(2)
n = 40
design = np.column_stack([np.ones(n), rng.normal(size=(n, 1))])
resid = rng.normal(size=n)
cov0 = reg.newey_west_cov(design, resid, bandwidth=0)
# HC0: (X'X)^-1 (Σ u² x x') (X'X)^-1 — reconstruct manually.
xtx_inv = np.linalg.inv(design.T @ design)
ux = design * resid.reshape(-1, 1)
hc0 = xtx_inv @ (ux.T @ ux) @ xtx_inv
assert np.allclose(cov0, hc0, atol=1e-12)
def test_hac_differs_from_naive_under_autocorrelation(self) -> None:
# Construct strongly AUTOCORRELATED residuals → HAC SE must differ from
# the naive iid OLS SE (the whole point of NW).
n = 80
rng = np.random.default_rng(3)
x = rng.normal(size=n)
design = np.column_stack([np.ones(n), x])
# AR(1) residuals (ρ=0.7) → positive autocorrelation.
e = np.zeros(n)
for t in range(1, n):
e[t] = 0.7 * e[t - 1] + rng.normal(0, 1)
hac = reg.newey_west_cov(design, e, bandwidth=reg.newey_west_bandwidth(n))
sigma2 = float(e @ e) / (n - design.shape[1])
naive = sigma2 * np.linalg.inv(design.T @ design)
# The slope variance estimates must differ materially under autocorrelation.
assert not math.isclose(hac[1, 1], naive[1, 1], rel_tol=0.05)
# --------------------------------------------------------------------------- #
# fit_almon_dl — recover the injected lag shape
# --------------------------------------------------------------------------- #
class TestFitAlmonDl:
def test_recovers_injected_best_lag_and_sign(self) -> None:
n, max_lag = 60, 6
x = _aperiodic_rate_deltas(n, seed=13)
beta = _hump_beta(max_lag, peak=2)
y = _y_from_lag_shape(x, beta, max_lag=max_lag, noise=0.002, seed=0)
fit = reg.fit_almon_dl(x, y, max_lag=max_lag, degree=2)
assert fit is not None
# Peak lag recovered.
assert fit["best_lag"] == 2
# Long-run sign negative (rate ↑ → demand ↓) and close to the truth.
assert fit["long_run_coef"] < 0
assert math.isclose(fit["long_run_coef"], float(beta.sum()), abs_tol=0.02)
# Clean injected signal → high R².
assert fit["r2"] is not None and fit["r2"] > 0.8
# Gate flags all green on this clean, long, correctly-signed series.
assert fit["gate_n_ok"] and fit["gate_r2_ok"] and fit["gate_sign_ok"]
def test_recovers_different_peak_lag(self) -> None:
# Shift the injected peak to lag 4 → the fit must track it.
n, max_lag = 64, 6
x = _aperiodic_rate_deltas(n, seed=21)
beta = _hump_beta(max_lag, peak=4)
y = _y_from_lag_shape(x, beta, max_lag=max_lag, noise=0.002, seed=1)
fit = reg.fit_almon_dl(x, y, max_lag=max_lag, degree=2)
assert fit is not None
assert fit["best_lag"] == 4
assert fit["long_run_coef"] < 0
def test_per_lag_reconstruction_length_and_finite(self) -> None:
n, max_lag = 60, 6
x = _aperiodic_rate_deltas(n)
beta = _hump_beta(max_lag, peak=2)
y = _y_from_lag_shape(x, beta, max_lag=max_lag, noise=0.001, seed=2)
fit = reg.fit_almon_dl(x, y, max_lag=max_lag, degree=2)
assert fit is not None
per_lag = fit["per_lag_coef"]
assert len(per_lag) == max_lag + 1
assert all(math.isfinite(c) for c in per_lag)
def test_hac_se_computed_for_every_lag(self) -> None:
n, max_lag = 60, 6
x = _aperiodic_rate_deltas(n)
beta = _hump_beta(max_lag, peak=2)
y = _y_from_lag_shape(x, beta, max_lag=max_lag, noise=0.003, seed=3)
fit = reg.fit_almon_dl(x, y, max_lag=max_lag, degree=2)
assert fit is not None
hac_se = fit["hac_se"]
# One HAC SE per reconstructed lag coefficient, all finite and ≥0.
assert len(hac_se) == max_lag + 1
assert all(math.isfinite(s) and s >= 0.0 for s in hac_se)
# Bandwidth follows the NW rule for this n.
assert fit["hac_bandwidth"] == reg.newey_west_bandwidth(fit["n"])
def test_degree_must_be_below_lag_count(self) -> None:
# degree ≥ max_lag+1 is not a constraint (degenerates to free lags) → refuse.
x = _aperiodic_rate_deltas(40)
beta = _hump_beta(6, peak=2)
y = _y_from_lag_shape(x, beta, max_lag=6, noise=0.0)
assert reg.fit_almon_dl(x, y, max_lag=6, degree=7) is None
def test_thin_series_returns_none(self) -> None:
# Too few full rows to fit (< _MIN_FIT_OBS) → None, not a crash.
x = _aperiodic_rate_deltas(10)
beta = _hump_beta(6, peak=2)
y = _y_from_lag_shape(x, beta, max_lag=6, noise=0.0)
assert reg.fit_almon_dl(x, y, max_lag=6, degree=2) is None
def test_zero_variance_y_returns_none(self) -> None:
x = _aperiodic_rate_deltas(50)
y: list[float | None] = [None] * 6 + [0.0] * 44 # constant → no variance
assert reg.fit_almon_dl(x, y, max_lag=6, degree=2) is None
# --------------------------------------------------------------------------- #
# build_fit_result — gate (mirror _elasticity_coef) → regression vs fallback
# --------------------------------------------------------------------------- #
_SEG: dict[str, str | None] = {"district": "Академический", "obj_class": None}
class TestBuildFitResult:
def test_gate_pass_emits_regression(self) -> None:
n, max_lag = 60, 6
x = _aperiodic_rate_deltas(n, seed=13)
beta = _hump_beta(max_lag, peak=2)
y = _y_from_lag_shape(x, beta, max_lag=max_lag, noise=0.002, seed=0)
res = reg.build_fit_result(x, y, segment=_SEG, max_lag=max_lag, degree=2)
assert res.source == "regression"
assert res.best_lag_months == 2
assert res.coef is not None and res.coef < 0
assert res.x_pct is not None and res.x_pct < 0 # demand drops
assert res.r2 is not None and res.r2 > 0.8
assert res.per_lag_coef is not None and len(res.per_lag_coef) == max_lag + 1
assert res.hac_se is not None and len(res.hac_se) == max_lag + 1
# Phrase carries the magnitude + peak lag.
assert "снижается" in res.phrase
assert f"{abs(round(res.x_pct, 1))}" in res.phrase
def test_thin_n_degrades_to_fallback(self) -> None:
# Enough to fit, but n < _MIN_OBS (30) → gate fails on n → fallback. We keep
# the diagnostic numbers (per_lag/r2/n) but make no claim.
n, max_lag = 28, 6 # ~22 usable rows < 30
x = _aperiodic_rate_deltas(n, seed=5)
beta = _hump_beta(max_lag, peak=2)
y = _y_from_lag_shape(x, beta, max_lag=max_lag, noise=0.001, seed=4)
res = reg.build_fit_result(x, y, segment=_SEG, max_lag=max_lag, degree=2)
assert res.source == "fallback"
assert res.n < reg._MIN_OBS
assert res.coef is None and res.x_pct is None and res.best_lag_months is None
assert res.phrase == reg._PHRASE_INSUFFICIENT
# Diagnostics retained (mirror _elasticity_coef returning r2/n in fallback).
assert res.per_lag_coef is not None
def test_wrong_sign_degrades_to_fallback(self) -> None:
# POSITIVE long-run (rate ↑ → demand ↑) violates the gate sign → fallback,
# even with plenty of obs and a strong fit.
n, max_lag = 60, 6
x = _aperiodic_rate_deltas(n, seed=13)
beta = -_hump_beta(max_lag, peak=2) # flip all signs → positive long-run
y = _y_from_lag_shape(x, beta, max_lag=max_lag, noise=0.002, seed=0)
res = reg.build_fit_result(x, y, segment=_SEG, max_lag=max_lag, degree=2)
assert res.source == "fallback"
assert res.coef is None
assert res.phrase == reg._PHRASE_INSUFFICIENT
def test_weak_r2_degrades_to_fallback(self) -> None:
# Pure noise regressand (no rate link) at large n: a 3-param Almon basis
# cannot overfit ~114 noise points, so R² collapses well below 0.1 → the
# gate fails on R² (or sign) → fallback. (At small n a flexible basis can
# spuriously clear R²≥0.1 — which is exactly why the n≥30 gate + advisory
# status exist; here we use n=120 so the no-signal case is unambiguous.)
n, max_lag = 120, 6
x = _aperiodic_rate_deltas(n, seed=13)
rng = np.random.default_rng(7)
y: list[float | None] = [None] * max_lag + [
float(v) for v in rng.normal(0, 0.05, size=n - max_lag)
]
res = reg.build_fit_result(x, y, segment=_SEG, max_lag=max_lag, degree=2)
assert res.source == "fallback"
assert res.coef is None
# Confirm it degraded specifically because the fit explains ~no variance.
assert res.r2 is not None and res.r2 < reg._MIN_R2
def test_empty_series_is_fallback_not_crash(self) -> None:
res = reg.build_fit_result([], [], segment=_SEG)
assert res.source == "fallback"
assert res.n == 0
assert res.phrase == reg._PHRASE_INSUFFICIENT
def test_as_dict_shape(self) -> None:
n, max_lag = 60, 6
x = _aperiodic_rate_deltas(n, seed=13)
beta = _hump_beta(max_lag, peak=2)
y = _y_from_lag_shape(x, beta, max_lag=max_lag, noise=0.002, seed=0)
d = reg.build_fit_result(x, y, segment=_SEG, max_lag=max_lag, degree=2).as_dict()
for key in (
"segment",
"best_lag_months",
"coef",
"x_pct",
"r2",
"n",
"per_lag_coef",
"hac_se",
"hac_bandwidth",
"almon_degree",
"source",
"phrase",
):
assert key in d
assert d["source"] == "regression"
assert isinstance(d["per_lag_coef"], list)
# --------------------------------------------------------------------------- #
# _build_phrase
# --------------------------------------------------------------------------- #
class TestBuildPhrase:
def test_phrase_from_shape(self) -> None:
p = reg._build_phrase(x_pct=-3.2, best_lag=2, gated=True)
assert "3.2%" in p
assert "2 мес" in p
assert "снижается" in p
def test_insufficient_when_not_gated(self) -> None:
assert reg._build_phrase(x_pct=-3.2, best_lag=2, gated=False) == reg._PHRASE_INSUFFICIENT
def test_insufficient_when_none(self) -> None:
assert reg._build_phrase(x_pct=None, best_lag=2, gated=True) == reg._PHRASE_INSUFFICIENT
assert reg._build_phrase(x_pct=-3.2, best_lag=None, gated=True) == reg._PHRASE_INSUFFICIENT
# --------------------------------------------------------------------------- #
# compute_district_rate_regression — DB orchestrator (mocked)
# --------------------------------------------------------------------------- #
class _FakeMacro:
def __init__(self, month: dt.date, key_rate: float | None) -> None:
self.month = month
self.key_rate = key_rate
class _FakeSales:
def __init__(self, months: list[dt.date], units: list[int]) -> None:
self.months = months
self.units = units
def _months(n: int) -> list[dt.date]:
out: list[dt.date] = []
y, m = 2021, 1
for _ in range(n):
out.append(dt.date(y, m, 1))
m += 1
if m == 13:
m = 1
y += 1
return out
class TestComputeDistrictRateRegression:
def test_orchestrator_wires_macro_and_sales(self, monkeypatch: pytest.MonkeyPatch) -> None:
# Build a macro key_rate series whose Δ drives a lag-2 demand response, then
# confirm the orchestrator assembles X (Δrate) and Y (Δln units), aligns
# them, and recovers the injected lag via the pure fit. (The orchestrator
# uses the module-default max_lag=6 internally.)
n = 60
months = _months(n)
# key_rate levels: integrate the aperiodic Δ so _delta() recovers them.
xdelta = _aperiodic_rate_deltas(n, seed=13)
levels: list[float] = []
acc = 10.0
for d in xdelta:
acc += d
levels.append(acc)
macro = [_FakeMacro(months[i], levels[i]) for i in range(n)]
# Units carrying the lag-2 signal: ln(u_t) = ln(base) + Σ_{k≤t} β·Δrate[k-lag].
beta_scalar = -0.05
lag = 2
ln_u = math.log(1000.0)
units: list[int] = []
for t in range(n):
if t > 0:
src = xdelta[t - lag] if t - lag >= 0 else 0.0
ln_u += beta_scalar * src
units.append(max(1, round(math.exp(ln_u))))
sales = _FakeSales(months, units)
monkeypatch.setattr(reg, "get_monthly_macro", lambda db, months_back: macro)
monkeypatch.setattr(reg, "build_sales_series", lambda db, spec, source, months_back: sales)
res = reg.compute_district_rate_regression(
object(), # type: ignore[arg-type]
district="Академический",
months_back=n,
)
assert res.segment["district"] == "Академический"
assert res.source == "regression"
# The single-lag injection at lag 2 → Almon shape peaks near lag 2.
assert res.best_lag_months in (1, 2, 3)
assert res.coef is not None and res.coef < 0
assert res.n >= reg._MIN_OBS
def test_orchestrator_graceful_on_empty(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(reg, "get_monthly_macro", lambda db, months_back: [])
monkeypatch.setattr(
reg,
"build_sales_series",
lambda db, spec, source, months_back: _FakeSales([], []),
)
res = reg.compute_district_rate_regression(
object(), # type: ignore[arg-type]
district="Пустой",
)
assert res.source == "fallback"
assert res.phrase == reg._PHRASE_INSUFFICIENT
# --------------------------------------------------------------------------- #
# compute_rate_regime_sensitivity — production adapter (DistributedLagFit →
# RateSensitivity) wiring the validated Almon-ADL estimator into the §9.6 consumers
# --------------------------------------------------------------------------- #
_ADAPT_REG = "app.services.forecasting.regression.compute_district_rate_regression"
def _fit(
*,
coef: float | None,
x_pct: float | None,
best_lag: int | None,
r2: float | None,
n: int,
source: str,
phrase: str = "phrase",
segment: dict[str, str | None] | None = None,
) -> reg.DistributedLagFit:
"""A REAL DistributedLagFit (the dataclass the adapter actually receives).
Not a hand-faked attribute bag: building the real frozen dataclass guarantees
the adapter mapping is tested against the true #978 contract and breaks loudly
if a field is renamed.
"""
return reg.DistributedLagFit(
segment=segment or {"district": "Академический", "obj_class": None},
best_lag_months=best_lag,
coef=coef,
x_pct=x_pct,
r2=r2,
n=n,
per_lag_coef=None,
hac_se=None,
hac_bandwidth=None,
almon_degree=2,
source=source,
phrase=phrase,
)
class TestComputeRateRegimeSensitivity:
def test_regression_source_maps_fields_and_medium(self) -> None:
# 'regression' (gate passed) → beta==coef (long-run Σβ), x_pct/y_lag/phrase
# mapped through, confidence 'medium' (advisory-grade, never 'high').
fit = _fit(
coef=-0.12,
x_pct=-11.3,
best_lag=2,
r2=0.42,
n=40,
source="regression",
phrase="спрос снижается …",
)
with patch(_ADAPT_REG, return_value=fit) as reg_mock:
out = reg.compute_rate_regime_sensitivity(
MagicMock(), spec=SegmentSpec(district="Академический", obj_class="комфорт")
)
assert isinstance(out, RateSensitivity)
assert out.beta == -0.12 # beta ← fit.coef (Almon long-run multiplier)
assert out.x_pct == -11.3
assert out.y_lag_months == 2
assert out.phrase == "спрос снижается …"
assert out.confidence == "medium"
assert out.r2 == 0.42
assert out.n_obs == 40
# Source-B-only fields have no analogue in a district×class fit.
assert out.z_area_floor is None
assert out.most_sensitive_bucket is None
# Adapter forwarded district + obj_class to the district regression.
call = reg_mock.call_args
assert call.kwargs["district"] == "Академический"
assert call.kwargs["obj_class"] == "комфорт"
def test_fallback_source_maps_none_and_low(self) -> None:
# 'fallback' (degrade) → beta is None, x_pct None, confidence 'low'; phrase
# is the insufficient phrase carried from the fit.
fit = _fit(
coef=None,
x_pct=None,
best_lag=None,
r2=0.04,
n=12,
source="fallback",
phrase=reg._PHRASE_INSUFFICIENT,
)
with patch(_ADAPT_REG, return_value=fit):
out = reg.compute_rate_regime_sensitivity(
MagicMock(), spec=SegmentSpec(district="Академический")
)
assert out.beta is None
assert out.x_pct is None
assert out.y_lag_months is None
assert out.confidence == "low"
assert out.phrase == reg._PHRASE_INSUFFICIENT
# Diagnostic numbers still surface (n carried through).
assert out.n_obs == 12
def test_district_none_short_circuits_low_no_call(self) -> None:
# spec.district is None → adapter must NOT call the district regression (it
# requires a str) and degrades to a low-confidence, beta=None result.
with patch(_ADAPT_REG) as reg_mock:
out = reg.compute_rate_regime_sensitivity(
MagicMock(), spec=SegmentSpec(obj_class="комфорт")
)
reg_mock.assert_not_called()
assert out.beta is None
assert out.x_pct is None
assert out.confidence == "low"
assert out.phrase == reg._PHRASE_INSUFFICIENT
# Segment dict still reflects the spec (shape preserved for consumers).
assert out.segment == SegmentSpec(obj_class="комфорт").as_dict()
def test_internal_failure_degrades_not_crash(self) -> None:
# Defensive guard: if the (graceful-by-contract) district regression still
# raises unexpectedly, the adapter degrades to insufficient, never crashes.
with patch(_ADAPT_REG, side_effect=RuntimeError("boom")):
out = reg.compute_rate_regime_sensitivity(
MagicMock(), spec=SegmentSpec(district="Академический")
)
assert out.beta is None
assert out.confidence == "low"
assert out.phrase == reg._PHRASE_INSUFFICIENT
def test_segment_carries_full_spec_shape(self) -> None:
# The returned segment mirrors spec.as_dict() (4-axis shape the consumers
# already serialise), not the 2-key regression segment.
fit = _fit(coef=-0.05, x_pct=-4.9, best_lag=1, r2=0.3, n=33, source="regression")
spec = SegmentSpec(obj_class="бизнес", room_bucket="2-к 45-60", district="X")
with patch(_ADAPT_REG, return_value=fit):
out = reg.compute_rate_regime_sensitivity(MagicMock(), spec=spec)
assert out.segment == spec.as_dict()
assert set(out.segment) == {"obj_class", "room_bucket", "district", "price_bucket"}
def test_uses_real_fit_via_synthetic_series_end_to_end(self, monkeypatch) -> None: # type: ignore[no-untyped-def]
# Strongest contract check: run the adapter over the REAL compute_district_
# rate_regression on a synthetic lag-2 series (no faking of the fit object) →
# confidence 'medium', beta == the real long-run coef, x_pct negative.
n = 60
months = _months(n)
xdelta = _aperiodic_rate_deltas(n, seed=13)
levels: list[float] = []
acc = 10.0
for d in xdelta:
acc += d
levels.append(acc)
macro = [_FakeMacro(months[i], levels[i]) for i in range(n)]
beta_scalar, lag = -0.05, 2
ln_u = math.log(1000.0)
units: list[int] = []
for t in range(n):
if t > 0:
src = xdelta[t - lag] if t - lag >= 0 else 0.0
ln_u += beta_scalar * src
units.append(max(1, round(math.exp(ln_u))))
sales = _FakeSales(months, units)
monkeypatch.setattr(reg, "get_monthly_macro", lambda db, months_back: macro)
monkeypatch.setattr(reg, "build_sales_series", lambda db, spec, source, months_back: sales)
out = reg.compute_rate_regime_sensitivity(
object(), # type: ignore[arg-type]
spec=SegmentSpec(district="Академический"),
months_back=n,
)
assert out.confidence == "medium"
assert out.beta is not None and out.beta < 0
assert out.x_pct is not None and out.x_pct < 0
assert out.y_lag_months in (1, 2, 3)