gendesign/tradein-mvp/backend/scripts
bot-backend 16d99e0f1a fix(estimator): убрать предикат по deals.rooms, а не подставлять в него area-бакет
Разворот предыдущего коммита ветки (a780e3e6) на корень: вместо подстановки
`area_bucket(area)` в предикат `d.rooms = ...` предикат УДАЛЁН во всех трёх местах.

ПОЧЕМУ НЕ БАКЕТ. `deals.rooms` — синтетика из площади (321 559 из 321 560 сделок
удовлетворяют `rooms == area_bucket(area_m2)`, max(rooms)=4), значит `d.rooms = X`
тождественно `d.area_m2 ∈ [граница_X, граница_X+1)`. Это ВТОРОЙ, ступенчатый фильтр
по площади поверх полосы `area_m2 BETWEEN :area_min AND :area_max`, стоящей строкой
ниже. Прод-замер по 1179 реальным запросам (trade_in_estimates, 2026-09-12) — какая
доля полосы ±15% переживает предикат:

  d.rooms = комнаты клиента   медиана 77.8%, у 180 запросов полоса вырезана ЦЕЛИКОМ
                              (пересечение пусто ⇒ коридора нет никогда)
  d.rooms = area_bucket(area) медиана 90.0%, пустых нет, НО у 902 из 1179 полоса
                              всё ещё усечена: 44.0 м² → сохраняется 50% полосы,
                              62.0 м² → 50%, 82.6 м² → 59.7%. Величину усечения
                              задаёт не модель, а случайное положение метража
                              относительно границ 30/44/62/85.
  без предиката               100% по построению

Т.е. бакет-ключ чинит катастрофический случай (пустое пересечение) и оставляет
произвольное усечение у 76.5% запросов. Полоса ±15% уже выражает «похожие по
площади сделки» — второго фильтра по тому же признаку быть не должно.

ЗАМЕР ЭФФЕКТА НА ЦЕНУ (1179 запросов, все три пути влияния коридора на headline:
cap/floor, sufficiency-гейт #oblast-E, deals-headline-fallback; листинговая сторона
берётся из сохранённой оценки, коридор пересчитан на сегодняшнем снимке deals для
всех вариантов, поэтому сравнение apples-to-apples; реплика сверена с ПРОДОВЫМ SQL
на 58 оценках × 3 варианта — 174/174 совпадений):

  коридор доступен   n>=3: 769 → 850 (бакет, +86/−5) → 874 (без ключа, +105/−0)
                    n>=10: 567 → 623 (бакет, +66/−10) → 691 (без ключа, +126/−2)

  сдвиг headline vs текущий прод   бакет            без ключа
    клиентов сдвинулось            28               120
    медиана сдвига                 +1.0%            −1.6%
    p10 / p90                      −30.6% / +6.1%   −8.6% / +4.3%
    сдвиг > ±10%                   10 (все вниз)    10 (7 вниз, 3 вверх)
    сдвиг > ±25%                   4                2

  по путям (медиана сдвига):       бакет            без ключа
    cap/floor, радиусная медиана   +3.9% (p10 −36.3%)   −1.7% (p10 −5.9%)
    cap/floor, якорь Tier C        −10.1% (5 сдвигов, 4 из них >10% вниз)  −0.8%
    sufficiency-гейт               −0.4%            +0.3%
    deals-fallback                 +3.5% (p10 −20.8%)   −0.1%
    якорь Tier A                   0 (коридор не влияет: cap exempt, floor требует
                                      anchor_tier is None)

Вариант без ключа даёт больше покрытия (+105/−0 против +86/−5), сдвиг с медианой
около нуля и БЕЗ кластера сильных падений, тогда как бакет-ключ несёт кластер
Tier C с медианой −10.1%. Худший случай (−41.8%, Малышева 84, 1к/54 м²: премиальный
лот прижимается cap'ом к коридору улицы) ОБЩИЙ для обоих вариантов — он появляется
от самого факта наличия коридора, а не от выбора ключа.

УТОЧНЕНИЕ ФАКТА ИЗ a780e3e6: «у 818 клиентов выборка не меняется» — неверно, их
793. Скрипт классифицировал через `min(max(rooms,0),4) == area_bucket`, из-за чего
27 клиентов с 5-6 комнатами попали в «совпадающие», хотя у них выборка меняется с
пустой на непустую. (Практического прироста они всё равно не получают: их метраж
158-456 м² в основном вне окна импорта `area BETWEEN 18 AND 200`.)

ЯКОРЬ ПРОТИВ МОЛЧАЛИВОГО ВОЗВРАТА. Ни один тест не краснел, если импортёр начнёт
писать настоящую комнатность. tests/test_3256_deals_rooms_key.py теперь ПАРСИТ CASE
из deploy/import-rosreestr.sh и сверяет его границы с `area_bucket()` (поточечно, на
границах и между ними); у самого CASE стоит комментарий-якорь «поменяешь на реальную
комнатность — вернись в #3256».

Каверза (e) харнеса: формулировка «бакеты 0-2 чисты» УБРАНА как неверная. Замер по
тому же пулу, который видит `_fetch_analogs` (свежесть 14 дней, вторичка, регион 66):
совпадение rooms == area_bucket — бакет 0: 69.8%, 1: 63.5%, 2: 60.1%, 3: 54.6%,
4: 30.9%. В бакетах 0-3 модальная комнатность совпадает с бакетом, в бакете 4 — нет
(мода 3, 54.5% пула). Добавлена перекрёстная ссылка: каверзы (d) и (e) СКЛАДЫВАЮТСЯ
(неправильное МЕСТО + неправильный СЕГМЕНТ), а не спорят.

Логи витрины `/street-deals` называли `rooms=%d` комнатностью клиента, хотя фильтра
по ней в запросе уже нет — теперь печатают фактический ключ (полосу площади), а
комнатность помечена как контекст запроса.

НЕ входит в этот PR (заводится отдельно): TVF `street_sales_vs_listings`
(data/sql/211_*.sql:89,113) — там асимметричный ключ (`d.rooms` синтетика,
`l.rooms` настоящая), копипастой не чинится; каверза (e) для
app/tasks/landing_showcase_deals.py:415/426.

Refs #3256
2026-09-12 02:25:09 +05:00
..
backfill_external_valuations_house_id.py fix(tradein/data): dry-run бэкфилла — savepoint-паритет с write-веткой (#2236) 2026-07-03 09:28:27 +03:00
backfill_houses_dadata.py chore(format): нормализация под ruff 0.15.20 — 161 файл, только формат (#2864) (#3022) 2026-08-21 12:01:52 +00:00
backfill_listing_sources.py fix(tradein/matching): снять слияние по ГАР-GUID, починить приёмник кадастра и keeper (#2674) 2026-08-06 05:11:47 +05:00
backtest_estimator.py fix(estimator): убрать предикат по deals.rooms, а не подставлять в него area-бакет 2026-09-12 02:25:09 +05:00
domclick_local_runner.py feat(tradein/domclick): local-runner + ingest tooling → main (split from parked Layer-B) 2026-06-28 10:26:52 +03:00
export_ekb_districts_svg.py feat(mera/b2c): границы районов ЕКБ статикой — основа настоящей карты в игре 2026-08-29 23:25:44 +05:00
geocode_deals_from_houses.py chore(format): нормализация под ruff 0.15.20 — 161 файл, только формат (#2864) (#3022) 2026-08-21 12:01:52 +00:00
geocode_deals_nominatim.py feat(tradein/geocoder): регион-параметризация геокодера — region_code в geocode()/known_city_hint, --region-code у скрипта сделок, region_code у admin geocode-missing (#3051) 2026-09-09 02:51:28 +03:00
ingest_domclick_jsonl.py fix(tradein/scraper): дневной снимок узнаёт свой прогон (#2701) (#2707) 2026-08-06 07:00:17 +00:00
README.md chore(tradein/geocoder): удалить остатки скриптов Яндекс-геокодера, часть 3 (#2593) 2026-07-31 23:16:19 +03:00

tradein-mvp/backend/scripts/

Ops scripts that touch the production database directly. Run via python -m scripts.<name> from the backend/ working directory after uv sync.

All scripts are idempotent / resumable where they write — re-running the same --batch label skips already-processed rows (UNIQUE constraints in target tables). Failures inside a per-row loop never roll back the outer transaction; each row is wrapped in a SAVEPOINT (db.begin_nested()) per .claude/rules/backend.md.


Address audit + backfill (issue #582) — REMOVED (#2593)

audit_address_mismatch.py, backfill_house_coords.py, _yandex_reverse.py и их SQL-хелперы (audit_address_sample.sql, address_audit_report.sql) удалены — весь pipeline опирался на Yandex Geocoder API, который выпилен из проекта (#2593, части 1-3). houses.address→lat/lon geocoding теперь идёт через app/services/geocoder.py (кадастр/геопортал ЕКБ-тиры + Nominatim fallback, единственный живой внешний провайдер) на обычном write-path (/api/v1/trade-in/estimate, listing ingest). Разовый forward-backfill недостающих houses координат — scripts/geocode_deals_nominatim.py (живой, работает с rosreestr_deals, не с houses — читай его docstring перед использованием на других таблицах). Таблица address_mismatch_audit осталась в схеме (используется house_dedup_merge.py при слиянии дублей домов, независимо от Yandex-аудита).


Matching backfill (PR J)

backfill_listing_sources.py — retroactive matching for ~18k listings

PR I (commit 7e24ccb) hooked the matching service into save_listings() so every new scrape now writes a listing_sources row + resolves a canonical houses row. This script does the same work retroactively for all existing listings — listing_sources only had rows from new scrapes post-PR I.

What it does per row:

  1. match_or_create_house() (Tier 0-3) — uses listings.house_source / house_ext_id when present (Avito Houses Catalog, Cian newbuilding), else falls back to address/lat/lon/cadastrals.
  2. upsert_listing_source() with method='backfill', confidence=0.9 (vs real-time source_link 1.0 — distinguishes the two in audits).
  3. UPDATE listings.house_id_fk when the row didn't already have one.
# Canary
DATABASE_URL=postgresql+psycopg://... \
    uv run python -m scripts.backfill_listing_sources \
        --limit 100 --dry-run

# Real run, one source at a time (staged rollout)
uv run python -m scripts.backfill_listing_sources --source avito

# Full run
uv run python -m scripts.backfill_listing_sources --batch-size 500

Idempotent / resumable — the source query is WHERE NOT EXISTS (SELECT 1 FROM listing_sources ls WHERE ls.ext_source = listings.source AND ls.ext_id = COALESCE(listings.source_id, listings.dedup_hash)). Re-runs only pick up rows still missing from listing_sources. upsert_listing_source adds a second layer of safety via ON CONFLICT (ext_source, ext_id) DO UPDATE.

No network calls — pure in-DB matching (Yandex Geocoder is blocked on prod, and match_or_create_house does not call it anyway).

Per-row SAVEPOINT (db.begin_nested()) per .claude/rules/backend.md — one bad row never aborts the surrounding batch.

Expected output (PR J initial run):

Source Rows Expected matched Notes
avito 9302 9000+ Many carry house_source/house_ext_id
cian 5158 5000+ Most carry house_source/house_ext_id
yandex 3704 3700+ No source_id → uses dedup_hash as ext_id
n1 264 264 All have address/coords

Expected duration: rough estimate ~5-15 minutes on prod for ~18k rows (advisory-lock + 1-3 DB roundtrips per listing for Tier 0-3, ~500 commit checkpoints at default batch size). Run with --limit 100 first to calibrate, then let the full job loose.

Final summary in the log includes per-source coverage % so you can verify the run landed:

backfill done (dry_run=False): processed=18428 matched=18428
  house_resolved=18200 house_failed=228 skipped=0 errors=0
  avito      processed=9302 matched=9302 house_resolved=9290 ...
  cian       processed=5158 matched=5158 house_resolved=5100 ...
  yandex     processed=3704 matched=3704 house_resolved=3540 ...
  n1         processed=264  matched=264  house_resolved=270  ...
final listing_sources coverage:
  avito      9302 / 9302 (100.0%)
  cian       5158 / 5158 (100.0%)
  ...

Estimator backtest (issue #648)

backtest_estimator.py — asking→sold accuracy harness

STRICTLY READ-ONLY (SELECT-only; no INSERT/UPDATE/DDL/commit). Measures the estimator's asking-median + Tukey-IQR core against rosreestr ДКП sold prices. For a sample of ДКП deals it predicts the asking median from nearby active listings (reusing the estimator's own _filter_outliers / _percentile), then reports per-deal signed/abs error % aggregated overall + per-rooms (студия / 1к / 2к / 3к / 4+), plus a city-wide deal-vs-asking headline spread.

DATABASE_URL=postgresql+psycopg://... \
    python -m scripts.backtest_estimator --sample 300 --since 2025-06-01

# machine-readable:
python -m scripts.backtest_estimator --json

Stage 1 correction block. On top of the raw [ASKING] metrics the harness emits a second [CORRECTED] block: from the SAME matched sample it derives a per-rooms asking→sold ratio ratio[bucket] = median(sold_ppm2) / median(pred_ask_ppm2) (global fallback for buckets with < MIN_BUCKET = 20 matched deals), then re-scores pred_sold = pred_ask * ratio[bucket] through the same metric math. This DEMONSTRATES that a per-rooms factor removes the systematic +29.6% asking→sold bias — it changes nothing in prod.

Honesty: by default the ratio is IN-SAMPLE (derived AND evaluated on the same deals), so the corrected bias is near-zero by construction. That proves the MECHANISM, not out-of-sample accuracy. Pass --holdout-split to fit on even-id deals and evaluate on the odd-id half (deterministic, no RNG) for an honest number. The production ratio (Stage 2) is fit over a SEPARATE window and A/B'd on held-out data.

# honest out-of-sample corrected number (even-id fit / odd-id eval):
python -m scripts.backtest_estimator --sample 600 --holdout-split

Caveats (also printed): CURRENT listings vs PAST deals (not point-in-time — needs listing_source_snapshots #570); asking-median + IQR core only; ДКП = registered price. Pure metric/ratio helpers are unit-tested in tests/test_backtest_estimator.py (no DB).


domclick_local_runner.py — DomClick EKB вторичка с домашнего IP (offline)

SELF-CONTAINED (без app.* импортов, stdlib + playwright.async_api). Запускается ОПЕРАТОРОМ на его машине с домашнего/резидентного IP — DomClick фронтит QRATOR, который банит datacenter/mobile-proxy, но пропускает домашний. Пишет JSONL (НЕ в БД); заливка в trade-in БД — отдельным шагом ingest_domclick_jsonl.py.

Транспорт — оба слоя через браузер (page.goto, verified live 2026-06-27):

  • enumerate — фронтовый SERP ekaterinburg.domclick.ru/search?... (SSR). Прямой BFF bff-search-web/api/offers/v1 режет QRATOR-403 уже с домашнего IP, а фронтовый SERP грузится чисто. id офферов — из DOM (a[href*="/card/"], ждём через wait_for_selector — карточки догидрируются ~6-12с), total — из <title>. Пагинация offset (page-size 20); при total>2000 — price-bisection бакета (границы из title).
  • detailpage.goto(card) → сырой HTML (__SSR_STATE__: renovation, living/kitchen, priceHistory, egrnData owners/collateral, sale_type, views, wall/floor) + offer-card v3 XHR price_prediction (AVM заполненный) и sold_similar (--with-sold-similar) — они с домашнего IP отдают 200 (в отличие от прод-прокси). Layer-A (price/area/rooms/floor/total_floors/lat/lon/address) тоже из card-SSR.

Режим окна

QRATOR ловит СТАРЫЙ headless (launch(headless=True)) → 403 | Домклик. NEW-headless И headed → 200 + полный __SSR_STATE__. Дефолт = new-headless (окно скрыто); --headed = видимое окно (debug).

Resume / enum-cache (рестарт не теряет позицию)

  • detail-resume: при старте читает JSONL и пропускает id с терминальным статусом (ok/blocked/parse_fail).
  • --enum-cache PATH: enumerate (~6400 id, ~1.5-2ч) пишет полный список id в файл с sentinel завершённости; следующий запуск грузит кэш и ПРОПУСКАЕТ enumerate → сразу detail. Композится с detail-resume → рестарт продолжает строго с текущей позиции, без пересбора и без перекачки готовых.

Темп (НЕ спалить домашний IP)

  • пауза между карточками --min-delay (12с) / --max-delay (30с);
  • render-wait --render-min (6с) / --render-max (10с) — settle поверх wait_for_selector;
  • «человеческий перерыв» каждые 25-40 карточек; на 403 — ретрай, потом status="blocked".

Дефолт ≈ 20-34с/карта; для быстрее — --min-delay 5 --max-delay 12 --render-min 3 --render-max 5 (~16-20с/карта; card-path чистый, запас есть). Полный свод ЕКБ (~6300) — несколько ночей; держи ПК от сна (иначе wall-clock простаивает, данные не теряются).

cd tradein-mvp/backend
# боевой прогон с кэшем id + sold_similar (рекомендуется):
python scripts/domclick_local_runner.py --out domclick_ekb.jsonl \
    --enum-cache enum_cache.jsonl --with-sold-similar
# быстрый smoke (маленькие задержки ТОЛЬКО для теста):
python scripts/domclick_local_runner.py --limit 2 --min-delay 3 --max-delay 5 --out dc_test.jsonl
# только перечислить (без detail):
python scripts/domclick_local_runner.py --enumerate-only --out enum.jsonl

Нужен python с playwright (python -c "import playwright").

ingest_domclick_jsonl.py — JSONL раннера → trade-in БД

Запускается ВНУТРИ tradein-контейнера (импортит app). Читает JSONL раннера, на каждую ok-запись: ScrapedLotsave_listings() (upsert + house-match хук в SAVEPOINT, fault-tolerant — нематчнутый листинг всё равно вставляется) → save_detail_enrichment() (detail-колонки COALESCE + offer_price_history). Идемпотентно (ON CONFLICT), --limit, --dry-run. Запуск: python -m scripts.ingest_domclick_jsonl --jsonl <path> (PYTHONPATH=/app).