МЕРА: в истории цен Яндекса больше нет выдуманных «прошлых цен» в 10 и 1000 раз ниже #3564

Merged
bot-backend merged 1 commit from fix/yandex-price-decimal-slip into main 2026-09-17 09:17:29 +00:00
Collaborator

#3385 — ×10 в yandex-истории цен

Что было. На проде 17.09.2026 у yandex 7 скачков ×10 в offer_price_history (на 06.09 было 6, 02.09 прибавился 10558950). Issue предполагал, что ошибочна верхняя точка и ловить её надо «на следующем наблюдении», когда цена вернётся к базе.

Почему не так — проверено на проде (только SELECT):

  • Все 7 — вторая точка серии, а нижняя — синтетический посев из gate-поля price.previous: change_time ровно на 24 ч раньше второй точки, recorded_at у обеих один (один батч писателя). Ни одна серия к «базе» не вернулась.
  • Ошибочна нижняя: по ней 13.9–37.5 тыс/м², по верхней 139–375 тыс/м²; верхняя = listings.price_rub, а 10387563 дальше дешевеет от неё же (14M → 13M → 12M).
  • Ветка писателя «история есть» на проде не записала ни одной строки: из 34 296 yandex-строк 5 738 триггерные (change_time = recorded_at), 20 245 — первые точки, 8 313 — вторые точки посева, 0 — из этой ветки. Смену listings.price_rub раньше пишет триггер record_listing_price_change (save_listings идёт первым), и писатель видит latest == lot.price_rub. То есть отлов «на следующем наблюдении» в yandex_price_history.py стоял бы в неисполняемой ветке и поймал бы 0 из 7.
  • Тот же дефект шире ×10: среди всех 8 313 посевов ещё 4 с ×175…×1000 (10 900 → 10 900 000, 25 000 → 25 000 000, 5 699 → 5 650 000, 20 500 → 3 600 000: цена в тысячах / чужая сделка). Распределение отношений посевов: самый большой честный скачок ×2.13, между ×2.13 и ×9.74 пусто, «вниз» на порядок — 0.

Что сделано.

  1. tradein-mvp/backend/app/services/yandex_price_history.py: при пустой истории price.previous, отличающаяся от текущей цены в 9.5 раза и больше в любую сторону (или не > 0), не сеется; текущая точка пишется как раньше. Счётчик seed_rejected в итоговой строке лога + warning с source_id. Посев синтетический, так что теряется только фиктивная точка.
  2. Миграция 310_yandex_seed_decimal_slips.sql: DELETE yandex-посевов по подписи батча (s.recorded_at = o.recorded_at, s.change_time = o.change_time + interval '24 hours') и тому же порогу ≥ 9.5. SET LOCAL lock_timeout = '5s', остановка с откатом при > 30 строк. Тот же критерий SELECT-ом на проде 17.09 — ровно 11 строк (7 × 10 + 4 × 175…1000), id: 14295, 10353, 3435360, 8156846, 23142090, 23142518, 23144066, 23146347, 23146404, 23152732, 23174965. diff_percent у вторых строк у всех NULL (писатель его не заполняет) — пересчитывать нечего. Повторный прогон — 0.
  3. Отлов «на следующем наблюдении» из текста issue не делал: ветка, где он должен жить, на проде не исполняется (см. выше), а триггерные строки — это наблюдения listings.price_rub, их 286 сознательно не трогает.

Отклонение от плана триажа: он предлагал окно ×9.5–10.5 и 7 строк; с окном остались бы 4 посева ×175…×1000 того же происхождения, поэтому порог односторонний (≥ 9.5).

Тесты

  • uv run python -m pytest tests/test_yandex_price_history.py — 19 passed (новые: 4 отвергнутых посева на прод-ценах ×10/×9.74/×1000/÷10 → в истории одна текущая точка; 3 сохранённых ×1.06/×2.13/×9.45 → две точки).
  • tests/test_3385_migration_310_yandex_seed_slips.py — настоящий файл миграции на живом Postgres (временная offer_price_history затеняет реальную в сессии): удаляются только yandex-посевы ×10/×1000/÷10; остаются ×2.13, ×9.45, cian-посев, триггерные ×10 и более поздняя триггерная строка; второй прогон ничего не меняет; 31 пара → исключение и откат. Локально на postgres:16.4: 2 passed; проверено, что строки реальной public.offer_price_history при этом не тронуты. Без БД — skip, записан в skip_allowlist.txt; в ci-tradein.yml бегут по-настоящему.
  • Файл миграции дважды через psql -v ON_ERROR_STOP=on, как в деплое: удалено = 1, затем = 0, rc=0.
  • Весь сьют: DATABASE_URL=... uv run python -m pytest tests/ -q -p no:cacheprovider6232 passed, 44 skipped, rc=0. ruff check app tests — чисто; ruff format --check по изменённым — чисто; scripts/check-migration-lock-timeout.py — rc=0; test_migration_numbering.py — 3 passed.

Фальсификация

  • Гейт в коде снят (_implausible_seedreturn False): 4 failed, 15 passed, assert 2 == 1 у всех четырёх test_first_capture_previous_off_by_order_is_not_seeded[...], rc=1.
  • Из миграции убрана подпись батча (s.recorded_at = o.recorded_at): At index 10 diff: (7, 10000000) != (7, 1000000) — удалена триггерная строка, rc=1.
  • Окно сужено до BETWEEN 9.5 AND 10.5: At index 2 diff: (2, 10900) != (2, 10900000) — посев ×1000 выжил, rc=1.
  • Порог остановки > 30> 3000: Failed: DID NOT RAISE Exception, rc=1.
    Исходники восстановлены, diff -q пуст; после восстановления 21 passed.

Приёмка на проде

После деплоя (проверить не раньше, чем в логе деплоя будет → Applying migration: 310_yandex_seed_decimal_slips.sql):

  1. В логе деплоя NOTICE: offer_price_history: удалено yandex-посевов на порядок = 11 (больше 11 — только если до деплоя успели появиться новые посевы; > 30 — деплой красный, миграция откатилась).
  2. SELECT count(*) FROM offer_price_history o JOIN offer_price_history s ON s.source='yandex' AND s.listing_id=o.listing_id AND s.recorded_at=o.recorded_at AND s.change_time=o.change_time+interval '24 hours' WHERE o.source='yandex' AND greatest(o.price_rub,s.price_rub)/least(o.price_rub,s.price_rub) >= 9.5;0, и запрос скачков ×10 по соседним yandex-точкам из issue → 0.
  3. Новый код в контейнере: после ближайшего yandex_city_sweep в docker logs tradein-scraper строки yandex_price_history: lots=... seed_rejected=N (слова seed_rejected в старом коде нет).
  4. До 01.10.2026: запрос из п.2 по-прежнему 0.

Деплой пересоздаёт tradein-backend/tradein-scraper; перед мержем проверить scrape_runs.status='running'.

Closes #3385

🤖 Generated with Claude Code

## #3385 — ×10 в yandex-истории цен **Что было.** На проде 17.09.2026 у yandex 7 скачков ×10 в `offer_price_history` (на 06.09 было 6, 02.09 прибавился `10558950`). Issue предполагал, что ошибочна верхняя точка и ловить её надо «на следующем наблюдении», когда цена вернётся к базе. **Почему не так — проверено на проде (только SELECT):** - Все 7 — вторая точка серии, а нижняя — **синтетический посев** из gate-поля `price.previous`: `change_time` ровно на 24 ч раньше второй точки, `recorded_at` у обеих один (один батч писателя). Ни одна серия к «базе» не вернулась. - Ошибочна **нижняя**: по ней 13.9–37.5 тыс/м², по верхней 139–375 тыс/м²; верхняя = `listings.price_rub`, а `10387563` дальше дешевеет от неё же (14M → 13M → 12M). - Ветка писателя «история есть» на проде **не записала ни одной строки**: из 34 296 yandex-строк 5 738 триггерные (`change_time = recorded_at`), 20 245 — первые точки, 8 313 — вторые точки посева, 0 — из этой ветки. Смену `listings.price_rub` раньше пишет триггер `record_listing_price_change` (save_listings идёт первым), и писатель видит `latest == lot.price_rub`. То есть отлов «на следующем наблюдении» в `yandex_price_history.py` стоял бы в неисполняемой ветке и поймал бы 0 из 7. - Тот же дефект шире ×10: среди всех 8 313 посевов ещё 4 с ×175…×1000 (`10 900 → 10 900 000`, `25 000 → 25 000 000`, `5 699 → 5 650 000`, `20 500 → 3 600 000`: цена в тысячах / чужая сделка). Распределение отношений посевов: самый большой честный скачок ×2.13, между ×2.13 и ×9.74 пусто, «вниз» на порядок — 0. **Что сделано.** 1. `tradein-mvp/backend/app/services/yandex_price_history.py`: при пустой истории `price.previous`, отличающаяся от текущей цены в 9.5 раза и больше в любую сторону (или не > 0), не сеется; текущая точка пишется как раньше. Счётчик `seed_rejected` в итоговой строке лога + warning с `source_id`. Посев синтетический, так что теряется только фиктивная точка. 2. Миграция `310_yandex_seed_decimal_slips.sql`: `DELETE` yandex-посевов по подписи батча (`s.recorded_at = o.recorded_at`, `s.change_time = o.change_time + interval '24 hours'`) и тому же порогу ≥ 9.5. `SET LOCAL lock_timeout = '5s'`, остановка с откатом при > 30 строк. Тот же критерий SELECT-ом на проде 17.09 — **ровно 11 строк** (7 × 10 + 4 × 175…1000), id: 14295, 10353, 3435360, 8156846, 23142090, 23142518, 23144066, 23146347, 23146404, 23152732, 23174965. `diff_percent` у вторых строк у всех NULL (писатель его не заполняет) — пересчитывать нечего. Повторный прогон — 0. 3. Отлов «на следующем наблюдении» из текста issue **не делал**: ветка, где он должен жить, на проде не исполняется (см. выше), а триггерные строки — это наблюдения `listings.price_rub`, их 286 сознательно не трогает. Отклонение от плана триажа: он предлагал окно ×9.5–10.5 и 7 строк; с окном остались бы 4 посева ×175…×1000 того же происхождения, поэтому порог односторонний (≥ 9.5). ## Тесты - `uv run python -m pytest tests/test_yandex_price_history.py` — 19 passed (новые: 4 отвергнутых посева на прод-ценах ×10/×9.74/×1000/÷10 → в истории одна текущая точка; 3 сохранённых ×1.06/×2.13/×9.45 → две точки). - `tests/test_3385_migration_310_yandex_seed_slips.py` — настоящий файл миграции на живом Postgres (временная `offer_price_history` затеняет реальную в сессии): удаляются только yandex-посевы ×10/×1000/÷10; остаются ×2.13, ×9.45, cian-посев, триггерные ×10 и более поздняя триггерная строка; второй прогон ничего не меняет; 31 пара → исключение и откат. Локально на postgres:16.4: 2 passed; проверено, что строки реальной `public.offer_price_history` при этом не тронуты. Без БД — skip, записан в `skip_allowlist.txt`; в ci-tradein.yml бегут по-настоящему. - Файл миграции дважды через `psql -v ON_ERROR_STOP=on`, как в деплое: `удалено = 1`, затем `= 0`, rc=0. - Весь сьют: `DATABASE_URL=... uv run python -m pytest tests/ -q -p no:cacheprovider` — **6232 passed, 44 skipped, rc=0**. `ruff check app tests` — чисто; `ruff format --check` по изменённым — чисто; `scripts/check-migration-lock-timeout.py` — rc=0; `test_migration_numbering.py` — 3 passed. ## Фальсификация - Гейт в коде снят (`_implausible_seed` → `return False`): `4 failed, 15 passed`, `assert 2 == 1` у всех четырёх `test_first_capture_previous_off_by_order_is_not_seeded[...]`, rc=1. - Из миграции убрана подпись батча (`s.recorded_at = o.recorded_at`): `At index 10 diff: (7, 10000000) != (7, 1000000)` — удалена триггерная строка, rc=1. - Окно сужено до `BETWEEN 9.5 AND 10.5`: `At index 2 diff: (2, 10900) != (2, 10900000)` — посев ×1000 выжил, rc=1. - Порог остановки `> 30` → `> 3000`: `Failed: DID NOT RAISE Exception`, rc=1. Исходники восстановлены, `diff -q` пуст; после восстановления 21 passed. ## Приёмка на проде После деплоя (проверить не раньше, чем в логе деплоя будет `→ Applying migration: 310_yandex_seed_decimal_slips.sql`): 1. В логе деплоя `NOTICE: offer_price_history: удалено yandex-посевов на порядок = 11` (больше 11 — только если до деплоя успели появиться новые посевы; > 30 — деплой красный, миграция откатилась). 2. `SELECT count(*) FROM offer_price_history o JOIN offer_price_history s ON s.source='yandex' AND s.listing_id=o.listing_id AND s.recorded_at=o.recorded_at AND s.change_time=o.change_time+interval '24 hours' WHERE o.source='yandex' AND greatest(o.price_rub,s.price_rub)/least(o.price_rub,s.price_rub) >= 9.5;` → **0**, и запрос скачков ×10 по соседним yandex-точкам из issue → 0. 3. Новый код в контейнере: после ближайшего `yandex_city_sweep` в `docker logs tradein-scraper` строки `yandex_price_history: lots=... seed_rejected=N` (слова `seed_rejected` в старом коде нет). 4. **До 01.10.2026**: запрос из п.2 по-прежнему 0. Деплой пересоздаёт tradein-backend/tradein-scraper; перед мержем проверить `scrape_runs.status='running'`. Closes #3385 🤖 Generated with [Claude Code](https://claude.com/claude-code)
bot-backend added 1 commit 2026-09-17 08:05:58 +00:00
МЕРА: yandex-история больше не сеет цену на порядок ниже/выше текущей (#3385)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 15s
CI / changes (pull_request) Successful in 21s
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 5m40s
e83742ac41
Все 7 скачков ×10 в yandex-истории цен на проде (17.09) — не «сдвиг на следующем
наблюдении», как предполагал issue, а синтетический посев из gate-поля
price.previous: нижняя точка стоит ровно на 24 ч раньше второй и записана тем же
батчем. Ошибочна нижняя цена (13–37 тыс/м² против 133–375 у верхней). Ещё 4 посева
того же рода — ×175…×1000 (цена в тысячах). Ветка писателя «история есть» на проде
не записала ни одной строки: смену цены раньше неё пишет триггер
record_listing_price_change, поэтому отлов «на следующем наблюдении» там поймал бы 0.

- yandex_price_history: price.previous, отличающаяся от текущей цены в 9.5 раза и
  больше в любую сторону (или не > 0), не сеется; счётчик seed_rejected в логе.
- миграция 310: удаляет yandex-посевы по подписи батча (recorded_at совпадает,
  change_time второй точки ровно +24 ч) с тем же порогом; lock_timeout 5s,
  остановка при > 30 строк; на проде ровно 11 кандидатов, повторный прогон — 0.
- тесты по значению: посев отвергнут/сохранён на прод-ценах; настоящий файл
  миграции на живом Postgres через временную таблицу.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bot-backend merged commit 2b3602364a into main 2026-09-17 09:17:29 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#3564
No description provided.