gendesign/tradein-mvp/backend/tests/test_backtest_sampling_spread.py
bot-backend 884a6b9cfe
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m12s
бэктест: режим выборки вразброс — ORDER BY id DESC садится на несколько улиц
ORDER BY id DESC берёт последние ВСТАВЛЕННЫЕ строки, а Росреестр грузится
пачками по домам: соседние id это один дом и одна улица. Замер на проде
31.08.2026 по Екатеринбургу, выборка 200:

    последние 200 по id : 38 разных улиц, до 22 сделок с ОДНОЙ улицы
    случайные 200       : 129 разных улиц, максимум 6 с одной

На этой выборке стоит витрина лэндинга: 20 строк с ЧЕТЫРЁХ улиц, 16 из них с
двух. Владелец заметил это как «почти все сделки из одного района».

ЧТО ЗАМЕР ПОКАЗАЛ, а что нет. Пять прогонов по 200 сделок:

    recent            MAPE 15.18%  покрытие 88.75%
    scattered mera    MAPE 14.96%  покрытие 83.13%
    scattered alpha   MAPE 15.97%  покрытие 88.34%
    scattered bravo   MAPE 13.69%  покрытие 83.85%
    scattered charlie MAPE 14.36%  покрытие 86.23%

Заголовочная точность УСТОЯЛА — опубликованные 14,5% лежат внутри разброса
представительной выборки. Кластеризация её не раздувала.

А покрытие коридором опубликовано как 88% — это верх диапазона: при пересборке
выборки величина гуляет 83-88 при медиане около 86. Публикуется лучший прогон
из пяти как единственный. Правка самих чисел лэндинга — отдельным заходом.

УМОЛЧАНИЕ НЕ ТРОНУТО. Докстринг _sample_sql обещает дефолтному пути побайтово
тот же SQL ради замороженного регресс-гейта; смена умолчания молча обнулила бы
сравнимость всей истории замеров. Режим выбирается флагом --spread.

Порядок по md5(id||seed), а не random(): нужен ВОСПРОИЗВОДИМЫЙ порядок, иначе
два прогона отличаются и из-за правки, и из-за состава выборки, и разделить
вклады нечем.

Тест держит обе стороны и проверен фальсификацией: сделать вразброс умолчанием
— падает identity-проверка (сравнение текстов её бы пропустило), заменить md5
на random() — падает проверка воспроизводимости.
2026-08-31 11:28:11 +05:00

88 lines
4.9 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.

"""Выборка бэктеста: режим «вразброс» и неприкосновенность умолчания.
ЗАЧЕМ. `ORDER BY id DESC` берёт последние ВСТАВЛЕННЫЕ строки, а Росреестр
грузится пачками по домам: соседние id — это один дом и одна улица. Замер на
проде 31.08.2026 по Екатеринбургу, выборка 200:
последние 200 по id : 38 разных улиц, до 22 сделок с ОДНОЙ улицы
случайные 200 : 129 разных улиц, максимум 6 с одной
Выборка втрое беднее случайной — а на ней стояла витрина лэндинга (20 строк с
ЧЕТЫРЁХ улиц, 16 из них с двух).
ЧТО ЭТОТ ТЕСТ ДЕРЖИТ. Две вещи, и вторая не менее важна первой:
1. Режим «вразброс» действительно меняет порядок и воспроизводим по seed.
2. УМОЛЧАНИЕ НЕ ТРОНУТО — дефолтный путь обязан отдавать ТОТ ЖЕ объект SQL.
Замороженный регресс-гейт сравнивает прогоны между собой; сменить умолчание
значило бы молча обнулить сравнимость всей истории замеров.
"""
from __future__ import annotations
import re
from scripts.backtest_estimator import (
_SAMPLE_SQL,
_SAMPLE_SQL_SCATTERED,
_SAMPLE_SQL_SCATTERED_CITY,
_sample_sql,
)
def _sql(obj) -> str:
return str(obj.text if hasattr(obj, "text") else obj)
class TestУмолчаниеНеТронуто:
def test_дефолтный_путь_отдаёт_тот_же_объект(self):
# Именно identity, а не равенство текста: докстринг `_sample_sql`
# обещает побайтовую неизменность, и «текст совпал» это обещание не
# проверяет — объект мог быть пересобран с другими bind-параметрами.
assert _sample_sql(None) is _SAMPLE_SQL
assert _sample_sql(None, scattered=False) is _SAMPLE_SQL
def test_умолчание_сортирует_по_id_и_не_знает_про_seed(self):
sql = _sql(_SAMPLE_SQL)
assert "ORDER BY id DESC" in sql
assert ":seed" not in sql, "в умолчание протёк параметр режима вразброс"
class TestРежимВразброс:
def test_выбирается_явно_и_меняет_порядок(self):
assert _sample_sql(None, scattered=True) is _SAMPLE_SQL_SCATTERED
assert _sample_sql("Екатеринбург", scattered=True) is _SAMPLE_SQL_SCATTERED_CITY
def test_порядок_воспроизводим_ае_случаен(self):
# random() дал бы разную выборку на каждом прогоне, и два замера
# подряд отличались бы и из-за правки, и из-за состава выборки —
# разделить вклады было бы нечем.
for obj in (_SAMPLE_SQL_SCATTERED, _SAMPLE_SQL_SCATTERED_CITY):
sql = _sql(obj)
assert "md5(" in sql, "порядок обязан считаться от id и seed"
assert ":seed" in sql
assert re.search(r"\brandom\s*\(", sql) is None, (
"random() делает выборку неповторимой между прогонами"
)
assert "ORDER BY id DESC" not in sql
def test_городской_вариант_фильтрует_город_а_общий_нет(self):
assert "city = CAST(:city AS text)" in _sql(_SAMPLE_SQL_SCATTERED_CITY)
assert "city = CAST(:city AS text)" not in _sql(_SAMPLE_SQL_SCATTERED)
def test_оба_режима_отбирают_по_одним_и_тем_же_условиям(self):
# Иначе разница между режимами объяснялась бы не порядком, а другим
# набором сделок — и сравнение двух прогонов ничего бы не значило.
общие = [
"source = 'rosreestr'",
"geom IS NOT NULL",
"price_per_m2 BETWEEN",
"rooms IS NOT NULL",
"area_m2 > 0",
"deal_date >= CAST(:since AS date)",
]
база = _sql(_SAMPLE_SQL)
вразброс = _sql(_SAMPLE_SQL_SCATTERED)
for условие in общие:
assert условие in база, условие
assert условие in вразброс, условие