fix(tradein): гейт против сдвига разряда (×10) в offer_price_history у всех писателей + миграция 286 (#3376) #3383
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
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lekss361/gendesign#3383
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "fix/3376-price-history-decimal-slips"
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?
Closes #3376 (пп. 1-3).
Дефект. В
offer_price_historyесть точки с ценой ровно ×10/÷10 к соседям — потерянный разряд вpriceHistoryисточника (не рынок). Прод-замер 06.09: по всем источникам (domklik/cian/yandex; у avito одна триггерная строка — не трогаем). Оценщик таблицу не читает; читают витрина (trade_in.py,mera.py), лендинг (landing_stats— медианный торг #3223), admin.Гейт на записи —
scraper_kit/offer_price_history.py::drop_decimal_slips(points, price_of, *, current_price, listing_id) -> (kept, dropped)рядом сvalidate_diff_percent: точка выбрасывается, если скачок к предыдущей ОСТАВЛЕННОЙ точке в[9.5, 10.5]в любую сторону и следующая точка (или текущая цена объявления, если следующей нет) возвращается к базе[0.9, 1.1]; без второго свидетеля точка остаётся. Warning с listing_id/prev/cur/next. Подключён во всех писателях вне тестов иdata/sql/:providers/domclick/detail.py:832— + SELECT текущей цены как свидетель;diff_percentсоседа пересчитывается от настоящей базы;providers/cian/detail.py:487— серия сортируется поchange_timeперед гейтом; свидетель —_snap_row.price_rub;app/services/yandex_price_history.py:72— подключён, но структурно сработать не может (вставляемая точка = текущая цена лота, второй свидетель совпадает с проверяемой) — потолок назван в коде; прод-строки yandex чистит миграция;scripts/local-cian/playwright_history.py— разовый локальный скрипт на голом psycopg,scraper_kitне импортирует.decimal_slips_dropped— в лог-строках писателей (в counters пайплайна не протянут:save_detail_enrichment -> boolпроверяется в 6 местах, смена типа — отдельный диф).Миграция
data/sql/286_offer_price_history_decimal_slips.sql(286 свободен по всем рефам origin): только loader-строки (change_time <> recorded_at), LAG/LEAD по(listing_id, change_time, id), последняя точка — противlistings.price_rub; DELETE спайков + пересчётdiff_percentследующей строки по формуле 285 (|x|>100 → NULL);lock_timeout 5s, temp-таблицаON COMMIT DROP,RAISE NOTICEпо источникам,RAISE EXCEPTIONпри >200; идемпотентна (второй прогон 0/0). Триггерlistings_price_change_trgне тронут.Поправка к замеру из issue: пример
377000 → 3770000 → 3720000— дефектная точка377000(÷10), а3770000— возврат; в 76 строках замера пара «провал + возврат» посчитана дважды. Предзамер на проде (SELECT из ветки) — в комментарии ниже.Тесты (
tests/scrapers/test_domclick_detail.py, где живут unit-тестыvalidate_diff_percent): 6 на гейт (спайк вверх/вниз, последняя точка со свидетелем и без, честный ×2, ×10 без возврата) + 1 на писателя: вINSERTуходят[3720000, 3770000]без377000,diff_percentсоседа1.34вместо900.0. Чужая фикстураtest_3253_domclick_house_fields.pyдополненаfetchone=lambda: None(писатель теперь читает цену листинга).Фальсификация (гейт заглушен
return list(points), 0):assert [3720000, 377000, 3770000] == [3720000, 3770000]и ещё 3 — красное по значению, спайк-точка попадает в INSERT; три негатива остаются зелёными и с заглушкой (нечувствительны по построению). Восстановлено.Прогоны: полный backend
5482 passed, 35 skipped(rc=0,|| rc=$?);-k migration_numberingrc=0; ruff check/format rc=0;check-migration-lock-timeout.pyзелёный.Приёмка на проде: после деплоя
RAISE NOTICEмиграции == предзамер;count(*) FILTER (WHERE abs(diff_percent) > 100)у loader-строк domklik → 0 (было 42); повтор предзамера → 0 строк; медианный торг #3223 до/после — в комментарии.Предзамер миграции 286 на проде (2026-09-06, SELECT из ветки, ничего не менял):
yandex 0 — у всех шести ×10-точек нет второго свидетеля (нет возврата, текущая цена лота = спайк): по правилу гейта остаются, честно.
Ожидаемый остаток
|diff_percent| > 100у loader-строк domklik после миграции — 20, а не 0 (было 42). Разобрал все 20:330000 → 3300000 → (нет), текущая цена 3300000;420000 → 4200000 → 4500000;1400000 → 14000000 → 13000000. Дефектная точка — первая (÷10), у неё нет «предыдущей», и критерий «скачок к prev + возврат next» её не видит, а следующая (база) получает diff 900 %. Свидетели для первой точки есть: next и next-next (или текущая цена объявления), они согласны между собой в [0.9, 1.1].4369000 → 9499789,5000000 → 12500000,1790000 → 11790000): не сдвиг разряда; гейт на записиvalidate_diff_percentтакие занулил бы (|x|>100= «другая величина»), а в базе они остались с 117–559 %.Доработка (после deep-ревью, одним заходом): (а) симметричное правило для первой точки серии — выбрасывать, если она ×10/÷10 к next и next согласен с next-next-или-текущей-ценой; в гейте и в миграции; (б) финальный шаг миграции —
|diff_percent| > 100у loader-строк всех источников → NULL, как делает гейт на записи (п.1 issue). Тогда приёмка→ 0выполняется по построению, и предзамер станет ~47 удалений + ~8 занулений.Deep-ревью (06.09): ❌ — три HIGH и два MEDIUM, все закрываются доработкой в этой же ветке:
lag()берёт предыдущую СЫРУЮ строку (включая удаляемые тем же проходом), а гейт — предыдущую ОСТАВЛЕННУЮ → на1M → 10M → 1M → 10Mмиграция удаляет честную точку (гейт оставляет[1M,1M], миграция[1M]); на1M → 10M → 1.05M → 9.9Mвторой прогон удаляет ещё одну — не идемпотентна (124 таких серии в переборе ревьюера). Переписываю выбор кандидатов как PL/pgSQL-зеркало гейта; идемпотентность доказывается property-тестомgate(gate(s)) == gate(s)на переборе серий; плюс L-4 (NOT EXISTSпо триггерной строке с той же ценой).diff_percentсоседа не пересчитывался (-90вместо0.0) — переносится пересчёт из domclick.|diff_percent| > 100у loader-строк всех источников (п.1 issue) → приёмка «→ 0» по построению.Ревью подтвердило: numeric-деление (не bigint),
diff_percent numeric(5,2)влезает, пересчёт от оставшихся строк, честный ×10 без возврата не режется, номер 286 свободен, писатели покрыты все (кроме локального dev-скрипта). Для прод-проверки в ветку добавится dry-run (ROLLBACK, два прохода: второй обязан дать 0/0/0) — прогоню на проде до мержа.Dry-run миграции 286 на проде (06.09 ~23:05 UTC,
scripts/sql/286_dryrun.sql, транзакция с ROLLBACK, первое живое исполнение PL/pgSQL):Предзамеры: первая точка (сырые строки) — cian 2, domklik 13, yandex 6;
|diff_percent| > 100— cian 2, domklik 42.price.previous÷10 к текущей цене лота). Замечание для ревью: у yandex вторая точка и «текущая цена» — одно и то же значение из одного батча, т.е. два свидетеля правила первой точки здесь не независимы; у domklik/cian последняя точка истории иlistings.price_rubтоже берутся с одной карточки. Это принято в исходном правиле для последней точки (deep-ревью-1), но стоит записать явно.nulled=9(domklik) — честные скачки ×2–×6.6, остаются с ценой, без «процента другой величины»; приёмка→ 0по|diff|>100выполняется по построению.Ветка:
721ceb98+dc63b183(#TODO-yandex-slip→ #3385).Замер «до» для приёмки #3223 (медианный торг,
landing_stats._PRICE_MOVES_SQL, span 14 дн.,abs(pct) <= 50), прод 06.09 ~23:10 UTC: n=7789, снижавших 3811, медиана −2.056 %/мес (в таблицеlanding_statsсейчас −2.05 при sample 3799 от 05:36). Строк с|pct| > 50в выборке 0 — ×10-точки как первая/последняя уже отсекались фильтром, так что после 286 ожидаю сдвиг в третьем знаке, не больше; сверю тем же запросом после деплоя.Deep-ревью 2 (06.09): ⚠️ minor, блокеров нет. Проверено исполнением, не чтением: одноразовый postgres:16 с настоящим файлом 286 на переборе 58 575 серий (336 900 строк) — удалённые id совпали с выходом
drop_decimal_slipsпобайтово, повторный прогон 0/0/0; read-only прогон гейта по всем 63 217 прод-строкам загрузчика — те же 55 id, что даёт SQL. Мутации property-теста воспроизвелись до штуки (136 / 1122, первые падающие серии дословно).RETURNING+rowcountпроверен на живом psycopg3 (-1послеfetchone()на пустом RETURNING). Номер 286 свободен по 432 refs; dry-run-файл под авто-применение не попадает.Разложение 55 удалений по свидетелю (прод): внутри серии по следующей точке — cian 12, domklik 22; первая точка по третьей точке — domklik 4; первая точка по
listings.price_rub— cian 2, domklik 9, yandex 6. Во всех 17 случаях со свидетелем-ценой значение точно равно второй точке (одно наблюдение в двух местах; у yandex буквально одна переменная); третья точка как свидетель отбраковывает 73 % кандидатов, цена объявления — 5.6 %.Решение (консервативно, DELETE необратим): для правила первой точки свидетель — только третья точка истории; fallback на
listings.price_rubснимается в гейте, миграции и dry-run. Ожидание становится 38 (34 внутри серий + 4 первых точки; cian 12, domklik 26, yandex 0) — yandex целиком уходит в #3385; 17 строк с вырожденным свидетелем остаются сdiff_percent → NULLфинальным шагом. Плюс косметика: числа в шапке 286 иRAISE EXCEPTION(47 → 38 под критерием), «76» из докстринга, пометка о нулевом эффекте L-4 на проде,ponytail:о строковой сортировкеchange_timeу cian, «писатели на живом тракте». Перед мержем — выгрузка удаляемых строк в файл на хосте и повторный dry-run (ожидание 38 / pass2 0).Dry-run v3 на проде (06.09 ~23:45 UTC, голова
1b86ba7e, ROLLBACK):Предзамеры: первая точка (сырые строки) — domklik 4 (cian/yandex 0);
|diff_percent| > 100— cian 2, domklik 42.Ровно ожидание из шапки 286 (34 + 4 = 38; yandex 0).
nulled=17— это 44 строки с|diff|>100минус удалённые спайки и минус пересчитанные соседи; после прохода|diff|>100у loader-строк — 0.Бэкап перед необратимым DELETE: тем же проходом под ROLLBACK выгружены все строки
offer_price_historyзатрагиваемых объявлений плюс все loader-строки с|diff|>100— 333 строки (COPY 333), файл на хосте Poincare~/backups/oph_286_backup_20260906.csv(вне git). Откат при необходимости —\copyобратно поid.