fix(tradein/estimate): не строить цену по одному-двум аналогам, не отдавать 0 ₽ #2629

Merged
lekss361 merged 1 commit from fix/tradein-analog-sufficiency into main 2026-08-02 12:41:48 +00:00
Owner

Summary

Money-path fix: the estimator built a headline price from as few as 1-4 scraped listings with no sufficiency check, and could return a literal median_price_rub=0 without an honest refusal reaching the consumer in every code path.

  • Adds HEADLINE_LISTINGS_MIN_N = 5 (estimator.py). Below the threshold the radius-listings aggregate (median/range/n_analogs/cv) is suppressed to the same "no usable listings" state — routing automatically through the already-existing, already-tested same-building anchor / #oblast-D deals-headline-fallback (ДКП Росреестра) / insufficient_data chain. listings_clean itself is deliberately not cleared, so the same-building anchor's own ghost-anchor guard (#1871) still tells "genuinely zero nearby listings" from "some nearby, just too few to trust".
  • Fixes a bug the fixture-regen surfaced: deals-headline-fallback gated on anchor_tier is None, but anchor_tier stays stale (not reset) when _compute_same_building_anchor rejects a candidate outright — blocking the fallback even with a large ДКП corridor available. Now gates on anchor is None (the actual computed anchor).
  • Explanation text is honest about why: "нашли N объявлений, недостаточно" vs the genuine "объявлений не найдено" — no path claims "no listings nearby" once the ДКП fallback engaged for a thin (not literally zero) sample.
  • insufficient_data (computed field, median_price_rub<=0) remains the single source of truth; verified the PDF exporter and API schema both already gate on it (no code change needed there — this was already correct, just re-verified end to end).

Does not touch analog selection/tiers/radii, the asking→sold ratio, quarter-index, hedonic correction, scrapers, or geocoding.

Threshold — 5, data-driven

Live repro (Серов, 2-комн 45 м², coords 59.604/60.577): n=3 listings → 42 391 ₽/м² (−36% vs the city-wide ДКП corridor at the same moment, 54 126 ₽/м²). A neighbouring street (Фуфачева) on n=4 listings gave 56 351 ₽/м² — 33% apart from the first street's 42 391 for the same room/area combo, same city. 5 was chosen to match the existing MIN_ANALOGS_TIER_0 convention ("enough to trust a cohort tier") already used elsewhere in this file — not a new, unrelated magic number.

Before/after (live prod, 2026-08-02, real listings + real ДКП corridor at capture time)

Case Before (live, deployed) After (this PR's code, same real prod inputs) Note
Серов 2к 45м² (ул. Кутузова, n=3) 42 391 ₽/м² (n=3, confidence=low) 54 126 ₽/м² (ДКП-fallback, 52 сделок, confidence=low) was −36% vs city ДКП median; now built directly from it
Серов 2к 45м² (ул. Фуфачева, n=4, другая улица) 56 351 ₽/м² 59 983 ₽/м² (ДКП-fallback, street-scoped, 4 сделки)
→ разброс между улицами (та же квартира, тот же город) 56 351 / 42 391 = 1.33× 59 983 / 54 126 = 1.11× variance from thin-sample noise cut ~3×; residual 11% comes from ДКП corridor street-vs-citywide scope resolution (DKP_CORRIDOR_CITY_WIDE_MIN_N, existing #oblast-D mechanism) — out of this PR's scope per the task boundary
Каменск-Уральский 3к 62м² (0 listings within 1-2 км, verified live via DB) 64 994 ₽/м² (already ДКП-fallback — genuinely 0 listings, not "thin") 64 994 ₽/м² — unaffected control gate only fires for 1..4, not 0
Первоуральск 3к 62м² (0 listings) 74 226 ₽/м² (already ДКП-fallback) 74 226 ₽/м² — unaffected control
Екатеринбург control (n=5, same-building anchor Tier C fires) 183 970 ₽/м² 183 970 ₽/м² — unaffected control anchor path bypasses the gate entirely; n=5 is also at/above the threshold either way

Note: today's live re-run did not reproduce a literal Каменск 3-комн → 0 ₽ for the task's exact coordinates — ДКП coverage for that room/area/street combo has since improved (DB is continuously updated by the daily importer) and the existing fallback already covers it (0 listings, unaffected by this gate). Reproduced the same literal-0 ₽ shape instead with an atypical room count (4) at the same coordinates (0 listings + insufficient ДКП match) — confirmed insufficient_data=True is set correctly and neither the PDF exporter nor the API JSON leak 0 as a confident number.

7 estimate rows were created on prod by this verification (trade_in_estimates, all against the currently-deployed code — my local fix isn't live yet, so all "after" numbers above were computed by feeding the same real captured prod listings + ДКП corridor into the patched _price_from_inputs locally, not by re-hitting the endpoint).

Tests

New tests/test_estimator_headline_sufficiency.py (14 tests, 2 layers — direct _price_from_inputs unit tests + estimate_quality integration tests). Falsified: reverted HEADLINE_LISTINGS_MIN_N to 0 (gate no-ops, same signature) — 11/14 fail on wrong VALUES (median/n_analogs/explanation content), not AttributeError, confirming the tests exercise the gate's behavior and not just its existence.

13 pre-existing test files needed fixture bumps (n=1..3 analogs → n≥5, mostly identical-price/symmetric so asserted medians stay byte-identical) — those files test other mechanics (repair coefficient, expected-sold ratio, quarter-index, range floor, segment multiplier, IMV blend, anchor-vs-radius gating) that used a thin sample purely as a convenience fixture, now incompatible with the new floor. tests/fixtures/backtest_baseline.json regenerated from the frozen 277-deal prod fixture (replay_fixture, dedup pinned OFF to match what test_backtest_regression_gate actually computes — the CLI --update-baseline script itself doesn't pin this and crashes on this fixture; pre-existing gap since #2173, unrelated to this PR, left as-is): expected_sold.overall.n 277→269 (8 deals now correctly route through the deals-fallback / stay insufficient instead of a noisy thin median), MAPE 13.23%→13.18%, headline spread_pct 17.98%→16.65%.

Full pytest: 3113 passed, 9 skipped, 1 pre-existing failure (test_search_api.py::test_search_cache_hit, 401 RBAC — unrelated, known, not touched).

Test plan

  • pytest tradein-mvp/backend full suite green (except the known pre-existing failure)
  • Falsification: gate-disabled run fails 11/14 new tests on value, not AttributeError
  • Live prod verification (read-only DB + real captured listings/ДКП replayed through the patched code) — see before/after table above
  • ruff check / ruff format clean

Refs: live money-path audit 2026-08-02 (Серов/Каменск-Уральский/Первоуральск/Екатеринбург)

## Summary Money-path fix: the estimator built a headline price from as few as **1-4 scraped listings** with no sufficiency check, and could return a literal `median_price_rub=0` without an honest refusal reaching the consumer in every code path. - Adds `HEADLINE_LISTINGS_MIN_N = 5` (`estimator.py`). Below the threshold the radius-listings aggregate (median/range/n_analogs/cv) is suppressed to the same "no usable listings" state — routing automatically through the **already-existing, already-tested** same-building anchor / `#oblast-D` deals-headline-fallback (ДКП Росреестра) / `insufficient_data` chain. `listings_clean` itself is deliberately **not** cleared, so the same-building anchor's own ghost-anchor guard (`#1871`) still tells "genuinely zero nearby listings" from "some nearby, just too few to trust". - Fixes a bug the fixture-regen surfaced: `deals-headline-fallback` gated on `anchor_tier is None`, but `anchor_tier` stays stale (not reset) when `_compute_same_building_anchor` rejects a candidate outright — blocking the fallback even with a large ДКП corridor available. Now gates on `anchor is None` (the actual computed anchor). - Explanation text is honest about *why*: "нашли N объявлений, недостаточно" vs the genuine "объявлений не найдено" — no path claims "no listings nearby" once the ДКП fallback engaged for a thin (not literally zero) sample. - `insufficient_data` (computed field, `median_price_rub<=0`) remains the single source of truth; verified the PDF exporter and API schema both already gate on it (no code change needed there — this was already correct, just re-verified end to end). Does **not** touch analog selection/tiers/radii, the asking→sold ratio, quarter-index, hedonic correction, scrapers, or geocoding. ## Threshold — 5, data-driven Live repro (Серов, 2-комн 45 м², coords 59.604/60.577): n=3 listings → 42 391 ₽/м² (−36% vs the city-wide ДКП corridor at the same moment, 54 126 ₽/м²). A neighbouring street (Фуфачева) on n=4 listings gave 56 351 ₽/м² — 33% apart from the first street's 42 391 for the **same room/area combo, same city**. `5` was chosen to match the existing `MIN_ANALOGS_TIER_0` convention ("enough to trust a cohort tier") already used elsewhere in this file — not a new, unrelated magic number. ## Before/after (live prod, 2026-08-02, real listings + real ДКП corridor at capture time) | Case | Before (live, deployed) | After (this PR's code, same real prod inputs) | Note | |---|---|---|---| | Серов 2к 45м² (ул. Кутузова, n=3) | **42 391 ₽/м²** (n=3, confidence=low) | **54 126 ₽/м²** (ДКП-fallback, 52 сделок, confidence=low) | was −36% vs city ДКП median; now built directly from it | | Серов 2к 45м² (ул. Фуфачева, n=4, другая улица) | 56 351 ₽/м² | 59 983 ₽/м² (ДКП-fallback, street-scoped, 4 сделки) | | | → разброс между улицами (та же квартира, тот же город) | **56 351 / 42 391 = 1.33×** | **59 983 / 54 126 = 1.11×** | variance from thin-sample noise cut ~3×; residual 11% comes from ДКП corridor street-vs-citywide scope resolution (`DKP_CORRIDOR_CITY_WIDE_MIN_N`, existing `#oblast-D` mechanism) — out of this PR's scope per the task boundary | | Каменск-Уральский 3к 62м² (0 listings within 1-2 км, verified live via DB) | 64 994 ₽/м² (already ДКП-fallback — genuinely 0 listings, not "thin") | 64 994 ₽/м² — **unaffected control** | gate only fires for 1..4, not 0 | | Первоуральск 3к 62м² (0 listings) | 74 226 ₽/м² (already ДКП-fallback) | 74 226 ₽/м² — **unaffected control** | | | Екатеринбург control (n=5, same-building anchor Tier C fires) | 183 970 ₽/м² | 183 970 ₽/м² — **unaffected control** | anchor path bypasses the gate entirely; n=5 is also at/above the threshold either way | Note: today's live re-run did not reproduce a literal `Каменск 3-комн → 0 ₽` for the task's exact coordinates — ДКП coverage for that room/area/street combo has since improved (DB is continuously updated by the daily importer) and the existing fallback already covers it (0 listings, unaffected by this gate). Reproduced the same literal-`0 ₽` shape instead with an atypical room count (4) at the same coordinates (0 listings + insufficient ДКП match) — confirmed `insufficient_data=True` is set correctly and neither the PDF exporter nor the API JSON leak `0` as a confident number. 7 estimate rows were created on prod by this verification (`trade_in_estimates`, all against the currently-**deployed** code — my local fix isn't live yet, so all "after" numbers above were computed by feeding the *same real captured prod listings + ДКП corridor* into the patched `_price_from_inputs` locally, not by re-hitting the endpoint). ## Tests New `tests/test_estimator_headline_sufficiency.py` (14 tests, 2 layers — direct `_price_from_inputs` unit tests + `estimate_quality` integration tests). Falsified: reverted `HEADLINE_LISTINGS_MIN_N` to `0` (gate no-ops, same signature) — **11/14 fail on wrong VALUES** (median/n_analogs/explanation content), not `AttributeError`, confirming the tests exercise the gate's behavior and not just its existence. 13 pre-existing test files needed fixture bumps (n=1..3 analogs → n≥5, mostly identical-price/symmetric so asserted medians stay byte-identical) — those files test *other* mechanics (repair coefficient, expected-sold ratio, quarter-index, range floor, segment multiplier, IMV blend, anchor-vs-radius gating) that used a thin sample purely as a convenience fixture, now incompatible with the new floor. `tests/fixtures/backtest_baseline.json` regenerated from the frozen 277-deal prod fixture (`replay_fixture`, dedup pinned OFF to match what `test_backtest_regression_gate` actually computes — the CLI `--update-baseline` script itself doesn't pin this and crashes on this fixture; pre-existing gap since `#2173`, unrelated to this PR, left as-is): `expected_sold.overall.n` 277→269 (8 deals now correctly route through the deals-fallback / stay insufficient instead of a noisy thin median), MAPE 13.23%→13.18%, headline `spread_pct` 17.98%→16.65%. Full `pytest`: **3113 passed, 9 skipped, 1 pre-existing failure** (`test_search_api.py::test_search_cache_hit`, 401 RBAC — unrelated, known, not touched). ## Test plan - [x] `pytest tradein-mvp/backend` full suite green (except the known pre-existing failure) - [x] Falsification: gate-disabled run fails 11/14 new tests on value, not AttributeError - [x] Live prod verification (read-only DB + real captured listings/ДКП replayed through the patched code) — see before/after table above - [x] `ruff check` / `ruff format` clean Refs: live money-path audit 2026-08-02 (Серов/Каменск-Уральский/Первоуральск/Екатеринбург)
lekss361 added 1 commit 2026-08-02 12:17:37 +00:00
fix(tradein/estimate): не строить цену по одному-двум аналогам, не отдавать 0 ₽
All checks were successful
CI / changes (pull_request) Successful in 7s
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m38s
31f82fc5e9
Live-аудит на проде (2026-08-02): headline-медиана строилась даже по 1-4
листингам без проверки достаточности выборки (Серов 2к/45м2, n=3 листинга ->
42 391 руб/м2 vs честный ДКП-коридор 54 126, -36%; соседняя улица того же
города на других 1-2 лотах давала разброс до 1.3x). Каменск-Уральский на
редких room-count давал буквальный median_price_rub=0 без явного отказа.

Fix:
- HEADLINE_LISTINGS_MIN_N=5 (estimator.py) - ниже порога радиусная медиана
  подавляется (listings_clean НЕ очищается - нужен ghost-anchor guard'у #1871)
  и маршрутизируется на уже существующий deals-headline-fallback (#oblast-D,
  сделки Росреестра) или честный insufficient_data, если сделок тоже мало.
- deals-headline-fallback гейт исправлен с anchor_tier is None на anchor is
  None - anchor_tier оставался stale (не сбрасывался в None), когда
  _compute_same_building_anchor отклонял кандидата целиком, блокируя fallback
  даже при большом ДКП-покрытии (найдено live regen'ом бэктест-фикстуры).
- Честные explanation-тексты: отличают "объявлений действительно нет" от
  "нашли N, но недостаточно для доверия" - не показывают противоречивые
  сообщения одновременно.
- insufficient_data (computed field, median_price_rub<=0) остаётся
  единственным источником истины - PDF/API уже не показывают 0 как число.

Regenerated tests/fixtures/backtest_baseline.json: 8 из 277 прод-сделок
теперь используют deals-fallback вместо шумной 2-4-листинговой медианы
(mape_pct 13.23%->13.18%, headline spread_pct 17.98%->16.65%).
lekss361 merged commit 8b79e9659b into main 2026-08-02 12:41:48 +00:00
lekss361 deleted branch fix/tradein-analog-sufficiency 2026-08-02 12:41:49 +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#2629
No description provided.