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 берёт последние ВСТАВЛЕННЫЕ строки, а Росреестр грузится
пачками по домам: соседние 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() — падает проверка воспроизводимости.
88 lines
4.9 KiB
Python
88 lines
4.9 KiB
Python
"""Выборка бэктеста: режим «вразброс» и неприкосновенность умолчания.
|
||
|
||
ЗАЧЕМ. `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 вразброс, условие
|