feat(tradein): populate offer_price_history for yandex from gate price.previous/trend #1754

Merged
lekss361 merged 1 commit from feat/yandex-price-history into main 2026-06-18 11:37:05 +00:00
Owner

Проблема

offer_price_history для yandex фактически пуст (50 строк против cian 8582). Источник истории цен — gate-ответ уже несёт price.previous+price.trend (парсятся в ScrapedLot.price_previous_rub/price_trend, yandex_realty.py #1725), но save_listings их роняет. Разведка показала: отдельный otsenka-скрапер НЕ нужен — gate-данных достаточно.

Решение

Новый сервис yandex_price_history.py (зеркало паттерна cian_price_history.py) пишет в offer_price_history из уже-распарсенных gate-полей. Вызывается сразу после save_listings в run_yandex_city_sweep (per-anchor) и run_yandex_full_load (on_bucket).

Логика (idempotent), per yandex-lot:

  • история пуста + price_previous_rub задан и ≠ текущей → seed previous-точка (price_previous_rub, change_time = now−1d) + current (price_rub, now);
  • история пуста без previous → только current;
  • есть история и цена изменилась → current (price_rub, now);
  • цена та же → skip.

Forward-наблюдения строят time-series по ходу свипов; price.previous даёт одну стартовую историческую точку. Дизайн-выбор для ревью: timestamp previous-точки синтетический (now−1d) — gate реального времени смены не несёт, только направление (trend) и предыдущее значение. Если для чарта нужна иная семантика — скажите, переделаю.

Изменения

  • app/services/yandex_price_history.py (нов) — record_yandex_price_history(db, lots) -> int. Резолв listing_id по (source='yandex', source_id); per-lot begin_nested() SAVEPOINT (битый lot не валит батч); psycopg v3 (CAST(:x AS bigint/numeric); change_time — нативный datetime БЕЗ CAST, т.к. CAST ломает SQLite-фикстуру).
  • app/services/scrape_pipeline.pyprice_history_rows в YandexCitySweepCounters/YandexFullLoadCounters; вызов после каждого save_listings, обёрнут в try/except+rollback (провал истории НЕ роняет сейв листингов).
  • tests/test_yandex_price_history.py (нов, 12 тестов).

Схема offer_price_history не менялась. yandex_realty.py не тронут (там чужой активный WIP).

Тесты

Full suite (prescribed deselects): 1975 passed, 1 skipped, 0 failed. Ruff clean.

Прод-смоук после merge

Дождаться yandex-свипа (или ручной trigger) → SELECT source,count(*) FROM offer_price_history GROUP BY 1 — yandex должен расти с ~50.

## Проблема `offer_price_history` для yandex фактически пуст (50 строк против cian 8582). Источник истории цен — gate-ответ **уже несёт** `price.previous`+`price.trend` (парсятся в `ScrapedLot.price_previous_rub`/`price_trend`, yandex_realty.py #1725), но `save_listings` их роняет. **Разведка показала: отдельный otsenka-скрапер НЕ нужен** — gate-данных достаточно. ## Решение Новый сервис `yandex_price_history.py` (зеркало паттерна `cian_price_history.py`) пишет в `offer_price_history` из уже-распарсенных gate-полей. Вызывается сразу после `save_listings` в `run_yandex_city_sweep` (per-anchor) и `run_yandex_full_load` (on_bucket). **Логика (idempotent), per yandex-lot:** - история пуста + `price_previous_rub` задан и ≠ текущей → seed previous-точка `(price_previous_rub, change_time = now−1d)` + current `(price_rub, now)`; - история пуста без previous → только current; - есть история и цена изменилась → current `(price_rub, now)`; - цена та же → skip. Forward-наблюдения строят time-series по ходу свипов; `price.previous` даёт одну стартовую историческую точку. **Дизайн-выбор для ревью:** timestamp previous-точки синтетический (`now−1d`) — gate реального времени смены не несёт, только направление (`trend`) и предыдущее значение. Если для чарта нужна иная семантика — скажите, переделаю. ## Изменения - `app/services/yandex_price_history.py` (нов) — `record_yandex_price_history(db, lots) -> int`. Резолв listing_id по `(source='yandex', source_id)`; per-lot `begin_nested()` SAVEPOINT (битый lot не валит батч); psycopg v3 (`CAST(:x AS bigint/numeric)`; `change_time` — нативный datetime БЕЗ CAST, т.к. CAST ломает SQLite-фикстуру). - `app/services/scrape_pipeline.py` — `price_history_rows` в `YandexCitySweepCounters`/`YandexFullLoadCounters`; вызов после каждого `save_listings`, обёрнут в try/except+rollback (провал истории НЕ роняет сейв листингов). - `tests/test_yandex_price_history.py` (нов, 12 тестов). Схема `offer_price_history` не менялась. `yandex_realty.py` не тронут (там чужой активный WIP). ## Тесты Full suite (prescribed deselects): **1975 passed, 1 skipped, 0 failed**. Ruff clean. ## Прод-смоук после merge Дождаться yandex-свипа (или ручной trigger) → `SELECT source,count(*) FROM offer_price_history GROUP BY 1` — yandex должен расти с ~50.
lekss361 added 1 commit 2026-06-18 11:24:27 +00:00
feat(tradein): populate offer_price_history for yandex from gate price.previous/trend
All checks were successful
CI / changes (pull_request) Successful in 6s
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
6c82528db5
lekss361 added the
enhancement
priority/p1
scope/backend
scrapers
status/review
labels 2026-06-18 11:24:34 +00:00
bot-reviewer approved these changes 2026-06-18 11:36:48 +00:00
bot-reviewer left a comment
Collaborator

APPROVE — clean worktree head 6c82528d.

Зеркало cian_price_history, psycopg v3-дисциплина соблюдена:

  • CAST(:source_id AS text)/CAST(:listing_id AS bigint)/CAST(:price_rub AS numeric); change_time биндится нативным datetime БЕЗ CAST (документированная причина — CAST ломает SQLite numeric-affinity фикстуру). Никаких :type.
  • Per-lot with db.begin_nested() SAVEPOINT — битый lot не валит батч (откат только его). db.commit() в конце.
  • Idempotent: previous-точка сидится ТОЛЬКО при пустой истории; latest==price → skip; latest!=price → одна current-точка. Дубликатов previous нет.
  • scrape_pipeline: вызов после save_listings (который уже закоммитил) в обоих run_yandex_city_sweep + run_yandex_full_load, обёрнут try/except+rollback — провал истории НЕ роняет сейв листингов.

Схема прод сверена: offer_price_history.{listing_id,change_time,price_rub} NOT NULL (null-price → SAVEPOINT-откат, graceful), source nullable; все 7328 yandex-листингов имеют source_id → резолв работает; oph baseline 50.

Дизайн-выбор synthetic now-1d для previous-точки принят (gate реального change-time не несёт). Если для чарта нужна иная семантика — отдельным PR.

Тесты на чистом дереве: 1975 passed, 0 failed. Ruff clean. Merge (merge style).

✅ APPROVE — clean worktree head 6c82528d. Зеркало cian_price_history, psycopg v3-дисциплина соблюдена: - CAST(:source_id AS text)/CAST(:listing_id AS bigint)/CAST(:price_rub AS numeric); change_time биндится нативным datetime БЕЗ CAST (документированная причина — CAST ломает SQLite numeric-affinity фикстуру). Никаких :x::type. - Per-lot `with db.begin_nested()` SAVEPOINT — битый lot не валит батч (откат только его). db.commit() в конце. - Idempotent: previous-точка сидится ТОЛЬКО при пустой истории; latest==price → skip; latest!=price → одна current-точка. Дубликатов previous нет. - scrape_pipeline: вызов после save_listings (который уже закоммитил) в обоих run_yandex_city_sweep + run_yandex_full_load, обёрнут try/except+rollback — провал истории НЕ роняет сейв листингов. Схема прод сверена: offer_price_history.{listing_id,change_time,price_rub} NOT NULL (null-price → SAVEPOINT-откат, graceful), source nullable; все 7328 yandex-листингов имеют source_id → резолв работает; oph baseline 50. Дизайн-выбор synthetic now-1d для previous-точки принят (gate реального change-time не несёт). Если для чарта нужна иная семантика — отдельным PR. Тесты на чистом дереве: 1975 passed, 0 failed. Ruff clean. Merge (merge style).
lekss361 merged commit 493142b990 into main 2026-06-18 11:37:05 +00:00
lekss361 deleted branch feat/yandex-price-history 2026-06-18 11:37:05 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
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#1754
No description provided.