From 1b86ba7e06283cd2f62a4ea79b9cfab6f1c780e9 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 6 Sep 2026 05:12:30 +0500 Subject: [PATCH] =?UTF-8?q?fix(#3376):=20=D0=BF=D0=B5=D1=80=D0=B2=D0=B0?= =?UTF-8?q?=D1=8F=20=D1=82=D0=BE=D1=87=D0=BA=D0=B0=20=E2=80=94=20=D1=82?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=BF=D0=BE=20=D1=82=D1=80?= =?UTF-8?q?=D0=B5=D1=82=D1=8C=D0=B5=D0=B9=20=D1=82=D0=BE=D1=87=D0=BA=D0=B5?= =?UTF-8?q?=20=D0=B8=D1=81=D1=82=D0=BE=D1=80=D0=B8=D0=B8;=20=D1=87=D0=B8?= =?UTF-8?q?=D1=81=D0=BB=D0=B0=20=D0=BF=D0=BE=D0=B4=20=D0=BA=D1=80=D0=B8?= =?UTF-8?q?=D1=82=D0=B5=D1=80=D0=B8=D0=B5=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../286_offer_price_history_decimal_slips.sql | 48 ++++++++++++------- .../tests/scrapers/test_domclick_detail.py | 42 +++++++++------- .../src/scraper_kit/offer_price_history.py | 45 ++++++++++------- .../src/scraper_kit/providers/cian/detail.py | 4 ++ tradein-mvp/scripts/sql/286_dryrun.sql | 20 ++++---- 5 files changed, 98 insertions(+), 61 deletions(-) diff --git a/tradein-mvp/backend/data/sql/286_offer_price_history_decimal_slips.sql b/tradein-mvp/backend/data/sql/286_offer_price_history_decimal_slips.sql index d6332393..1ef8bed9 100644 --- a/tradein-mvp/backend/data/sql/286_offer_price_history_decimal_slips.sql +++ b/tradein-mvp/backend/data/sql/286_offer_price_history_decimal_slips.sql @@ -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; diff --git a/tradein-mvp/backend/tests/scrapers/test_domclick_detail.py b/tradein-mvp/backend/tests/scrapers/test_domclick_detail.py index dad63111..e91512c7 100644 --- a/tradein-mvp/backend/tests/scrapers/test_domclick_detail.py +++ b/tradein-mvp/backend/tests/scrapers/test_domclick_detail.py @@ -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") diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/offer_price_history.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/offer_price_history.py index 8e8eb726..32585da0 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/offer_price_history.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/offer_price_history.py @@ -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 выброшена как сдвиг разряда " diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/detail.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/detail.py index 030345da..72bed942 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/detail.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/detail.py @@ -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"]), diff --git a/tradein-mvp/scripts/sql/286_dryrun.sql b/tradein-mvp/scripts/sql/286_dryrun.sql index 66e11266..dbb3f861 100644 --- a/tradein-mvp/scripts/sql/286_dryrun.sql +++ b/tradein-mvp/scripts/sql/286_dryrun.sql @@ -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