fix(tradein/pricing): скоупить сторону объявлений по городу, как сторону сделок (#2583 H2) #2617
No reviewers
Labels
No labels
Fable 5 ревью
GG-форсайт
admin
analytics
auth
automation
bug
business
chore
ci
compliance
data
data-moat
docs
duplicate
dx
enhancement
feedback/max
generative
needs-discussion
needs-human
observability
pause-bots
performance
priority/p0
priority/p1
priority/p2
priority/p3
scope/backend
scope/db
scope/devops
scope/frontend
scope/qa
scrapers
security
site-finder
stage/1
stage/2
status/blocked
status/done
status/needs-analysis
status/needs-fix
status/qa
status/ready
status/review
status/wip
tech-debt
tradein
ux
week ревью 1
wontfix
ИРД
вторичка
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lekss361/gendesign#2617
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "fix/tradein-asking-sold-city-scope"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
asking_to_sold_ratiosrefresh (tradein-mvp/backend/app/tasks/asking_to_sold_ratio.py) scopes the SOLD side (deal_side/deal_global) to Ekaterinburg (city ILIKE :asking_city), but the ASKING side (ask_side/ask_global) had NO city filter at all. Oblast sweeps went live 2026-07-12 — cheap oblast listings entered the ask-median denominator unscoped while the sold-median numerator stayed EKB-only. Result:ratio = sold_median / ask_mediansystemically inflated, buyout prices overstated 2.5-5.3% across nearly all room buckets (including EKB estimates, since the ratio itself is shared).AND (city IS NULL OR city ILIKE :asking_city)to bothask_sideandask_global, symmetric to the deal-side predicate.city IS NULLis deliberately kept (not excluded) —listings.cityis populated only for Avito so far (#2598/#2606: Cian/Domclick/Yandex rows are still NULL); a naive symmetric filter without the NULL-tolerance would drop ~71% of the ask sample instead of the ~19% of genuinely-oblast rows. As column coverage grows, the predicate tightens itself with no further code changes; once coverage is complete it can be hardened to the strictly symmetriccity ILIKE :asking_city.FROM listingsoccurrence — confirmed exactly these two CTEs reference the ask-side, no other unscoped spot.Measurements (prod, read-only, 2026-08-02)
Listings passing the existing WHERE predicates (
is_active,rooms IS NOT NULL, ppm² band, novostroyki guard), grouped by city:After the fix, the ask-side sample is
NULL + Екатеринбург = 8218 + 2132 = 10 350rows (out of 11 508) — not the 2 132 EKB-only rows a naive fix would collapse to, and not the full 11 508 unscoped rows of the current bug.Per-room-bucket
ask_medianand resultingratio(sold_median unchanged, EKB-only):Matches the audit's expected +2.5% to +5.3% range.
Heads-up: once
asking_to_sold_ratio_refreshruns on its next schedule (06:00-07:00 UTC), buyout prices will drop ~2.5-5.3% system-wide (studio bucket ~unaffected). This is the fix taking effect — the previous ratio was inflated by oblast listings improperly diluting the ask-median — not a regression. Be ready for a "why did prices drop" question.Scope / what was NOT touched
estimator.py— reads the ratio unchanged.deal_side/deal_global— already correctly scoped, untouched.n_deals >= 30/n_listings >= 30thresholds and bucket-1fallback logic — untouched.asking_cityconfig /_ASKING_CITY_PATTERNvalue — untouched (still%Екатеринбург%, now referenced by both sides).Test plan
test_ask_side_and_ask_global_scoped_to_asking_city(asserts the new predicate is present in both CTEs) andtest_ask_side_keeps_city_is_null_rows_not_naive_filter(guards against the naive/wrong fix that would silently drop city-IS-NULL rows). Verified viagit stashon the implementation-only lines: both FAIL against unpatched code (AssertionError: ask_side AS: missing IS NULL toleranceetc.), then PASS aftergit stash pop.test_rederivation_scopes_sold_side_to_asking_city(removed now-false assertion that ask-side is unfiltered) andtest_migration_080_derivation_is_subset_of_refresh_sql's_drop_city_guardhelper to also normalise-away the new ask-side predicate so refresh-vs-080-seed subset comparison still holds.tests/test_asking_to_sold_ratio.py: 21/21 passed.pytestintradein-mvp/backend: 3058 passed, 9 skipped, 1 pre-existing failure (tests/test_search_api.py::test_search_cache_hit, 401 RBAC — unrelated, not touched by this PR).ruff checkclean on both changed files.Refs #2583
Deep review — APPROVE (независимая перепроверка на проде)
Все замеры автора воспроизведены байт-в-байт на проде (read-only, 2026-08-02): срез по городам 8218/2132/285/267/245/211/150 = 11 508; после фикса 10 350; global ask 136 547 → 140 794; ratio 0.9089 → 0.8815. Наивный вариант (
AND city ILIKE :asking_cityбезIS NULL) даёт 2 132 строки и ratio 0.8230 (−9.4%) — подтверждено, NULL-толерантность выбрана правильно.Фальсификация тестов: реализация откачена на
main-версию файла (git checkout <base> -- app/tasks/asking_to_sold_ratio.py), тесты оставлены → ровно 2 падения (test_ask_side_and_ask_global_scoped_to_asking_city,test_ask_side_keeps_city_is_null_rows_not_naive_filter). Отдельная мутация «наивный фикс» → 3 падения. Заявление автора подтверждено.Остаточный перекос — цифры для протокола (H2 закрыт НЕ полностью)
Фикс убирает 1 158 областных строк, но часть областных объявлений остаётся внутри ветки
city IS NULL. Замер по координатам (расстояние от центра ЕКБ 56.8389, 60.6057) среди 8 218 NULL-city строк:То есть ~210 заведомо неЕКБ-строк (в основном Нижний Тагил, ~197) остаются в знаменателе. «Идеальный» фикс (гео-фильтр ≤30 км поверх городского) дал бы:
Вывод: фикс снимает ~80–85% ошибки H2, остаточное завышение коэффициента ≈0.6–1.2%. Это приемлемо (сильно лучше, чем ничего), но H2 нельзя считать полностью закрытым — оставить follow-up в #2583 до полного покрытия
listings.city. Хорошая новость: колонка уже проставляется на новых прогонах (за 3 дня: cian 939/953, yandex 3002/3704, avito 1722/1722), так что остаток будет сам сходиться к нулю — «предикат сам ужесточается» из комментария подтверждается фактами.Дополнения к описанию эффекта на цены
estimator.py:2911-2919клампитeffective_ratio > 1.0до 1.0 (ESTIMATE_EXPECTED_SOLD_LE_ASKING, default True, на проде не переопределён). Поэтому фактический эффект на выкупную цену отличается от таблицы ratio:Итого клиентам: −2.5…−4.9% на 1/2/3-комнатных, ~+0.3% на студиях, 0% на 4+.
Прочее (не блокирует)
deals.cityне имеет NULL вообще (0 из 96 974) — асимметрия предикатов (deal-сторона строгая, ask-сторона NULL-толерантная) доказуемо безвредна.%Екатеринбург%корректно матчит обе таблицы: вdealsформыЕкатеринбург/город Екатеринбург/городской округ город Екатеринбург, вlistingsровноЕкатеринбург; ложных срабатываний по остальным 5 значениям колонки нет.dealsвообще нет segment-маркера; плюсdeals.roomsобрезан на 4 (max=4, >4 нет), а в listings в bucket 4 сваливаются 5–10-комнатные. Предсуществующее, в этом PR чинить не нужно, но стоит завести отдельно.listings.cityиз write-only колонки (#2598) в money-critical чтение — любой будущий mis-stamp двигает цены напрямую. Гард на месте (test_city_sweep.py::test_resolve_city_name_known_oblast_slugs, set-equality поCITY_LOCATIONS), но это стоит помнить.Полный pytest воспроизведён локально:
1 failed, 3058 passed, 9 skipped— единственный фейлtests/test_search_api.py::test_search_cache_hit(401), pre-existing.ruff checkчист. Диff строго в границах: толькоtasks/asking_to_sold_ratio.py+ его тесты;estimator.py, миграции, пороги и deal-сторона не тронуты.