fix(#3376): первая точка — только по третьей точке истории; числа под критерием
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
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 4m58s

This commit is contained in:
bot-backend 2026-09-06 05:12:30 +05:00
parent dc63b1830b
commit 1b86ba7e06
5 changed files with 98 additions and 61 deletions

View file

@ -14,8 +14,10 @@
-- эти точки не влияли.
--
-- Код починен в том же PR: гейт drop_decimal_slips в scraper_kit/offer_price_history.py
-- подключён к писателям истории (domclick/detail.py, cian/detail.py). Эта миграция
-- отрабатывает задним числом по уже собранным строкам.
-- подключён к писателям НА ЖИВОМ ТРАКТЕ (domclick/detail.py, cian/detail.py). Эта
-- миграция отрабатывает задним числом по уже собранным строкам. Разовый
-- scripts/local-cian/playwright_history.py гейта не знает, но переиграть миграцию не
-- может: он выбирает только объявления вообще БЕЗ offer_price_history.
--
-- ВЫБОРКА КАНДИДАТОВ ЗЕРКАЛИТ ГЕЙТ 1:1, А НЕ «ТОТ ЖЕ КРИТЕРИЙ»
-- Первая редакция этого файла брала предыдущую точку через lag(), то есть СЫРУЮ
@ -35,21 +37,30 @@
-- КРИТЕРИЙ — ДВА СВИДЕТЕЛЯ, И У ПЕРВОЙ ТОЧКИ ТОЖЕ ДВА
-- Внутри серии: 1) скачок к предыдущей ОСТАВЛЕННОЙ точке, price/base ∈ [9.5, 10.5]
-- в любую сторону; 2) возврат к базе у следующей точки, next/base ∈ [0.9, 1.1].
-- Первая точка: 1) она ×10/÷10 ко второй оставшейся; 2) вторая подтверждена третьей
-- оставшейся, а если третьей нет — текущей ценой объявления, witness/second ∈ [0.9, 1.1].
-- Первая точка: 1) она ×10/÷10 ко второй оставшейся; 2) вторая подтверждена ТРЕТЬЕЙ
-- оставшейся, third/second ∈ [0.9, 1.1]. Третьей нет → кандидата нет.
-- Окно шире чистой десятки, потому что сдвиг разряда часто идёт вместе с настоящим
-- мелким изменением цены (377 000 → 3 720 000 это ×9.87). Одного скачка НЕ хватает:
-- ×10 без подтверждения — это возможное честное изменение цены (400 000 → 4 000 000 →
-- 8 000 000 — разгон, а не разряд), и такие точки остаются. Нет второго свидетеля
-- вообще (последняя точка серии, а listings.price_rub пуст) → точку не трогаем.
--
-- ПОЧЕМУ У ПЕРВОЙ ТОЧКИ НЕТ ОТКАТА НА listings.price_rub. Он был и снят сознательно:
-- у 17 из 21 кандидата первой точки (yandex 6, domklik 9, cian 2) listings.price_rub
-- ТОЧНО равна второй точке — у yandex это буквально одна переменная lot.price_rub,
-- записанная в двух местах. «Два свидетеля» там вырождаются в одного, а DELETE
-- необратим. Эти 17 строк остаются жить; их процент, если он не процент, занулит
-- финальный шаг |diff_percent| > 100 → NULL. Для yandex отлов переехал в #3385 —
-- сравнение со СЛЕДУЮЩИМ наблюдением. У ПОСЛЕДНЕЙ точки цена объявления свидетелем
-- остаётся: она там сравнивается с базой СЛЕВА, то есть с другим наблюдением.
--
-- СКОЛЬКО ЖДЁМ УДАЛЕНИЙ
-- Предзамер на проде 06.09.2026 под ДВУХСВИДЕТЕЛЬСКИМ критерием: 35 строк внутри
-- серий + ~12 первых точек (12 из 20 остатков domklik — ровно вид 330 000 → 3 300 000)
-- ≈ 47. Прежняя цифра 76 в шапке была из ОДНОСВИДЕТЕЛЬСКОГО замера (просто отношение
-- к предыдущей строке) и к этому критерию отношения не имеет. Порог остановки 200 —
-- четырёхкратный запас: поймали больше — критерий ловит не то, и миграция обязана
-- упасть, а не молча вычистить историю.
-- Замер на проде 06.09.2026 ровно под ЭТИМ критерием: 34 строки внутри серий
-- (cian 12, domklik 22) + 4 первых точки по третьей точке (domklik 4) = 38.
-- По источникам: cian 12, domklik 26, yandex 0 — yandex целиком отпал вместе с
-- откатом на listings.price_rub (см. выше), его случай уехал в #3385.
-- Порог остановки 200 — примерно пятикратный запас: поймали больше — критерий
-- ловит не то, и миграция обязана упасть, а не молча вычистить историю.
--
-- ТОЛЬКО СТРОКИ ЗАГРУЗЧИКА: change_time <> recorded_at
-- Признак происхождения тот же, что в 285 (там же его доказательство): триггер
@ -65,6 +76,9 @@
-- же цена есть у ТРИГГЕРНОЙ строки того же объявления — значит listings.price_rub
-- когда-то реально равнялась этому значению, и подозреваемая точка загрузчика
-- подтверждена независимым писателем. Такую не удаляем и оставляем в цепочке базой.
-- ЧЕСТНО ПРО ЕГО ВЕС: на прод-данных 06.09.2026 он не спас НИ ОДНОЙ строки (0 из 21
-- кандидата правила первой точки). Механизм проверен только на синтетике — держим
-- как страховку на будущих прогонах, а не как замеренную защиту.
--
-- ПЕРЕСЧЁТ diff_percent У СЛЕДУЮЩЕЙ СТРОКИ
-- У строки, шедшей за удалённой, предыдущая цена сменилась — процент, посчитанный
@ -187,10 +201,8 @@ WITH kept AS (
row_number() OVER w AS rn,
lead(oph.id) OVER w AS second_id,
lead(oph.price_rub) OVER w AS second_price,
lead(oph.price_rub, 2) OVER w AS third_price,
l.price_rub AS listing_price
lead(oph.price_rub, 2) OVER w AS third_price
FROM offer_price_history oph
LEFT JOIN listings l ON l.id = oph.listing_id
WHERE oph.change_time <> oph.recorded_at
AND NOT EXISTS (SELECT 1 FROM oph_decimal_slips s WHERE s.id = oph.id)
WINDOW w AS (PARTITION BY oph.listing_id ORDER BY oph.change_time, oph.id)
@ -202,8 +214,11 @@ SELECT k.id, k.listing_id, k.second_id, k.source
AND k.second_price > 0
AND (k.second_price / k.price_rub BETWEEN 9.5 AND 10.5
OR k.price_rub / k.second_price BETWEEN 9.5 AND 10.5)
AND COALESCE(k.third_price, k.listing_price) > 0
AND COALESCE(k.third_price, k.listing_price) / k.second_price BETWEEN 0.9 AND 1.1
-- Свидетель — только ТРЕТЬЯ точка истории. listings.price_rub здесь НЕ
-- подставляется: у 17 из 21 кандидата она в точности равна второй точке,
-- то есть это то же наблюдение, а не второй свидетель (см. шапку).
AND k.third_price > 0
AND k.third_price / k.second_price BETWEEN 0.9 AND 1.1
AND NOT EXISTS (
SELECT 1
FROM offer_price_history t
@ -236,7 +251,8 @@ BEGIN
IF to_delete > 200 THEN
RAISE EXCEPTION
'offer_price_history: к удалению % строк при предзамере 06.09.2026 ≈ 47 — '
'offer_price_history: к удалению % строк при замере 06.09.2026 = 38 '
'(34 внутри серий + 4 первых точки; cian 12, domklik 26, yandex 0) — '
'критерий ловит не то, миграция остановлена', to_delete;
END IF;

View file

@ -737,9 +737,9 @@ def test_validate_diff_percent_bool_treated_as_none() -> None:
# ── drop_decimal_slips (#3376) — сдвиг разряда в priceHistory источника ───────
# Прод-замер 06.09.2026: 76 точек ровно ×10/÷10 к соседям у 55 объявлений
# (domklik 48, cian 21, yandex 6). Вид: 377 000 → 3 770 000 → 3 720 000 — цена
# возвращается к базе следующей же точкой.
# Прод-замер 06.09.2026 под ЭТИМ критерием: 38 точек (cian 12, domklik 26,
# yandex 0). Вид: 377 000 → 3 770 000 → 3 720 000 — цена возвращается к базе
# следующей же точкой.
def test_drop_decimal_slips_drops_spike_up(caplog: pytest.LogCaptureFixture) -> None:
@ -792,15 +792,10 @@ def test_drop_decimal_slips_last_point_kept_without_current_price() -> None:
# ── правило ПЕРВОЙ точки (#3376, прод-разбор 06.09.2026) ─────────────────────
# 12 из 20 остатков domklik — серии, у которых дефектная точка первая: 330 000 →
# 3 300 000 (текущая цена 3 300 000), 420 000 → 4 200 000 → 4 500 000. Слева базы
# нет, основное правило такую точку не видит. Свидетелей по-прежнему двое.
def test_drop_decimal_slips_drops_first_point_witnessed_by_current_price() -> None:
"""330 000 → 3 300 000 при цене 3 300 000: вторую точку подтверждает цена лота."""
kept, dropped = drop_decimal_slips([330000, 3300000], lambda p: p, current_price=3300000)
assert (kept, dropped) == ([3300000], 1)
# Серии, у которых дефектная точка первая: 420 000 → 4 200 000 → 4 500 000. Слева
# базы нет, основное правило такую точку не видит. Свидетелей по-прежнему двое, и
# оба — из самой истории: вторая точка и ТРЕТЬЯ. Кандидатов первой точки на проде
# было 21, свидетеля из истории имеют 4 (все domklik).
def test_drop_decimal_slips_drops_first_point_witnessed_by_third() -> None:
@ -815,9 +810,18 @@ def test_drop_decimal_slips_keeps_first_point_when_series_runs_away() -> None:
assert drop_decimal_slips(series, lambda p: p) == (series, 0)
def test_drop_decimal_slips_keeps_first_point_without_second_witness() -> None:
"""Две точки и текущей цены нет — второго свидетеля не существует."""
def test_drop_decimal_slips_keeps_first_point_without_third_point() -> None:
"""330 000 → 3 300 000: третьей точки нет — свидетеля нет, ДАЖЕ с ценой лота.
Цена объявления в правиле первой точки не участвует сознательно: у 17 из 21
прод-кандидата (yandex 6, domklik 9, cian 2) listings.price_rub в точности
равнялась второй точке у yandex это буквально одна переменная lot.price_rub,
записанная в двух местах. Такой «второй свидетель» то же наблюдение, а
удаление точки необратимо. Верни COALESCE на current_price в правило первой
точки первый assert покраснеет.
"""
series = [330000, 3300000]
assert drop_decimal_slips(series, lambda p: p, current_price=3300000) == (series, 0)
assert drop_decimal_slips(series, lambda p: p, current_price=None) == (series, 0)
@ -827,7 +831,8 @@ def test_drop_decimal_slips_first_point_decided_on_kept_series() -> None:
1M 10M 100M 10M: 100M выбрасывает основное правило, и только после этого
видно, что первая точка ÷10 к оставшейся серии [10M, 10M]. Считай правило по
сырым соседям первый проход оставил бы 1M, а второй выбросил, то есть гейт
перестал бы быть идемпотентным (перебор ниже ловит 1122 такие серии).
перестал бы быть идемпотентным (перебор ниже ловит 1116 таких прогонов, и эта
серия первый из них).
"""
kept, dropped = drop_decimal_slips(
[1_000_000, 10_000_000, 100_000_000, 10_000_000], lambda p: p
@ -875,11 +880,12 @@ def test_drop_decimal_slips_is_idempotent(caplog: pytest.LogCaptureFixture) -> N
пошагово, а доказать идемпотентность на самом SQL-файле нечем прод-прогон
один. Раз выборка кандидатов повторяет гейт, свойство переносится на неё.
ФАЛЬСИФИЦИРУЕМОСТЬ (замерено на этом же переборе 19 525 серий × 3 цены):
ФАЛЬСИФИЦИРУЕМОСТЬ (перемерено 06.09.2026 на этом же переборе 19 525 серий
× 3 цены = 58 575 прогонов):
база = сырая предыдущая точка вместо оставленной 136 неидемпотентных
прогонов, первый же (1M, 10M, 1.05M, 9.9M) при цене 1M: [1M, 9.9M] [1M];
решение по первой точке на сырых соседях вместо оставшихся 1122 прогона,
первый (1M, 10M, 100M) при цене 10M: [1M, 10M] [10M].
решение по первой точке на сырых соседях вместо оставшихся 1116 прогонов,
первый (1M, 10M, 100M, 10M) без текущей цены: [1M, 10M, 10M] [10M, 10M].
"""
# Гейт логирует каждый выброс; на таком переборе это десятки тысяч записей.
caplog.set_level(logging.CRITICAL, logger="scraper_kit.offer_price_history")

View file

@ -18,8 +18,10 @@ loader клал поле источника ``diff``, а там рубли. Пр
Здесь же живёт второй гейт той же границы записи drop_decimal_slips (#3376):
он выбрасывает из СЕРИИ точки с потерянным разрядом источника (×10/÷10 с
подтверждением у соседей), а recompute_diff_percent чинит процент у соседа
выброшенной точки. Оба вызываются писателями истории (domclick/detail.py,
cian/detail.py) и зеркалятся миграцией 286.
выброшенной точки. Оба вызываются писателями на живом тракте (domclick/detail.py,
cian/detail.py) и зеркалятся миграцией 286. Загейчены именно они: разовый
scripts/local-cian/playwright_history.py пишет мимо гейта, но переиграть миграцию
не может он выбирает только объявления вообще БЕЗ offer_price_history.
"""
from __future__ import annotations
@ -103,12 +105,20 @@ def _is_decimal_slip(prev: float, cur: float, nxt: float | None) -> bool:
def _is_first_point_slip(first: float, second: float, witness: float | None) -> bool:
"""Первая точка серии = сдвиг разряда: у неё нет prev, свидетели справа.
Прод-разбор 06.09.2026: 12 из 20 остатков domklik серии вида
``330 000 3 300 000`` (текущая цена 3 300 000) и ``420 000 4 200 000
4 500 000``. Дефектная точка ПЕРВАЯ, основное правило её не видит: базы слева
нет. Свидетелей по-прежнему двое ×10 ко ВТОРОЙ точке и подтверждение второй
точки третьей-или-текущей-ценой. Одного скачка мало: ``400 000 4 000 000
8 000 000`` это разгон цены, а не потерянный разряд.
Прод-разбор 06.09.2026: серии вида ``420 000 4 200 000 4 500 000``.
Дефектная точка ПЕРВАЯ, основное правило её не видит: базы слева нет.
Свидетелей по-прежнему двое ×10 ко ВТОРОЙ точке и подтверждение второй точки
ТРЕТЬЕЙ. Одного скачка мало: ``400 000 4 000 000 8 000 000`` это разгон
цены, а не потерянный разряд.
Свидетель здесь только третья точка истории; текущей цены объявления в этом
правиле нет СОЗНАТЕЛЬНО. У 17 из 21 прод-кандидата (yandex 6, domklik 9,
cian 2) ``listings.price_rub`` точно равнялась второй точке у yandex это
буквально одна переменная ``lot.price_rub``, записанная в двух местах. Такой
«второй свидетель» то же самое наблюдение, и два свидетеля вырождаются в
одного, а выброс точки необратим. У ПОСЛЕДНЕЙ точки (_is_decimal_slip) цена
объявления свидетелем остаётся: там она сравнивается с базой СЛЕВА, то есть с
другим наблюдением.
"""
if not _shifted_by_decade(first, second) or witness is None:
return False
@ -124,16 +134,16 @@ def drop_decimal_slips(
) -> tuple[list[T], int]:
"""Убрать из серии точки с потерянным разрядом (#3376).
Прод-замер 06.09.2026: 76 строк-выбросов у 55 объявлений во всех источниках
кроме avito ``prev=377000 3770000 next=3720000``. Это не рынок, а разряд,
Вид дефекта: ``prev=377000 3770000 next=3720000``. Это не рынок, а разряд,
потерянный в priceHistory источника: цена возвращается к базе следующей же
точкой. Читатели таблицы (медианный торг #3223, админка, /scrapers) получают
от такой точки выброс в сотни процентов.
от такой точки выброс в сотни процентов. Прод-замер 06.09.2026 под ЭТИМ
критерием: 38 строк (cian 12, domklik 26, yandex 0).
Критерий требует ДВУХ свидетелей: скачок ×10 к предыдущей точке И возврат к
базе у следующей. Одного скачка мало так выглядит и честная смена цены.
У ПЕРВОЙ точки предыдущей нет, для неё работает зеркальное правило
(_is_first_point_slip) оба свидетеля справа.
(_is_first_point_slip) оба свидетеля справа, и оба из самой истории.
ИДЕМПОТЕНТНОСТЬ свойство, а не пожелание: ``gate(gate(s)) == gate(s)``.
Его держит property-тест перебором серий длины 2-6 (см.
@ -152,8 +162,9 @@ def drop_decimal_slips(
Args:
points: точки одного объявления, упорядоченные по времени (возрастание).
price_of: как достать цену из точки (у писателей разная форма точки).
current_price: текущая цена объявления второй свидетель для ПОСЛЕДНЕЙ
точки, у которой нет следующей. None последнюю точку не трогаем.
current_price: текущая цена объявления второй свидетель ТОЛЬКО для
ПОСЛЕДНЕЙ точки, у которой нет следующей. None последнюю точку не
трогаем. В правиле первой точки не участвует (_is_first_point_slip).
listing_id: только для логов.
Returns:
@ -188,9 +199,11 @@ def drop_decimal_slips(
prev = cur
# Первая точка — последней: её свидетели это уже ОСТАВШИЕСЯ вторая и третья
# точки (см. про идемпотентность выше).
# точки (см. про идемпотентность выше). Третьей нет → свидетеля нет: current_price
# здесь не подставляем, она слишком часто копия второй точки (см.
# _is_first_point_slip).
if len(kept_prices) >= 2 and kept_prices[0] is not None and kept_prices[1] is not None:
witness = kept_prices[2] if len(kept_prices) >= 3 else now_price
witness = kept_prices[2] if len(kept_prices) >= 3 else None
if _is_first_point_slip(kept_prices[0], kept_prices[1], witness):
logger.warning(
"offer_price_history: первая точка %s выброшена как сдвиг разряда "

View file

@ -475,6 +475,10 @@ def save_detail_enrichment(
# Сдвиги разряда (#3376) выбрасываем до вставки; гейту нужна серия по возрастанию
# времени, а _extract_price_changes порядок источника не гарантирует. Второй
# свидетель для последней точки — цена в listings (_snap_row, читалась выше).
# ponytail: сортировка лексикографическая по str(change_time). На наблюдаемом
# формате cian (`2026-06-11T08:23:11.950626Z`, всегда одна зона) она совпадает с
# хронологией; на смешанных форматах/зонах разойдётся с миграцией 286, которая
# сортирует timestamptz. Апгрейд — парсить в datetime, если формат поедет.
_changes = sorted(
(c for c in enrichment.price_changes if c.get("change_time") and c.get("price_rub")),
key=lambda c: str(c["change_time"]),

View file

@ -27,17 +27,16 @@ SET LOCAL lock_timeout = '5s';
-- SELECT не выражается — его считает DO-блок ниже. А эти два выражаются.
-- (а) кандидаты правила ПЕРВОЙ точки по СЫРЫМ строкам, до удалений фазы 1.
-- Ожидание из шапки 286: ~12, из них домклик большинство.
-- Ожидание из шапки 286: 4, все domklik (yandex 0 — их свидетелем была бы
-- listings.price_rub, а откат на неё снят).
SELECT 'предзамер: первая точка (сырые строки)' AS metric, k.source, count(*)
FROM (
SELECT oph.source,
oph.price_rub,
row_number() OVER w AS rn,
lead(oph.price_rub) OVER w AS second_price,
lead(oph.price_rub, 2) OVER w AS third_price,
l.price_rub AS listing_price
lead(oph.price_rub, 2) OVER w AS third_price
FROM offer_price_history oph
LEFT JOIN listings l ON l.id = oph.listing_id
WHERE oph.change_time <> oph.recorded_at
WINDOW w AS (PARTITION BY oph.listing_id ORDER BY oph.change_time, oph.id)
) k
@ -46,8 +45,8 @@ SELECT 'предзамер: первая точка (сырые строки)' A
AND k.second_price > 0
AND (k.second_price / k.price_rub BETWEEN 9.5 AND 10.5
OR k.price_rub / k.second_price BETWEEN 9.5 AND 10.5)
AND COALESCE(k.third_price, k.listing_price) > 0
AND COALESCE(k.third_price, k.listing_price) / k.second_price BETWEEN 0.9 AND 1.1
AND k.third_price > 0
AND k.third_price / k.second_price BETWEEN 0.9 AND 1.1
GROUP BY k.source
ORDER BY k.source;
@ -153,10 +152,8 @@ FOR pass_no IN 1..2 LOOP
row_number() OVER w AS rn,
lead(oph.id) OVER w AS second_id,
lead(oph.price_rub) OVER w AS second_price,
lead(oph.price_rub, 2) OVER w AS third_price,
l.price_rub AS listing_price
lead(oph.price_rub, 2) OVER w AS third_price
FROM offer_price_history oph
LEFT JOIN listings l ON l.id = oph.listing_id
WHERE oph.change_time <> oph.recorded_at
AND NOT EXISTS (SELECT 1 FROM oph_decimal_slips s WHERE s.id = oph.id)
WINDOW w AS (PARTITION BY oph.listing_id ORDER BY oph.change_time, oph.id)
@ -168,8 +165,9 @@ FOR pass_no IN 1..2 LOOP
AND k.second_price > 0
AND (k.second_price / k.price_rub BETWEEN 9.5 AND 10.5
OR k.price_rub / k.second_price BETWEEN 9.5 AND 10.5)
AND COALESCE(k.third_price, k.listing_price) > 0
AND COALESCE(k.third_price, k.listing_price) / k.second_price BETWEEN 0.9 AND 1.1
-- Свидетель — только третья точка истории (см. шапку 286).
AND k.third_price > 0
AND k.third_price / k.second_price BETWEEN 0.9 AND 1.1
AND NOT EXISTS (
SELECT 1
FROM offer_price_history t