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
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:
parent
dc63b1830b
commit
1b86ba7e06
5 changed files with 98 additions and 61 deletions
|
|
@ -14,8 +14,10 @@
|
||||||
-- эти точки не влияли.
|
-- эти точки не влияли.
|
||||||
--
|
--
|
||||||
-- Код починен в том же PR: гейт drop_decimal_slips в scraper_kit/offer_price_history.py
|
-- Код починен в том же 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, А НЕ «ТОТ ЖЕ КРИТЕРИЙ»
|
-- ВЫБОРКА КАНДИДАТОВ ЗЕРКАЛИТ ГЕЙТ 1:1, А НЕ «ТОТ ЖЕ КРИТЕРИЙ»
|
||||||
-- Первая редакция этого файла брала предыдущую точку через lag(), то есть СЫРУЮ
|
-- Первая редакция этого файла брала предыдущую точку через lag(), то есть СЫРУЮ
|
||||||
|
|
@ -35,21 +37,30 @@
|
||||||
-- КРИТЕРИЙ — ДВА СВИДЕТЕЛЯ, И У ПЕРВОЙ ТОЧКИ ТОЖЕ ДВА
|
-- КРИТЕРИЙ — ДВА СВИДЕТЕЛЯ, И У ПЕРВОЙ ТОЧКИ ТОЖЕ ДВА
|
||||||
-- Внутри серии: 1) скачок к предыдущей ОСТАВЛЕННОЙ точке, price/base ∈ [9.5, 10.5]
|
-- Внутри серии: 1) скачок к предыдущей ОСТАВЛЕННОЙ точке, price/base ∈ [9.5, 10.5]
|
||||||
-- в любую сторону; 2) возврат к базе у следующей точки, next/base ∈ [0.9, 1.1].
|
-- в любую сторону; 2) возврат к базе у следующей точки, next/base ∈ [0.9, 1.1].
|
||||||
-- Первая точка: 1) она ×10/÷10 ко второй оставшейся; 2) вторая подтверждена третьей
|
-- Первая точка: 1) она ×10/÷10 ко второй оставшейся; 2) вторая подтверждена ТРЕТЬЕЙ
|
||||||
-- оставшейся, а если третьей нет — текущей ценой объявления, witness/second ∈ [0.9, 1.1].
|
-- оставшейся, third/second ∈ [0.9, 1.1]. Третьей нет → кандидата нет.
|
||||||
-- Окно шире чистой десятки, потому что сдвиг разряда часто идёт вместе с настоящим
|
-- Окно шире чистой десятки, потому что сдвиг разряда часто идёт вместе с настоящим
|
||||||
-- мелким изменением цены (377 000 → 3 720 000 это ×9.87). Одного скачка НЕ хватает:
|
-- мелким изменением цены (377 000 → 3 720 000 это ×9.87). Одного скачка НЕ хватает:
|
||||||
-- ×10 без подтверждения — это возможное честное изменение цены (400 000 → 4 000 000 →
|
-- ×10 без подтверждения — это возможное честное изменение цены (400 000 → 4 000 000 →
|
||||||
-- 8 000 000 — разгон, а не разряд), и такие точки остаются. Нет второго свидетеля
|
-- 8 000 000 — разгон, а не разряд), и такие точки остаются. Нет второго свидетеля
|
||||||
-- вообще (последняя точка серии, а listings.price_rub пуст) → точку не трогаем.
|
-- вообще (последняя точка серии, а 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 строк внутри
|
-- Замер на проде 06.09.2026 ровно под ЭТИМ критерием: 34 строки внутри серий
|
||||||
-- серий + ~12 первых точек (12 из 20 остатков domklik — ровно вид 330 000 → 3 300 000)
|
-- (cian 12, domklik 22) + 4 первых точки по третьей точке (domklik 4) = 38.
|
||||||
-- ≈ 47. Прежняя цифра 76 в шапке была из ОДНОСВИДЕТЕЛЬСКОГО замера (просто отношение
|
-- По источникам: cian 12, domklik 26, yandex 0 — yandex целиком отпал вместе с
|
||||||
-- к предыдущей строке) и к этому критерию отношения не имеет. Порог остановки 200 —
|
-- откатом на listings.price_rub (см. выше), его случай уехал в #3385.
|
||||||
-- четырёхкратный запас: поймали больше — критерий ловит не то, и миграция обязана
|
-- Порог остановки 200 — примерно пятикратный запас: поймали больше — критерий
|
||||||
-- упасть, а не молча вычистить историю.
|
-- ловит не то, и миграция обязана упасть, а не молча вычистить историю.
|
||||||
--
|
--
|
||||||
-- ТОЛЬКО СТРОКИ ЗАГРУЗЧИКА: change_time <> recorded_at
|
-- ТОЛЬКО СТРОКИ ЗАГРУЗЧИКА: change_time <> recorded_at
|
||||||
-- Признак происхождения тот же, что в 285 (там же его доказательство): триггер
|
-- Признак происхождения тот же, что в 285 (там же его доказательство): триггер
|
||||||
|
|
@ -65,6 +76,9 @@
|
||||||
-- же цена есть у ТРИГГЕРНОЙ строки того же объявления — значит listings.price_rub
|
-- же цена есть у ТРИГГЕРНОЙ строки того же объявления — значит listings.price_rub
|
||||||
-- когда-то реально равнялась этому значению, и подозреваемая точка загрузчика
|
-- когда-то реально равнялась этому значению, и подозреваемая точка загрузчика
|
||||||
-- подтверждена независимым писателем. Такую не удаляем и оставляем в цепочке базой.
|
-- подтверждена независимым писателем. Такую не удаляем и оставляем в цепочке базой.
|
||||||
|
-- ЧЕСТНО ПРО ЕГО ВЕС: на прод-данных 06.09.2026 он не спас НИ ОДНОЙ строки (0 из 21
|
||||||
|
-- кандидата правила первой точки). Механизм проверен только на синтетике — держим
|
||||||
|
-- как страховку на будущих прогонах, а не как замеренную защиту.
|
||||||
--
|
--
|
||||||
-- ПЕРЕСЧЁТ diff_percent У СЛЕДУЮЩЕЙ СТРОКИ
|
-- ПЕРЕСЧЁТ diff_percent У СЛЕДУЮЩЕЙ СТРОКИ
|
||||||
-- У строки, шедшей за удалённой, предыдущая цена сменилась — процент, посчитанный
|
-- У строки, шедшей за удалённой, предыдущая цена сменилась — процент, посчитанный
|
||||||
|
|
@ -187,10 +201,8 @@ WITH kept AS (
|
||||||
row_number() OVER w AS rn,
|
row_number() OVER w AS rn,
|
||||||
lead(oph.id) OVER w AS second_id,
|
lead(oph.id) OVER w AS second_id,
|
||||||
lead(oph.price_rub) OVER w AS second_price,
|
lead(oph.price_rub) OVER w AS second_price,
|
||||||
lead(oph.price_rub, 2) OVER w AS third_price,
|
lead(oph.price_rub, 2) OVER w AS third_price
|
||||||
l.price_rub AS listing_price
|
|
||||||
FROM offer_price_history oph
|
FROM offer_price_history oph
|
||||||
LEFT JOIN listings l ON l.id = oph.listing_id
|
|
||||||
WHERE oph.change_time <> oph.recorded_at
|
WHERE oph.change_time <> oph.recorded_at
|
||||||
AND NOT EXISTS (SELECT 1 FROM oph_decimal_slips s WHERE s.id = oph.id)
|
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)
|
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 > 0
|
||||||
AND (k.second_price / k.price_rub BETWEEN 9.5 AND 10.5
|
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)
|
OR k.price_rub / k.second_price BETWEEN 9.5 AND 10.5)
|
||||||
AND COALESCE(k.third_price, k.listing_price) > 0
|
-- Свидетель — только ТРЕТЬЯ точка истории. listings.price_rub здесь НЕ
|
||||||
AND COALESCE(k.third_price, k.listing_price) / k.second_price BETWEEN 0.9 AND 1.1
|
-- подставляется: у 17 из 21 кандидата она в точности равна второй точке,
|
||||||
|
-- то есть это то же наблюдение, а не второй свидетель (см. шапку).
|
||||||
|
AND k.third_price > 0
|
||||||
|
AND k.third_price / k.second_price BETWEEN 0.9 AND 1.1
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM offer_price_history t
|
FROM offer_price_history t
|
||||||
|
|
@ -236,7 +251,8 @@ BEGIN
|
||||||
|
|
||||||
IF to_delete > 200 THEN
|
IF to_delete > 200 THEN
|
||||||
RAISE EXCEPTION
|
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;
|
'критерий ловит не то, миграция остановлена', to_delete;
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -737,9 +737,9 @@ def test_validate_diff_percent_bool_treated_as_none() -> None:
|
||||||
|
|
||||||
|
|
||||||
# ── drop_decimal_slips (#3376) — сдвиг разряда в priceHistory источника ───────
|
# ── drop_decimal_slips (#3376) — сдвиг разряда в priceHistory источника ───────
|
||||||
# Прод-замер 06.09.2026: 76 точек ровно ×10/÷10 к соседям у 55 объявлений
|
# Прод-замер 06.09.2026 под ЭТИМ критерием: 38 точек (cian 12, domklik 26,
|
||||||
# (domklik 48, cian 21, yandex 6). Вид: 377 000 → 3 770 000 → 3 720 000 — цена
|
# yandex 0). Вид: 377 000 → 3 770 000 → 3 720 000 — цена возвращается к базе
|
||||||
# возвращается к базе следующей же точкой.
|
# следующей же точкой.
|
||||||
|
|
||||||
|
|
||||||
def test_drop_decimal_slips_drops_spike_up(caplog: pytest.LogCaptureFixture) -> None:
|
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) ─────────────────────
|
# ── правило ПЕРВОЙ точки (#3376, прод-разбор 06.09.2026) ─────────────────────
|
||||||
# 12 из 20 остатков domklik — серии, у которых дефектная точка первая: 330 000 →
|
# Серии, у которых дефектная точка первая: 420 000 → 4 200 000 → 4 500 000. Слева
|
||||||
# 3 300 000 (текущая цена 3 300 000), 420 000 → 4 200 000 → 4 500 000. Слева базы
|
# базы нет, основное правило такую точку не видит. Свидетелей по-прежнему двое, и
|
||||||
# нет, основное правило такую точку не видит. Свидетелей по-прежнему двое.
|
# оба — из самой истории: вторая точка и ТРЕТЬЯ. Кандидатов первой точки на проде
|
||||||
|
# было 21, свидетеля из истории имеют 4 (все domklik).
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
def test_drop_decimal_slips_drops_first_point_witnessed_by_third() -> None:
|
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)
|
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]
|
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)
|
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 выбрасывает основное правило, и только после этого
|
1M → 10M → 100M → 10M: 100M выбрасывает основное правило, и только после этого
|
||||||
видно, что первая точка ÷10 к оставшейся серии [10M, 10M]. Считай правило по
|
видно, что первая точка ÷10 к оставшейся серии [10M, 10M]. Считай правило по
|
||||||
сырым соседям — первый проход оставил бы 1M, а второй выбросил, то есть гейт
|
сырым соседям — первый проход оставил бы 1M, а второй выбросил, то есть гейт
|
||||||
перестал бы быть идемпотентным (перебор ниже ловит 1122 такие серии).
|
перестал бы быть идемпотентным (перебор ниже ловит 1116 таких прогонов, и эта
|
||||||
|
серия — первый из них).
|
||||||
"""
|
"""
|
||||||
kept, dropped = drop_decimal_slips(
|
kept, dropped = drop_decimal_slips(
|
||||||
[1_000_000, 10_000_000, 100_000_000, 10_000_000], lambda p: p
|
[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-файле нечем — прод-прогон
|
пошагово, а доказать идемпотентность на самом SQL-файле нечем — прод-прогон
|
||||||
один. Раз выборка кандидатов повторяет гейт, свойство переносится на неё.
|
один. Раз выборка кандидатов повторяет гейт, свойство переносится на неё.
|
||||||
|
|
||||||
ФАЛЬСИФИЦИРУЕМОСТЬ (замерено на этом же переборе — 19 525 серий × 3 цены):
|
ФАЛЬСИФИЦИРУЕМОСТЬ (перемерено 06.09.2026 на этом же переборе — 19 525 серий
|
||||||
|
× 3 цены = 58 575 прогонов):
|
||||||
• база = сырая предыдущая точка вместо оставленной → 136 неидемпотентных
|
• база = сырая предыдущая точка вместо оставленной → 136 неидемпотентных
|
||||||
прогонов, первый же — (1M, 10M, 1.05M, 9.9M) при цене 1M: [1M, 9.9M] → [1M];
|
прогонов, первый же — (1M, 10M, 1.05M, 9.9M) при цене 1M: [1M, 9.9M] → [1M];
|
||||||
• решение по первой точке на сырых соседях вместо оставшихся → 1122 прогона,
|
• решение по первой точке на сырых соседях вместо оставшихся → 1116 прогонов,
|
||||||
первый — (1M, 10M, 100M) при цене 10M: [1M, 10M] → [10M].
|
первый — (1M, 10M, 100M, 10M) без текущей цены: [1M, 10M, 10M] → [10M, 10M].
|
||||||
"""
|
"""
|
||||||
# Гейт логирует каждый выброс; на таком переборе это десятки тысяч записей.
|
# Гейт логирует каждый выброс; на таком переборе это десятки тысяч записей.
|
||||||
caplog.set_level(logging.CRITICAL, logger="scraper_kit.offer_price_history")
|
caplog.set_level(logging.CRITICAL, logger="scraper_kit.offer_price_history")
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,10 @@ loader клал поле источника ``diff``, а там рубли. Пр
|
||||||
Здесь же живёт второй гейт той же границы записи — drop_decimal_slips (#3376):
|
Здесь же живёт второй гейт той же границы записи — drop_decimal_slips (#3376):
|
||||||
он выбрасывает из СЕРИИ точки с потерянным разрядом источника (×10/÷10 с
|
он выбрасывает из СЕРИИ точки с потерянным разрядом источника (×10/÷10 с
|
||||||
подтверждением у соседей), а recompute_diff_percent чинит процент у соседа
|
подтверждением у соседей), а recompute_diff_percent чинит процент у соседа
|
||||||
выброшенной точки. Оба вызываются писателями истории (domclick/detail.py,
|
выброшенной точки. Оба вызываются писателями на живом тракте (domclick/detail.py,
|
||||||
cian/detail.py) и зеркалятся миграцией 286.
|
cian/detail.py) и зеркалятся миграцией 286. Загейчены именно они: разовый
|
||||||
|
scripts/local-cian/playwright_history.py пишет мимо гейта, но переиграть миграцию
|
||||||
|
не может — он выбирает только объявления вообще БЕЗ offer_price_history.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
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:
|
def _is_first_point_slip(first: float, second: float, witness: float | None) -> bool:
|
||||||
"""Первая точка серии = сдвиг разряда: у неё нет prev, свидетели справа.
|
"""Первая точка серии = сдвиг разряда: у неё нет prev, свидетели справа.
|
||||||
|
|
||||||
Прод-разбор 06.09.2026: 12 из 20 остатков domklik — серии вида
|
Прод-разбор 06.09.2026: серии вида ``420 000 → 4 200 000 → 4 500 000``.
|
||||||
``330 000 → 3 300 000`` (текущая цена 3 300 000) и ``420 000 → 4 200 000 →
|
Дефектная точка ПЕРВАЯ, основное правило её не видит: базы слева нет.
|
||||||
4 500 000``. Дефектная точка ПЕРВАЯ, основное правило её не видит: базы слева
|
Свидетелей по-прежнему двое — ×10 ко ВТОРОЙ точке и подтверждение второй точки
|
||||||
нет. Свидетелей по-прежнему двое — ×10 ко ВТОРОЙ точке и подтверждение второй
|
ТРЕТЬЕЙ. Одного скачка мало: ``400 000 → 4 000 000 → 8 000 000`` это разгон
|
||||||
точки третьей-или-текущей-ценой. Одного скачка мало: ``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:
|
if not _shifted_by_decade(first, second) or witness is None:
|
||||||
return False
|
return False
|
||||||
|
|
@ -124,16 +134,16 @@ def drop_decimal_slips(
|
||||||
) -> tuple[list[T], int]:
|
) -> tuple[list[T], int]:
|
||||||
"""Убрать из серии точки с потерянным разрядом (#3376).
|
"""Убрать из серии точки с потерянным разрядом (#3376).
|
||||||
|
|
||||||
Прод-замер 06.09.2026: 76 строк-выбросов у 55 объявлений во всех источниках
|
Вид дефекта: ``prev=377000 → 3770000 → next=3720000``. Это не рынок, а разряд,
|
||||||
кроме avito — ``prev=377000 → 3770000 → next=3720000``. Это не рынок, а разряд,
|
|
||||||
потерянный в priceHistory источника: цена возвращается к базе следующей же
|
потерянный в priceHistory источника: цена возвращается к базе следующей же
|
||||||
точкой. Читатели таблицы (медианный торг #3223, админка, /scrapers) получают
|
точкой. Читатели таблицы (медианный торг #3223, админка, /scrapers) получают
|
||||||
от такой точки выброс в сотни процентов.
|
от такой точки выброс в сотни процентов. Прод-замер 06.09.2026 под ЭТИМ
|
||||||
|
критерием: 38 строк (cian 12, domklik 26, yandex 0).
|
||||||
|
|
||||||
Критерий требует ДВУХ свидетелей: скачок ×10 к предыдущей точке И возврат к
|
Критерий требует ДВУХ свидетелей: скачок ×10 к предыдущей точке И возврат к
|
||||||
базе у следующей. Одного скачка мало — так выглядит и честная смена цены.
|
базе у следующей. Одного скачка мало — так выглядит и честная смена цены.
|
||||||
У ПЕРВОЙ точки предыдущей нет, для неё работает зеркальное правило
|
У ПЕРВОЙ точки предыдущей нет, для неё работает зеркальное правило
|
||||||
(_is_first_point_slip) — оба свидетеля справа.
|
(_is_first_point_slip) — оба свидетеля справа, и оба из самой истории.
|
||||||
|
|
||||||
ИДЕМПОТЕНТНОСТЬ — свойство, а не пожелание: ``gate(gate(s)) == gate(s)``.
|
ИДЕМПОТЕНТНОСТЬ — свойство, а не пожелание: ``gate(gate(s)) == gate(s)``.
|
||||||
Его держит property-тест перебором серий длины 2-6 (см.
|
Его держит property-тест перебором серий длины 2-6 (см.
|
||||||
|
|
@ -152,8 +162,9 @@ def drop_decimal_slips(
|
||||||
Args:
|
Args:
|
||||||
points: точки одного объявления, упорядоченные по времени (возрастание).
|
points: точки одного объявления, упорядоченные по времени (возрастание).
|
||||||
price_of: как достать цену из точки (у писателей разная форма точки).
|
price_of: как достать цену из точки (у писателей разная форма точки).
|
||||||
current_price: текущая цена объявления — второй свидетель для ПОСЛЕДНЕЙ
|
current_price: текущая цена объявления — второй свидетель ТОЛЬКО для
|
||||||
точки, у которой нет следующей. None → последнюю точку не трогаем.
|
ПОСЛЕДНЕЙ точки, у которой нет следующей. None → последнюю точку не
|
||||||
|
трогаем. В правиле первой точки не участвует (_is_first_point_slip).
|
||||||
listing_id: только для логов.
|
listing_id: только для логов.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|
@ -188,9 +199,11 @@ def drop_decimal_slips(
|
||||||
prev = cur
|
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:
|
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):
|
if _is_first_point_slip(kept_prices[0], kept_prices[1], witness):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"offer_price_history: первая точка %s выброшена как сдвиг разряда "
|
"offer_price_history: первая точка %s выброшена как сдвиг разряда "
|
||||||
|
|
|
||||||
|
|
@ -475,6 +475,10 @@ def save_detail_enrichment(
|
||||||
# Сдвиги разряда (#3376) выбрасываем до вставки; гейту нужна серия по возрастанию
|
# Сдвиги разряда (#3376) выбрасываем до вставки; гейту нужна серия по возрастанию
|
||||||
# времени, а _extract_price_changes порядок источника не гарантирует. Второй
|
# времени, а _extract_price_changes порядок источника не гарантирует. Второй
|
||||||
# свидетель для последней точки — цена в listings (_snap_row, читалась выше).
|
# свидетель для последней точки — цена в listings (_snap_row, читалась выше).
|
||||||
|
# ponytail: сортировка лексикографическая по str(change_time). На наблюдаемом
|
||||||
|
# формате cian (`2026-06-11T08:23:11.950626Z`, всегда одна зона) она совпадает с
|
||||||
|
# хронологией; на смешанных форматах/зонах разойдётся с миграцией 286, которая
|
||||||
|
# сортирует timestamptz. Апгрейд — парсить в datetime, если формат поедет.
|
||||||
_changes = sorted(
|
_changes = sorted(
|
||||||
(c for c in enrichment.price_changes if c.get("change_time") and c.get("price_rub")),
|
(c for c in enrichment.price_changes if c.get("change_time") and c.get("price_rub")),
|
||||||
key=lambda c: str(c["change_time"]),
|
key=lambda c: str(c["change_time"]),
|
||||||
|
|
|
||||||
|
|
@ -27,17 +27,16 @@ SET LOCAL lock_timeout = '5s';
|
||||||
-- SELECT не выражается — его считает DO-блок ниже. А эти два выражаются.
|
-- SELECT не выражается — его считает DO-блок ниже. А эти два выражаются.
|
||||||
|
|
||||||
-- (а) кандидаты правила ПЕРВОЙ точки по СЫРЫМ строкам, до удалений фазы 1.
|
-- (а) кандидаты правила ПЕРВОЙ точки по СЫРЫМ строкам, до удалений фазы 1.
|
||||||
-- Ожидание из шапки 286: ~12, из них домклик большинство.
|
-- Ожидание из шапки 286: 4, все domklik (yandex 0 — их свидетелем была бы
|
||||||
|
-- listings.price_rub, а откат на неё снят).
|
||||||
SELECT 'предзамер: первая точка (сырые строки)' AS metric, k.source, count(*)
|
SELECT 'предзамер: первая точка (сырые строки)' AS metric, k.source, count(*)
|
||||||
FROM (
|
FROM (
|
||||||
SELECT oph.source,
|
SELECT oph.source,
|
||||||
oph.price_rub,
|
oph.price_rub,
|
||||||
row_number() OVER w AS rn,
|
row_number() OVER w AS rn,
|
||||||
lead(oph.price_rub) OVER w AS second_price,
|
lead(oph.price_rub) OVER w AS second_price,
|
||||||
lead(oph.price_rub, 2) OVER w AS third_price,
|
lead(oph.price_rub, 2) OVER w AS third_price
|
||||||
l.price_rub AS listing_price
|
|
||||||
FROM offer_price_history oph
|
FROM offer_price_history oph
|
||||||
LEFT JOIN listings l ON l.id = oph.listing_id
|
|
||||||
WHERE oph.change_time <> oph.recorded_at
|
WHERE oph.change_time <> oph.recorded_at
|
||||||
WINDOW w AS (PARTITION BY oph.listing_id ORDER BY oph.change_time, oph.id)
|
WINDOW w AS (PARTITION BY oph.listing_id ORDER BY oph.change_time, oph.id)
|
||||||
) k
|
) k
|
||||||
|
|
@ -46,8 +45,8 @@ SELECT 'предзамер: первая точка (сырые строки)' A
|
||||||
AND k.second_price > 0
|
AND k.second_price > 0
|
||||||
AND (k.second_price / k.price_rub BETWEEN 9.5 AND 10.5
|
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)
|
OR k.price_rub / k.second_price BETWEEN 9.5 AND 10.5)
|
||||||
AND COALESCE(k.third_price, k.listing_price) > 0
|
AND k.third_price > 0
|
||||||
AND COALESCE(k.third_price, k.listing_price) / k.second_price BETWEEN 0.9 AND 1.1
|
AND k.third_price / k.second_price BETWEEN 0.9 AND 1.1
|
||||||
GROUP BY k.source
|
GROUP BY k.source
|
||||||
ORDER BY k.source;
|
ORDER BY k.source;
|
||||||
|
|
||||||
|
|
@ -153,10 +152,8 @@ FOR pass_no IN 1..2 LOOP
|
||||||
row_number() OVER w AS rn,
|
row_number() OVER w AS rn,
|
||||||
lead(oph.id) OVER w AS second_id,
|
lead(oph.id) OVER w AS second_id,
|
||||||
lead(oph.price_rub) OVER w AS second_price,
|
lead(oph.price_rub) OVER w AS second_price,
|
||||||
lead(oph.price_rub, 2) OVER w AS third_price,
|
lead(oph.price_rub, 2) OVER w AS third_price
|
||||||
l.price_rub AS listing_price
|
|
||||||
FROM offer_price_history oph
|
FROM offer_price_history oph
|
||||||
LEFT JOIN listings l ON l.id = oph.listing_id
|
|
||||||
WHERE oph.change_time <> oph.recorded_at
|
WHERE oph.change_time <> oph.recorded_at
|
||||||
AND NOT EXISTS (SELECT 1 FROM oph_decimal_slips s WHERE s.id = oph.id)
|
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)
|
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 > 0
|
||||||
AND (k.second_price / k.price_rub BETWEEN 9.5 AND 10.5
|
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)
|
OR k.price_rub / k.second_price BETWEEN 9.5 AND 10.5)
|
||||||
AND COALESCE(k.third_price, k.listing_price) > 0
|
-- Свидетель — только третья точка истории (см. шапку 286).
|
||||||
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
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM offer_price_history t
|
FROM offer_price_history t
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue