gendesign/backend/scripts/backtest_rate_sensitivity.py
Light1YT cc66f51863
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 28s
Deploy / build-worker (push) Successful in 28s
Deploy / deploy (push) Successful in 58s
fix(backtest): add binomial significance gate to §9.6 verdict
The OOS verdict flagged a variant 'candidate to promote' on hit-rate >= 0.5+margin
+ lag_stable alone. On thin data this over-claims: Source A Almon-ADL scored 6/10
(0.60) lag-stable and was flagged as signal, but P(X>=6|10,0.5)~=0.377 -- a coin
flip. Live ground-truth confirmed no signal (full-sample R2~=0.003, wrong sign).

Add exact stdlib-only one-sided binomial _binom_sf_ge + _VERDICT_ALPHA=0.05 and
require P(X>=hits|n_test,0.5) < alpha in both verdict() and cross_source_verdict()
on top of the effect-size margin. hits recovered exactly as round(hit_rate*n_test)
(n_test==scored invariant; no evaluator shape change). Verdict text now states
n_test + the binomial p on pass and fail. Evaluator/estimator math and the
read-only SELECT discipline untouched. Refs #978.
2026-06-04 16:42:45 +05:00

1919 lines
79 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.

"""Backtest harness — out-of-sample validation of the §9.6 rate-sensitivity engine.
Forgejo issue #978 (#951-B validation, Site Finder v2 EPIC 7). **STRICTLY
READ-ONLY**: this script issues only SELECT queries against prod. It never
INSERTs, UPDATEs, or runs DDL.
WHAT IT MEASURES
----------------
The §9.6 engine (#1009 ``rate_sensitivity.py``) regresses a segment's monthly
Δln(sold-units) on Δ(key_rate) at several lags, picks the lag with the most
negative gate-passing slope (β), and emits an ADVISORY phrase. That engine is
the gate that decides whether the advisory forecast can be PROMOTED. This
harness is the out-of-sample (OOS) check that gate must clear: does β fit on a
TRAIN window actually predict the SIGN of Δln(sales) on a held-out TEST window?
We mirror the trade-in ``backtest_estimator.py`` discipline (read-only,
holdout, in-sample-vs-OOS honesty block):
1. Build the monthly sold-units series from Source B (``objective_lots``,
EKB-wide and per ``class``) and the monthly ``key_rate`` (last-known per
month, reusing ``forecasting.macro_series.get_monthly_macro``).
2. Align by year-month → Δln(sales) (reuse ``sales_series.log_diff``) and
Δkey_rate (first difference).
3. TIME-ORDERED holdout (NOT random / even-odd — this is a time series): fit
on the first ``holdout_frac`` of months, evaluate on the rest. Fit reuses
``rate_sensitivity.best_lag`` (→ β + winning lag on TRAIN only).
4. Predict each TEST month's Δln(sales) sign (and magnitude) from
β·Δkey_rate@lag, strictly point-in-time (macro as-of each test month, the
lagged regressor never reaches past the test month).
5. Report the OOS directional hit-rate (fraction of test months where the
predicted sign matches the actual sign), signed MAE, n_train / n_test, and
the winning lag — alongside the in-sample R².
IN-SAMPLE vs OUT-OF-SAMPLE HONESTY (copy trade-in discipline)
------------------------------------------------------------
We report BOTH the in-sample R² (high by construction — the slope is fit to
minimise residuals on the very points it is scored on) AND the OOS directional
hit-rate (the only trustworthy number). Stated plainly: the in-sample R² is NOT
evidence the engine predicts — it is arithmetic. Only the held-out directional
hit-rate, computed point-in-time on months the fit never saw, tells us whether
β·Δrate@lag carries real predictive value worth promoting from advisory.
PER-TIER
--------
We backtest EKB-wide and each ``class``. A class-specific β is only worth
promoting if it beats the EKB-wide prior OUT-OF-SAMPLE (directional hit-rate
lift). A tier with fewer than ``_MIN_BACKTEST_MONTHS`` aligned train+test
months is SKIPPED with a printed note (no silent drop).
VERDICT
-------
We print whether the EKB-wide OOS directional hit-rate beats a 0.5 coin-flip
baseline (by at least ``_VERDICT_HITRATE_MARGIN``) AND the winning lag is the
same on TRAIN and on a full-sample refit (lag stability) →
"engine has OOS predictive value (candidate to promote from advisory)" vs
"insufficient OOS signal — keep advisory". We stay honest: 102 raw months still
collapse to few test points after Δ (loses 1), the survivorship-thinned early
months, and the holdout split — if the OOS test set is tiny we say so rather
than over-claim.
TWO CLEANER CROSS-CHECKS (#978b)
--------------------------------
The Source B OOS verdict was ``no signal`` (hit-rate < coin-flip). Source B is
survivorship-CONFOUNDED — its monthly counts trend ~3x upward 2019→2025 purely
because only currently-listed lots are visible, so the engine's negative verdict
could be an ARTIFACT rather than a true ``no signal``. We add two controls:
• ``--source A`` — build the series from ``objective_corpus_room_month``
(Objective's corp_sum monthly deal AGGREGATE). This counts deals PER MONTH
regardless of current listing → survivorship-FREE. It is only ~13 months
deep on prod (≈2025-05→2026-05), statistically thin, but a clean cross-check
(the verdict carries an explicit thin-data caveat for it).
• ``--detrend`` — before differencing, fit a linear time trend
``ln(units) ~ a + b·month_index`` and subtract it, regressing the Δ of the
RESIDUALS vs Δrate. A spurious monotone survivorship trend lands almost
entirely in ``b`` and is removed, so it can no longer drive the regression.
The trend is fit on the TRAIN months ONLY and projected point-in-time onto
the test months (#978 Part A: fitting it on train+test together leaks future
info into the test residuals and inflates the detrended OOS hit-rate).
Read both alongside Source B raw: if Source B DETRENDED still shows no OOS
signal AND survivorship-free Source A agrees (thin caveat aside), the engine's
negative verdict is a real ``no signal``, not a survivorship artifact.
CAVEATS (read before trusting the numbers)
------------------------------------------
(a) SURVIVORSHIP — Source B (``objective_lots``) is the last UPSERT snapshot
per lot: only currently-listed lots are visible, so sold-and-delisted
lots are undercounted in OLDER months. The monthly sold-units series is
therefore biased low on the early window (a ~3x spurious upward trend).
``--detrend`` and ``--source A`` are the controls for this.
(b) SHORT Δ-SERIES — even ~102 months of lots collapse after first-difference
+ the survivorship-thinned head + the holdout split. The OOS test window
can be small; the hit-rate's confidence is correspondingly weak. The
verdict notes this explicitly. Source A (~13 months) is thinner still and
will usually be SKIPPED below ``_MIN_BACKTEST_MONTHS`` — never faked.
(c) ADVISORY MECHANISM ONLY — this exercises the β / lag CORE of the engine.
It does NOT replay the full module (shrinkage to the EKB prior, the
confounded-window flag, the Z-bucket phrase). It answers one question:
does the fitted slope predict direction out-of-sample?
CANDIDATE-METHOD VARIANTS (#978 Almon / #979 deseasonalize)
-----------------------------------------------------------
Two forecast modules were shipped advisory-only and NOT wired into prod, pending
a backtest proving they recover OOS signal the production ``best_lag``-on-raw
path missed. This harness evaluates BOTH as opt-in variants, each isolated
against the always-on raw reference (we never explode into all combinations):
• ``--deseasonalize`` (#979 ``normalize.py``) — divide month-of-year seasonal
factors out of the units series BEFORE ``log_diff`` (cleaner regressand),
then score with the SAME ``best_lag`` engine. Factors are fit on TRAIN
months ONLY and applied point-in-time onto the test months (a test-month
observation must NEVER influence the factors — same leakage discipline as
``--detrend``).
• ``--almon`` (#978 ``regression.py``) — replace ``best_lag``'s single winning
lag with an Almon polynomial distributed-lag estimator (a smooth β curve over
lags 0..6, reported via its long-run multiplier). A NEW OOS evaluator
(``evaluate_oos_almon``) fits on TRAIN only and predicts each test month
``Σ_j β_j·Δrate[tj]`` strictly point-in-time.
A NEGATIVE result is a valid, honest outcome — you cannot extract signal that
isn't there. The failure mode this harness guards against is a leaky evaluator
that FALSELY shows signal, so both methods' TRAIN/TEST boundaries are pinned and
unit-tested on synthetic series with known answers.
USAGE
-----
DATABASE_URL=postgresql+psycopg://... \
python -m scripts.backtest_rate_sensitivity --since 2019-01-01
# per-class, machine-readable:
python -m scripts.backtest_rate_sensitivity --classes комфорт,бизнес --json
# the #978b cross-checks: survivorship-free Source A + a detrend control:
python -m scripts.backtest_rate_sensitivity --source both --detrend
# evaluate the candidate methods OOS (#978 Almon-ADL + #979 deseasonalize):
python -m scripts.backtest_rate_sensitivity --source B --almon --deseasonalize
"""
from __future__ import annotations
import argparse
import json
import logging
import math
from dataclasses import dataclass
from datetime import date
from pathlib import Path
from typing import Any
import numpy as np
from sqlalchemy import text
from sqlalchemy.orm import Session
# Reuse the §9.6 engine's PURE math (β / lag selection) and the Y-axis Δln
# helper, verbatim — the backtest must score the SAME functions production runs,
# not a re-implementation. Deferred import (see _import_engine) so `--help` and
# the pure-logic unit tests don't pull app.core.config.Settings, which
# fail-fasts when DATABASE_URL is unset.
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
logger = logging.getLogger("backtest_rate_sensitivity")
# ── Named constants ───────────────────────────────────────────────────────────
# Default lower bound of the backtest window. 2019-01 matches the solid daily
# key_rate history (macro_indicator key_rate spans 2019-01→2026-06) and lets
# Source B contribute its full ~102 distinct months.
_DEFAULT_SINCE: str = "2019-01-01"
# Fraction of the (time-ordered) aligned months used to FIT; the remainder is
# the held-out TEST window. 0.7 ≈ "fit on the older 70%, judge on the newer
# 30%" — enough train depth for best_lag's gate while leaving a test window.
_HOLDOUT_FRAC: float = 0.7
# Minimum aligned (train+test) months a tier must have before we backtest it.
# Below this the time-ordered split leaves too few test points for the
# directional hit-rate to mean anything → skip the tier with a printed note
# (never a silent drop). 18 ≈ "≥1 year to fit + a handful to test".
_MIN_BACKTEST_MONTHS: int = 18
# How far the EKB-wide OOS directional hit-rate must clear the 0.5 coin-flip
# baseline before the verdict calls the engine predictive. A small margin so a
# tiny test window can't flip the verdict on one lucky month.
_VERDICT_HITRATE_MARGIN: float = 0.05
# One-sided significance level for the verdict's binomial gate. The hit-rate must
# be statistically distinguishable from a fair coin (p=0.5) at this α before the
# verdict promotes — i.e. P(X ≥ hits | n_test, 0.5) < _VERDICT_ALPHA. This is a
# HARD gate on top of the effect-size margin above: it prevents promoting on THIN
# data where a high hit-rate is consistent with chance (the #978 near-miss:
# 6/10 = 0.60 has one-sided p ≈ 0.377, indistinguishable from a coin flip).
_VERDICT_ALPHA: float = 0.05
# Source B premise filter — residential квартиры, the only segment §9.6 scores
# (mirrors sales_series._DEFAULT_PREMISE_KIND).
_PREMISE_KIND: str = "квартира"
# Sentinel for the EKB-wide (all-classes) tier in tables / JSON.
_EKB_WIDE: str = "EKB-wide"
# Series-source labels.
# B = objective_lots.registration_date COUNT(*) — long but survivorship-CONFOUNDED.
# A = objective_corpus_room_month SUM(deals) — short (~13 mo) but survivorship-FREE.
_SOURCE_B: str = "B"
_SOURCE_A: str = "A"
_SOURCE_BOTH: str = "both"
_SOURCES: tuple[str, ...] = (_SOURCE_B, _SOURCE_A)
# Minimum finite (>0) points _detrend_log needs to fit a line. Below this we
# can't separate trend from level, so we pass the log values through unchanged
# (the difference step then behaves exactly like the raw log_diff path).
_DETREND_MIN_POINTS: int = 3
# Estimator selector — which OOS evaluator backtest_tier dispatches to.
# best_lag = single-winning-lag OLS (the production §9.6 core, evaluate_oos).
# almon = #978 Almon polynomial distributed-lag (evaluate_oos_almon).
# Default is best_lag so existing variants/tests are byte-identical (back-compat).
_ESTIMATOR_BEST_LAG: str = "best_lag"
_ESTIMATOR_ALMON: str = "almon"
def _import_engine() -> tuple[Any, Any, Any]:
"""Lazy import of the §9.6 engine's pure funcs + Δln helper.
Returns ``(best_lag, ols_slope_r2, log_diff)``. Deferred so ``--help`` and
the pure-metric unit tests don't import app.core.config.Settings (which
fail-fasts without DATABASE_URL). Supports both
``python -m scripts.backtest_rate_sensitivity`` and stand-alone runs.
"""
try:
from app.services.forecasting.rate_sensitivity import best_lag, ols_slope_r2
from app.services.forecasting.sales_series import log_diff
except ImportError: # pragma: no cover — fallback for adhoc invocation
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.services.forecasting.rate_sensitivity import best_lag, ols_slope_r2
from app.services.forecasting.sales_series import log_diff
return best_lag, ols_slope_r2, log_diff
def _import_lags() -> tuple[int, ...]:
"""Lazy import of the engine's lag grid (_LAGS) — same deferral rationale."""
try:
from app.services.forecasting.rate_sensitivity import _LAGS
except ImportError: # pragma: no cover — fallback for adhoc invocation
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.services.forecasting.rate_sensitivity import _LAGS
return _LAGS
def _import_regression() -> tuple[Any, int]:
"""Lazy import of the #978 Almon distributed-lag estimator + its lag window.
Returns ``(fit_almon_dl, _MAX_LAG)``. Deferred for the SAME reason as
``_import_engine`` — app.services.forecasting.regression pulls
app.core.config.Settings (fail-fasts without DATABASE_URL), and the
pure-logic unit tests must import this module cheaply. ``_MAX_LAG`` (the
regression module's distributed-lag window upper bound, default 6) is read
so the Almon evaluator's point-in-time prediction iterates the SAME lag span
the estimator fitted.
"""
try:
from app.services.forecasting.regression import _MAX_LAG, fit_almon_dl
except ImportError: # pragma: no cover — fallback for adhoc invocation
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.services.forecasting.regression import _MAX_LAG, fit_almon_dl
return fit_almon_dl, _MAX_LAG
def _import_normalize() -> tuple[Any, Any]:
"""Lazy import of the #979 seasonal deseasonalization helpers.
Returns ``(seasonal_factors, deseasonalize_values)`` from
app.services.forecasting.normalize. Deferred for the SAME reason as
``_import_engine`` (the normalize module pulls SalesSeries → Settings).
"""
try:
from app.services.forecasting.normalize import (
deseasonalize_values,
seasonal_factors,
)
except ImportError: # pragma: no cover — fallback for adhoc invocation
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.services.forecasting.normalize import (
deseasonalize_values,
seasonal_factors,
)
return seasonal_factors, deseasonalize_values
def _session() -> Session:
"""Lazy SessionLocal factory — see _import_engine for why it's deferred."""
try:
from app.core.db import SessionLocal
except ImportError: # pragma: no cover — fallback for adhoc invocation
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.core.db import SessionLocal
return SessionLocal()
# --------------------------------------------------------------------------- #
# Data carriers
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class TierResult:
"""OOS backtest result for one tier (EKB-wide or a single class).
Every numeric field is None when the tier was skipped (too few months) —
``skipped`` carries the reason. ``oos_hit_rate`` is the only trustworthy
accuracy number; ``in_sample_r2`` is high by construction (see honesty
block) and reported only for contrast.
"""
tier: str
source: str
detrended: bool
n_aligned: int
n_train: int
n_test: int
train_lag: int | None
train_beta: float | None
in_sample_r2: float | None
oos_hit_rate: float | None
oos_signed_mae: float | None
full_sample_lag: int | None
lag_stable: bool
skipped: str | None
deseasonalized: bool = False
estimator: str = _ESTIMATOR_BEST_LAG
def as_dict(self) -> dict[str, Any]:
return {
"tier": self.tier,
"source": self.source,
"detrended": self.detrended,
"deseasonalized": self.deseasonalized,
"estimator": self.estimator,
"n_aligned": self.n_aligned,
"n_train": self.n_train,
"n_test": self.n_test,
"train_lag": self.train_lag,
"train_beta": _round_or_none(self.train_beta, 4),
"in_sample_r2": _round_or_none(self.in_sample_r2, 4),
"oos_hit_rate": _round_or_none(self.oos_hit_rate, 4),
"oos_signed_mae": _round_or_none(self.oos_signed_mae, 4),
"full_sample_lag": self.full_sample_lag,
"lag_stable": self.lag_stable,
"skipped": self.skipped,
}
def _round_or_none(value: float | None, digits: int) -> float | None:
return round(value, digits) if value is not None else None
# --------------------------------------------------------------------------- #
# Pure logic — NO DB. Unit-tested in tests/scripts/test_backtest_rate_sensitivity
# on synthetic series (inject sales=f(rate@lag) → high OOS hit-rate; inject
# noise → hit-rate ≈ 0.5; time-ordered split correctness; thin-tier skip).
# --------------------------------------------------------------------------- #
def _binom_sf_ge(k: int, n: int, p: float = 0.5) -> float:
"""Exact one-sided binomial survival: P(X ≥ k) for X ~ Binomial(n, p). PURE.
Computed EXACTLY via ``math.comb`` (stdlib only — NO scipy/statsmodels, to
mirror the §9.6 engine's no-heavy-deps discipline):
``Σ_{i=k..n} C(n, i) · p^i · (1p)^(ni)``.
This is the probability the observed directional hit count (or anything more
extreme) arises by chance from a fair coin — small ⇒ the hit-rate is real
signal, not luck. It is the verdict's significance gate (see ``_VERDICT_ALPHA``).
Guards (return 1.0 = "no evidence against the null"):
• ``n <= 0`` → 1.0 (no trials, nothing to distinguish);
• ``k`` clamped to ``[0, n]``;
• ``k <= 0`` → 1.0 (P(X ≥ 0) = 1 trivially).
"""
if n <= 0:
return 1.0
k = max(0, min(k, n))
if k <= 0:
return 1.0
q = 1.0 - p
total = 0.0
for i in range(k, n + 1):
total += math.comb(n, i) * (p**i) * (q ** (n - i))
# Clamp tiny floating-point overshoot — a probability can't exceed 1.0.
return min(1.0, total)
def _rate_first_diff(rate_levels: list[float | None]) -> list[float | None]:
"""First difference of the key_rate level series: out[t] = r_t r_{t-1}.
Δ in percentage points. out[0] = None (no prior point); None if either of
the two points is None. Mirrors rate_sensitivity._delta (kept local so the
backtest's pure logic has no DB-importing dependency at module load). PURE.
"""
out: list[float | None] = [None]
for i in range(1, len(rate_levels)):
cur = rate_levels[i]
prev = rate_levels[i - 1]
if cur is None or prev is None:
out.append(None)
else:
out.append(float(cur) - float(prev))
return out
def _detrend_log(
values: list[float | int | None], *, fit_n: int | None = None
) -> list[float | None]:
"""Linear-detrend the LOG of a units series → log-residuals. PURE (no DB).
The survivorship control for #978b. We:
1. Map each unit count to ``ln(units)``; None or ≤0 → None (ln undefined,
same rule as ``sales_series.log_diff``).
2. Fit ``ln(units) ~ a + b·month_index`` by least squares (numpy
``polyfit`` deg-1) over the FINITE points only, ``month_index`` = the
original position 0..n-1 so gaps don't shift the trend.
3. Return the residuals ``ln(units) (a + b·month_index)`` at each finite
index (None where the input was None/≤0). Output length = input length.
A spurious monotone survivorship trend lands almost entirely in ``b`` and is
subtracted out, so the downstream first-difference + regression can't be
driven by it. The caller differences these residuals (they are already in
log space) instead of calling ``log_diff`` again.
LOOK-AHEAD LEAKAGE GUARD (#978 reopen, Part A): when ``fit_n`` is given the
trend ``(a, b)`` is estimated ONLY on the finite points among the first
``fit_n`` months (the TRAIN slice), then PROJECTED point-in-time onto every
month (test residual = ``ln(units_t) (a + b·t)`` with TRAIN-fitted a,b).
This is mandatory in the backtest: fitting the trend on train+test together
lets the held-out TEST observations shape ``(a, b)``, so the test residuals
embed future information and the detrended OOS hit-rate is inflated. With
``fit_n=None`` (default) the trend is fit on the whole finite series — only
safe for a non-holdout, full-sample descriptive detrend, NEVER for OOS
scoring.
Below ``_DETREND_MIN_POINTS`` finite points (counted within the fit window
when ``fit_n`` is set) a line is not identifiable, so we PASS THROUGH the log
values unchanged (residual == log value); differencing them then reproduces
the raw ``log_diff`` path exactly. PURE.
"""
logs: list[float | None] = []
for v in values:
if v is None:
logs.append(None)
continue
vf = float(v)
logs.append(math.log(vf) if vf > 0 else None)
# The trend is fit only on finite points whose index is inside the fit window
# (TRAIN slice [0:fit_n] when fit_n is given; the whole series otherwise).
fit_upper = len(logs) if fit_n is None else max(0, fit_n)
fit_idx = [i for i, lv in enumerate(logs) if lv is not None and i < fit_upper]
if len(fit_idx) < _DETREND_MIN_POINTS:
return logs # not enough TRAIN points to fit a trend → passthrough of logs
xs = np.array([float(i) for i in fit_idx], dtype=float)
ys = np.array([float(logs[i]) for i in fit_idx], dtype=float) # type: ignore[arg-type]
# Degenerate x-variance (all same index — impossible for ≥3 distinct idx but
# guard anyway) → no trend to remove, passthrough.
if float(np.ptp(xs)) == 0.0:
return logs
slope, intercept = np.polyfit(xs, ys, 1)
# Project the TRAIN-fitted (a, b) onto EVERY month, incl. the held-out test
# months — strictly point-in-time, no test observation entered the fit.
out: list[float | None] = []
for i, lv in enumerate(logs):
if lv is None:
out.append(None)
else:
out.append(float(lv) - (float(slope) * float(i) + float(intercept)))
return out
def _time_ordered_split(n: int, holdout_frac: float) -> int:
"""Index where TEST begins for a time-ordered holdout of ``n`` months.
Returns ``n_train`` — the count of the OLDEST months used to FIT; months
``[n_train:]`` are the held-out TEST window. NOT random / even-odd: a time
series must train on the past and test on the future. ``holdout_frac`` is
the TRAIN fraction; clamped so both halves keep ≥1 month when n ≥ 2. PURE.
"""
if n <= 0:
return 0
n_train = round(n * holdout_frac)
# Keep at least one month on each side once we have ≥2 months to split.
n_train = max(1, min(n_train, n - 1)) if n >= 2 else n
return n_train
def _shift_for_lag(x: list[float | None], lag: int) -> list[float | None]:
"""Right-shift the regressor by ``lag`` (y[t] ← x[tlag]); leading = None.
Same alignment best_lag uses internally: the rate change LEADS sales by
``lag`` months. Output is truncated to len(x) so it stays aligned to y.
PURE.
"""
shifted: list[float | None] = [None] * lag + list(x)
return shifted[: len(x)]
def evaluate_oos(
delta_sales: list[float | None],
rate_deltas: list[float | None],
*,
holdout_frac: float = _HOLDOUT_FRAC,
) -> dict[str, Any]:
"""Time-ordered OOS backtest of the §9.6 β/lag fit. PURE (no DB).
Steps:
1. Split the aligned months time-ordered: fit on the oldest
``holdout_frac``, test on the newest remainder.
2. Reuse ``best_lag`` on the TRAIN slice only → winning lag + β (TRAIN).
best_lag applies the engine's gate (n≥min, R²≥min, slope<0); if no lag
passes on TRAIN there is nothing to validate → empty result.
3. For each TEST month predict Δln(sales) = β·Δrate[tlag], strictly
point-in-time: the lagged regressor is built on the FULL aligned rate
series, so a test month at index t reads Δrate at tlag which is at or
before t — never the future. Score the SIGN vs the actual Δln(sales).
4. Also refit ``best_lag`` on the FULL aligned series → full-sample lag
(for the verdict's lag-stability check).
Returns a dict with: n_aligned, n_train, n_test, train_lag, train_beta,
in_sample_r2 (R² of the winning lag ON TRAIN — high by construction),
oos_hit_rate (fraction of finite test months with matching sign; None if no
scorable test month), oos_signed_mae (mean |predicted actual| on test),
full_sample_lag, lag_stable.
"""
best_lag, _ols_slope_r2, _log_diff = _import_engine()
n = len(delta_sales)
n_train = _time_ordered_split(n, holdout_frac)
n_test = n - n_train
empty: dict[str, Any] = {
"n_aligned": n,
"n_train": n_train,
"n_test": n_test,
"train_lag": None,
"train_beta": None,
"in_sample_r2": None,
"oos_hit_rate": None,
"oos_signed_mae": None,
"full_sample_lag": None,
"lag_stable": False,
}
if n_train < 2 or n_test < 1:
return empty
train_sales = delta_sales[:n_train]
train_rate = rate_deltas[:n_train]
train_fit = best_lag(train_sales, train_rate)
if train_fit is None:
# No gated lag on TRAIN → the engine has nothing to validate here.
return empty
lag = int(train_fit["lag"])
beta = float(train_fit["slope"])
in_sample_r2 = float(train_fit["r2"])
# Point-in-time prediction on TEST. The lagged regressor is built over the
# FULL aligned series, then sliced to the test window: a test month at
# absolute index t reads Δrate[tlag] (≤ t), so no future leaks in.
shifted_full = _shift_for_lag(rate_deltas, lag)
hits = 0
scored = 0
abs_err_sum = 0.0
for t in range(n_train, n):
actual = delta_sales[t]
x = shifted_full[t]
if actual is None or x is None:
continue
predicted = beta * float(x)
scored += 1
abs_err_sum += abs(predicted - float(actual))
# Directional hit: same sign. A flat actual (0.0) can't be directionally
# predicted, so it never counts as a hit (only nonzero signs match).
if (predicted > 0 and actual > 0) or (predicted < 0 and actual < 0):
hits += 1
oos_hit_rate = (hits / scored) if scored > 0 else None
oos_signed_mae = (abs_err_sum / scored) if scored > 0 else None
full_fit = best_lag(delta_sales, rate_deltas)
full_lag = int(full_fit["lag"]) if full_fit is not None else None
lag_stable = full_lag is not None and full_lag == lag
return {
"n_aligned": n,
"n_train": n_train,
"n_test": scored, # report the number actually SCORED, not the raw span
"train_lag": lag,
"train_beta": beta,
"in_sample_r2": in_sample_r2,
"oos_hit_rate": oos_hit_rate,
"oos_signed_mae": oos_signed_mae,
"full_sample_lag": full_lag,
"lag_stable": lag_stable,
}
def evaluate_oos_almon(
delta_sales: list[float | None],
rate_deltas: list[float | None],
*,
holdout_frac: float = _HOLDOUT_FRAC,
) -> dict[str, Any]:
"""Time-ordered OOS backtest of the #978 Almon distributed-lag fit. PURE (no DB).
Mirrors ``evaluate_oos``'s contract and return-dict shape exactly so
``backtest_tier`` can wrap it identically — but the estimator is the Almon
polynomial distributed-lag model (``regression.fit_almon_dl``) instead of the
single-winning-lag OLS (``rate_sensitivity.best_lag``). The Almon model fits
ONE smooth coefficient curve β_0..β_K over the whole 0..max_lag window, so a
TEST month's prediction sums the contributions of ALL lags, not just one.
Steps:
1. Split the aligned months time-ordered (same boundary ``evaluate_oos``
uses): fit on the oldest ``holdout_frac``, test on the newest remainder.
2. ``fit_almon_dl(rate_deltas[:n_train], delta_sales[:n_train])`` on the
TRAIN slice only — NB the regression module's signature is
``fit_almon_dl(x, y)`` with ``x`` the regressor (Δrate) and ``y`` the
regressand (Δln(sales)), so Δrate is the FIRST arg. None (math-infeasible
/ too thin) → empty result (nothing to validate). The fit's ``best_lag``
(peak-|β_j| lag) is the "train_lag"; its ``long_run_coef`` (Σ_j β_j) is
reported as "train_beta" (the long-run multiplier); its ``r2`` is the
in-sample R².
3. For each TEST month at absolute index t, predict
``Σ_{j=0..max_lag} per_lag[j]·Δrate[tj]`` STRICTLY point-in-time: each
lagged regressor is built over the FULL aligned series via
``_shift_for_lag(rate_deltas, j)`` then read at index t, so a test month
only ever reads Δrate at indices ≤ t (never the future). Score the SIGN
vs the actual Δln(sales); skip a month when the actual OR any required
lag value is None (don't fabricate a partial lag profile).
4. Refit ``fit_almon_dl`` on the FULL aligned series → full-sample peak lag
(for the verdict's lag-stability check).
Returns the SAME dict keys as ``evaluate_oos`` (n_aligned, n_train, n_test,
train_lag, train_beta [=long-run Σβ here], in_sample_r2, oos_hit_rate,
oos_signed_mae, full_sample_lag, lag_stable). The in-sample R² is high by
construction (honesty block applies identically); only oos_hit_rate is
trustworthy.
"""
fit_almon_dl, max_lag = _import_regression()
n = len(delta_sales)
n_train = _time_ordered_split(n, holdout_frac)
n_test = n - n_train
empty: dict[str, Any] = {
"n_aligned": n,
"n_train": n_train,
"n_test": n_test,
"train_lag": None,
"train_beta": None,
"in_sample_r2": None,
"oos_hit_rate": None,
"oos_signed_mae": None,
"full_sample_lag": None,
"lag_stable": False,
}
if n_train < 2 or n_test < 1:
return empty
train_sales = delta_sales[:n_train]
train_rate = rate_deltas[:n_train]
# fit_almon_dl(x, y): x = Δrate (regressor) FIRST, y = Δln(sales) (regressand).
fit = fit_almon_dl(train_rate, train_sales, max_lag=max_lag)
if fit is None:
# Almon fit math-infeasible on TRAIN → nothing to validate here.
return empty
per_lag = fit["per_lag_coef"] # tuple β_0..β_max_lag from the Almon form
train_lag = int(fit["best_lag"])
train_beta = float(fit["long_run_coef"]) # long-run Σβ reported as the "beta" field
in_sample_r2 = float(fit["r2"]) if fit["r2"] is not None else None
# Build one point-in-time-safe shifted view per lag over the FULL aligned
# series. shifted_by_lag[j][t] == rate_deltas[t-j] (None where t-j < 0), so a
# test month at index t reads only Δrate at indices ≤ t — never the future.
shifted_by_lag = [_shift_for_lag(rate_deltas, j) for j in range(max_lag + 1)]
hits = 0
scored = 0
abs_err_sum = 0.0
for t in range(n_train, n):
actual = delta_sales[t]
if actual is None:
continue
# The Almon prediction needs the FULL lag profile at t; a single missing
# lag means we can't form Σ per_lag[j]·Δrate[t-j] → skip (no fabrication).
lag_vals: list[float] = []
complete = True
for j in range(max_lag + 1):
xv = shifted_by_lag[j][t]
if xv is None:
complete = False
break
lag_vals.append(float(xv))
if not complete:
continue
predicted = sum(per_lag[j] * lag_vals[j] for j in range(max_lag + 1))
scored += 1
abs_err_sum += abs(predicted - float(actual))
# Directional hit: same nonzero sign (a flat 0.0 actual can't be
# directionally predicted, so it never counts as a hit).
if (predicted > 0 and actual > 0) or (predicted < 0 and actual < 0):
hits += 1
oos_hit_rate = (hits / scored) if scored > 0 else None
oos_signed_mae = (abs_err_sum / scored) if scored > 0 else None
# Full-sample refit (x=Δrate first, y=Δln(sales)) for the lag-stability check.
full_fit = fit_almon_dl(rate_deltas, delta_sales, max_lag=max_lag)
full_lag = int(full_fit["best_lag"]) if full_fit is not None else None
lag_stable = full_lag is not None and full_lag == train_lag
return {
"n_aligned": n,
"n_train": n_train,
"n_test": scored, # report the number actually SCORED, not the raw span
"train_lag": train_lag,
"train_beta": train_beta,
"in_sample_r2": in_sample_r2,
"oos_hit_rate": oos_hit_rate,
"oos_signed_mae": oos_signed_mae,
"full_sample_lag": full_lag,
"lag_stable": lag_stable,
}
def align_series(
sales_by_month: dict[date, int],
rate_by_month: dict[date, float],
) -> tuple[list[date], list[int], list[float]]:
"""Inner-join the sold-units and key_rate monthly series by year-month. PURE.
Returns ``(months, units, rates)`` ASC over the months present in BOTH
series (a month with no rate or no sales row is dropped so Δ pairs stay
meaningful). units/rates are aligned to months by index.
"""
common = sorted(set(sales_by_month) & set(rate_by_month))
months = list(common)
units = [int(sales_by_month[m]) for m in common]
rates = [float(rate_by_month[m]) for m in common]
return months, units, rates
def _delta_sales_series(
units: list[int], *, detrend: bool, fit_n: int | None = None
) -> list[float | None]:
"""Build the Δ(log-units) regressand for one tier. PURE (deferred import).
Two variants, both ending in a Δ of log-space values ``evaluate_oos`` scores:
• ``detrend=False`` — the production path: ``log_diff(units)`` = first
difference of ``ln(units)``.
• ``detrend=True`` — the #978b control: first linear-detrend ``ln(units)``
(``_detrend_log``), THEN first-difference the residuals. We difference
the residuals DIRECTLY (they are already in log space) rather than
``log_diff`` (which would re-take logs of residuals that may be ≤0).
``fit_n`` (the TRAIN month count) is forwarded to ``_detrend_log`` so the
detrend trend is estimated on TRAIN months only and projected point-in-time
onto the test months — the #978 Part A look-ahead-leakage fix. It is ignored
on the non-detrend path (``log_diff`` is already point-in-time: a first
difference reads only t and t1).
"""
if not detrend:
_bl, _ols, log_diff = _import_engine()
return log_diff(units)
return _rate_first_diff(_detrend_log(units, fit_n=fit_n))
def _deseasonalize_units(months: list[date], units: list[int], *, fit_n: int) -> list[float | None]:
"""Deseasonalize a units series → Δln(deseasonalized units). PURE (deferred import).
The #979 control. Mirrors ``_delta_sales_series``'s train-only discipline:
1. Fit month-of-year seasonal factors on the TRAIN months ONLY
(``seasonal_factors(months[:fit_n], units[:fit_n])``). A test-month
observation must NEVER influence the factors (look-ahead leakage rule).
2. Deseasonalize the FULL series with the TRAIN-fit factors
(``deseasonalize_values(months, units, factors)`` → raw_t / factor[m]).
3. ``log_diff`` the deseasonalized units → Δln regressand (the same Y-axis
``evaluate_oos`` scores).
``log_diff`` itself is point-in-time (a first difference reads only t and
t1), so the ONLY leakage risk is the factor fit — pinned to the TRAIN slice
here. The deseasonalized values are floats (raw / factor); ``log_diff`` is
float-math throughout, so no int-rounding is applied (unlike the prod
``normalize_demand`` wrapper, which rounds only to honour SalesSeries.units's
int contract — irrelevant for the regressand).
"""
seasonal_factors, deseasonalize_values = _import_normalize()
_bl, _ols, log_diff = _import_engine()
train_months = months[:fit_n]
train_units = units[:fit_n]
adjustment = seasonal_factors(train_months, train_units)
deseasonalized = deseasonalize_values(months, units, adjustment.factors)
return log_diff(deseasonalized)
def backtest_tier(
sales_by_month: dict[date, int],
rate_by_month: dict[date, float],
*,
tier: str,
source: str = _SOURCE_B,
detrend: bool = False,
deseasonalize: bool = False,
estimator: str = _ESTIMATOR_BEST_LAG,
holdout_frac: float = _HOLDOUT_FRAC,
min_months: int = _MIN_BACKTEST_MONTHS,
) -> TierResult:
"""Build Δ-series for one tier, run the OOS backtest, wrap as TierResult.
Aligns the tier's monthly sold-units to the monthly key_rate, computes the
regressand and Δrate (first diff), then delegates to the OOS evaluator named
by ``estimator`` (``evaluate_oos`` for ``best_lag``, ``evaluate_oos_almon``
for ``almon``). The regressand is one of three PREPROCESSING variants, all
fit point-in-time on TRAIN months only (no look-ahead leakage):
• plain ``log_diff(units)`` (default),
• Δ of linear-detrended ``ln(units)`` when ``detrend`` (#978b control), or
• ``log_diff`` of month-of-year deseasonalized units when ``deseasonalize``
(#979 control — see ``_deseasonalize_units``).
``detrend`` and ``deseasonalize`` are mutually exclusive preprocessing paths;
if both are set ``deseasonalize`` takes precedence (the planner never emits
that combination). ``source``/``detrend``/``deseasonalize``/``estimator`` are
recorded on the result for labelling. Tiers with fewer than ``min_months``
aligned months are SKIPPED (TierResult with ``skipped`` set, all metrics
None) — no silent drop. PURE aside from the deferred engine import.
"""
months, units, rates = align_series(sales_by_month, rate_by_month)
n_aligned = len(months)
if n_aligned < min_months:
return TierResult(
tier=tier,
source=source,
detrended=detrend,
deseasonalized=deseasonalize,
estimator=estimator,
n_aligned=n_aligned,
n_train=0,
n_test=0,
train_lag=None,
train_beta=None,
in_sample_r2=None,
oos_hit_rate=None,
oos_signed_mae=None,
full_sample_lag=None,
lag_stable=False,
skipped=f"only {n_aligned} aligned months (< {min_months})",
)
# All preprocessing that ESTIMATES anything (the detrend trend, the seasonal
# factors) must be fit on TRAIN months ONLY, then projected point-in-time
# onto the test months — otherwise the held-out TEST data shapes the
# preprocessing and the OOS hit-rate is inflated by look-ahead leakage (#978
# Part A for detrend; the #979 leakage rule for deseasonalize). We compute the
# SAME train boundary the evaluator will use (len(delta_sales) == len(units)
# == n_aligned, so the split index matches) and pass it as the fit window.
n_train = _time_ordered_split(n_aligned, holdout_frac)
if deseasonalize:
delta_sales = _deseasonalize_units(months, units, fit_n=n_train)
else:
delta_sales = _delta_sales_series(units, detrend=detrend, fit_n=n_train)
rate_deltas = _rate_first_diff([float(r) for r in rates])
if estimator == _ESTIMATOR_ALMON:
res = evaluate_oos_almon(delta_sales, rate_deltas, holdout_frac=holdout_frac)
else:
res = evaluate_oos(delta_sales, rate_deltas, holdout_frac=holdout_frac)
return TierResult(
tier=tier,
source=source,
detrended=detrend,
deseasonalized=deseasonalize,
estimator=estimator,
n_aligned=res["n_aligned"],
n_train=res["n_train"],
n_test=res["n_test"],
train_lag=res["train_lag"],
train_beta=res["train_beta"],
in_sample_r2=res["in_sample_r2"],
oos_hit_rate=res["oos_hit_rate"],
oos_signed_mae=res["oos_signed_mae"],
full_sample_lag=res["full_sample_lag"],
lag_stable=res["lag_stable"],
skipped=None,
)
def verdict(
ekb: TierResult,
*,
margin: float = _VERDICT_HITRATE_MARGIN,
) -> dict[str, Any]:
"""Decide whether the EKB-wide tier shows OOS predictive value. PURE.
The engine is a promotion CANDIDATE when, on the EKB-wide tier, ALL hold:
• a gated lag was found and scored on a non-empty test window, AND
• the OOS directional hit-rate beats the 0.5 coin-flip baseline by at
least ``margin`` (minimum EFFECT SIZE), AND
• the hit-rate is STATISTICALLY SIGNIFICANT vs a fair coin: the exact
one-sided binomial ``P(X ≥ hits | n_test, 0.5) < _VERDICT_ALPHA`` (a HARD
gate — a high rate on a tiny window is consistent with chance and must
NOT promote; this is the #978 near-miss: 6/10 = 0.60 has p ≈ 0.377), AND
• the winning lag is the same on TRAIN and on the full-sample refit
(lag stability — a lag that jumps between windows is not a signal).
``hits`` (the integer directional-hit count the binomial needs) is recovered
as ``round(oos_hit_rate * n_test)``: both the hit-rate and n_test derive from
the SAME integer division in ``evaluate_oos`` (``hits / scored`` with
``n_test == scored``), so this round-trips EXACTLY without threading a new
field through the deep-reviewed evaluator return shape.
Returns ``{"promote": bool, "reason": str, "thin_warning": str | None}``.
Honest: if the OOS test window is tiny the reason says so, and significance
is now a HARD gate, not just an advisory caveat.
"""
if ekb.skipped is not None:
return {
"promote": False,
"reason": f"insufficient OOS signal — keep advisory ({ekb.skipped})",
"thin_warning": None,
}
if ekb.oos_hit_rate is None or ekb.n_test < 1:
return {
"promote": False,
"reason": (
"insufficient OOS signal — keep advisory "
"(no gated lag on TRAIN or empty test window)"
),
"thin_warning": None,
}
beats_coin = ekb.oos_hit_rate >= 0.5 + margin
# Recover the integer hit count exactly (see docstring) and test significance.
hits = round(ekb.oos_hit_rate * ekb.n_test)
p_value = _binom_sf_ge(hits, ekb.n_test, 0.5)
significant = p_value < _VERDICT_ALPHA
thin_warning: str | None = None
if ekb.n_test < min(_MIN_BACKTEST_MONTHS // 2, 6):
thin_warning = (
f"OOS test window is small (n_test={ekb.n_test}); the hit-rate's "
"confidence is weak — treat the verdict as indicative, not proof."
)
if beats_coin and ekb.lag_stable and significant:
reason = (
f"engine has OOS predictive value (candidate to promote from "
f"advisory): EKB-wide OOS hit-rate={ekb.oos_hit_rate:.2f} over "
f"n_test={ekb.n_test} > 0.5+{margin:.2f}, lag stable "
f"(lag={ekb.train_lag}), and significant "
f"(one-sided binomial p={p_value:.3f} < {_VERDICT_ALPHA:.2f})"
)
return {"promote": True, "reason": reason, "thin_warning": thin_warning}
bits: list[str] = []
if not beats_coin:
bits.append(f"hit-rate={ekb.oos_hit_rate:.2f} ≤ 0.5+{margin:.2f}")
if not ekb.lag_stable:
bits.append(f"lag unstable (train={ekb.train_lag}, full={ekb.full_sample_lag})")
# Significance reported whenever the effect size cleared the bar but the
# window is too thin to rule out chance — the #978 transparency requirement.
if beats_coin and not significant:
bits.append(
f"hit-rate={ekb.oos_hit_rate:.2f} over n_test={ekb.n_test} is not "
f"significant (one-sided binomial p={p_value:.2f}{_VERDICT_ALPHA:.2f})"
)
reason = "insufficient OOS signal — keep advisory (" + "; ".join(bits) + ")"
return {"promote": False, "reason": reason, "thin_warning": thin_warning}
def tier_lift(ekb: TierResult, tier: TierResult) -> float | None:
"""OOS directional hit-rate lift of a class tier over EKB-wide. PURE.
Returns ``tier.oos_hit_rate ekb.oos_hit_rate`` (positive = the
class-specific β beats the EKB-wide prior OOS). None if either side has no
scorable OOS hit-rate.
"""
if ekb.oos_hit_rate is None or tier.oos_hit_rate is None:
return None
return tier.oos_hit_rate - ekb.oos_hit_rate
# --------------------------------------------------------------------------- #
# DB layer — READ-ONLY SELECTs only.
# --------------------------------------------------------------------------- #
# Source B monthly sold-units by registration month (the one solid long series).
# COUNT(*) of lots whose deal registered in each month, EKB-wide or filtered to
# one class / district. Parameterised; psycopg3 CAST(:x AS type), NEVER :x::type.
# Survivorship caveat (only currently-listed lots) is documented in the module
# docstring — it biases OLDER months low.
_SOURCE_B_UNITS_SQL = text(
"""
SELECT
CAST(date_trunc('month', ol.registration_date) AS date) AS month,
COUNT(*) AS units
FROM objective_lots ol
WHERE ol.premise_kind = CAST(:premise_kind AS text)
AND ol.registration_date IS NOT NULL
AND ol.registration_date >= CAST(:since AS date)
AND (CAST(:cls AS text) IS NULL OR LOWER(ol.class) = LOWER(CAST(:cls AS text)))
AND (CAST(:district AS text) IS NULL OR ol.district = CAST(:district AS text))
GROUP BY 1
ORDER BY 1
"""
)
# Source A monthly deal AGGREGATE — survivorship-FREE. Objective's corp_sum API
# reports deals registered per month per (corpus × room_bucket) regardless of
# whether the lot is still listed, so it does NOT undercount old months the way
# Source B does. room_bucket is aggregated away by the SUM/GROUP BY 1 unless a
# class filter is given (class is stored capitalised here — "Комфорт" — so we
# fold case to match the lowercase --classes input). report_month is already a
# month-first DATE; date_trunc is belt-and-braces. Only ~13 months deep on prod.
# Parameterised; psycopg3 CAST(:x AS type), NEVER :x::type.
_SOURCE_A_UNITS_SQL = text(
"""
SELECT
CAST(date_trunc('month', crm.report_month) AS date) AS month,
SUM(crm.deals_total_count) AS units
FROM objective_corpus_room_month crm
WHERE crm.report_month >= CAST(:since AS date)
AND (CAST(:cls AS text) IS NULL OR LOWER(crm.class) = LOWER(CAST(:cls AS text)))
GROUP BY 1
ORDER BY 1
"""
)
# Distinct classes present in Source A over the window (for --classes all on A).
_SOURCE_A_CLASSES_SQL = text(
"""
SELECT DISTINCT LOWER(crm.class) AS cls
FROM objective_corpus_room_month crm
WHERE crm.report_month >= CAST(:since AS date)
AND crm.class IS NOT NULL
ORDER BY 1
"""
)
# Distinct classes present in Source B over the window (for --classes all).
_SOURCE_B_CLASSES_SQL = text(
"""
SELECT DISTINCT LOWER(ol.class) AS cls
FROM objective_lots ol
WHERE ol.premise_kind = CAST(:premise_kind AS text)
AND ol.registration_date IS NOT NULL
AND ol.registration_date >= CAST(:since AS date)
AND ol.class IS NOT NULL
ORDER BY 1
"""
)
def load_sales_by_month(
db: Session,
*,
since: str,
obj_class: str | None,
district: str | None,
) -> dict[date, int]:
"""Run the Source B monthly sold-units SELECT → {month1st: units}.
READ-ONLY. ``obj_class``/``district`` None → no filter (EKB-wide). Months
with no rows simply do not appear (the caller aligns on the intersection
with the rate series, so absent months drop out naturally).
"""
rows = db.execute(
_SOURCE_B_UNITS_SQL,
{
"premise_kind": _PREMISE_KIND,
"since": since,
"cls": obj_class,
"district": district,
},
).all()
out: dict[date, int] = {}
for r in rows:
if r[0] is None:
continue
out[r[0]] = int(r[1] or 0)
return out
def load_classes(db: Session, *, since: str) -> list[str]:
"""Run the Source B distinct-classes SELECT → lowercase class list. READ-ONLY."""
rows = db.execute(
_SOURCE_B_CLASSES_SQL,
{"premise_kind": _PREMISE_KIND, "since": since},
).all()
return [r[0] for r in rows if r[0] is not None]
def load_sales_by_month_source_a(
db: Session,
*,
since: str,
obj_class: str | None,
) -> dict[date, int]:
"""Run the Source A monthly deal-aggregate SELECT → {month1st: units}.
READ-ONLY. ``obj_class`` None → no class filter (room_bucket aggregated
away). Survivorship-FREE (deals counted regardless of current listing).
Months with no rows simply do not appear. No district filter — corp_sum
aggregates are not district-resolved the way the lots snapshot is.
"""
rows = db.execute(
_SOURCE_A_UNITS_SQL,
{"since": since, "cls": obj_class},
).all()
out: dict[date, int] = {}
for r in rows:
if r[0] is None:
continue
out[r[0]] = int(r[1] or 0)
return out
def load_classes_source_a(db: Session, *, since: str) -> list[str]:
"""Run the Source A distinct-classes SELECT → lowercase class list. READ-ONLY."""
rows = db.execute(
_SOURCE_A_CLASSES_SQL,
{"since": since},
).all()
return [r[0] for r in rows if r[0] is not None]
def load_rate_by_month(db: Session, *, since: str) -> dict[date, float]:
"""Monthly last-known key_rate → {month1st: rate}. READ-ONLY.
Reuses ``forecasting.macro_series.get_monthly_macro`` (DISTINCT ON
month-end last-known + LOCF carry-forward) rather than re-deriving the
resample — the backtest must read the SAME monthly key_rate the engine
does. ``months_back`` is computed from ``since`` so the macro grid covers
the whole backtest window. Months with a None carried rate are dropped.
"""
from app.services.forecasting.macro_series import (
_month_start,
get_monthly_macro,
)
since_date = date.fromisoformat(since)
today = date.today()
months_back = (today.year - since_date.year) * 12 + (today.month - since_date.month)
macro = get_monthly_macro(db, months_back=max(0, months_back))
floor = _month_start(since_date)
out: dict[date, float] = {}
for m in macro:
if m.key_rate is None or m.month < floor:
continue
out[m.month] = float(m.key_rate)
return out
# --------------------------------------------------------------------------- #
# Orchestration (READ-ONLY) + rendering
# --------------------------------------------------------------------------- #
def _load_sales(
db: Session,
*,
source: str,
since: str,
obj_class: str | None,
district: str | None,
) -> dict[date, int]:
"""Dispatch the monthly sold-units load to the right source. READ-ONLY.
Source B uses ``objective_lots`` (premise+district filters). Source A uses
``objective_corpus_room_month`` (survivorship-free aggregate; no district
filter — corp_sum aggregates are not district-resolved, so ``district`` is
ignored for A and the caller is responsible for warning if it was set).
"""
if source == _SOURCE_A:
return load_sales_by_month_source_a(db, since=since, obj_class=obj_class)
return load_sales_by_month(db, since=since, obj_class=obj_class, district=district)
def _load_classes_for(db: Session, *, source: str, since: str) -> list[str]:
"""Dispatch class auto-discovery to the right source. READ-ONLY."""
if source == _SOURCE_A:
return load_classes_source_a(db, since=since)
return load_classes(db, since=since)
def run_backtest(
db: Session,
*,
since: str,
holdout_frac: float,
classes: list[str] | None,
district: str | None,
source: str = _SOURCE_B,
detrend: bool = False,
deseasonalize: bool = False,
estimator: str = _ESTIMATOR_BEST_LAG,
rate_by_month: dict[date, float] | None = None,
) -> dict[str, Any]:
"""Drive ONE source/variant of the read-only backtest → results dict. No writes.
Loads the monthly key_rate (or reuses ``rate_by_month`` when the caller has
already loaded it once for several variants), then the EKB-wide and per-class
sold-units series for ``source``, backtests each tier (``backtest_tier``,
with the ``detrend`` / ``deseasonalize`` preprocessing and the ``estimator``
applied), and assembles the per-source verdict + per-tier OOS lifts.
``classes`` None → auto-discover every class present in the chosen source; an
empty list → EKB-wide only. ``district`` narrows ALL tiers for Source B only
(ignored for Source A). ``deseasonalize`` (#979) and ``estimator`` ("almon"
selects the #978 Almon distributed-lag evaluator) are recorded on the run
dict + params alongside ``detrend`` so labels/JSON carry the full variant.
"""
if rate_by_month is None:
rate_by_month = load_rate_by_month(db, since=since)
logger.info("loaded key_rate months: %d (since=%s)", len(rate_by_month), since)
if classes is None:
classes = _load_classes_for(db, source=source, since=since)
logger.info("source=%s auto-discovered classes: %s", source, classes)
a_district_ignored = source == _SOURCE_A and district is not None
eff_district = None if source == _SOURCE_A else district
# EKB-wide tier (no class filter).
ekb_sales = _load_sales(db, source=source, since=since, obj_class=None, district=eff_district)
ekb = backtest_tier(
ekb_sales,
rate_by_month,
tier=_EKB_WIDE,
source=source,
detrend=detrend,
deseasonalize=deseasonalize,
estimator=estimator,
holdout_frac=holdout_frac,
)
logger.info(
"source=%s detrend=%s deseasonalize=%s estimator=%s EKB-wide: "
"aligned=%d train=%d test=%d lag=%s hit_rate=%s",
source,
detrend,
deseasonalize,
estimator,
ekb.n_aligned,
ekb.n_train,
ekb.n_test,
ekb.train_lag,
ekb.oos_hit_rate,
)
tiers: list[TierResult] = []
lifts: dict[str, float | None] = {}
for cls in classes:
cls_sales = _load_sales(
db, source=source, since=since, obj_class=cls, district=eff_district
)
res = backtest_tier(
cls_sales,
rate_by_month,
tier=cls,
source=source,
detrend=detrend,
deseasonalize=deseasonalize,
estimator=estimator,
holdout_frac=holdout_frac,
)
tiers.append(res)
lifts[cls] = tier_lift(ekb, res)
logger.info(
"source=%s tier=%s aligned=%d test=%d hit_rate=%s lift=%s skipped=%s",
source,
cls,
res.n_aligned,
res.n_test,
res.oos_hit_rate,
lifts[cls],
res.skipped,
)
vd = verdict(ekb)
return {
"source": source,
"detrended": detrend,
"deseasonalized": deseasonalize,
"estimator": estimator,
"a_district_ignored": a_district_ignored,
"params": {
"since": since,
"holdout_frac": holdout_frac,
"district": district,
"classes": classes,
"source": source,
"detrended": detrend,
"deseasonalized": deseasonalize,
"estimator": estimator,
"min_backtest_months": _MIN_BACKTEST_MONTHS,
"lags": list(_import_lags()),
},
"ekb_wide": ekb.as_dict(),
"tiers": [t.as_dict() for t in tiers],
"lifts": {k: _round_or_none(v, 4) for k, v in lifts.items()},
"verdict": vd,
"ekb_result": ekb, # carried for the renderer (stripped from JSON)
"tier_results": tiers,
}
def _variant_label(
source: str,
detrend: bool,
*,
deseasonalize: bool = False,
estimator: str = _ESTIMATOR_BEST_LAG,
) -> str:
"""Human label for a variant run. PURE.
The method descriptor is mutually-exclusive (the planner never combines
them), so the label names the ONE active method:
• ``estimator == 'almon'`` → ``f"{source} Almon-ADL"`` (the #978 estimator),
• ``deseasonalize`` → ``f"{source} deseasonalized"`` (#979 preprocessing),
• ``detrend`` → ``f"{source} detrended"`` (#978b control),
• otherwise (best_lag, raw) → ``f"{source} raw"`` (the always-on reference).
"""
if estimator == _ESTIMATOR_ALMON:
return f"{source} Almon-ADL"
if deseasonalize:
return f"{source} deseasonalized"
if detrend:
return f"{source} detrended"
return f"{source} raw"
def _variant_label_for_run(run: dict[str, Any]) -> str:
"""Label a run dict via ``_variant_label`` (reads source/detrend/deseason/estim)."""
return _variant_label(
run["source"],
run["detrended"],
deseasonalize=run.get("deseasonalized", False),
estimator=run.get("estimator", _ESTIMATOR_BEST_LAG),
)
def _plan_variants(
sources: list[str],
detrend: bool,
*,
deseasonalize: bool = False,
almon: bool = False,
) -> list[tuple[str, bool, bool, str]]:
"""Which variants to run, in report order. PURE.
Each entry is ``(source, detrend, deseasonalize, estimator)``. For each
requested source we ALWAYS run the RAW reference (best_lag on raw units),
then ADD only the explicitly-requested method variants — we do NOT explode
into all combinations:
• ``--detrend`` → ``(src, True, False, best_lag)`` (#978b survivorship control),
• ``--deseasonalize``→ ``(src, False, True, best_lag)`` (#979 seasonal preprocessing),
• ``--almon`` → ``(src, False, False, almon)`` (#978 Almon distributed-lag).
The method flags are independent: passing several adds several variants per
source (raw + each requested method), each isolating ONE method against the
raw reference for the verdict's side-by-side comparison.
"""
variants: list[tuple[str, bool, bool, str]] = []
for src in sources:
variants.append((src, False, False, _ESTIMATOR_BEST_LAG))
if detrend:
variants.append((src, True, False, _ESTIMATOR_BEST_LAG))
if deseasonalize:
variants.append((src, False, True, _ESTIMATOR_BEST_LAG))
if almon:
variants.append((src, False, False, _ESTIMATOR_ALMON))
return variants
def cross_source_verdict(
runs: list[dict[str, Any]],
*,
margin: float = _VERDICT_HITRATE_MARGIN,
min_months: int = _MIN_BACKTEST_MONTHS,
) -> dict[str, Any]:
"""Compare the EKB-wide OOS verdict across every method variant. PURE.
The §9.6 question, generalised across the variants present: does ANY
method/source recover OOS directional signal the raw best_lag baseline
missed? Variants can be the survivorship controls (B detrended, A) AND the
two new candidate methods — ``deseasonalized`` (#979 month-of-year seasonal
preprocessing) and ``Almon-ADL`` (#978 polynomial distributed-lag estimator).
We line up each variant's EKB-wide OOS hit-rate vs the 0.5 coin-flip baseline
(decision rule UNCHANGED: a variant "beats" only if scorable AND
``oos_hit_rate >= 0.5 + margin`` AND ``lag_stable``) and synthesise:
• If NO variant clears coin-flip+margin → the negative verdict is
corroborated as a real ``no signal``, not an artifact and not something
the new methods rescue — neither deseasonalizing the input nor the
smoother Almon estimator extracts signal that isn't there. Source A's
thin-data caveat is attached when an A row drove the comparison.
• If a control OR a candidate method DOES clear the bar while raw best_lag
did not → flag that variant: the raw verdict may be a survivorship
artifact, or the method recovered signal worth promoting from advisory.
PURE — operates on already-computed run dicts. Returns a dict with a
``lines`` list (rendered as-is) plus structured fields for JSON.
"""
rows: list[dict[str, Any]] = []
signal_variants: list[str] = []
thin_variants: list[str] = []
for run in runs:
ekb: TierResult = run["ekb_result"]
label = _variant_label_for_run(run)
hr = ekb.oos_hit_rate
scorable = ekb.skipped is None and hr is not None and ekb.n_test >= 1
beats_margin = bool(scorable and hr is not None and hr >= 0.5 + margin)
# Significance gate (mirrors verdict()): recover the integer hit count
# exactly from the rate × window (same integer division in evaluate_oos)
# and require the one-sided binomial p < _VERDICT_ALPHA. A high hit-rate
# on a thin window is NOT a signal — the #978 6/10 near-miss.
hits = round(hr * ekb.n_test) if scorable and hr is not None else 0
p_value = _binom_sf_ge(hits, ekb.n_test, 0.5) if scorable else 1.0
significant = p_value < _VERDICT_ALPHA
beats = bool(beats_margin and ekb.lag_stable and significant)
thin = scorable and ekb.n_test < min(min_months // 2, 6)
if beats:
signal_variants.append(label)
if run["source"] == _SOURCE_A and (thin or not scorable):
thin_variants.append(label)
rows.append(
{
"variant": label,
"source": run["source"],
"detrended": run["detrended"],
"deseasonalized": run.get("deseasonalized", False),
"estimator": run.get("estimator", _ESTIMATOR_BEST_LAG),
"scorable": scorable,
"oos_hit_rate": _round_or_none(hr, 4),
"n_test": ekb.n_test,
"lag_stable": ekb.lag_stable,
"binom_p": _round_or_none(p_value, 4) if scorable else None,
"significant": significant,
"beats_coin": beats,
"skipped": ekb.skipped,
}
)
# Width the label column to the widest variant label present (Almon-ADL /
# deseasonalized are longer than the original "B detrended").
label_w = max((len(r["variant"]) for r in rows), default=13)
label_w = max(label_w, 13)
lines: list[str] = []
lines.append(
"CROSS-SOURCE / CROSS-METHOD VERDICT (raw best_lag vs controls "
"[detrended, A] vs candidate methods [deseasonalized #979, Almon-ADL #978]):"
)
for r in rows:
if not r["scorable"]:
why = r["skipped"] or "no gated lag / empty test window"
lines.append(f" {r['variant']:<{label_w}} → not scorable ({why})")
else:
# Spell out WHY a variant does/doesn't count as signal — including the
# binomial p so a "high rate but thin" row is transparent (#978).
if r["beats_coin"]:
tag = (
f"SIGNAL > coin-flip (binomial p={_fmt_rate(r['binom_p'])} "
f"< {_VERDICT_ALPHA:.2f})"
)
elif not r["significant"]:
tag = (
f"no signal (hit-rate not significant: binomial "
f"p={_fmt_rate(r['binom_p'])}{_VERDICT_ALPHA:.2f})"
)
else:
tag = "no signal (≤ coin-flip / lag unstable)"
lines.append(
f" {r['variant']:<{label_w}} → OOS_hit={_fmt_rate(r['oos_hit_rate'])} "
f"(n_test={r['n_test']}, lag_stable={'yes' if r['lag_stable'] else 'no'}) "
f"{tag}"
)
if signal_variants:
conclusion = (
"CONCLUSION: OOS signal above coin-flip appears in: "
+ ", ".join(signal_variants)
+ ". The §9.6 negative verdict on raw best_lag may be a SURVIVORSHIP "
"ARTIFACT, or a candidate method (deseasonalize / Almon-ADL) recovers "
"directional signal worth promoting from advisory — inspect the flagged "
"variant(s) above."
)
promote_any = True
else:
conclusion = (
"CONCLUSION: NO variant — neither the survivorship controls (detrended B, "
"survivorship-free A) NOR the candidate methods (#979 deseasonalize, #978 "
"Almon-ADL) — beats coin-flip+margin out-of-sample. The §9.6 negative "
"verdict is a REAL 'no signal', NOT a survivorship artifact, and the new "
"methods do not recover signal that isn't there. Keep advisory."
)
promote_any = False
lines.append(" " + conclusion)
thin_caveat: str | None = None
if thin_variants:
thin_caveat = (
"Source A is statistically THIN (~13 months on prod). Treat any A row "
"as an indicative cross-check only, never as proof — variant(s): "
+ ", ".join(thin_variants)
+ "."
)
lines.append(f" !! {thin_caveat}")
return {
"rows": rows,
"signal_variants": signal_variants,
"promote_any": promote_any,
"conclusion": conclusion,
"thin_caveat": thin_caveat,
"lines": lines,
}
def run_all(
db: Session,
*,
since: str,
holdout_frac: float,
classes: list[str] | None,
district: str | None,
sources: list[str],
detrend: bool,
deseasonalize: bool = False,
almon: bool = False,
) -> dict[str, Any]:
"""Run every requested variant + the cross-source/method verdict. No writes.
Loads the monthly key_rate ONCE and reuses it across variants. ``sources`` is
a subset of (B, A); the always-on RAW reference runs per source, plus one
variant per requested method: ``detrend`` (#978b control), ``deseasonalize``
(#979 preprocessing), ``almon`` (#978 distributed-lag estimator). Returns
``{"variants": [run, ...], "cross_verdict": {...}}``.
"""
rate_by_month = load_rate_by_month(db, since=since)
logger.info("loaded key_rate months: %d (since=%s)", len(rate_by_month), since)
variants = _plan_variants(sources, detrend, deseasonalize=deseasonalize, almon=almon)
runs: list[dict[str, Any]] = []
for src, dt_flag, deseason_flag, estimator in variants:
runs.append(
run_backtest(
db,
since=since,
holdout_frac=holdout_frac,
classes=classes,
district=district,
source=src,
detrend=dt_flag,
deseasonalize=deseason_flag,
estimator=estimator,
rate_by_month=rate_by_month,
)
)
cross = cross_source_verdict(runs)
return {"variants": runs, "cross_verdict": cross}
def _fmt_rate(v: float | None) -> str:
return " n/a" if v is None else f"{v:.3f}"
def _fmt_lag(v: int | None) -> str:
return "n/a" if v is None else str(v)
_SOURCE_BLURB: dict[str, str] = {
_SOURCE_B: "Source B (objective_lots.registration_date COUNT) — survivorship-CONFOUNDED.",
_SOURCE_A: "Source A (objective_corpus_room_month SUM deals) — survivorship-FREE, ~13 mo.",
}
def render_table(results: dict[str, Any]) -> str:
"""Render ONE variant's backtest results as a plain-text stdout report."""
params = results["params"]
ekb: TierResult = results["ekb_result"]
tiers: list[TierResult] = results["tier_results"]
lifts: dict[str, Any] = results["lifts"]
vd = results["verdict"]
source = results["source"]
detrended = results["detrended"]
deseasonalized = results.get("deseasonalized", False)
estimator = results.get("estimator", _ESTIMATOR_BEST_LAG)
# Short method tag for the title line (one active method per variant).
if estimator == _ESTIMATOR_ALMON:
method_tag = " · Almon-ADL"
elif deseasonalized:
method_tag = " · deseasonalized"
elif detrended:
method_tag = " · detrended"
else:
method_tag = ""
lines: list[str] = []
lines.append("=" * 78)
lines.append(f"BACKTEST [source {source}{method_tag}]: §9.6 rate-sensitivity OOS validation")
lines.append("=" * 78)
lines.append(
f"since={params['since']} holdout_frac={params['holdout_frac']} "
f"district={params['district'] or '(all)'} lags={params['lags']}"
)
lines.append(_SOURCE_BLURB.get(source, source))
if detrended:
lines.append(
"DETRENDED: ln(units) linearly detrended (residuals) BEFORE differencing — "
"removes a spurious monotone (survivorship) trend so it can't drive β. "
"Trend fit on TRAIN months only, projected point-in-time onto test (no leakage)."
)
if deseasonalized:
lines.append(
"DESEASONALIZED (#979): month-of-year seasonal factors divided out of units "
"BEFORE log_diff — strips the calendar pattern so a seasonal peak isn't read as "
"a rate effect. Factors fit on TRAIN months only, applied point-in-time (no leakage)."
)
if estimator == _ESTIMATOR_ALMON:
lines.append(
"ALMON-ADL (#978): Almon polynomial distributed-lag estimator (smooth β over lags "
"0..6) replaces single-lag best_lag. 'beta' column = LONG-RUN Σβ multiplier; 'lag' "
"= peak-|β_j| lag. Fit on TRAIN months only; test prediction sums all lags "
"point-in-time (no leakage)."
)
if results.get("a_district_ignored"):
lines.append(
"NOTE: --district was IGNORED for Source A (corp_sum aggregates are not "
"district-resolved)."
)
lines.append("")
header = (
f" {'tier':<12} {'src':>3} {'detr':>5} {'aligned':>7} {'train':>6} {'test':>5} "
f"{'lag':>4} {'beta':>9} {'inR2':>7} {'OOS_hit':>8} {'OOS_MAE':>8} {'lift':>7} "
f"{'stable':>7}"
)
lines.append(header)
lines.append(" " + "-" * (len(header) - 2))
lines.append(_fmt_tier_row(ekb, lift=None))
for t in tiers:
lines.append(_fmt_tier_row(t, lift=lifts.get(t.tier)))
# Skips called out explicitly (no silent drop).
skipped = [t for t in tiers if t.skipped is not None]
if skipped:
lines.append("")
lines.append("SKIPPED tiers (too few aligned months):")
for t in skipped:
lines.append(f" {t.tier:<12} {t.skipped}")
# In-sample-vs-OOS honesty block (copy trade-in discipline).
lines.append("")
lines.append("HONESTY — in-sample vs out-of-sample:")
lines.append(" inR2 is the TRAIN-window R² — high BY CONSTRUCTION (β is fit to minimise those")
lines.append(
" residuals). It is NOT evidence the engine predicts. The only trustworthy number"
)
lines.append(
" is OOS_hit: the directional hit-rate on held-out future months the fit never saw,"
)
lines.append(
" scored strictly point-in-time. A coin flip scores ~0.50; the engine must beat it."
)
# Per-tier interpretation.
lines.append("")
lines.append("PER-TIER — does a class-specific β beat the EKB-wide prior OOS?")
any_lift = False
for t in tiers:
lift = lifts.get(t.tier)
if lift is None:
continue
any_lift = True
verdict_word = "beats EKB-wide" if lift > 0 else "no lift over EKB-wide"
lines.append(
f" {t.tier:<12} OOS_hit={_fmt_rate(t.oos_hit_rate)} "
f"lift={lift:+.3f}{verdict_word}"
)
if not any_lift:
lines.append(" (no class tier had a scorable OOS hit-rate to compare)")
# Per-variant verdict.
lines.append("")
lines.append("VERDICT (this variant):")
lines.append(f" {vd['reason']}")
if vd.get("thin_warning"):
lines.append(f" !! {vd['thin_warning']}")
lines.append("")
if source == _SOURCE_A:
lines.append("Caveats: Source A is survivorship-FREE but THIN (~13 mo) — usually too short")
lines.append("to clear _MIN_BACKTEST_MONTHS; an indicative cross-check, not proof.")
else:
lines.append("Caveats: Source B survivorship undercounts OLD months (use --detrend / -A")
lines.append("as controls); short Δ-series + holdout → small test window. β/lag CORE only.")
lines.append("=" * 78)
return "\n".join(lines)
def render_all(payload: dict[str, Any]) -> str:
"""Render every variant table then the cross-source verdict block."""
blocks: list[str] = [render_table(run) for run in payload["variants"]]
cross = payload["cross_verdict"]
blocks.append("=" * 78)
blocks.append("\n".join(cross["lines"]))
blocks.append("=" * 78)
return "\n\n".join(blocks)
def _fmt_tier_row(t: TierResult, *, lift: float | None) -> str:
"""Format one tier row for the table."""
lift_s = " -" if lift is None else f"{lift:+.3f}"
detr_s = "yes" if t.detrended else "no"
return (
f" {t.tier:<12} {t.source:>3} {detr_s:>5} {t.n_aligned:>7} {t.n_train:>6} "
f"{t.n_test:>5} {_fmt_lag(t.train_lag):>4} {_fmt_beta(t.train_beta):>9} "
f"{_fmt_rate(t.in_sample_r2):>7} {_fmt_rate(t.oos_hit_rate):>8} "
f"{_fmt_rate(t.oos_signed_mae):>8} {lift_s:>7} "
f"{('yes' if t.lag_stable else 'no'):>7}"
)
def _fmt_beta(v: float | None) -> str:
return " n/a" if v is None else f"{v:+.4f}"
# --------------------------------------------------------------------------- #
# Entry point
# --------------------------------------------------------------------------- #
def _parse_classes(raw: str | None) -> list[str] | None:
"""Parse --classes: None/'all' → None (auto-discover); CSV → lowercase list.
An empty string → [] (EKB-wide only). PURE.
"""
if raw is None:
return None
raw = raw.strip()
if raw == "":
return []
if raw.lower() == "all":
return None
return [c.strip().lower() for c in raw.split(",") if c.strip()]
def _parse_source(raw: str | None) -> list[str]:
"""Parse --source: B/A → that one source; both/None → [B, A]. PURE.
Case-insensitive. Returns the ordered list of sources to run (B before A so
the report leads with the long series). Unknown value → ValueError.
"""
if raw is None:
return list(_SOURCES)
val = raw.strip().lower()
if val in ("both", ""):
return list(_SOURCES)
if val == _SOURCE_B.lower():
return [_SOURCE_B]
if val == _SOURCE_A.lower():
return [_SOURCE_A]
raise ValueError(f"--source must be one of B, A, both (got {raw!r})")
def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
"""argparse setup, factored out for testability."""
p = argparse.ArgumentParser(
description=(
"READ-ONLY out-of-sample validation of the §9.6 rate-sensitivity engine (Forgejo #978)."
),
)
p.add_argument(
"--since",
default=_DEFAULT_SINCE,
help=f"Lower bound (ISO date) of the backtest window (default {_DEFAULT_SINCE}).",
)
p.add_argument(
"--source",
default=_SOURCE_BOTH,
help="Series source: 'B' (objective_lots, survivorship-confounded), 'A' "
"(objective_corpus_room_month, survivorship-free, ~13 mo), or 'both' "
f"(default '{_SOURCE_BOTH}').",
)
p.add_argument(
"--detrend",
action="store_true",
help="Also run a DETRENDED variant of each source: linearly detrend "
"ln(units) before differencing (removes a spurious monotone "
"survivorship trend so it can't drive the regression).",
)
p.add_argument(
"--deseasonalize",
action="store_true",
help="Also run a DESEASONALIZED variant of each source (#979): divide out "
"month-of-year seasonal factors before log_diff (strips the calendar "
"pattern so a seasonal peak isn't read as a rate effect). Factors fit "
"on TRAIN months only, applied point-in-time.",
)
p.add_argument(
"--almon",
action="store_true",
help="Also run an ALMON-ADL variant of each source (#978): replace the "
"single-lag best_lag estimator with an Almon polynomial distributed-lag "
"fit (smooth β over lags 0..6, long-run multiplier). Fit on TRAIN "
"months only; test prediction sums all lags point-in-time.",
)
p.add_argument(
"--holdout-frac",
type=float,
default=_HOLDOUT_FRAC,
help=f"TRAIN fraction of the time-ordered split (default {_HOLDOUT_FRAC}). "
"Fit on the oldest fraction, test on the newest remainder.",
)
p.add_argument(
"--classes",
default="all",
help="Comma-separated classes to backtest, or 'all' to auto-discover "
"(default 'all'). Empty → EKB-wide only.",
)
p.add_argument(
"--district",
default=None,
help="Optional district filter applied to ALL tiers (default: all districts).",
)
p.add_argument(
"--json",
action="store_true",
help="Emit machine-readable JSON instead of the text table.",
)
return p.parse_args(argv)
def _json_payload(payload: dict[str, Any]) -> dict[str, Any]:
"""Strip the renderer-only carriers from a run_all payload for JSON output."""
variants = [
{k: v for k, v in run.items() if k not in ("ekb_result", "tier_results")}
for run in payload["variants"]
]
cross = {k: v for k, v in payload["cross_verdict"].items() if k != "lines"}
return {"variants": variants, "cross_verdict": cross}
def main(argv: list[str] | None = None) -> int:
"""CLI entry point.
Returns 0 when at least one variant's EKB-wide tier was scorable (backtested,
not skipped); 1 if every requested variant was skipped (too thin) — e.g.
``--source A`` alone on prod today (~13 months < _MIN_BACKTEST_MONTHS).
"""
args = _parse_args(argv)
classes = _parse_classes(args.classes)
sources = _parse_source(args.source)
logger.info(
"backtest start: since=%s holdout_frac=%.2f classes=%s district=%s sources=%s "
"detrend=%s deseasonalize=%s almon=%s",
args.since,
args.holdout_frac,
"auto" if classes is None else classes,
args.district,
sources,
args.detrend,
args.deseasonalize,
args.almon,
)
db = _session()
try:
payload = run_all(
db,
since=args.since,
holdout_frac=args.holdout_frac,
classes=classes,
district=args.district,
sources=sources,
detrend=args.detrend,
deseasonalize=args.deseasonalize,
almon=args.almon,
)
finally:
db.close()
if args.json:
print(json.dumps(_json_payload(payload), ensure_ascii=False, indent=2, default=str))
else:
print(render_all(payload))
any_scorable = any(run["ekb_result"].skipped is None for run in payload["variants"])
return 0 if any_scorable else 1
if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())