fix(tradein/estimate): фильтр свежести в якоре дома и знаменателе коэффициента выкупа (#2656) #2661
No reviewers
Labels
No labels
Fable 5 ревью
GG-форсайт
admin
analytics
auth
automation
bug
business
chore
ci
compliance
data
data-moat
docs
duplicate
dx
enhancement
feedback/max
generative
needs-discussion
needs-human
observability
pause-bots
performance
priority/p0
priority/p1
priority/p2
priority/p3
scope/backend
scope/db
scope/devops
scope/frontend
scope/qa
scrapers
security
site-finder
stage/1
stage/2
status/blocked
status/done
status/needs-analysis
status/needs-fix
status/qa
status/ready
status/review
status/wip
tech-debt
tradein
ux
week ревью 1
wontfix
ИРД
вторичка
No milestone
No project
No assignees
3 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lekss361/gendesign#2661
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "fix/2656-anchor-ratio-freshness"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Что было
Фильтр свежести (
scraped_at > NOW() - LISTINGS_FRESH_DAYS дней) стоял ТОЛЬКО в главном радиусном пути эстиматора (_COMMON_WHERE+ inline-копия Tier W). Четыре другие выборки, которые двигают деньги, читалиis_activeбез свежести:estimator._fetch_anchor_compsTier A (:1885)median_ppm2 = new_ppm2,median_price = point,n_analogs = anchor["n"]estimator._fetch_anchor_compsTier C (:1977)asking_to_sold_ratio.ask_side(:157)asking_to_sold_ratio.ask_global(:217)is_activeсвежесть не заменяет: он означает РАЗНОЕ у разных источников (TTL деактивации 30 дней у cian/yandex-вторички, NULL-сегмент не деактивируется никогда), аscraped_at— одно и то же. Прод 2026-08: 21 132 из 37 497 «активных» строк протухли по 14-дневной мерке самого эстиматора и при этом были полностью годны для якоря.Побочный дефект, вскрытый замером:
anchor_tierоставался"C", когда_compute_same_building_anchorвернулаNone, и этот залипший флаг молча глушил IMV-blend (if anchor_tier is None and …,estimator.py:2770), quarter-index (#764 Guard-1a), radius-floor от ДКП-коридора и corridor-clamp-exempt Tier A.Что стало
AND scraped_at > NOW() - (:fresh_days || ' days')::intervalво всех четырёх местах.app.core.config.LISTINGS_FRESH_DAYS. Держать его вestimator.pyнельзя: тот сам импортируетarea_bucketизapp.tasks.asking_to_sold_ratio, обратный импорт дал бы цикл. Оба модуля читают константу изapp.core.config.anchor_tierсбрасывается явно у источника (if anchor is None: anchor_tier = None) — для всех трёх причин «якоря нет»:Noneиз_compute_same_building_anchor, гейт Tier C #1795, low-conf гейт #audit-1 (раньше сбрасывал только последний). Не как побочный эффект фильтра.anchor_tier— после сброса флага открылась щель (тир добыт, якоря нет, headline подавлен → карточку не заполнял ни blend, ни display-блок). Отображение, не деньги.Числа последствий
Прогон всех 1040 записей
trade_in_estimatesчерез настоящую_price_from_inputs(замер в #2656) + перезамер против полной ветки (обе половины фильтра + сбросanchor_tierвместе):insufficient_data6anchor_tierСброс
anchor_tierтянет цену вверх и смягчает худший случай. Он меняет цену у 99 оценок (9.76%): 82 — через квартальный индекс (двусторонний, до +26% и до −30%), 17 — через IMV-blend (всегда вверх). Кламп коридора и пол по ДКП не срабатывают ни разу.У сохранивших якорь сдвиг симметричный: 241 вверх, 227 вниз, медиана 0.00%. Медианный размер пула 19 → 13.
Потеря якоря шире потери порога — механизма два
low→ подавление.include_primary(#1774). Решение «пускать ли новостроечные перепродажи» теперь принимается на уже прореженном наборе. Дом, где протухание сильнее ударило по вторичке, может перевернуться из «пускать» в «не пускать» и потерять заодно свежие первичные комплы. Пример: 3 свежих вторички + 5 протухших вторичек + 4 свежих новостройки — до правки пул 12 и якорь есть; после правки вторичек 3 против 4 первичек, гард #1186 включается, пул 3, якорь потерян при семи живых комплах. Внутри измеренных чисел, направление консервативное.estimate_sb_min_compsНЕ трогаю (остаётся 4). Замер: повышать нельзя (хуже по всем осям), понижение до 3 — единственная реальная компенсация, но оно обнуляет гейт тонкого пулаestimate_sb_gate_min_n=3(условен наn < 3) — отдельное решение под бэктест, отдельный PR.Почему обе половины одним PR
Порознь они дадут два заметных скачка цены вместо одного меньшего: якорная половина −3.63%, компенсирующая половина знаменателя +1.25%.
Ловушка деплоя — нужен ручной пересчёт сразу после
asking_to_sold_ratiosпересчитывается ночным заданием (06:00-07:00 UTC). После деплоя одну ночь действует только якорная половина — то есть полные −3.63% без компенсирующего +1.25%. Либо дёрнуть пересчёт вручную сразу после деплоя (UPDATE scrape_schedules SET next_run_at = now() …вtradein-scraper, подхват ≤60 с), либо сознательно прожить эту ночь.Тесты
Новый
tests/test_freshness_filter_2656.py(8 тестов):_COMMON_WHEREи его inline-копии Tier W (самое вероятное место следующего расхождения того же сорта);app.core.config, локальных копий нет, литерала'14 days'в SQL нет);:fresh_daysзабинден в обоих тирах якоря;tier is None);anchor_tierсбрасывается, когда якорь не построен;anchor_tierбольше не глушит IMV-blend (цена реально двигается);Фальсификация:
git stash pushна файлах реализации → все 7 исходных краснеют; возврат старого условия display-блока валит №8; снятие предиката из Tier W валит №1. Полный прогон:3356 passed, 9 skipped.Честно про два места, которые НЕ являются защитой:
test_migration_080_derivation_is_subset_of_refresh_sql— добавлен нормализатор нового предиката; он проходит и без фикса (совместимость, не гард).test_estimator_price_spine::test_tier_c_corridor_gate_suppresses_anchor— раньше закреплял залипший"C"как ожидаемое поведение; переписан наis None.Frozen backtest gate
Сброс
anchor_tierоткрывает quarter-index-гейтGuard-1aна 19 из 277 сделок фикстуры, а фикстура захвачена ДО правки и ответов на эти lookup'ы не содержит (был жёсткийRuntimeError: control flow diverged from capture). Сделано:_make_call_stubполучилon_exhausted— quarter-index-lookup'ы отвечают «промах» (None/{});ratio_resolverостаётся строгим намеренно (лишний вызов там = молча другой коэффициент выкупа);unrecorded_lookup_calls, которая попадает в baseline целым числом (сравнивается ТОЧНО). Иначе ослабление выключило бы канарейку «реплей разошёлся с захватом» навсегда и для всех будущих PR. Сейчас в baseline закоммичено19: любой будущий PR, открывающий НОВЫЙ незаписанный путь, снова валит гейт громко, а перезахват фикстуры доведёт число до нуля (проверено: правка baseline 19→18 гейт валит).Перезахват фикстуры (предпочтительный путь) не сделан осознанно: он требует живой прод-БД
tradeinи тянет НОВУЮ выборку сделок — то есть не «дозаписывает» 19 ответов, а меняет саму систему отсчёта регресс-гейта по причинам, не связанным с этим PR.Попутно найдено и починено: документированная регенерация baseline (
--from-fixture --update-baseline) с #2173 писала baseline, который гейт не мог совпасть НИКОГДА — тест пиннитestimate_dedup_analogs_enabled=False(фикстура — OFF-захват), а CLI нет. Пин переехал внутрьreplay_fixture, оба пути согласованы. Регенерированный baseline отличается ровно одной строкой (новый ключ), ни одна метрика не сдвинулась.Что гейт по-прежнему НЕ мерит: для этих 19 сделок квартальный индекс в реплее не применяется вовсе (в проде применился бы), а именно он даёт 82 из 99 изменений цены от сброса. Источник истины по эффекту сброса — прод-замер выше, не гейт.
Test plan
asking_to_sold_ratiosпересчитан вручную (см. ловушку выше),ratioв бакетах 44-62 и 62-85 ушёл вверх (+2.43% / +4.58% по замеру), глобальный — вниз на 0.49%quarter_index: appliedв логах выросла примерно вдвое (112 → ~197 на выборке 1014) — если не выросла, сбросanchor_tierдо прода не доехалn_listingsвasking_to_sold_ratiosупал (NULL-сегмент в знаменателе: 674 → ~20 строк)НЕ мержить без решения владельца. По состоянию на 12.08 цена решения почти нулевая: сумма выкупа +0.09%, типовая оценка −0.23%, но 66 оценок (6.3%) двигаются больше чем на 10% в обе стороны. Сверка с медианами зарегистрированных ДКП по тому же дому: там, где правка шевелит цену, новое число ближе к сделкам (медиана ошибки 22.90% → 19.56% на 147 подвижках ≥5%). Это по-прежнему вопрос политики, а не кода — но аргумент «правка стоит клиенту 2.25%» больше не действует.
Refs #2656
Глубокое ревью: дефектов в коде не найдено, но есть дыра в покрытии замера — и она моя.
Что проверено и претензий нет
Предикат побайтово совпадает во всех четырёх новых местах и в двух существующих: та же колонка (
scraped_at, а неlast_seen_at), то же построение интервала, то же окно. Ловушка psycopg v3 здесь не срабатывает — каст ложится на скобочное выражение,:fresh_daysсвязывается штатно и забинден в обоих тирах якоря и в параметрах пересчёта. Это важнее, чем кажется: у якоря стоитexcept Exception, который глотает ошибку вrows = [], то есть несвязанный параметр молча удалил бы якорь вместо пятисотки.Переезд константы чистый: значение не изменилось, второго определения не осталось, аргумент про цикл импортов верен. Сброс
anchor_tierполный — прослежены все присваивания, после негоanchor_tier is Noneстрого эквивалентноanchor is None. Ключ расчёта коэффициента совпадает с ключом применения (повтора истории #2620 нет). Индексlistings_scraped_idxуже существует и теперь становится применим — план не деградирует.Прод-числа воспроизведены независимыми запросами: премия протухших в якорном сегменте +7.3-7.6% (я говорил +7.7%), глобальный коэффициент −0.51% (я говорил −0.49%), бакеты 44-62 и 62-85 сходятся. Плюс проверено то, чего я не проверял: строк с
scraped_at IS NULLноль (скрытого доп-исключения нет), и порогn_listings >= 30выдерживают все пять бакетов — ни один не проваливается в global-фолбэк.Дыра: замер описывает не то состояние кода, которое смержится
Замер на 1040 оценках шёл против деплойнутого кода, равного
main, — то есть с залипшимanchor_tierна месте. Сам залипший тир был находкой того замера, а не его условием. Значит −2.42% относится к «main + два SQL-фильтра», а не к этой ветке.Сброс тира открывает сразу четырёх потребителей цены, и они тянут в разные стороны:
:2799:2897:3041:3361→:5466Суммарный знак чтением кода не выводится. По фикстуре бэктеста тир менялся у 20 из 277 сделок (7.2%) — если на проде похоже, это ~7% оценок с новой мутацией цены поверх измеренных −2.42%.
И замороженный бэктест это увидеть не может: ослабление стаба сделано ровно в том месте (quarter-index), где эффект бы и проявился. Его «метрики не сдвинулись» — молчание, а не зелёный свет.
Перезамер против полной ветки уже запущен. До него мержить не надо: получится решение по цене, принятое по числу от другого кода.
Ослабленный стаб: намерение законное, эффект — маскировка
Асимметрия (строгий
ratio_resolver, мягкий quarter-index) показывает, что автор понимал компромисс. Но перестала проверяться «канарейка расхождения с захватом» — навсегда и для всех будущих PR, а не только для 19 известных сделок. Будущая правка, которая ошибочно откроет Guard-1a, получит «индекса нет» вместо исключения; а поскольку это no-op, метрики не сдвинутся и гейт останется зелёным на настоящей регрессии.В соседнем тесте того же файла есть прецедент: когда дедуп сменил последовательность вызовов, чинили пином флага, чтобы реплей следовал захвату, а не ослаблением стаба. Здесь флага нет, поэтому вариантов два: перезахватить фикстуру с прода (снимает обе проблемы разом) либо считать число исчерпанных вызовов и вынести его в метрики (~6 строк) — тогда
19становится закоммиченной константой, и любой будущий PR, открывающий новые незаписанные пути, падает громко.Мелочи
include_primary(#1774) теперь решается на уже прореженном наборе строк: дом, где протухание сильнее ударило по вторичке, может перевернуться в «не пускать первичку» и потерять заодно свежие новостроечные перепродажи. Это внутри измеренных −3.63%, направление консервативное — но это второй механизм за «потеря якоря шире потери порога», а в теле PR назван только первый.:2878, новый — через:2799, и есть достижимая щель, где не срабатывает ни один. Только отображение, на цену не влияет.Самооценка автора по тестам оказалась точной и местами строже, чем заслуживает: заглушка вместо Postgres покрывает больше, чем он ей приписал (краснеет и без предиката, и при несвязанном параметре).
Обратимость
Откат кода возвращает якорную половину сразу, а знаменатель — только после ручного пинка расписания пересчёта или одной ночи. Асимметрия та же, что и в прямую сторону. Сохранённые оценки не переписываются, миграции нет.
Перезамер против полной ветки сделан. Дыра была реальная, и число меняется — в лучшую сторону.
Оснастка
estimator.pyкоммита2878a88cзагружен отдельным модулем черезimportlib— деплойнутый код не тронут, обе версии_price_from_inputsживут в одном процессе. Пул якоря для новых состояний берётся настоящей веткой_fetch_anchor_comps(фильтр в самом SQL), а не питоновской репликацией. Все три состояния прогнаны одним скриптом на одном снимке данных, 1014 оценок, 0 ошибок:_price_from_inputsветки отличается от main только сбросом — значит переход v2→v3 изолирует его точно.Число для решения
Нести владельцу надо −2.25%, а не −2.42%. Сброс отыгрывает назад +0.113% — он тянет вверх, а не вниз.
Худший случай тоже смягчился: −48.6% в v2 → −40.6% в v3. Это та самая оценка, что теряет якорь и падает на радиус; сброс включает ей квартальный индекс, и падение выходит менее резким.
Отказы: 79 → 85, сброс на них не влияет вовсе.
Чистый вклад сброса и его механизм
Флаг меняется у 190 оценок (18.7%), цену меняет у 99 (9.76%) — ревью по фикстуре предсказывало 7.2%, порядок совпал. Вверх 73, вниз 26. Медиана нулевая, но хвост толстый: у 46 оценок сдвиг больше 10%. Остальные 91 — только метаданные (ярлык тира в API, карточка IMV, confidence).
Атрибуция по маркерам в логах, полная — необъяснённых изменений ноль:
Опасение ревью про «вниз через кламп коридора у залипшего
"A"» не подтвердилось: кандидатов всего 3, ни у одного кламп не включился. Пол по ДКП не срабатывает ни разу ни в одном из трёх состояний — на этих данных его условие не выполняется ни у одной оценки (маркер живой, проверено на клампе коридора: 39/42/42 срабатывания).Квартальный индекс — главный канал и единственный двусторонний: он множитель, поэтому у 26 оценок тянет вниз, вплоть до −30.2%.
Чего замер честно не покрывает
Канал IMV-blend занижен.
imv_evalпередан как отсутствующий (живой Avito требует запроса к площадке), blend питается либо им, либо house-level якорем из БД. Из 190 сменивших флаг якорь в БД есть только у 37 — у них blend и сработал. Остальные 145 в проде могли бы включить blend через живойimv_eval.То есть 17 — нижняя граница канала, верхняя ≤162. Направление от этого не меняется (blend в наблюдаемых случаях всегда вверх, то есть сброс смягчает падение), но +0.113% надо читать как нижнюю оценку отыгрыша, а не как точное число. Точнее без похода на Авито не измерить.
Вывод
Сброс
anchor_tier— правильная часть PR, и мержить его надо вместе с фильтрами: он чинит настоящий баг и по деньгам работает в противофазе.Но это не бесплатный довесок к однострочнику: у 46 оценок он сам по себе двигает цену больше чем на 10%, у 26 — вниз, и всё через квартальный индекс, который на этих оценках до сих пор просто не включался. И именно этот канал заглушен в бэктесте — то есть «метрики не сдвинулись» про сброс не говорит ничего. Ревью право.
Что делаю до мержа: расстабить квартальный индекс в фикстуре, чтобы главный канал изменений перестал быть слепым пятном, плюс мелочи ревью.
Прод-верификация после деплоя — вешать на частоту
quarter_index: appliedв логах: она должна вырасти примерно вдвое (112 → 197 на выборке из 1014). Не выросла — значит сброс до прода не доехал.Все замечания ревью закрыты вторым коммитом. CI зелёный (7/7), 3356 тестов, PR готов к решению.
Что сделано
Канарейка бэктеста восстановлена — счётчиком, а не перезахватом. Перезахват фикстуры отвергнут по существу: capture-режим тянет новую выборку сделок, то есть не дозаписывает 19 недостающих ответов к тем же 277, а меняет систему отсчёта регресс-гейта по причинам, к этому PR отношения не имеющим (два месяца новых объявлений и сделок). Вместо этого число исчерпанных вызовов вынесено в метрику
unrecorded_lookup_calls, в baseline закоммичено19. Сравнение целых точное, поэтому любой будущий PR, открывающий новые незаписанные пути, упадёт громко, а перезахват фикстуры доведёт число до нуля. Фальсификация: правка baseline19 → 18роняет гейт.Карточка Avito IMV больше не исчезает — гейт по тиру снят, остался только «карточка ещё не заполнена». Плюс тест ровно на ту щель: три объявления, headline подавлен, карточка обязана выжить. Возврат старого условия делает тест красным.
Inline-копия Tier W добавлена в набор проверяемых мест — снятие предиката оттуда теперь роняет тест. Это было самое вероятное место следующего расхождения, того же сорта, что и починенное этим PR.
Тело PR обновлено: −2.25% вместо −2.42%, вклад сброса с разбивкой по механизмам, худший случай −40.6%, второй механизм потери якоря (переворот
include_primary#1774), и прод-верификация по частотеquarter_index: applied.Побочная находка: регенерация baseline сломана с #2173
Документированная команда
--from-fixture --update-baselineписала baseline, который тест не совпал бы никогда. Причина: гейт-тест монкипатчитestimate_dedup_analogs_enabled=False(фикстура — OFF-захват), а CLI этого не делает. Первый прогон по документированной команде выдал уехавшие метрики (mape 13.18 → 13.1,n 269 → 266).Пин переехал внутрь
replay_fixtureподtry/finally, оба пути согласованы. После этого регенерация дала ровно одну новую строку в diff и ни одной сдвинувшейся метрики — что заодно служит подтверждением, что правки в реплее цену не двигают.Это стоит знать отдельно от решения по цене: починка инструмента лежит в этом же PR. Если правка цены будет отклонена, её надо вынуть отдельно, иначе регенерация baseline останется сломанной для всех.
Что осталось честно непокрытым
Гейт по-прежнему не мерит главный канал правки: для тех 19 сделок квартальный индекс в реплее не применяется, а он даёт 82 из 99 изменений цены от сброса. Счётчик это фиксирует и заставит вернуться к вопросу, но перезахват не заменяет. Источник истины по эффекту сброса — прод-замер, а не гейт, и так к нему и надо относиться.
Отдельного взгляда стоит пин
estimate_dedup_analogs_enabledвнутриreplay_fixture: он мутирует глобальные настройки на время реплея (с восстановлением вfinally). Это чужая инфраструктура, тронута ради корректной регенерации.Перезамер на 12.08 (замер в теле PR — от 05.08, с тех пор в main 151 коммит). Число изменилось качественно: эффекта по деньгам больше практически нет.
Ветка обновлена мержем main (см. коммит выше) — CI зелёный, 4248 passed / 18 skipped.
Оснастка — та же, что 05.08
Деплойнутый
estimator.py(md5 сверен сorigin/main) иestimator.pyсмерженной ветки живут в одном процессе (ветка загружена черезimportlibотдельным модулем), обоим скармливаются одни живые данные. Пул якоря для новых состояний берёт настоящая_fetch_anchor_compsветки, таблица коэффициентов — настоящий_REDERIVE_SQLветки, выполненный как SELECT (шапка INSERT срезана, в прод ничего не пишется). 1052 оценки из 1080 (28 без координат), 0 ошибок.Добавлены два арма, которых 05.08 не было, — они изолируют половины:
Проверка оснастки: прогон v1 против СОХРАНЁННЫХ прод-оценок, созданных за последние сутки, — медиана |Δ headline| = 0.04%, 44% совпадают точно. На оценках старше расхождение растёт ровно так, как и должно от дрейфа данных (7 суток — 1.04%, 30 суток — 11.8%).
Число для решения
anchor_tier(v2→v3)Устойчивость: сумма без 1% хвостов с каждой стороны — −0.12%, усечённое среднее −0.18%, p10 = −4.5%, p90 = +3.4%. То есть по сумме — ноль, по отдельным оценкам подвижки остались большие и двусторонние.
«Нет цены выкупа» 33 → 33: ровно одна оценка теряет её и ровно одна приобретает.
Почему число уехало — состав данных, а не код
1. Якорная половина сдулась, потому что вторичка перестала протухать.
Протухшая масса (19 989 строк из 45 030) на 84% новостройки, а их гард #1186 в якорь почти не пускает. Премия протухших над свежими в якорном сегменте: было +7.7%, стало +3.3%. Пул якоря всё ещё режется у 765 оценок из 923 (медиана −20%), но до потери самого якоря это доходит вчетверо реже.
2. Половина знаменателя частично перевернулась. Протухшая часть знаменателя теперь — cian/yandex вторичка (2428 строк, медианы 128 906 и 137 179 ₽/м²), она дешевле свежей популяции (147 137). Дорогой NULL-сегмент (654 строки по 162–169 тыс.) её больше не перевешивает. Итог: фильтр поднимает
ask_median147 137 → 148 648 и опускает глобальный коэффициент 0.8444 → 0.8354.По сумме знаменатель всё ещё тянет вверх (+0.71%) — деньги сидят в бакетах 62–85 и 85+. Но по типовой оценке он теперь тянет вниз (медиана −0.23%) и работает в ОДНУ сторону с якорной половиной, а не в противофазе. Формулировку «обе половины надо мержить вместе, иначе два скачка вместо одного меньшего» это не отменяет, но её обоснование стало слабее.
Живая прод-таблица и пересчёт main-овским SQL прямо сейчас расходятся на 0.02% — ночной пересчёт работает, «ловушка деплоя» из тела PR в силе.
3. Сброс
anchor_tierстал вдвое весомее: +0.46%. Флаг меняется у 150 оценок (14.3%), цену меняет у 64 (6.1%). Атрибуция по маркерам в логах полная, необъяснённых ноль: 47 через квартальный индекс, 17 через IMV-blend, кламп коридора и пол по ДКП — 0 раз, как и 05.08. Вверх 46 / вниз 18, медиана +1.90%, размах от −23.0% до +25.7%.4. Последствие «уедут в
insufficient_data» исчезло вместе с гейтом. #oblast-F (#2823, смержен 09.08) снял обнуление headline на тонкой выборке. Потеря якоря больше никого не роняет в отказ: 15 оценок без headline до правки и 15 после.Направление: новое число ближе к реальным сделкам
914 оценок сверены с медианой зарегистрированных ДКП по тому же дому (≤150 м, 12 мес., с матчем по комнатности; при тонкой выборке — ≤600 м):
То есть там, где правка реально шевелит цену, она шевелит её к ценам сделок, и заметно. Оговорка честная: ДКП — не полностью независимый эталон (сделки кормят коридор и квартальный индекс внутри самого эстиматора) и это заявленная цена договора, а не обязательно уплаченная.
Постановка НЕ устарела
Проверено по
git show origin/main: оба SQL якоря (estimator.pyTier A / Tier C) и обе CTE коэффициента (asking_to_sold_ratio.pyask_side/ask_global) на сегодняшнем main по-прежнему читаютis_activeбез фильтра свежести. Соседние правки (#2797 TTL, #2771 координаты, #2818 адреса) починили ПОСТАВЩИКА данных, а не потребителей. Дефект в силе; изменилась только его цена.Что стоит решить заодно
#2823 добавил второе окно свежести:
LISTINGS_FRESH_DAYS_RELAXED = 60— радиусный путь расширяет окно 14 → 60 дней, когда выборка тоньшеHEADLINE_LISTINGS_MIN_N(сработало у 19 оценок из 1052; всего хоть одна релаксация — у 219). Якорь после этого PR остаётся жёстко на 14. Тезис тела PR «окно живёт в ОДНОМ месте» относится к базовому окну и остаётся верным, но вопрос политики открыт: в тонком рынке мы теперь предпочитаем 60-дневный радиусный пул 14-дневному якорю по тому же дому. Отдельным решением, не этим PR.Мержить не буду — решение за владельцем. Технически правка не изменилась и по-прежнему бесспорна: цена местами строится на объявлениях, которых никто не видел месяц. Но аргумент «это стоит −2.25% клиенту» больше не действует: по сумме сегодня +0.09%, по типовой оценке −0.23%. Цена решения упала почти до нуля, а сверка с ДКП говорит, что оставшиеся подвижки идут в правильную сторону.
Обновил ветку до сегодняшнего main — и это было не формальностью
Зелёный CI на этом PR считался 12.08 в 13:06, а main с тех пор ушёл на 17 коммитов, включая три правки того же самого якоря: #2816 (приведение якоря к базису ремонта), #2839 (квартал цели), #2852. Проверки тестировали голову ветки, а не результат слияния — то есть про сегодняшнее состояние не говорили ничего.
Влил
origin/main(слияние текстово чистое,git merge-tree) и прогнал.Что показал бэктест-гейт
Он упал. Но не там, где можно было ждать: расхождение единственное во всём наборе метрик.
Сравнивал не первое несовпадение, а весь набор (
_assert_matchостанавливается на первом — своим скриптом обошёл всё дерево). Все денежные метрики совпали: общий MAPE, покрытие, sharpness, калибровка по бакетам, по комнатам, по сегментам, headline-spread — побайтно.Что это значит. На фикстуре из 277 сделок правка не двигает деньги вообще. Отличается только диагностический счётчик: ветка не делает 19 «неучтённых lookup-вызовов», которые делает main. Поведенческая разница реальная, но на этой выборке она денежно нейтральна.
Это независимое подтверждение вашего же перезамера (+0.09% по сумме): другой инструмент, другая выборка, тот же вывод.
Оговорка, которая обязана быть
Фикстура зовёт
_price_from_inputsнапрямую, минуяestimate_quality— это выяснилось при разборе #2839. Значит часть путей она не проходит, и «метрики не сдвинулись» здесь означает «не сдвинулись на покрытых путях», а не «не сдвинулись нигде». Ваши ±100% на отдельных оценках фикстурой не воспроизводятся и не опровергаются.Что осталось сделать
Эталон бэктеста надо обновить на
unrecorded_lookup_calls: 0— но не механически. Сначала стоит понять, почему счётчик обнулился: если эти 19 вызовов исчезли потому, что фильтр свежести отсёк входные данные, это ожидаемо; если потому, что какой-то путь перестал исполняться — это отдельный факт, который стоит назвать до обновления эталона.Ветка обновлена и запушена (
d7017ebf), CI пересчитается. Локально: 1019 passed по тестам оценщика/якоря/коэффициента, падает только гейт и только на этом счётчике.Не мержу. Ваше «решение за владельцем» остаётся в силе, но его цена теперь измерена дважды и независимо: по сумме ноль, по направлению — к ценам сделок.
Почему счётчик обнулился — разобрался, эталон обновлять можно
Выше я предостерёг от механического обновления эталона, пока не понятно, почему
unrecorded_lookup_callsушёл 19 → 0. Разобрался; ответ снимает возражение.Что означает счётчик
scripts/backtest_estimator.py:1494-1506: заглушка квартального индекса отвечает «промах» на вызовы, которых нет в захваченной фикстуре, и считает их. Смысл — канарейка «реплей разошёлся с захватом»:То есть 19 — это не метрика качества, а мера расхождения реплея с прод-захватом, намеренно вбитая в эталон точным числом, чтобы любое её изменение роняло гейт. Гейт сработал ровно по назначению.
Моя гипотеза была неверной
Я предположил, что счётчик обнулила вчерашняя #2839 (снятие квартального фолбэка). Проверил на чистом
origin/main: гейт зелёный, 2 passed — значит на main счётчик по-прежнему 19, и #2839 тут ни при чём.Обнуляет его сам фильтр свежести: те 19 оценок, что в реплее просили незахваченный квартальный индекс, с фильтром до этого блока просто не доходят — часть из 57, теряющих якорь.
Что это значит для решения
Направление правильное. Комментарий в оснастке прямо говорит, что 19 — это занижение эффекта правки: «для этих 19 сделок квартальный индекс в реплее НЕ применяется вовсе (в проде — применился бы), т.е. гейт занижает эффект правки». Ноль означает, что реплей и захват сошлись, а не разошлись.
Ничего не маскируется: все денежные метрики совпали побайтно, я сверил весь набор, а не первое несовпадение.
Эталон обновлять можно — на
unrecorded_lookup_calls: 0, с пометкой почему. Это не ослабление канарейки: следующее расхождение так же уронит гейт, просто отсчёт пойдёт от нуля.Сам обновлять не стал: правка принадлежит этому PR, а решение о его мерже — за владельцем. Если решение будет «мержим», обновление эталона входит в тот же коммит.
Разбит надвое по решению владельца 16.08.2026
Безопасная половина выделена в #2920 и смержена: сброс залипавшего
anchor_tier, восстановление канарейки бэктеста и починка штатной регенерации эталона. Здесь остаётся только спорная часть — фильтры свежести.Что остаётся в этом PR
scraped_at > NOW() - :fresh_daysв SQL якоря дома, оба тира (_fetch_anchor_comps);asking_to_sold_ratio._REDERIVE_SQL);LISTINGS_FRESH_DAYSв конфиг.Перезамер на текущем коде — почему решение отложено
Прежние числа (сумма выкупа +0,09%, типовая оценка −0,23%, 66 из 1052 оценок двигаются больше 10%) устарели: main переписал оценщик. Замер повторён на слитом с main состоянии, 996 парных оценок.
Живой путь оценки — эффекта почти нет: медиана изменения 0,000%, ни одна оценка не двигается больше чем на 10%, худший случай −1,67%. Причина выяснилась по ходу: миграции 264–266 от 15.08 и джобы деактивации уже сняли ту самую популяцию протухших строк, которую фильтр отсекал на лету — было 674 строки, осталось 34. То есть на estimate-уровне правка сегодня в основном дублирует уже сделанную чистку данных.
А вот знаменатель коэффициента выкупа живёт отдельно — он пересчитывается ночной джобой, и там эффект реальный:
Все бакеты идут вниз — то есть предложение клиенту системно уменьшится примерно на процент после первого же ночного пересчёта.
Расхождение, которое стоит отдельно поправить
Комментарий в
asking_to_sold_ratio.py:33-34ссылается на августовский замер, где бакеты 44–62 и 62–85 двигались вверх (+2,43% и +4,58%). Сегодня те же бакеты идут вниз. Состав данных с тех пор изменился, а комментарий остался и вводит следующего читателя в заблуждение.Про красный CI
Гейт бэктеста падал на
unrecorded_lookup_calls: 0 != 19, и это списывали на фильтр свежести. Оказалось иначе — виновата была третья правка (сбросanchor_tier), она меняла число обращений к квартальному индексу. Эта причина уехала в #2920 вместе с перегенерированным эталоном, так что после слияния с main гейт здесь должен вести себя иначе — проверять заново.Решение по этому PR — за владельцем: согласиться на системную просадку выкупа около процента ради того, чтобы цена не строилась на объявлениях месячной давности, либо считать вопрос закрытым чисткой данных и PR закрыть.
Смотрел, можно ли снять конфликт, чтобы PR был готов к твоему решению. Снимать не стал — и вот почему это правильнее, чем «привести в mergeable».
Дрейф с точки ветвления
Ветка отстала от
mainна 280 коммитов. По файлам, которые она правит:app/services/estimator.pyapp/core/config.pytests/fixtures/backtest_baseline.jsonapp/tasks/asking_to_sold_ratio.pyТочка ветвления —
d6c000ed(05.08).Почему это не «просто конфликт»
Ценность этого PR — не в диффе, а в замерах: −3.63 % на якорной половине, +1.25 % на знаменателе, итог −2.25 %, потом перезамер 12.08 → +0.09 %. Решение мержить или нет принимается по этим числам.
И сам PR уже показал, насколько они хрупкие: перезамер той же оснасткой через неделю перевернул вывод (−2.25 % → +0.09 %) — не из-за правок кода, а потому что изменился состав данных («вторичка перестала протухать, avito/domklik 0 % протухших»).
С тех пор прошло ещё 19 дней, а
estimator.py— файл, чьё поведение и мерили — прибавил 336 строк за 9 коммитов. Числа в шапке описывают систему, которой больше нет.Если я сейчас разрешу конфликт, PR станет зелёным и будет выглядеть готовым к решению — с цифрами, на которые уже нельзя опереться. Это ровно тот класс, который я всю ночь ловил в другом месте: артефакт выглядит исправным, а вывод из него делать нельзя.
Что нужно, чтобы решение снова стало осмысленным
estimator.pyпридётся разбирать руками, механически он не сойдётся при +336 строках.scripts/backtest_estimator.pyна всех записяхtrade_in_estimates) — получить актуальные проценты вместо августовских.backtest_baseline.jsonна main тоже сдвинулся (1 коммит), а PR пиннит в нёмunrecorded_lookup_calls: 19. Возможно, потребуется перезахват — а он, по твоему же описанию в шапке, меняет саму систему отсчёта и требует живой прод-БД.Пункт 1 я могу сделать, но только вместе с пунктом 2 — иначе получится зелёный PR с недействительными обоснованиями. Пункт 2 — это прогон против боевых данных оценщика, и запускать его без твоего слова я не буду.
Предложение
Скажи, делать ли перезамер — и я пройду все три пункта разом, с новыми числами в шапке. Либо, если политика цены за это время определилась в другую сторону, PR проще закрыть: он висит с 05.08, и держать его дальше «на всякий случай» дороже, чем переписать заново от актуального main, когда решение появится.
Сам по себе он от времени не улучшается — только устаревает.
Ревью + актуализация 26.08 (ветка обновлена мержем main, конфликты разрешены)
PR висел с 05.08 и разъехался с main. Что сделано:
Разрешение конфликтов. Всё, что из этого PR уже влито на main отдельно (сброс залипшего
anchor_tier, backtest-машинерияon_exhausted/unrecorded_lookup_calls, перезахваченная фикстура сunrecorded=0), — взято в main-версии. Остаток PR против main теперь ровно ядро: константа вapp.core.config+ предикат свежести в 4 местах (+ тесты). Diff 5 файлов, +414/−7.Краснота теста переведена на «по значению»: прямой импорт
LISTINGS_FRESH_DAYSиз config умирал на main ImportError'ом (константы там ещё нет) — теперьgetattr, и на main-коде 5 тестов падают ассертами (предикат отсутствует / окно None / протухшие комплы в пуле), 3 зелёных (сброс anchor_tier уже влит). С фиксом — 8/8.Два адверсариальных вопроса к дифу — сняты прод-замером (26.08, Poincare):
scraped_at IS NULLмолча выпадет из якоря? — Активных строк с NULLscraped_atноль. Не случай.Прогоны: свой тест 8/8, соседи (
test_asking_to_sold_ratio,test_estimator_price_spine) и весь backtest-гейт (test_backtest_regression_gateреально исполнялся, не скипнут) — 125 passed суммарно.Приёмка после деплоя: следующий ночной
asking_to_sold_ratio_refresh—n_listingsNULL-сегмента в ask-строках схлопывается с ~674 до ~20 (замер из описания PR); по якорю —SELECTчисла домов Tier A на протухших строках → 0.Смержен и на проде (26.08, деплой
036608f1зелёный, код в контейнере проверен):fresh_daysв контейнере: estimator — 17 вхождений (включая оба якорных тира), asking_to_sold_ratio — 3 (было 0);Приёмка с датой: после ближайшего ночного
asking_to_sold_ratio_refresh(сегодня ~00:40 UTC) —n_listingsNULL-сегмента в ask-строках схлопывается с ~674 до ~20 (замер из описания PR); проверить завтра запросом поasking_to_sold_ratios.Приёмка по знаменателю прошла (27.08, первый ночной refresh с фиксом)
asking_to_sold_ratio_refreshотработал 27.08 06:12 (уже на коде с фильтром, деплой был 26.08 15:35). Прямого диффа со вчерашними строками нет (refresh делает DELETE), поэтому фальсификация тем же трактом — ask-популяция с фильтром и без на живой базе:Сохранённые строки за 06:12: global n_listings = 17 625 (доп. сужение city-скоупом и ppm²-полосой), пер-бакеты 1 731–4 907. Если бы refresh шёл без фильтра, n_listings был бы масштаба 48k — числа однозначно различают эры.
Обе приёмки PR закрыты (якорь — вчера, знаменатель — сегодня).