From 2878a88c673a32c51805d6093baa52981ed32326 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 5 Aug 2026 21:27:38 +0500 Subject: [PATCH 1/3] =?UTF-8?q?fix(tradein/estimate):=20=D1=84=D0=B8=D0=BB?= =?UTF-8?q?=D1=8C=D1=82=D1=80=20=D1=81=D0=B2=D0=B5=D0=B6=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=B8=20=D0=B2=20=D1=8F=D0=BA=D0=BE=D1=80=D0=B5=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=BC=D0=B0=20=D0=B8=20=D0=B7=D0=BD=D0=B0=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D0=B0=D1=82=D0=B5=D0=BB=D0=B5=20=D0=BA=D0=BE=D1=8D=D1=84=D1=84?= =?UTF-8?q?=D0=B8=D1=86=D0=B8=D0=B5=D0=BD=D1=82=D0=B0=20=D0=B2=D1=8B=D0=BA?= =?UTF-8?q?=D1=83=D0=BF=D0=B0=20(#2656)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Цена местами строилась на объявлениях, которых никто не видел месяц. Главный радиусный путь эстиматора нёс `scraped_at > NOW() - LISTINGS_FRESH_DAYS дней` (_COMMON_WHERE), а четыре денежные выборки — нет: - `_fetch_anchor_comps` Tier A и Tier C: якорь ЗАМЕЩАЕТ headline (median_ppm2/median_price/n_analogs), т.е. правит цену напрямую; - `asking_to_sold_ratio` ask_side и ask_global: знаменатель коэффициента, на который умножается expected_sold_price. `is_active` свежесть не заменяет — он означает разное у разных источников (TTL деактивации 30 дней, NULL-сегмент не деактивируется никогда), а `scraped_at` одно и то же. Прод: 21 132 из 37 497 активных строк протухли по 14-дневной мерке самого эстиматора и были полностью годны для якоря. Окно вынесено в app.core.config.LISTINGS_FRESH_DAYS (одно место на всех): держать его в estimator.py нельзя — тот сам импортирует area_bucket из app.tasks.asking_to_sold_ratio, обратный импорт дал бы цикл. Второй половиной — залипший anchor_tier: он оставался "C"/"A", когда якорь не был построен, и молча глушил IMV-blend, quarter-index (#764 Guard-1a), radius-floor и corridor-clamp-exempt Tier A. Теперь сбрасывается явно, у источника, для всех трёх причин (None из _compute_same_building_anchor, гейт Tier C #1795, low-conf гейт #audit-1). Обе половины одним PR намеренно: порознь они дадут два заметных скачка цены вместо одного меньшего (якорная половина −3.63%, знаменатель +1.25%, вместе −2.42% от суммы выкупа на 1040 реальных оценках). Тесты: tests/test_freshness_filter_2656.py — предикат во всех четырёх местах, единственность константы, бинд :fresh_days, протухший комп не в пуле якоря, сброс anchor_tier и разглушённый IMV-blend. Все 7 краснеют без фикса (проверено git stash). --- tradein-mvp/backend/app/core/config.py | 14 + tradein-mvp/backend/app/services/estimator.py | 41 ++- .../backend/app/tasks/asking_to_sold_ratio.py | 25 +- .../backend/scripts/backtest_estimator.py | 47 ++- .../tests/test_asking_to_sold_ratio.py | 13 +- .../tests/test_estimator_price_spine.py | 10 +- .../tests/test_freshness_filter_2656.py | 305 ++++++++++++++++++ 7 files changed, 436 insertions(+), 19 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_freshness_filter_2656.py diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index e8d888ca..8c44ca32 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -30,6 +30,20 @@ _AUTH_DB_DEFAULT_NAME = "auth" # Роль приложения из data/sql/auth/002_auth_app_role.sql (least privilege). _AUTH_DB_DEFAULT_USER = "auth_app" +# ── Окно свежести объявлений (#2656) ────────────────────────────────────────── +# ЕДИНСТВЕННОЕ место, где живёт это число. Читают: estimator (_COMMON_WHERE +# радиусного пути, inline-копия Tier W, оба SQL якоря дома) и ночной пересчёт +# asking_to_sold_ratios (знаменатель коэффициента выкупа). Лежит здесь, а не в +# estimator.py, потому что estimator сам импортирует area_bucket из +# app.tasks.asking_to_sold_ratio — обратный импорт дал бы цикл. +# +# ЗАЧЕМ фильтр вообще: `is_active` означает РАЗНОЕ для разных источников (TTL +# деактивации 30 дней у cian/yandex-вторички, NULL-сегмент не деактивируется +# никогда), а `scraped_at` — одно и то же. Ослабление этого окна или подмена +# `scraped_at` на `last_seen_at` впускает в ценовые выборки объявления, которых +# никто не видел месяц (прод 2026-08: 21 132 из 37 497 активных строк). +LISTINGS_FRESH_DAYS = 14 # объявления не старше 14 дней + class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index e93a0951..dd6c962a 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -53,7 +53,7 @@ from scraper_kit.providers.yandex.valuation import ( from sqlalchemy import text from sqlalchemy.orm import Session -from app.core.config import Settings, settings +from app.core.config import LISTINGS_FRESH_DAYS, Settings, settings from app.schemas.trade_in import ( AggregatedEstimate, AnalogLot, @@ -90,7 +90,9 @@ FALLBACK_RADIUS_M = 2000 AREA_TOLERANCE = 0.15 # ±15% площади MAX_ANALOGS_PER_ADDRESS = 5 # анти-bias: не больше 5 лотов с одного адреса MIN_ANALOGS_PER_SOURCE = 5 # гарантированный минимум на live source -LISTINGS_FRESH_DAYS = 14 # объявления не старше 14 дней +# LISTINGS_FRESH_DAYS (окно свежести объявлений) живёт в app.core.config — #2656: +# его читает и ночной пересчёт asking_to_sold_ratios, а импортировать estimator +# оттуда нельзя (estimator сам тянет area_bucket из этой задачи → цикл импорта). DEALS_PERIOD_MONTHS = 12 # сделки за последний год # #oblast-D (non-EKB deals-headline-fallback): минимум ДКП-сделок, чтобы @@ -1857,7 +1859,9 @@ def _fetch_anchor_comps( ненадёжны.) Tier D — фолбэк: None tier (caller остаётся на радиусном median-пути). - Excludes lots без price_per_m2. is_active=true. Best-effort: ([], None) на ошибке. + Excludes lots без price_per_m2. is_active=true И scraped_at свежее + LISTINGS_FRESH_DAYS (#2656 — то же окно, что у радиусного пути; см. + комментарий в SQL Tier A). Best-effort: ([], None) на ошибке. Returns (comps, tier) где tier ∈ {'A','C', None}. comps — list dict с ключами price_per_m2 (int>0), area_m2 (float|None), rooms (int|None), @@ -1883,6 +1887,15 @@ def _fetch_anchor_comps( listing_segment, source_id FROM listings WHERE is_active = true + -- #2656: то же окно свежести, что несёт _COMMON_WHERE + -- радиусного пути. Без него якорь строился на + -- объявлениях, которых никто не видел месяц (is_active + -- живёт по TTL деактивации 30д, у NULL-сегмента — вообще + -- бессрочно), и они ЗАМЕЩАЛИ headline (median_ppm2/ + -- median_price/n_analogs ниже), т.е. двигали деньги + -- напрямую. Прод 2026-08: 31 дом из 952 получал якорь + -- Tier A исключительно за счёт протухших строк. + AND scraped_at > NOW() - (:fresh_days || ' days')::interval AND price_per_m2 > 0 AND lower(translate(address, 'ёЁ', 'ее')) LIKE :street_like AND lower(translate(address, 'ёЁ', 'ее')) ~ :house_re @@ -1900,6 +1913,7 @@ def _fetch_anchor_comps( "lon": lon, "lat": lat, "radius": ANCHOR_TIER_A_RADIUS_M, + "fresh_days": LISTINGS_FRESH_DAYS, }, ) .mappings() @@ -1975,6 +1989,9 @@ def _fetch_anchor_comps( days_on_market, photo_urls, lat, lon FROM listings WHERE is_active = true + -- #2656: окно свежести, симметрично Tier A выше и + -- _COMMON_WHERE радиусного пути (см. комментарий там). + AND scraped_at > NOW() - (:fresh_days || ' days')::interval AND price_per_m2 > 0 AND rooms = CAST(:rooms AS integer) AND area_m2 BETWEEN :area_min AND :area_max @@ -1994,6 +2011,7 @@ def _fetch_anchor_comps( "area_max": area * 1.25, "lon": lon, "lat": lat, + "fresh_days": LISTINGS_FRESH_DAYS, }, ) .mappings() @@ -2682,7 +2700,18 @@ def _price_from_inputs( gate_thin, ) anchor = None - anchor_tier = None + + # #2656: якорь не построен — сбрасываем tier-флаг ЯВНО. Причин три: + # _compute_same_building_anchor вернула None (комплов меньше min_comps, в т.ч. + # после MAD-клипа), гейт Tier C #1795 или low-conf гейт #audit-1 выше. Раньше + # сброс делал только последний из трёх, и в остальных случаях anchor_tier + # залипал равным anchor_tier_fetched ("C"/"A") при радиусном headline. Флаг + # читают IMV-blend (`anchor_tier is None`, ниже), quarter-index #764 Guard-1a, + # radius-floor от ДКП-коридора, corridor-clamp (Tier A exempt) и api_analog_tier + # — залипший флаг молча глушил их все, будто headline построил якорь. Замер + # #2656: 24 оценки без якоря до и после правки всё равно меняли цену из-за него. + if anchor is None: + anchor_tier = None if anchor is not None: # #694: якорь мутирует headline — UI-аналоги должны отражать ЭТИ комплы. @@ -3172,6 +3201,10 @@ def _price_from_inputs( # and blocked this fallback even with a large, valid ДКП corridor # available (observed: 677 deals for one fixture case). `anchor is None` # is the ground truth of whether the anchor actually produced a headline. + # #2656 update: тот залипший флаг теперь сбрасывается у источника (см. `if + # anchor is None: anchor_tier = None` в anchor-блоке выше), т.е. два условия + # стали эквивалентны. Гард оставлен на `anchor is None` НАМЕРЕННО — это + # по-прежнему прямая проверка факта «якорь дал headline», а не производный флаг. if ( median_ppm2 <= 0 and anchor is None diff --git a/tradein-mvp/backend/app/tasks/asking_to_sold_ratio.py b/tradein-mvp/backend/app/tasks/asking_to_sold_ratio.py index e6220687..de5d8300 100644 --- a/tradein-mvp/backend/app/tasks/asking_to_sold_ratio.py +++ b/tradein-mvp/backend/app/tasks/asking_to_sold_ratio.py @@ -26,7 +26,14 @@ ask_side / per_bucket + deal_global / ask_global / global_row: трейлинг- n_listings>=30 для per_rooms, global -1 строка всегда). ON CONFLICT убран — DELETE идёт первым, конфликтов нет (повторный прогон в одной tx невозможен, refresh = re-seed по семантике). -#2620 — ОДНО ПРЕДНАМЕРЕННОЕ РАСХОЖДЕНИЕ с 080: deal_side бакетится по +#2656 — ВТОРОЕ ПРЕДНАМЕРЕННОЕ РАСХОЖДЕНИЕ с 080 (первое — #2620 ниже): ask_side/ask_global +несут фильтр свежести `scraped_at > NOW() - LISTINGS_FRESH_DAYS дней` — тот же, что эстиматор +применяет к ЧИСЛИТЕЛЮ (_COMMON_WHERE). Без него знаменатель считался по бессрочной популяции +объявлений, а числитель — по 14-дневной, т.е. коэффициент калибровался на одном рынке, а +применялся к другому. Замер на проде (2026-08, #2656): глобальный коэффициент −0.49%, бакеты +44-62 +2.43% и 62-85 +4.58%; NULL-сегмент в знаменателе схлопывается с 674 строк до 20. + +#2620 — ПЕРВОЕ ПРЕДНАМЕРЕННОЕ РАСХОЖДЕНИЕ с 080: deal_side бакетится по LEAST(GREATEST(rooms,0),4), а ask_side — по _AREA_ROOMS_BUCKET_SQL (площадь, та же формула, что deals.rooms получает при импорте). Причина — deals.rooms НЕ настоящая комнатность (Росреестр её не отдаёт), это синтетика из площади; сравнивать её с РЕАЛЬНЫМИ комнатами @@ -43,7 +50,7 @@ import logging from sqlalchemy import text from sqlalchemy.orm import Session -from app.core.config import settings +from app.core.config import LISTINGS_FRESH_DAYS, settings from app.services import scrape_runs as runs_mod # Нижняя граница ppm² — отсекает нежилые/технические сделки; не меняется. @@ -119,7 +126,8 @@ _DELETE_SQL = text( # бакет LEAST(GREATEST(rooms,0),4) (rooms уже синтетика-из-площади при импорте, см. #2620 # комментарий у _AREA_ROOMS_BUCKET_SQL выше). # ask_median = percentile_cont(0.5) по listings.price_per_m2 -# (is_active, та же ppm²-полоса [_PPM2_MIN, asking_ratio_ppm2_max], тот же город что +# (is_active + свежесть scraped_at ≤ LISTINGS_FRESH_DAYS (#2656), та же ppm²-полоса +# [_PPM2_MIN, asking_ratio_ppm2_max], тот же город что # SOLD-сторона — city IS NULL OR city ILIKE :asking_city, #2583 H2). Бакет — # _AREA_ROOMS_BUCKET_SQL (площадь, #2620), НЕ listings.rooms — см. комментарий там. # per_rooms строки — только при n_deals>=30 AND n_listings>=30 AND ask>0 AND sold>0. @@ -156,6 +164,13 @@ _REDERIVE_SQL = text( COUNT(*) AS n_listings FROM listings WHERE is_active + -- #2656: окно свежести — то же самое, что эстиматор применяет к ЧИСЛИТЕЛЮ + -- (_COMMON_WHERE, LISTINGS_FRESH_DAYS). Без него числитель оценки считался + -- по 14-дневной популяции, а знаменатель коэффициента — по бессрочной: + -- калибровка и применение по разным рынкам. `is_active` для этого не годится + -- — он означает разное у разных источников (TTL деактивации 30д, NULL-сегмент + -- не деактивируется никогда: 97.4% таких строк протухшие и при этом дорогие). + AND scraped_at > NOW() - (:fresh_days || ' days')::interval AND rooms IS NOT NULL -- #2620 hardening: area_m2 IS NULL falls into the CASE ELSE branch (bucket 4) -- of _AREA_ROOMS_BUCKET_SQL — a latent "everything unmeasured looks like a big @@ -216,6 +231,9 @@ _REDERIVE_SQL = text( COUNT(*) AS n_listings FROM listings WHERE is_active + -- #2656: то же окно свежести, что и в ask_side выше (см. комментарий там) + -- — global-строка должна считаться по той же популяции, что per-bucket. + AND scraped_at > NOW() - (:fresh_days || ' days')::interval AND rooms IS NOT NULL -- #2620 hardening: same area_m2 IS NOT NULL as ask_side — keeps the global-row -- population consistent with the per-bucket rows it's a fallback for. @@ -313,6 +331,7 @@ def recompute_asking_to_sold_ratios(db: Session, run_id: int) -> dict[str, int]: "ppm2_min": _PPM2_MIN, "ppm2_max": settings.asking_ratio_ppm2_max, "asking_city": _ASKING_CITY_PATTERN, + "fresh_days": LISTINGS_FRESH_DAYS, }, ) diff --git a/tradein-mvp/backend/scripts/backtest_estimator.py b/tradein-mvp/backend/scripts/backtest_estimator.py index 30be3429..aadf2193 100644 --- a/tradein-mvp/backend/scripts/backtest_estimator.py +++ b/tradein-mvp/backend/scripts/backtest_estimator.py @@ -1462,8 +1462,15 @@ def load_fixture(path: str) -> dict[str, Any]: return json.loads(Path(path).read_text(encoding="utf-8")) +_UNRECORDED = object() # sentinel: «на лишний вызов ответа нет» → RuntimeError + + def _make_call_stub( - calls: list[Any], *, label: str, coerce: Callable[[Any], Any] + calls: list[Any], + *, + label: str, + coerce: Callable[[Any], Any], + on_exhausted: Any = _UNRECORDED, ) -> Callable[[Any], Any]: """Build an ORDER-based (FIFO) replay stub from recorded ``[arg, return]`` pairs. @@ -1478,6 +1485,17 @@ def _make_call_stub( stays correct if a call site ever loops. Calling the stub MORE times than recorded raises RuntimeError — control flow diverged from capture. + ``on_exhausted`` (#2656) ослабляет ЭТО последнее правило для конкретной + callable: значение возвращается вместо RuntimeError, когда фикстура записала + меньше вызовов, чем сделал реплей. Нужно, когда правка РАЗБЛОКИРОВАЛА путь, + которого при захвате фикстуры не было (сброс залипшего ``anchor_tier`` открыл + quarter-index-гейт `Guard-1a` на 19 из 277 сделок) — у прод-фикстуры на такие + вызовы ответа нет и взять его негде, пока фикстуру не перезахватят с прода. + Использовать ТОЛЬКО для lookup'ов, у которых «промах» — валидное состояние + (quarter-index: None/{} = индекса нет → блок no-op). ``ratio_resolver`` + остаётся строгим НАМЕРЕННО: лишний вызов там означал бы, что реплей взял + другой коэффициент выкупа, т.е. молча другие деньги. + ``coerce`` maps each JSON-plain recorded return back to the live callable's return type (tuple / dict) so unpacking at the call site behaves identically. """ @@ -1487,10 +1505,13 @@ def _make_call_stub( def _stub(_arg: Any) -> Any: nonlocal idx if idx >= len(returns): - raise RuntimeError( - f"{label}: replay made call #{idx + 1} but fixture recorded only " - f"{len(returns)} — control flow diverged from capture" - ) + if on_exhausted is _UNRECORDED: + raise RuntimeError( + f"{label}: replay made call #{idx + 1} but fixture recorded only " + f"{len(returns)} — control flow diverged from capture" + ) + idx += 1 + return on_exhausted ret = returns[idx] idx += 1 return ret @@ -1530,11 +1551,23 @@ def replay_fixture(fixture: dict[str, Any]) -> dict[str, Any]: kw["ratio_resolver"] = _make_call_stub( rec.get("ratio_calls") or [], label="ratio_resolver", coerce=_coerce_ratio_return ) + # #2656: quarter-index-lookup'ы отвечают «промах» (None / {}) на вызовы, которых + # в фикстуре нет — см. _make_call_stub. Сброс залипшего anchor_tier открыл гейт + # Guard-1a на 19 из 277 сделок, а прод-фикстура захвачена ДО правки. Следствие, + # которое надо знать при чтении метрик: для этих 19 сделок квартальный индекс в + # реплее НЕ применяется вовсе (в проде — применился бы), т.е. гейт занижает + # эффект правки. Уйдёт при следующем перезахвате фикстуры с прода. kw["quarter_index_lookup"] = _make_call_stub( - rec.get("qi_calls") or [], label="quarter_index_lookup", coerce=_coerce_qi_return + rec.get("qi_calls") or [], + label="quarter_index_lookup", + coerce=_coerce_qi_return, + on_exhausted=None, ) kw["quarter_indexes_lookup"] = _make_call_stub( - rec.get("qis_calls") or [], label="quarter_indexes_lookup", coerce=_coerce_qis_return + rec.get("qis_calls") or [], + label="quarter_indexes_lookup", + coerce=_coerce_qis_return, + on_exhausted={}, ) pr = m._price_from_inputs(**kw) diff --git a/tradein-mvp/backend/tests/test_asking_to_sold_ratio.py b/tradein-mvp/backend/tests/test_asking_to_sold_ratio.py index 671d8f58..e41a5c67 100644 --- a/tradein-mvp/backend/tests/test_asking_to_sold_ratio.py +++ b/tradein-mvp/backend/tests/test_asking_to_sold_ratio.py @@ -208,6 +208,11 @@ def test_migration_080_derivation_is_subset_of_refresh_sql() -> None: #2620 hardening: the refresh also adds `AND area_m2 IS NOT NULL` to ask_side/ask_global (NULL area_m2 would fall into the CASE ELSE branch = bucket 4 — a latent trap). Absent in the 080 seed; dropped here the same way as the other guards above. + + #2656: the refresh now also adds the freshness predicate (`scraped_at > NOW() - + LISTINGS_FRESH_DAYS days`) to each ask_* CTE — the same window the estimator applies to + the NUMERATOR (_COMMON_WHERE). Absent in the 080 seed; normalised away here, asserted + on its own in tests/test_freshness_filter_2656.py. """ seed_sql = _MIGRATION_080.read_text("utf-8") # Extract the WITH … (up to the ON CONFLICT) from the seed. @@ -269,11 +274,17 @@ def test_migration_080_derivation_is_subset_of_refresh_sql() -> None: """ return re.sub(r"AND\s+area_m2\s+IS\s+NOT\s+NULL", "", s) + def _drop_freshness_guard(s: str) -> str: + """Remove the #2656 freshness predicate (absent in the 080 seed).""" + return re.sub(r"AND\s+scraped_at\s*>\s*NOW\(\)\s*-\s*\(:fresh_days[^\n]*?interval", "", s) + def _norm(s: str) -> str: return _strip_sql( _normalise_ppm2( _normalise_bucket_expr( - _drop_area_not_null_guard(_drop_city_guard(_drop_segment_guard(s))) + _drop_freshness_guard( + _drop_area_not_null_guard(_drop_city_guard(_drop_segment_guard(s))) + ) ) ) ) diff --git a/tradein-mvp/backend/tests/test_estimator_price_spine.py b/tradein-mvp/backend/tests/test_estimator_price_spine.py index fd38466b..1398f514 100644 --- a/tradein-mvp/backend/tests/test_estimator_price_spine.py +++ b/tradein-mvp/backend/tests/test_estimator_price_spine.py @@ -200,8 +200,10 @@ def test_same_building_anchor_tier_a_mutates_headline() -> None: def test_tier_c_corridor_gate_suppresses_anchor() -> None: """Tier C anchor ppm2 >> corridor_high × mult → anchor suppressed. - anchor_tier remains "C" in the result (gate sets anchor=None but doesn't - reset anchor_tier); headline stays at the radius median. + #2656: anchor_tier теперь СБРАСЫВАЕТСЯ в None (раньше гейт ставил anchor=None, + но оставлял залипший "C" — и этот флаг молча глушил IMV-blend/quarter-index/ + radius-floor, будто headline построил якорь). Headline как и раньше остаётся + радиусной медианой. """ # 5 comps at 300k ppm2; corridor_high=150k; gate threshold=150k×1.5=225k. # 300k > 225k → suppressed. @@ -215,8 +217,8 @@ def test_tier_c_corridor_gate_suppresses_anchor() -> None: ratio=None, ) - # Tier C gate sets anchor=None but leaves anchor_tier="C". - assert pr.anchor_tier == "C" + # #2656: гейт ставит anchor=None → tier-флаг сбрасывается вместе с ним. + assert pr.anchor_tier is None # Headline was NOT mutated by the suppressed anchor — stays at radius median. assert pr.median_price == radius_median_price # anchor_comps_used stays empty (anchor didn't fire). diff --git a/tradein-mvp/backend/tests/test_freshness_filter_2656.py b/tradein-mvp/backend/tests/test_freshness_filter_2656.py new file mode 100644 index 00000000..e84a50c0 --- /dev/null +++ b/tradein-mvp/backend/tests/test_freshness_filter_2656.py @@ -0,0 +1,305 @@ +"""#2656 — фильтр свежести объявлений там, где он двигает деньги. + +Дыра (найдена аудитом 2026-08, код читан на origin/main): главный радиусный путь +эстиматора нёс `scraped_at > NOW() - LISTINGS_FRESH_DAYS дней` (_COMMON_WHERE), а +ЧЕТЫРЕ другие выборки, которые тоже двигают цену, — нет: + + 1. `_fetch_anchor_comps` Tier A (same building) — якорь ЗАМЕЩАЕТ headline + (median_ppm2/median_price/n_analogs), т.е. правит цену напрямую; + 2. `_fetch_anchor_comps` Tier C (микро-радиус ≤500 м) — то же замещение; + 3. `asking_to_sold_ratio.ask_side` — знаменатель коэффициента выкупа; + 4. `asking_to_sold_ratio.ask_global` — он же, global-фолбэк. + +`is_active` не заменяет свежесть: он означает РАЗНОЕ у разных источников (TTL +деактивации 30 дней у cian/yandex-вторички, NULL-сегмент не деактивируется никогда), +а `scraped_at` — одно и то же. Прод 2026-08: 21 132 из 37 497 «активных» строк +протухли по 14-дневной мерке самого эстиматора и при этом были полностью годны +для якоря. + +Пятый (побочный) дефект, вскрытый тем же замером: `anchor_tier` оставался равным +"C", когда якорь НЕ был построен, и этот залипший флаг молча глушил IMV-blend, +quarter-index и radius-floor — см. вторую половину файла. + +Эти тесты — защита от снятия предиката: именно так дыра и появилась (в главном +пути фильтр есть, в якоре и знаменателе забыли). Проверка на красноту сделана +`git stash` на файлах реализации — все 7 тестов падают без фикса. +""" + +from __future__ import annotations + +import inspect +import os +import re +from datetime import UTC, datetime, timedelta +from typing import Any +from unittest.mock import MagicMock + +# Settings требует DATABASE_URL при инициализации (fail-fast, C-3). +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.core.config import LISTINGS_FRESH_DAYS +from app.services import estimator as est_mod +from app.services.estimator import _fetch_anchor_comps, _price_from_inputs +from app.services.geocoder import GeocodeResult +from app.tasks import asking_to_sold_ratio as ratio_mod + +# Ровно тот предикат, что стоит в _COMMON_WHERE радиусного пути. Сверяем ПОДСТРОКОЙ, +# а не регуляркой: цель — чтобы во всех местах стоял ОДИН И ТОТ ЖЕ текст, а не пять +# похожих вариантов, которые потом разъедутся по смыслу. +_PREDICATE = "AND scraped_at > NOW() - (:fresh_days || ' days')::interval" + +_ANCHOR_SRC = inspect.getsource(_fetch_anchor_comps) +_TIER_C_MARK = "# ── Tier C: micro-radius" +_TIER_A_SRC = _ANCHOR_SRC[: _ANCHOR_SRC.index(_TIER_C_MARK)] +_TIER_C_SRC = _ANCHOR_SRC[_ANCHOR_SRC.index(_TIER_C_MARK) :] + +_REDERIVE_SQL = str(ratio_mod._REDERIVE_SQL.text) +_ASK_SIDE_SQL = _REDERIVE_SQL[ + _REDERIVE_SQL.index("ask_side AS") : _REDERIVE_SQL.index("per_bucket AS") +] +_ASK_GLOBAL_SQL = _REDERIVE_SQL[ + _REDERIVE_SQL.index("ask_global AS") : _REDERIVE_SQL.index("global_row AS") +] + + +# ── 1. Предикат стоит во всех четырёх местах ───────────────────────────────── + + +def test_freshness_predicate_present_in_all_four_places() -> None: + """Все четыре денежные выборки несут ТОТ ЖЕ предикат свежести, что _COMMON_WHERE. + + Falsifiable: на непропатченном коде FALSE во всех четырёх (там только + `is_active = true`), TRUE после правки. + """ + places = { + "_COMMON_WHERE (эталон, радиусный путь)": est_mod._COMMON_WHERE, + "_fetch_anchor_comps Tier A": _TIER_A_SRC, + "_fetch_anchor_comps Tier C": _TIER_C_SRC, + "asking_to_sold_ratio ask_side": _ASK_SIDE_SQL, + "asking_to_sold_ratio ask_global": _ASK_GLOBAL_SQL, + } + missing = [name for name, sql in places.items() if _PREDICATE not in sql] + assert not missing, f"фильтр свежести #2656 снят в: {missing}" + + +def test_freshness_window_lives_in_one_place() -> None: + """Окно берётся из ОДНОЙ константы (app.core.config.LISTINGS_FRESH_DAYS), не из литерала. + + Иначе значения разъедутся — ровно так дыра #2656 и появилась. Проверяем и то, что + оба модуля импортируют имя из config (а не завели свою копию), и то, что в SQL нет + зашитого «14 days». + """ + assert LISTINGS_FRESH_DAYS == 14 # текущее значение; меняется в одном месте + for mod in (est_mod, ratio_mod): + src = inspect.getsource(mod) + assert re.search( + r"from app\.core\.config import [^\n]*LISTINGS_FRESH_DAYS", src + ), f"{mod.__name__}: LISTINGS_FRESH_DAYS не импортирован из app.core.config" + assert not re.search( + r"^LISTINGS_FRESH_DAYS\s*=", src, re.M + ), f"{mod.__name__}: локальная копия константы — значение разъедется" + assert "'14 days'" not in _REDERIVE_SQL + assert "'14 days'" not in _ANCHOR_SRC + + +def test_freshness_param_bound_in_both_anchor_tiers() -> None: + """Оба тира якоря БИНДЯТ :fresh_days — иначе SQL упадёт на невязанном параметре. + + Отдельный тест от предиката: забыть параметр в dict — ровно та ошибка, которую + статическая проверка текста SQL не ловит, а прод ловит 500-й. + """ + for name, src in (("Tier A", _TIER_A_SRC), ("Tier C", _TIER_C_SRC)): + assert '"fresh_days": LISTINGS_FRESH_DAYS,' in src, f"{name}: :fresh_days не забинден" + + +# ── 2. Протухший комп не попадает в пул якоря, свежий попадает ─────────────── + +_ADDRESS = "Екатеринбург, ул. Евгения Савкова, 29" +_LAT, _LON = 56.838, 60.595 + + +def _listing_row( + *, source_id: str, days_ago: int, price_per_m2: float = 150_000.0 +) -> dict[str, Any]: + """Строка listings в форме .mappings() + служебный scraped_at для заглушки ниже.""" + return { + "price_per_m2": price_per_m2, + "area_m2": 50.0, + "rooms": 2, + "floor": 3, + "total_floors": 9, + "address": _ADDRESS, + "source": "avito", + "source_url": f"https://avito.ru/offer/{source_id}", + "price_rub": int(price_per_m2 * 50.0), + "listing_date": None, + "days_on_market": 15, + "photo_urls": [], + "lat": _LAT, + "lon": _LON, + "listing_segment": "vtorichka", + "source_id": source_id, + "scraped_at": datetime.now(tz=UTC) - timedelta(days=days_ago), + } + + +def _predicate_aware_db(rows: list[dict[str, Any]]) -> MagicMock: + """Session-заглушка, ИСПОЛНЯЮЩАЯ предикат свежести вместо Postgres. + + Настоящей БД в тестах нет, поэтому отсев по `scraped_at` делает сама заглушка — + но ТОЛЬКО если SQL реально несёт предикат И :fresh_days забинден. Без фикса + предиката в SQL нет → протухшие строки доезжают до пула якоря → тест краснеет. + Это эмуляция ровно одного WHERE-условия, не мини-Postgres: остальные предикаты + (адрес/гео/сегмент) заданы данными фикстуры так, чтобы они тривиально проходили. + """ + db = MagicMock() + + def _execute(stmt: Any, params: dict[str, Any] | None = None) -> MagicMock: + sql = str(stmt) + kept = rows + if _PREDICATE in sql: + cutoff = datetime.now(tz=UTC) - timedelta(days=params["fresh_days"]) + kept = [r for r in rows if r["scraped_at"] > cutoff] + result = MagicMock() + result.mappings.return_value.all.return_value = kept + return result + + db.execute.side_effect = _execute + return db + + +def test_stale_comps_excluded_fresh_kept_in_anchor_pool() -> None: + """4 свежих + 3 протухших компа → в якорь идут только 4 свежих. + + Прод-мотивация: протухшие строки в сегменте, из которого строится якорь, в среднем + на 7.7% дороже живых (дорогое висит дольше и первым выпадает из выдачи), т.е. + фантом в тонком пуле тянет якорь ВВЕРХ. Замер #2656: 31 дом из 952 получал якорь + Tier A исключительно за счёт протухших. + """ + rows = [_listing_row(source_id=f"f{i}", days_ago=1) for i in range(4)] + rows += [_listing_row(source_id=f"s{i}", days_ago=40, price_per_m2=260_000.0) for i in range(3)] + + comps, tier = _fetch_anchor_comps( + _predicate_aware_db(rows), + address=_ADDRESS, + target_house_id=None, + lat=_LAT, + lon=_LON, + rooms=2, + area=50.0, + ) + + assert tier == "A" + assert len(comps) == 4 + assert all(c["price_per_m2"] == 150_000.0 for c in comps), "протухший комп попал в якорь" + + +def test_thin_pool_loses_anchor_when_only_stale_comps_remain() -> None: + """3 свежих + 5 протухших: было ≥ min_comps=4 → якоря больше нет (Tier D фолбэк). + + Это и есть заявленная цена правки: 139 из 1014 реальных оценок теряют якорь и + уезжают на радиусный путь / ДКП-фолбэк. Честнее, чем якорь на объявлениях, + которых никто не видел месяц. + """ + rows = [_listing_row(source_id=f"f{i}", days_ago=2) for i in range(3)] + rows += [_listing_row(source_id=f"s{i}", days_ago=45) for i in range(5)] + + comps, tier = _fetch_anchor_comps( + _predicate_aware_db(rows), + address=_ADDRESS, + target_house_id=None, + lat=_LAT, + lon=_LON, + rooms=2, + area=50.0, + ) + + # Tier A не набрал порог; Tier C на тех же строках — тоже (заглушка отдаёт их же). + assert tier is None + assert comps == [] + + +# ── 3. anchor_tier сбрасывается, когда якорь не построен ───────────────────── + + +def _geo() -> GeocodeResult: + return GeocodeResult( + lat=_LAT, lon=_LON, full_address="Екатеринбург, ул. Ленина, 5", provider="dadata" + ) + + +def _price( + *, + anchor_comps: list[dict[str, Any]], + anchor_tier_fetched: str | None, + imv_anchor: dict[str, Any] | None = None, +) -> est_mod.PricingResult: + listings = [ + {"price_per_m2": 200_000.0, "address": f"ул. Ленина, {i + 5}", "source": "avito"} + for i in range(6) + ] + return _price_from_inputs( + listings=listings, + area_m2=50.0, + rooms=2, + repair_state=None, + floor=5, + total_floors=9, + target_year=None, + analog_tier="W", + fallback_used=False, + area_widened=False, + anchor_comps=anchor_comps, + anchor_tier_fetched=anchor_tier_fetched, + dkp_raw=None, + imv_anchor=imv_anchor, + imv_eval=None, + yandex_val_present=False, + cian_val_present=False, + ratio_resolver=lambda _ppm2: (None, None), + quarter_index_lookup=lambda _q: None, + quarter_indexes_lookup=lambda _qs: {}, + target_house_cadnum=None, + dadata_coarse=False, + geo=_geo(), + dadata_qc_geo=None, + ) + + +def _anchor_comp(ppm2: float) -> dict[str, Any]: + return {"price_per_m2": ppm2, "area_m2": 50.0, "rooms": 2, "floor": 3, "total_floors": 9} + + +def test_anchor_tier_reset_when_anchor_not_built() -> None: + """Якорь не построен (комплов меньше min_comps) → anchor_tier=None, а не залипшая "C". + + Побочный дефект, вскрытый замером #2656: сброс делал только low-conf гейт, а когда + _compute_same_building_anchor возвращала None сама, флаг оставался равным + anchor_tier_fetched. Дальше по коду он читается как «headline построил якорь». + """ + pr = _price( + anchor_comps=[_anchor_comp(150_000.0), _anchor_comp(155_000.0)], anchor_tier_fetched="C" + ) + + assert pr.anchor_tier is None + assert pr.anchor_comps_used == [] + assert pr.median_ppm2 == 200_000.0 # headline остался радиусным + + +def test_sticky_anchor_tier_no_longer_mutes_imv_blend() -> None: + """Тот же случай + IMV-якорь: blend (гейт `anchor_tier is None`) обязан сработать. + + Это и есть денежное последствие залипшего флага: 24 оценки, у которых якоря нет ни + до, ни после правки, всё равно меняли цену — IMV-blend молча глушился. Falsifiable: + без фикса anchor_tier == "C" → blend пропускается → median_price остаётся 10 000 000. + """ + imv = {"recommended_price": 14_000_000} # > медианы (10 млн) × threshold 1.15 + pr = _price( + anchor_comps=[_anchor_comp(150_000.0), _anchor_comp(155_000.0)], + anchor_tier_fetched="C", + imv_anchor=imv, + ) + + assert pr.anchor_tier is None + assert pr.median_price > 10_000_000, "IMV-blend заглушен залипшим anchor_tier" + assert pr.avito_imv_summary is not None From 828e32e9abecb84229670bc12557c863c229945b Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 5 Aug 2026 22:06:49 +0500 Subject: [PATCH 2/3] =?UTF-8?q?fix(tradein/estimate):=20=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=BD=D1=83=D1=82=D1=8C=20=D0=BA=D0=B0=D0=BD=D0=B0=D1=80=D0=B5?= =?UTF-8?q?=D0=B9=D0=BA=D1=83=20=D0=B1=D1=8D=D0=BA=D1=82=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=B0=20=D0=B8=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA?= =?UTF-8?q?=D1=83=20IMV=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D1=81=D0=B1?= =?UTF-8?q?=D1=80=D0=BE=D1=81=D0=B0=20anchor=5Ftier=20(#2656)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit По итогам глубокого ревью PR #2661. 1. Ослабление replay-стаба выключало канарейку «реплей разошёлся с захватом» навсегда и для всех будущих PR, а не только для 19 сделок этой правки. Теперь число неотвеченных lookup-вызовов считается и выносится в метрику unrecorded_lookup_calls — baseline сравнивает целые ТОЧНО, поэтому 19 стало закоммиченной константой: новый незаписанный путь снова валит гейт громко, а перезахват фикстуры доведёт число до нуля. Перезахват фикстуры (лучший путь по ревью) не сделан осознанно: он требует живой прод-БД tradein и тянет НОВУЮ выборку сделок, то есть не «дозаписывает» 19 ответов, а меняет саму систему отсчёта регресс-гейта по причинам, не связанным с этим PR. Попутно найдено и починено: документированная регенерация baseline (--from-fixture --update-baseline) с #2173 писала baseline, который гейт не мог совпасть НИКОГДА — тест пиннит estimate_dedup_analogs_enabled=False, а CLI нет. Пин переехал внутрь replay_fixture, теперь оба пути согласованы. Регенерированный baseline отличается ровно одной строкой (новый ключ), ни одна метрика не сдвинулась. 2. Сброс anchor_tier открывал щель в отображении: комплы якоря добыты, якорь не построен, headline подавлен → не срабатывал ни blend, ни display-only блок, и пользователь терял карточку Avito IMV. Гейт по tier снят, защита от двойного заполнения (`avito_imv_summary is None`) остаётся. 3. Тест мест теперь стережёт и inline-копию _COMMON_WHERE в Tier W — самое вероятное место следующего расхождения того же сорта. Фальсификация: правка baseline 19→18 валит гейт; снятие предиката из Tier W валит тест мест; возврат старого условия display-блока валит новый тест карточки. --- tradein-mvp/backend/app/services/estimator.py | 11 +- .../backend/scripts/backtest_estimator.py | 135 +++++++++++------- .../tests/fixtures/backtest_baseline.json | 3 +- .../tests/test_freshness_filter_2656.py | 44 +++++- 4 files changed, 135 insertions(+), 58 deletions(-) diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index dd6c962a..11b7dff1 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -2874,8 +2874,15 @@ def _price_from_inputs( # Диапазон расширяем даже если медиану не двигали. range_high = new_range_high - # Display-only IMV summary when headline built by same-building anchor. - if anchor_tier is not None and avito_imv_summary is None: + # Display-only IMV summary — когда карточку не заполнил blend выше. + # #2656: условие было `anchor_tier is not None and ...`. После сброса залипшего + # флага оставалась щель: комплы якоря добыты (тир был "C"), якорь не построен, а + # headline подавлен/нулевой → blend не срабатывает (ему нужны listings_clean и + # median_price > 0), старый блок тоже (tier уже None) — и пользователь ТЕРЯЛ + # карточку IMV, которую видел раньше. Гейт по tier снят: `avito_imv_summary is + # None` уже гарантирует, что двойного заполнения не будет. Display-only — + # median/expected_sold/ranges блок не трогает. + if avito_imv_summary is None: if imv_anchor is not None and imv_anchor.get("recommended_price"): _disp_mc = int(imv_anchor["market_count"]) if imv_anchor.get("market_count") else None avito_imv_summary = AvitoImvSummary( diff --git a/tradein-mvp/backend/scripts/backtest_estimator.py b/tradein-mvp/backend/scripts/backtest_estimator.py index aadf2193..69a065c4 100644 --- a/tradein-mvp/backend/scripts/backtest_estimator.py +++ b/tradein-mvp/backend/scripts/backtest_estimator.py @@ -1471,6 +1471,7 @@ def _make_call_stub( label: str, coerce: Callable[[Any], Any], on_exhausted: Any = _UNRECORDED, + unrecorded_counter: list[int] | None = None, ) -> Callable[[Any], Any]: """Build an ORDER-based (FIFO) replay stub from recorded ``[arg, return]`` pairs. @@ -1496,6 +1497,13 @@ def _make_call_stub( остаётся строгим НАМЕРЕННО: лишний вызов там означал бы, что реплей взял другой коэффициент выкупа, т.е. молча другие деньги. + ``unrecorded_counter`` — одноэлементный список-счётчик таких «промахов»; + ``replay_fixture`` выносит сумму в метрику ``unrecorded_lookup_calls``, а та + попадает в baseline целым числом (сравнивается ТОЧНО). Иначе ослабление + выключило бы канарейку «реплей разошёлся с захватом» навсегда и для всех + будущих PR: с закоммиченным числом новое расхождение всё так же валит гейт, + а перезахват фикстуры с прода доведёт его до нуля. + ``coerce`` maps each JSON-plain recorded return back to the live callable's return type (tuple / dict) so unpacking at the call site behaves identically. """ @@ -1510,6 +1518,8 @@ def _make_call_stub( f"{label}: replay made call #{idx + 1} but fixture recorded only " f"{len(returns)} — control flow diverged from capture" ) + if unrecorded_counter is not None: + unrecorded_counter[0] += 1 idx += 1 return on_exhausted ret = returns[idx] @@ -1534,7 +1544,11 @@ def replay_fixture(fixture: dict[str, Any]) -> dict[str, Any]: ``params`` block. Touches NO DB / network and does NOT consult ``settings_at_capture`` — it prices against the live committed ``estimator.settings`` defaults (so a settings change is caught as a metric - drift, not silently honoured). Deterministic: same fixture → identical dict. + drift, not silently honoured). ЕДИНСТВЕННОЕ исключение — + ``estimate_dedup_analogs_enabled``, пиннится в False на время реплея: это не + настройка точности, а условие воспроизводимости ЗАХВАЧЕННОГО контрольного + потока (см. комментарий у пина ниже). Deterministic: same fixture → identical + dict. """ est = _import_estimator_full() m = est.m @@ -1543,58 +1557,75 @@ def replay_fixture(fixture: dict[str, Any]) -> dict[str, Any]: predictions: list[Prediction] = [] sold_ppm2_all: list[float] = [] pred_ppm2_all: list[float] = [] + unrecorded: list[int] = [0] # #2656: счётчик lookup-вызовов без записи в фикстуре - for rec in deals: - kw = dict(rec["kwargs"]) - sold_ppm2_all.append(float(rec["sold_ppm2"])) - kw["geo"] = est.GeocodeResult(**kw["geo"]) - kw["ratio_resolver"] = _make_call_stub( - rec.get("ratio_calls") or [], label="ratio_resolver", coerce=_coerce_ratio_return - ) - # #2656: quarter-index-lookup'ы отвечают «промах» (None / {}) на вызовы, которых - # в фикстуре нет — см. _make_call_stub. Сброс залипшего anchor_tier открыл гейт - # Guard-1a на 19 из 277 сделок, а прод-фикстура захвачена ДО правки. Следствие, - # которое надо знать при чтении метрик: для этих 19 сделок квартальный индекс в - # реплее НЕ применяется вовсе (в проде — применился бы), т.е. гейт занижает - # эффект правки. Уйдёт при следующем перезахвате фикстуры с прода. - kw["quarter_index_lookup"] = _make_call_stub( - rec.get("qi_calls") or [], - label="quarter_index_lookup", - coerce=_coerce_qi_return, - on_exhausted=None, - ) - kw["quarter_indexes_lookup"] = _make_call_stub( - rec.get("qis_calls") or [], - label="quarter_indexes_lookup", - coerce=_coerce_qis_return, - on_exhausted={}, - ) + # #2656: фикстура захвачена с ВЫКЛЮЧЕННЫМ кросс-source дедупом (#2087 H4 был + # no-op по умолчанию на момент захвата), а с #2173 дефолт ON. Реплей обязан идти + # по ЗАХВАЧЕННОМУ контрольному потоку: с активным дедупом _dedup_cross_source + # подрезал бы listings до quarter_indexes_lookup и записанная последовательность + # вызовов разъехалась бы. Пин ЗДЕСЬ, а не только в CI-гейте: гейт монкипатчил флаг + # сам, а документированная регенерация baseline (--from-fixture --update-baseline) + # — нет, и с #2173 писала baseline, который тест не совпал бы НИКОГДА. + _dedup_saved = m.settings.estimate_dedup_analogs_enabled + m.settings.estimate_dedup_analogs_enabled = False + try: + for rec in deals: + kw = dict(rec["kwargs"]) + sold_ppm2_all.append(float(rec["sold_ppm2"])) + kw["geo"] = est.GeocodeResult(**kw["geo"]) + kw["ratio_resolver"] = _make_call_stub( + rec.get("ratio_calls") or [], label="ratio_resolver", coerce=_coerce_ratio_return + ) + # #2656: quarter-index-lookup'ы отвечают «промах» (None / {}) на вызовы, которых + # в фикстуре нет — см. _make_call_stub. Сброс залипшего anchor_tier открыл гейт + # Guard-1a на 19 из 277 сделок, а прод-фикстура захвачена ДО правки. Следствие, + # которое надо знать при чтении метрик: для этих 19 сделок квартальный индекс в + # реплее НЕ применяется вовсе (в проде — применился бы), т.е. гейт занижает + # эффект правки — счётчик unrecorded_lookup_calls ниже это число и держит. + # Уйдёт (до нуля) при следующем перезахвате фикстуры с прода. + kw["quarter_index_lookup"] = _make_call_stub( + rec.get("qi_calls") or [], + label="quarter_index_lookup", + coerce=_coerce_qi_return, + on_exhausted=None, + unrecorded_counter=unrecorded, + ) + kw["quarter_indexes_lookup"] = _make_call_stub( + rec.get("qis_calls") or [], + label="quarter_indexes_lookup", + coerce=_coerce_qis_return, + on_exhausted={}, + unrecorded_counter=unrecorded, + ) - pr = m._price_from_inputs(**kw) + pr = m._price_from_inputs(**kw) - es_ppm2 = float(pr.expected_sold_per_m2) if pr.expected_sold_per_m2 is not None else None - es_price = float(pr.expected_sold_price) if pr.expected_sold_price is not None else None - r_low = ( - float(pr.expected_sold_range_low) if pr.expected_sold_range_low is not None else None - ) - r_high = ( - float(pr.expected_sold_range_high) if pr.expected_sold_range_high is not None else None - ) - prediction = Prediction( - deal_id=int(rec["deal_id"]), - rooms=rec["rooms"], - area_m2=float(rec["area_m2"]), - sold_ppm2=float(rec["sold_ppm2"]), - median_ppm2=float(pr.median_ppm2), - confidence=pr.confidence, - anchor_tier=pr.anchor_tier, - expected_sold_ppm2=es_ppm2, - expected_sold_price=es_price, - range_low=r_low, - range_high=r_high, - ) - predictions.append(prediction) - pred_ppm2_all.append(prediction.median_ppm2) + _es_ppm2_raw = pr.expected_sold_per_m2 + _es_price_raw = pr.expected_sold_price + _r_low_raw = pr.expected_sold_range_low + _r_high_raw = pr.expected_sold_range_high + es_ppm2 = float(_es_ppm2_raw) if _es_ppm2_raw is not None else None + es_price = float(_es_price_raw) if _es_price_raw is not None else None + r_low = float(_r_low_raw) if _r_low_raw is not None else None + r_high = float(_r_high_raw) if _r_high_raw is not None else None + prediction = Prediction( + deal_id=int(rec["deal_id"]), + rooms=rec["rooms"], + area_m2=float(rec["area_m2"]), + sold_ppm2=float(rec["sold_ppm2"]), + median_ppm2=float(pr.median_ppm2), + confidence=pr.confidence, + anchor_tier=pr.anchor_tier, + expected_sold_ppm2=es_ppm2, + expected_sold_price=es_price, + range_low=r_low, + range_high=r_high, + ) + predictions.append(prediction) + pred_ppm2_all.append(prediction.median_ppm2) + + finally: + m.settings.estimate_dedup_analogs_enabled = _dedup_saved # The fixture stores ONLY priced deals, so n_no_prediction is 0 here. metrics = _compute_full_metrics(predictions, n_no_prediction=0) @@ -1609,6 +1640,10 @@ def replay_fixture(fixture: dict[str, Any]) -> dict[str, Any]: "ask_median_ppm2": ask_median, "spread_pct": spread_pct, } + # #2656: сколько lookup-вызовов фикстура ответить не смогла (см. _make_call_stub). + # Целое → baseline сравнивает ТОЧНО: рост = новый путь разошёлся с захватом (гейт + # падает громко), падение до 0 = фикстуру перезахватили и ослабление можно снять. + metrics["unrecorded_lookup_calls"] = unrecorded[0] return metrics diff --git a/tradein-mvp/backend/tests/fixtures/backtest_baseline.json b/tradein-mvp/backend/tests/fixtures/backtest_baseline.json index a040e084..ce167d78 100644 --- a/tradein-mvp/backend/tests/fixtures/backtest_baseline.json +++ b/tradein-mvp/backend/tests/fixtures/backtest_baseline.json @@ -150,5 +150,6 @@ "sharpness": { "median_rel_width": 0.743, "n": 269 - } + }, + "unrecorded_lookup_calls": 19 } diff --git a/tradein-mvp/backend/tests/test_freshness_filter_2656.py b/tradein-mvp/backend/tests/test_freshness_filter_2656.py index e84a50c0..6a884e9c 100644 --- a/tradein-mvp/backend/tests/test_freshness_filter_2656.py +++ b/tradein-mvp/backend/tests/test_freshness_filter_2656.py @@ -39,7 +39,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost: from app.core.config import LISTINGS_FRESH_DAYS from app.services import estimator as est_mod -from app.services.estimator import _fetch_anchor_comps, _price_from_inputs +from app.services.estimator import _fetch_analogs, _fetch_anchor_comps, _price_from_inputs from app.services.geocoder import GeocodeResult from app.tasks import asking_to_sold_ratio as ratio_mod @@ -53,6 +53,12 @@ _TIER_C_MARK = "# ── Tier C: micro-radius" _TIER_A_SRC = _ANCHOR_SRC[: _ANCHOR_SRC.index(_TIER_C_MARK)] _TIER_C_SRC = _ANCHOR_SRC[_ANCHOR_SRC.index(_TIER_C_MARK) :] +# Tier W не использует _COMMON_WHERE — у него ПОБАЙТОВАЯ inline-копия того же блока +# (комментарий на месте требует держать пути в синхроне). Самое вероятное место +# следующего расхождения — ровно того сорта, что чинит этот PR, поэтому тоже под тестом. +_ANALOGS_SRC = inspect.getsource(_fetch_analogs) +_TIER_W_SRC = _ANALOGS_SRC[_ANALOGS_SRC.index("# ── Tier W: wide") :] + _REDERIVE_SQL = str(ratio_mod._REDERIVE_SQL.text) _ASK_SIDE_SQL = _REDERIVE_SQL[ _REDERIVE_SQL.index("ask_side AS") : _REDERIVE_SQL.index("per_bucket AS") @@ -62,17 +68,22 @@ _ASK_GLOBAL_SQL = _REDERIVE_SQL[ ] -# ── 1. Предикат стоит во всех четырёх местах ───────────────────────────────── +# ── 1. Предикат стоит во всех местах, где читаются listings ────────────────── def test_freshness_predicate_present_in_all_four_places() -> None: - """Все четыре денежные выборки несут ТОТ ЖЕ предикат свежести, что _COMMON_WHERE. + """Все денежные выборки несут ТОТ ЖЕ предикат свежести, что _COMMON_WHERE. - Falsifiable: на непропатченном коде FALSE во всех четырёх (там только + Четыре починенных места + два уже защищённых (_COMMON_WHERE и его inline-копия + в Tier W): их держим под тем же тестом, потому что снятие предиката ТАМ — та же + дыра, только с другой стороны. + + Falsifiable: на непропатченном коде FALSE во всех четырёх починенных (там только `is_active = true`), TRUE после правки. """ places = { "_COMMON_WHERE (эталон, радиусный путь)": est_mod._COMMON_WHERE, + "_fetch_analogs Tier W (inline-копия _COMMON_WHERE)": _TIER_W_SRC, "_fetch_anchor_comps Tier A": _TIER_A_SRC, "_fetch_anchor_comps Tier C": _TIER_C_SRC, "asking_to_sold_ratio ask_side": _ASK_SIDE_SQL, @@ -233,10 +244,11 @@ def _price( anchor_comps: list[dict[str, Any]], anchor_tier_fetched: str | None, imv_anchor: dict[str, Any] | None = None, + n_listings: int = 6, ) -> est_mod.PricingResult: listings = [ {"price_per_m2": 200_000.0, "address": f"ул. Ленина, {i + 5}", "source": "avito"} - for i in range(6) + for i in range(n_listings) ] return _price_from_inputs( listings=listings, @@ -303,3 +315,25 @@ def test_sticky_anchor_tier_no_longer_mutes_imv_blend() -> None: assert pr.anchor_tier is None assert pr.median_price > 10_000_000, "IMV-blend заглушен залипшим anchor_tier" assert pr.avito_imv_summary is not None + + +def test_imv_card_survives_when_headline_suppressed_and_anchor_absent() -> None: + """Карточка Avito IMV не исчезает в щели «тир добыт, якоря нет, headline подавлен». + + Раньше её заполнял display-only блок под условием `anchor_tier is not None`. После + сброса залипшего флага открылась щель: blend не срабатывает (нужен median_price > 0), + старый блок тоже (tier уже None) — и пользователь ТЕРЯЛ карточку, которую видел + раньше. n=3 объявления (< HEADLINE_LISTINGS_MIN_N) → headline подавлен, median 0. + Отображение, не деньги: median/expected_sold блок не трогает. + """ + pr = _price( + anchor_comps=[_anchor_comp(150_000.0), _anchor_comp(155_000.0)], + anchor_tier_fetched="C", + imv_anchor={"recommended_price": 14_000_000}, + n_listings=3, + ) + + assert pr.anchor_tier is None + assert pr.median_price == 0 # headline подавлен гейтом достаточности + assert pr.avito_imv_summary is not None, "карточка IMV потеряна" + assert pr.avito_imv_summary.recommended_price == 14_000_000 From 7a5997b059465f29224773f3ff7718b0e6d21b9b Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 26 Aug 2026 18:25:54 +0500 Subject: [PATCH 3/3] =?UTF-8?q?test(2656):=20=D0=BA=D1=80=D0=B0=D1=81?= =?UTF-8?q?=D0=BD=D0=BE=D1=82=D0=B0=20=D0=BD=D0=B0=20main=20=E2=80=94=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D0=B7=D0=BD=D0=B0=D1=87=D0=B5=D0=BD=D0=B8=D1=8E?= =?UTF-8?q?,=20=D0=BD=D0=B5=20ImportError?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getattr вместо прямого импорта LISTINGS_FRESH_DAYS из config: на origin/main константы там ещё нет, и тест умирал ImportError'ом на сборке модуля — «возможности нет» вместо «значение неверно». Теперь на main: 5 красных ассертами (предикат отсутствует/окно None/протухшие комплы в пуле), 3 зелёных (сброс anchor_tier уже влит отдельно). Co-Authored-By: Claude Opus 5 --- .../tests/test_freshness_filter_2656.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/tradein-mvp/backend/tests/test_freshness_filter_2656.py b/tradein-mvp/backend/tests/test_freshness_filter_2656.py index 3d50216a..999f2ffd 100644 --- a/tradein-mvp/backend/tests/test_freshness_filter_2656.py +++ b/tradein-mvp/backend/tests/test_freshness_filter_2656.py @@ -37,12 +37,17 @@ from unittest.mock import MagicMock # Settings требует DATABASE_URL при инициализации (fail-fast, C-3). os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") -from app.core.config import LISTINGS_FRESH_DAYS +from app.core import config as core_config from app.services import estimator as est_mod from app.services.estimator import _fetch_analogs, _fetch_anchor_comps, _price_from_inputs from app.services.geocoder import GeocodeResult from app.tasks import asking_to_sold_ratio as ratio_mod +# getattr, а не прямой импорт: на origin/main константы в config ещё нет — тест +# обязан падать ассертом О ЗНАЧЕНИИ (None != 14 / предикат отсутствует), а не +# ImportError'ом на сборке модуля (красное должно значить «значение неверно»). +LISTINGS_FRESH_DAYS = getattr(core_config, "LISTINGS_FRESH_DAYS", None) + # Ровно тот предикат, что стоит в _COMMON_WHERE радиусного пути. Сверяем ПОДСТРОКОЙ, # а не регуляркой: цель — чтобы во всех местах стоял ОДИН И ТОТ ЖЕ текст, а не пять # похожих вариантов, которые потом разъедутся по смыслу. @@ -103,12 +108,12 @@ def test_freshness_window_lives_in_one_place() -> None: assert LISTINGS_FRESH_DAYS == 14 # текущее значение; меняется в одном месте for mod in (est_mod, ratio_mod): src = inspect.getsource(mod) - assert re.search( - r"from app\.core\.config import [^\n]*LISTINGS_FRESH_DAYS", src - ), f"{mod.__name__}: LISTINGS_FRESH_DAYS не импортирован из app.core.config" - assert not re.search( - r"^LISTINGS_FRESH_DAYS\s*=", src, re.M - ), f"{mod.__name__}: локальная копия константы — значение разъедется" + assert re.search(r"from app\.core\.config import [^\n]*LISTINGS_FRESH_DAYS", src), ( + f"{mod.__name__}: LISTINGS_FRESH_DAYS не импортирован из app.core.config" + ) + assert not re.search(r"^LISTINGS_FRESH_DAYS\s*=", src, re.M), ( + f"{mod.__name__}: локальная копия константы — значение разъедется" + ) assert "'14 days'" not in _REDERIVE_SQL assert "'14 days'" not in _ANCHOR_SRC