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.
686 lines
29 KiB
Python
686 lines
29 KiB
Python
"""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·(j−peak)² (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[t−j] (+ 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 → 10−2 = 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)
|