feat(tradein): source-quota in estimator analogs (fix Cian/Yandex starvation) #491
No reviewers
Labels
No labels
admin
analytics
auth
automation
bug
business
chore
ci
compliance
data
data-moat
docs
duplicate
dx
enhancement
Fable 5 ревью
feedback/max
generative
GG-форсайт
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#491
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "feat/tradein-estimator-source-quota"
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?
Bug
/api/v1/trade-in/estimate/{id}для адресов с крупным ЖК (напр. Монтёрская 8/2) возвращал 100% Avito аналогов. Причина: 91 Avito листинг сdistance=0м(тот же дом) +ORDER BY distance LIMIT 50→ Cian/Yandex/N1 (ближайший Cian в 175м) полностью вытеснены. UI корректно показывал "Циан — нет данных", но медиана считалась на однобокой выборке.Changes
tradein-mvp/backend/app/services/estimator.pyMAX_ANALOGS_PER_ADDRESS = 5иMIN_ANALOGS_PER_SOURCE = 5рядом сAREA_TOLERANCE_fetch_analogsпереработана (Approach B — fetch wider, filter in Python):relevance_scoreиrn_addr(per-address row_number); в WHERE добавлен фильтрrn_addr <= :max_per_addrMIN_ANALOGS_PER_SOURCEстрок по relevance из каждого source; оставшиеся слоты (50 − len(guaranteed)) заполняются из remainder по relevance; финальная сортировка + LIMIT 50tradein-mvp/backend/tests/test_estimator_source_quota.py— новый файл, 4 test cases:test_address_cap_limits_per_address_listings— per-address cap не превышаетсяtest_source_quota_prevents_cian_starvation— Cian гарантированно >= min(supply, MIN_ANALOGS_PER_SOURCE) (regression test for bug)test_source_quota_includes_all_when_supply_below_min— все 3 Cian попадают, если их меньше квотыtest_fallback_signal_reflects_radius—fallback_usedboolean сохраняет существующую семантикуAcceptance criteria
_fetch_analogssignature unchangedMerged via deep-code-reviewer — verdict APPROVE.
Algorithm verified end-to-end:
relevance_scoreformula = identical to previous inline ORDER BY (no scoring regression)row_number() OVER (PARTITION BY address ORDER BY relevance_score)caps per-address contribution;WHERE rn_addr <= 5correctly limitsby_sourcepreserves SQL relevance order →src_rows[:5]= top-5 per source;id(row)dedup safe (each row is freshly materialized dict);remaining_slots = 50 − len(guaranteed)correctly boundedresult.sort(key=lambda r: r.get("relevance_score") or 0.0)— relevance always computed in SQL (CASE returns 0, never NULL), soor 0.0fallback is defensive but never triggers4 sources × 5 = 20guaranteed max +30filler =50; edge case "1 source only" → 5 guaranteed + 45 filler = 50 from same source (preserves lower bound)Edge cases verified:
min(len, 5)takes all (Test 3)by_source, silently skippedresult[:50]no-op, returns allPARTITION BY addresswith NULL → all NULLs in one partition (minor concern, addresses rarely NULL post-geocode backfill)Compliance:
CAST(:x AS type)used throughout, no:x::typeestimate()lines 433/441/453 work unchanged_filter_outliers/_percentile/price_per_m2consume same keysMinor (non-blocking):
ORDER BY relevance_score— for identical scores order is non-deterministic; acceptableST_DWithinis primary filter; CTE evaluated once; acceptable for interactive /estimateCross-PR context: