From 35c8ef444530274bb25570b2fa1551210362f0ca Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 13 Sep 2026 13:46:02 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein):=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=20=D1=86=D0=B5=D0=BD=D0=B0?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B5=D0=B4=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20->=20=D1=86=D0=B5=D0=BD=D0=B0=20=D1=81=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D0=BA=D0=B8=20=D1=81=D1=82=D0=B0=D0=BB=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B3=D0=B8=D0=BE=D0=BD=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=BC=20?= =?UTF-8?q?(#3512)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit asking_to_sold_ratios (миграция 080) считался ИСКЛЮЧИТЕЛЬНО по рынку Екатеринбурга (деривация фильтровала обе стороны на city ILIKE '%Екатеринбург%'), а estimator читал строку только по rooms_bucket/district и применял этот коэффициент к любому адресу — включая Москву и Московскую область, чей рынок дороже на порядок (медиана ДКП за 12 мес: 66 - 90 278 руб/кв.м, 50 - 161 176, 77 - 322 976). Миграция 304 добавляет asking_to_sold_ratios.region_code (существующие 6 строк - регион 66) и перестраивает PK на (region_code, rooms_bucket, district). app/tasks/asking_to_sold_ratio.py: деривация региона 66 осталась байт-в-байт прежней (та же городская квота, тот же порог 30/30, то же окно 12 мес) - числа для 66 не меняются. Для каждого региона из реестра app.services.regions (кроме 66) добавлен отдельный derivation-путь, симметрично скоупленный по region_code без городской квоты (она была костылём конкретно исторического asking-покрытия ЕКБ). app/services/estimator.py: _get_asking_sold_ratio теперь получает region_code цели (target_region_code, тот же код, что уже скоупит ДКП-коридор) и ищет строку по нему; кэш ключуется на (region_code, bucket), а не на голый bucket. Регион без единой строки (ни per-bucket, ни global -1) деградирует явно - (None, None), без подмены чужим региональным коэффициентом - ratio_basis в ответе честно остаётся None. --- tradein-mvp/backend/app/services/estimator.py | 64 ++++-- .../backend/app/tasks/asking_to_sold_ratio.py | 190 ++++++++++++++++-- .../sql/304_asking_to_sold_ratios_region.sql | 88 ++++++++ .../test_asking_sold_ratio_cache_poison.py | 11 +- .../tests/test_asking_to_sold_ratio.py | 143 +++++++++++-- .../test_estimator_ratio_per_region_3512.py | 140 +++++++++++++ ...st_estimator_ratio_savepoint_retry_2265.py | 8 +- .../tests/test_estimator_ratio_tier_fix.py | 27 ++- 8 files changed, 620 insertions(+), 51 deletions(-) create mode 100644 tradein-mvp/backend/data/sql/304_asking_to_sold_ratios_region.sql create mode 100644 tradein-mvp/backend/tests/test_estimator_ratio_per_region_3512.py diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index a1da6726..d450269d 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -668,8 +668,10 @@ def _anchor_repair_factor(anchor_renovation: str | None, target_repair: str | No # within-price-tier sold/asking НЕВЫЧИСЛИМ из asking-less ДКП-сделок; корректное # обусловливание дают per-rooms blend + захардженный hedonic (год+площадь). _ASKING_SOLD_RATIO_CACHE_TTL_S = 300.0 -# Cache key: rooms bucket (единственный legacy per-rooms путь после #2002). -_asking_sold_ratio_cache: dict[int, tuple[float | None, str | None, float]] = {} +# Cache key: (region_code, rooms bucket) — #3512. Регион — часть ключа с миграции 304 +# (asking_to_sold_ratios ключуется по (region_code, rooms_bucket, district)); один +# бакет теперь несёт РАЗНЫЕ ratio для разных регионов, общий int-ключ смешал бы их. +_asking_sold_ratio_cache: dict[tuple[int, int], tuple[float | None, str | None, float]] = {} def _get_asking_sold_ratio( @@ -677,6 +679,7 @@ def _get_asking_sold_ratio( rooms: int | None, area_m2: float | None = None, anchor_ppm2: float | None = None, + region_code: int = regions_mod.DEFAULT_REGION_CODE, ) -> tuple[float | None, str | None]: """Возвращает (ratio, basis) asking→sold для area-бакета клиентской квартиры. @@ -692,9 +695,24 @@ def _get_asking_sold_ratio( rooms-ключе vs area-ключе. area_bucket() — тот же Python-двойник, что и в asking_to_sold_ratio.py (см. комментарий там, границы 30/44/62/85 = import-rosreestr.sh). - Запрос к asking_to_sold_ratios (migration 080): per-rooms строка - (WHERE rooms_bucket = bucket AND district = '') → fallback на global -1 - (basis='global_fallback'). Ничего нет → (None, None). + #3512: region_code — регион ЗАПРОСА (target_region_code резолвера, см. + _ratio_resolver ниже), НЕ дефолт 66 всегда. Раньше ratio считался ИСКЛЮЧИТЕЛЬНО + по ЕКБ и применялся к любому адресу, включая Москву (region_code=77) — коэффициент + рынка на порядок дешевле (медиана ДКП: 66 — 90 278 ₽/м², 77 — 322 976 ₽/м², + замер 13.09.2026) домножал московскую asking-цену. Дефолт-параметр (66) сохранён + ради обратной совместимости позиционных вызовов (тот же идиом, что + `_is_plausible_deal(region_code: int = regions_mod.DEFAULT_REGION_CODE)`). + + Запрос к asking_to_sold_ratios (migration 080 + 304): per-rooms строка СВОЕГО + региона (WHERE region_code = :region_code AND rooms_bucket = bucket AND + district = '') → fallback на global -1 ТОГО ЖЕ региона (basis='global_fallback'). + Ни одной строки для региона (регион ещё не накопил 30/30 сделок/listings, см. + app/tasks/asking_to_sold_ratio.py) → явная деградация (None, None) + INFO-лог — + та же идиома, что sber_region_series_name делает для отсутствующего в карте + региона, но БЕЗ подстановки чужого регионального ratio: значение другого рынка + здесь — не нейтральный фолбэк (в отличие от общероссийского sber-ряда), а именно + та ошибка калибровки, которую #3512 устраняет. ratio_basis в ответе честно + остаётся None — сайту не приписывается ЧУЖОЙ региональный коэффициент. anchor_ppm2 сохранён в сигнатуре для совместимости с call-site (резолвер вызывается с ФИНАЛЬНЫМ headline ppm² после anchor/blend-мутаций — см. @@ -702,11 +720,12 @@ def _get_asking_sold_ratio( статистический артефакт (#2002, см. module-level комментарий выше). Таблицы нет / любая ошибка → (None, None), НЕ raise (graceful). - Кэшируется на ключ bucket с TTL _ASKING_SOLD_RATIO_CACHE_TTL_S. + Кэшируется на ключ (region_code, bucket) с TTL _ASKING_SOLD_RATIO_CACHE_TTL_S. """ bucket = area_bucket(area_m2) if area_m2 else min(max(rooms or 0, 0), 4) + cache_key = (region_code, bucket) - cached = _asking_sold_ratio_cache.get(bucket) + cached = _asking_sold_ratio_cache.get(cache_key) if cached is not None: ratio, basis, fetched = cached if (time.monotonic() - fetched) < _ASKING_SOLD_RATIO_CACHE_TTL_S: @@ -730,24 +749,39 @@ def _get_asking_sold_ratio( text( """ SELECT ratio, basis FROM asking_to_sold_ratios - WHERE rooms_bucket = CAST(:b AS int) AND district = '' + WHERE region_code = CAST(:region_code AS int) + AND rooms_bucket = CAST(:b AS int) AND district = '' """ ), - {"b": bucket}, + {"region_code": region_code, "b": bucket}, ).fetchone() if row is None: - # Бакет тонкий (n<30 при seed'е) или отсутствует → global (-1). + # Бакет тонкий (n<30 при seed'е) или отсутствует → global (-1) + # ТОГО ЖЕ региона (#3512 — никогда чужого региона). row = db.execute( text( """ SELECT ratio, basis FROM asking_to_sold_ratios - WHERE rooms_bucket = -1 AND district = '' + WHERE region_code = CAST(:region_code AS int) + AND rooms_bucket = -1 AND district = '' """ ), + {"region_code": region_code}, ).fetchone() if row is not None and row.ratio is not None: ratio = float(row.ratio) basis = row.basis + elif row is None: + # #3512: у региона нет НИ ОДНОЙ строки (ни per-rooms, ни global -1) — + # обычно потому, что регион ещё не набрал 30/30 сделок/listings + # (см. app/tasks/asking_to_sold_ratio.py). Явная деградация, не + # подмена чужим регионом. + logger.info( + "asking_to_sold_ratio: region_code=%d нет ни одной строки " + "(bucket=%d) — sold-коррекция не применяется для этого региона", + region_code, + bucket, + ) ok = True break except Exception as exc: @@ -776,7 +810,7 @@ def _get_asking_sold_ratio( # Кэшируем ТОЛЬКО успешный lookup. ratio может быть None (строки нет — # стабильный факт БД, безопасно кэшировать на TTL); транзиентный None выше # уже вернулся ранним return и сюда не доходит. - _asking_sold_ratio_cache[bucket] = (ratio, basis, time.monotonic()) + _asking_sold_ratio_cache[cache_key] = (ratio, basis, time.monotonic()) return ratio, basis @@ -5068,7 +5102,11 @@ async def estimate_quality( appm2: float | None, ) -> tuple[float | None, str | None]: # #2620-2: area-bucket key (payload.rooms — фолбэк только без площади). - return _get_asking_sold_ratio(db, payload.rooms, payload.area_m2, anchor_ppm2=appm2) + # #3512: region_code — регион ЦЕЛИ (target_region_code, резолвлен по geo выше, + # тот же код, что уже скоупит ДКП-коридор, #3051 PR-A) — НЕ дефолт 66 всегда. + return _get_asking_sold_ratio( + db, payload.rooms, payload.area_m2, anchor_ppm2=appm2, region_code=target_region_code + ) def _qi_lookup(q: str) -> tuple[float, int] | None: return _lookup_quarter_index( 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 de5d8300..39278451 100644 --- a/tradein-mvp/backend/app/tasks/asking_to_sold_ratio.py +++ b/tradein-mvp/backend/app/tasks/asking_to_sold_ratio.py @@ -51,6 +51,7 @@ from sqlalchemy import text from sqlalchemy.orm import Session from app.core.config import LISTINGS_FRESH_DAYS, settings +from app.services import regions as regions_mod from app.services import scrape_runs as runs_mod # Нижняя граница ppm² — отсекает нежилые/технические сделки; не меняется. @@ -68,13 +69,29 @@ _PPM2_MIN: int = 30_000 # занижает ask-медиану → ratio завышен на 2.5-5.3% по всем бакетам, выкупные цены # системно переплачены). Теперь ask_side/ask_global ТОЖЕ скоупятся этим паттерном # (предикат `city IS NULL OR city ILIKE :asking_city` — см. комментарий на месте в CTE -# ниже) — симметрично deal-стороне. Когда появится per-city ratio через зарезервированный -# столбец `district` (#647), эта константа станет per-city параметром для обеих сторон. +# ниже) — симметрично deal-стороне. +# +# #3512 (per-region ratio, migration 304): эта городская квота — костыль ИМЕННО региона +# 66 (asking-скрейп исторически покрывал только сам ЕКБ, см. #C2 выше), и трогать её +# нельзя — числа региона 66 обязаны остаться byte-for-byte прежними. Для ЛЮБОГО другого +# региона (deals.region_code / listings.region_code — обе таблицы несут колонку) городской +# квоты не было и не нужно: там достаточно `region_code = :region_code` симметрично на +# обеих сторонах (см. _REDERIVE_SQL_REGION ниже) — колонка `district` остаётся +# зарезервированной под #647 (гео-районы ВНУТРИ региона), per-region разрез теперь +# несёт `region_code`, а не `district`. _ASKING_CITY_PATTERN: str = "%Екатеринбург%" # Верхняя граница берётся из settings.asking_ratio_ppm2_max (default 1_200_000). # QA-note: точное значение сверить с `SELECT max(price_per_m2) FROM deals # WHERE source='rosreestr'` на проде — ceiling должен быть > max(ppm²) premium-сделок. +# #3512: регионы, для которых считается ОТДЕЛЬНАЯ (не-ЕКБ) деривация ниже — +# весь реестр покрытия (app.services.regions — ЕДИНСТВЕННЫЙ источник правды про регионы) +# минус 66 (у него своя историческая деривация выше). Появление нового региона в реестре +# автоматически включает его в пересчёт ratio, без правки этого файла. +_OTHER_REGION_CODES: tuple[int, ...] = tuple( + sorted(code for code in regions_mod.REGIONS if code != 66) +) + # #2620 — синтетический "бакет комнат по площади", ИСТОЧНИК ИСТИНЫ: # tradein-mvp/deploy/import-rosreestr.sh (Росреестр не отдаёт комнатность — deals.rooms # синтезируется из area_m2 при импорте ровно этим CASE). Три представления ОДНОЙ формулы — @@ -108,14 +125,26 @@ def area_bucket(area_m2: float) -> int: logger = logging.getLogger(__name__) -# ── True-mirror cleanup: drop all #648 rows before re-derivation ────────────── +# ── True-mirror cleanup: drop all #648 EKB rows before re-derivation ────────── # district = '' — все строки #648 (district зарезервирован под #647, пока всегда ''). +# #3512: явный region_code=66 — эта DELETE трогает ТОЛЬКО ЕКБ-строки; остальные регионы +# чистит своя _DELETE_SQL_REGION в цикле ниже (иначе один бланкет-DELETE стирал бы +# только что вставленные строки другого региона на повторном прогоне того же transaction). # Удаляем ПЕРЕД re-derive, чтобы бакеты, упавшие ниже порога 30/30, не оставались # stale (ON CONFLICT DO UPDATE такие строки бы не тронул). В одной транзакции с INSERT. _DELETE_SQL = text( """ DELETE FROM asking_to_sold_ratios - WHERE district = '' + WHERE region_code = 66 AND district = '' + """ +) + +# #3512: та же true-mirror очистка, но per-region (используется в цикле для +# _OTHER_REGION_CODES) — CAST(:region_code AS int), никогда :region_code::int (psycopg v3). +_DELETE_SQL_REGION = text( + """ + DELETE FROM asking_to_sold_ratios + WHERE region_code = CAST(:region_code AS int) AND district = '' """ ) @@ -266,19 +295,133 @@ _REDERIVE_SQL = text( ) INSERT INTO asking_to_sold_ratios ( rooms_bucket, district, ratio, sold_median, ask_median, - n_deals, n_listings, window_months, basis + n_deals, n_listings, window_months, basis, region_code ) SELECT rooms_bucket, district, ratio, sold_median, ask_median, - n_deals, n_listings, window_months, basis FROM global_row + n_deals, n_listings, window_months, basis, 66 FROM global_row UNION ALL SELECT rooms_bucket, district, ratio, sold_median, ask_median, - n_deals, n_listings, window_months, basis FROM per_bucket + n_deals, n_listings, window_months, basis, 66 FROM per_bucket + """ +) + +# ── Generic per-region derivation (#3512) ───────────────────────────────────── +# Байт-в-байт та же деривация, что _REDERIVE_SQL выше (тот же 12-мес трейлинг, +# ppm²-полоса, novostroyki/freshness/area_m2-гарды, area-бакет ask-стороны, порог +# 30/30, global -1 fallback) — ЕДИНСТВЕННОЕ отличие: городская квота ЕКБ (city ILIKE / +# city IS NULL OR city ILIKE) заменена на симметричный `region_code = :region_code` +# на ОБЕИХ сторонах. Городской квоты здесь не было и не нужно: она была костылём +# конкретно исторического asking-покрытия ЕКБ (#C2), а не общим правилом. +_REDERIVE_SQL_REGION = text( + f""" + WITH + deal_side AS ( + SELECT + LEAST(GREATEST(rooms, 0), 4) AS rooms_bucket, + percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS sold_median, + COUNT(*) AS n_deals + FROM deals + WHERE source = 'rosreestr' + AND rooms IS NOT NULL + AND region_code = CAST(:region_code AS int) + AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max + AND deal_date >= CURRENT_DATE - INTERVAL '12 months' + GROUP BY LEAST(GREATEST(rooms, 0), 4) + ), + ask_side AS ( + SELECT + {_AREA_ROOMS_BUCKET_SQL} AS rooms_bucket, + percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS ask_median, + COUNT(*) AS n_listings + FROM listings + WHERE is_active + AND scraped_at > NOW() - (:fresh_days || ' days')::interval + AND rooms IS NOT NULL + AND area_m2 IS NOT NULL + AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max + AND (listing_segment IS NULL OR listing_segment = 'vtorichka') + AND region_code = CAST(:region_code AS int) + GROUP BY {_AREA_ROOMS_BUCKET_SQL} + ), + per_bucket AS ( + SELECT + d.rooms_bucket, + ''::text AS district, + (d.sold_median / a.ask_median)::numeric AS ratio, + round(d.sold_median)::bigint AS sold_median, + round(a.ask_median)::bigint AS ask_median, + d.n_deals::int AS n_deals, + a.n_listings::int AS n_listings, + 12 AS window_months, + 'per_rooms'::text AS basis + FROM deal_side d + JOIN ask_side a USING (rooms_bucket) + WHERE d.n_deals >= 30 + AND a.n_listings >= 30 + AND a.ask_median IS NOT NULL + AND a.ask_median > 0 + AND d.sold_median IS NOT NULL + AND d.sold_median > 0 + ), + deal_global AS ( + SELECT + percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS sold_median, + COUNT(*) AS n_deals + FROM deals + WHERE source = 'rosreestr' + AND rooms IS NOT NULL + AND region_code = CAST(:region_code AS int) + AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max + AND deal_date >= CURRENT_DATE - INTERVAL '12 months' + ), + ask_global AS ( + SELECT + percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS ask_median, + COUNT(*) AS n_listings + FROM listings + WHERE is_active + AND scraped_at > NOW() - (:fresh_days || ' days')::interval + AND rooms IS NOT NULL + AND area_m2 IS NOT NULL + AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max + AND (listing_segment IS NULL OR listing_segment = 'vtorichka') + AND region_code = CAST(:region_code AS int) + ), + global_row AS ( + SELECT + -1 AS rooms_bucket, + ''::text AS district, + (d.sold_median / a.ask_median)::numeric AS ratio, + round(d.sold_median)::bigint AS sold_median, + round(a.ask_median)::bigint AS ask_median, + d.n_deals::int AS n_deals, + a.n_listings::int AS n_listings, + 12 AS window_months, + 'global_fallback'::text AS basis + FROM deal_global d + CROSS JOIN ask_global a + WHERE a.ask_median IS NOT NULL + AND a.ask_median > 0 + AND d.sold_median IS NOT NULL + AND d.sold_median > 0 + ) + INSERT INTO asking_to_sold_ratios ( + rooms_bucket, district, ratio, sold_median, ask_median, + n_deals, n_listings, window_months, basis, region_code + ) + SELECT rooms_bucket, district, ratio, sold_median, ask_median, + n_deals, n_listings, window_months, basis, CAST(:region_code AS int) FROM global_row + UNION ALL + SELECT rooms_bucket, district, ratio, sold_median, ask_median, + n_deals, n_listings, window_months, basis, CAST(:region_code AS int) FROM per_bucket """ ) # ── Post-insert counters ────────────────────────────────────────────────────── # Считываем итог из таблицы (всё ещё в той же транзакции — до commit): сколько строк # записано всего, сколько per_rooms, был ли использован global -1 fallback. +# #3512: считается ОДИН раз в самом конце, ПОСЛЕ всех регионов (ЕКБ + цикл по +# _OTHER_REGION_CODES) — district='' покрывает все регионы разом, счётчики суммарные. _COUNTERS_SQL = text( """ SELECT @@ -292,13 +435,22 @@ _COUNTERS_SQL = text( def recompute_asking_to_sold_ratios(db: Session, run_id: int) -> dict[str, int]: - """Пересчитать asking_to_sold_ratios (TRUE-MIRROR refresh, #648 Stage 4). + """Пересчитать asking_to_sold_ratios per region (#3512, TRUE-MIRROR refresh #648 Stage 4). Sync (вызывается scheduler-триггером в executor, как snapshot_listing_sources). - В ОДНОЙ транзакции (атомарно — таблица никогда не пуста mid-refresh): - 1. DELETE FROM asking_to_sold_ratios WHERE district = '' — снести stale-строки. - 2. Заново прогнать 080-derivation INSERT...SELECT (per_rooms при 30/30 + global -1). - Затем counters из таблицы, commit, mark_done. Семантика == re-seed миграции 080. + В ОДНОЙ транзакции (атомарно — таблица никогда не пуста mid-refresh для уже + посчитанных регионов): + 1. Регион 66 (ЕКБ): DELETE (region_code=66, district='') → 080-derivation + INSERT...SELECT (byte-for-byte прежняя логика — городская квота ЕКБ, #C2/#2583). + 2. Каждый прочий регион реестра app.services.regions (минус 66): тот же + DELETE/INSERT цикл, но derivation скоупится по `region_code` симметрично + на sold- и asking-стороне (без городской квоты — она не применима вне + ЕКБ, см. комментарий у generic-derivation SQL ниже). + Регион без своих ДКП-сделок ИЛИ без активных listings просто не получает строк + (global_row/per_bucket WHERE-гарды отфильтровывают NULL-медианы) — это НЕ + ошибка, а честный признак «данных пока недостаточно», ловится потребителем + (estimator._get_asking_sold_ratio) через отсутствие строки → явная деградация. + Затем ОДИН общий counters-запрос по всей таблице, commit, mark_done. Финализирует scrape_runs (mark_done / mark_failed) и пишет counters. @@ -323,7 +475,8 @@ def recompute_asking_to_sold_ratios(db: Session, run_id: int) -> dict[str, int]: } try: # DELETE + re-derive INSERT в одной транзакции (НЕ коммитим между ними — - # таблица не должна остаться пустой, если INSERT упадёт). + # таблица не должна остаться пустой, если INSERT упадёт). Регион 66 — + # прежняя ЕКБ-деривация байт-в-байт; остальные регионы — цикл ниже (#3512). db.execute(_DELETE_SQL) db.execute( _REDERIVE_SQL, @@ -334,6 +487,17 @@ def recompute_asking_to_sold_ratios(db: Session, run_id: int) -> dict[str, int]: "fresh_days": LISTINGS_FRESH_DAYS, }, ) + for region_code in _OTHER_REGION_CODES: + db.execute(_DELETE_SQL_REGION, {"region_code": region_code}) + db.execute( + _REDERIVE_SQL_REGION, + { + "region_code": region_code, + "ppm2_min": _PPM2_MIN, + "ppm2_max": settings.asking_ratio_ppm2_max, + "fresh_days": LISTINGS_FRESH_DAYS, + }, + ) row = db.execute(_COUNTERS_SQL).mappings().first() if row is not None: diff --git a/tradein-mvp/backend/data/sql/304_asking_to_sold_ratios_region.sql b/tradein-mvp/backend/data/sql/304_asking_to_sold_ratios_region.sql new file mode 100644 index 00000000..e77940c4 --- /dev/null +++ b/tradein-mvp/backend/data/sql/304_asking_to_sold_ratios_region.sql @@ -0,0 +1,88 @@ +-- 304_asking_to_sold_ratios_region.sql +-- asking_to_sold_ratios: ключ (region_code, rooms_bucket, district) — #3512. +-- +-- ПРОБЛЕМА. asking_to_sold_ratios (миграция 080) несёт РОВНО 6 строк (per area-бакет +-- 0..4 + global -1), все с district='' — колонки региона в таблице НЕТ ВООБЩЕ (проверено +-- на проде 13.09.2026: `\d asking_to_sold_ratios` без region_code). Деривация +-- (app/tasks/asking_to_sold_ratio.py) скоупит ОБЕ стороны расчёта на +-- `city ILIKE '%Екатеринбург%'` — ratio физически посчитан только по рынку ЕКБ. Но +-- estimator.py (_get_asking_sold_ratio) читает строку по `rooms_bucket AND district=''` +-- БЕЗ единого понятия региона запроса — тот же коэффициент домножает asking-цену ЛЮБОГО +-- адреса, включая Москву (region_code=77) и Московскую обл. (region_code=50). Рынки +-- разные на порядок: медиана ДКП за 12 мес по region_code — 66: 90 278 ₽/м², +-- 50: 161 176 ₽/м², 77: 322 976 ₽/м² (замер 13.09.2026) — екатеринбургский sold/asking +-- коэффициент, применённый к московской asking-цене, calibration mismatch, а не оценка. +-- +-- ЧТО ДЕЛАЕТ ЭТА МИГРАЦИЯ (только схема — derivation региона 66 остаётся байт-в-байт +-- прежней, деривация ДРУГИХ регионов теперь считается отдельным SQL-путём в +-- app/tasks/asking_to_sold_ratio.py, сама эта миграция ничего не пересчитывает): +-- 1. ADD COLUMN region_code int NOT NULL DEFAULT 66 — существующие 6 строк ВСЕ +-- екатеринбургские (единственная деривация на момент 080/098/109), DEFAULT +-- backfill'ит их на 66 автоматически при ALTER (Postgres 11+ — DEFAULT на +-- NOT NULL столбце без DEFAULT ранее вычисляется один раз, без rewrite всей +-- таблицы, но здесь таблица микроскопическая — 6 строк, cost неощутим в любом +-- случае). +-- 2. PK (rooms_bucket, district) -> PK (region_code, rooms_bucket, district) — строки +-- теперь живут per (регион, area-бакет, district). district остаётся зарезервирован +-- под #647 (гео-районы ВНУТРИ региона), всегда ''. +-- +-- ПОЧЕМУ НЕ НОВАЯ ПАРАЛЛЕЛЬНАЯ ТАБЛИЦА (в отличие от 098 tiered-подхода). Там +-- параллельная таблица защищала fetchone()-safety при flag-OFF откате (#928). Здесь +-- отката не предполагается: region_code — обязательный параметр запроса везде, где +-- ratio читается (estimator._get_asking_sold_ratio), и ADD COLUMN NOT NULL DEFAULT 66 +-- не меняет СУЩЕСТВУЮЩИЕ 6 строк по значению (только добавляет им явный регион) — +-- старый flag-OFF путь `WHERE rooms_bucket=B AND district=''` для региона 66 +-- продолжает возвращать ТУ ЖЕ строку, если добавить `AND region_code=66` (что и +-- делает estimator.py в этом же PR). +-- +-- ИДЕМПОТЕНТНОСТЬ: ADD COLUMN IF NOT EXISTS — повторный прогон no-op на колонке. +-- PK-guard (как 298_deal_city_price_bands_region.sql) смотрит ТЕКУЩЕЕ определение PK +-- через pg_get_constraintdef и меняет его ТОЛЬКО если это ещё старый +-- PK(rooms_bucket, district) — повторный прогон видит PK(region_code, rooms_bucket, +-- district) и ничего не трогает. strict/exit-1 на деплое — без SET LOCAL lock_timeout +-- ALTER TABLE мог бы зависнуть под конкурентным чтением; таблица in-memory-масштаба +-- (6 строк), но конвенция проекта (#2752) требует потолок на любой блокирующий DDL. +-- +-- ЗАВИСИМОСТИ: 080_asking_to_sold_ratios.sql (таблица, PK(rooms_bucket, district)), +-- 002_core_tables.sql (deals.region_code, listings.region_code — обе уже существуют). +-- Apply after: 303_scrape_schedules_seed_landing_showcase_deals.sql + +BEGIN; + +SET LOCAL lock_timeout = '5s'; + +ALTER TABLE asking_to_sold_ratios + ADD COLUMN IF NOT EXISTS region_code int NOT NULL DEFAULT 66; + +COMMENT ON COLUMN asking_to_sold_ratios.region_code IS + 'Регион коэффициента (deals.region_code / listings.region_code, #3512). ' + 'Существующие строки (посчитаны деривацией, скоупленной на ЕКБ, #C2) — 66. ' + 'Часть составного PK (region_code, rooms_bucket, district).'; + +-- PK (rooms_bucket, district) -> PK (region_code, rooms_bucket, district), идемпотентно: +-- смотрим ТЕКУЩЕЕ определение PK и меняем его только если это ещё старый PK без региона. +DO $$ +DECLARE + v_pk_def text; +BEGIN + SELECT pg_get_constraintdef(oid) INTO v_pk_def + FROM pg_constraint + WHERE conrelid = 'asking_to_sold_ratios'::regclass + AND contype = 'p'; + + IF v_pk_def = 'PRIMARY KEY (rooms_bucket, district)' THEN + ALTER TABLE asking_to_sold_ratios DROP CONSTRAINT asking_to_sold_ratios_pkey; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = 'asking_to_sold_ratios'::regclass + AND contype = 'p' + ) THEN + ALTER TABLE asking_to_sold_ratios + ADD CONSTRAINT asking_to_sold_ratios_pkey + PRIMARY KEY (region_code, rooms_bucket, district); + END IF; +END $$; + +COMMIT; diff --git a/tradein-mvp/backend/tests/test_asking_sold_ratio_cache_poison.py b/tradein-mvp/backend/tests/test_asking_sold_ratio_cache_poison.py index 1a1b7222..8e1cd1eb 100644 --- a/tradein-mvp/backend/tests/test_asking_sold_ratio_cache_poison.py +++ b/tradein-mvp/backend/tests/test_asking_sold_ratio_cache_poison.py @@ -81,26 +81,27 @@ def test_transient_error_returns_none_and_does_not_cache() -> None: ratio, basis = estimator._get_asking_sold_ratio(db, rooms=2) # type: ignore[arg-type] assert (ratio, basis) == (None, None) assert db.rolled_back is True - # bucket=2 НЕ должен попасть в кэш — иначе следующая оценка застрянет на None. - assert 2 not in estimator._asking_sold_ratio_cache + # #3512: ключ кэша — (region_code, bucket); region_code не передан → дефолт 66. + # (66, 2) НЕ должен попасть в кэш — иначе следующая оценка застрянет на None. + assert (66, 2) not in estimator._asking_sold_ratio_cache def test_transient_error_then_success_retries_not_poisoned() -> None: # 1) транзиентный сбой — None, кэш не отравлен r1, _ = estimator._get_asking_sold_ratio(_BoomDB(), rooms=2) # type: ignore[arg-type] assert r1 is None - assert 2 not in estimator._asking_sold_ratio_cache + assert (66, 2) not in estimator._asking_sold_ratio_cache # 2) следующий вызов с рабочей БД должен РЕТРАИТЬ и вернуть реальный ratio r2, basis2 = estimator._get_asking_sold_ratio(_OkDB(0.84), rooms=2) # type: ignore[arg-type] assert r2 == pytest.approx(0.84) assert basis2 == "per_rooms" # теперь успешный результат закэширован - assert estimator._asking_sold_ratio_cache[2][0] == pytest.approx(0.84) + assert estimator._asking_sold_ratio_cache[(66, 2)][0] == pytest.approx(0.84) def test_successful_lookup_is_cached() -> None: r, basis = estimator._get_asking_sold_ratio(_OkDB(0.77), rooms=1) # type: ignore[arg-type] assert r == pytest.approx(0.77) assert basis == "per_rooms" - cached = estimator._asking_sold_ratio_cache.get(1) + cached = estimator._asking_sold_ratio_cache.get((66, 1)) assert cached is not None and cached[0] == pytest.approx(0.77) 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 7879c7e7..1889e7ab 100644 --- a/tradein-mvp/backend/tests/test_asking_to_sold_ratio.py +++ b/tradein-mvp/backend/tests/test_asking_to_sold_ratio.py @@ -34,8 +34,20 @@ _MIGRATION_082 = _SQL_DIR / "082_scrape_schedules_seed_ratio_refresh.sql" # Emitted SQL text (SQLAlchemy text() clause → .text gives the raw string). _DELETE_SQL = str(ratio_mod._DELETE_SQL.text) _REDERIVE_SQL = str(ratio_mod._REDERIVE_SQL.text) +_DELETE_SQL_REGION = str(ratio_mod._DELETE_SQL_REGION.text) +_REDERIVE_SQL_REGION = str(ratio_mod._REDERIVE_SQL_REGION.text) _COUNTERS_SQL = str(ratio_mod._COUNTERS_SQL.text) -_ALL_SQL = _DELETE_SQL + "\n" + _REDERIVE_SQL + "\n" + _COUNTERS_SQL +_ALL_SQL = ( + _DELETE_SQL + + "\n" + + _REDERIVE_SQL + + "\n" + + _DELETE_SQL_REGION + + "\n" + + _REDERIVE_SQL_REGION + + "\n" + + _COUNTERS_SQL +) _TASK_SRC = inspect.getsource(ratio_mod.recompute_asking_to_sold_ratios) @@ -43,14 +55,17 @@ _TASK_SRC = inspect.getsource(ratio_mod.recompute_asking_to_sold_ratios) def test_refresh_deletes_district_rows_first() -> None: - """True-mirror: DELETE FROM asking_to_sold_ratios WHERE district='' before the INSERT. + """True-mirror: DELETE FROM asking_to_sold_ratios WHERE region=66/district='' first. The 080 seed used ON CONFLICT DO UPDATE which leaves stale per-rooms rows for buckets that drop below 30/30 on a later run. The refresh must DELETE first instead. + + #3512: the EKB delete is now scoped to `region_code = 66` too (region-aware ratios) — + the other-region loop uses its own _DELETE_SQL_REGION, tested separately below. """ flat_del = re.sub(r"\s+", " ", _DELETE_SQL).strip() assert "DELETE FROM asking_to_sold_ratios" in flat_del - assert "WHERE district = ''" in flat_del + assert "WHERE region_code = 66 AND district = ''" in flat_del # The re-derivation is an INSERT (no ON CONFLICT — DELETE precedes it). assert "INSERT INTO asking_to_sold_ratios" in _REDERIVE_SQL assert "ON CONFLICT" not in _REDERIVE_SQL @@ -278,12 +293,24 @@ def test_migration_080_derivation_is_subset_of_refresh_sql() -> None: """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 _drop_region_code_column(s: str) -> str: + """Remove the #3512 region_code column/literal (absent in the 080 seed). + + Migration 304 adds region_code to the table; the refresh INSERT now writes it + explicitly (region_code, ... ) / ( ..., 66) — the 080 seed predates the column. + """ + s = re.sub(r"basis,\s*region_code", "basis", s) + s = re.sub(r"basis,\s*66\s+FROM", "basis FROM", s) + return s + def _norm(s: str) -> str: return _strip_sql( _normalise_ppm2( _normalise_bucket_expr( _drop_freshness_guard( - _drop_area_not_null_guard(_drop_city_guard(_drop_segment_guard(s))) + _drop_region_code_column( + _drop_area_not_null_guard(_drop_city_guard(_drop_segment_guard(s))) + ) ) ) ) @@ -406,10 +433,10 @@ class _FakeMappingResult: class _FakeDB: """Minimal stand-in for a SQLAlchemy Session — records execute() calls. - Execute call order: - 1. DELETE asking_to_sold_ratios - 2. REDERIVE INSERT asking_to_sold_ratios - 3. COUNTERS SELECT (returns counters_row) + #3512: execute call order is now EKB (DELETE + INSERT) followed by a + DELETE+INSERT pair per _OTHER_REGION_CODES region, then ONE counters SELECT + at the very end — identified by statement IDENTITY (`stmt is ratio_mod._COUNTERS_SQL`), + not by a hardcoded call index, so the fake stays correct if the region registry grows. """ def __init__( @@ -423,8 +450,7 @@ class _FakeDB: def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeMappingResult: self.executed.append((stmt, params)) - # 3rd call is the counters SELECT (asking_to_sold_ratios). - if len(self.executed) == 3: + if stmt is ratio_mod._COUNTERS_SQL: return _FakeMappingResult(self._counters_row) return _FakeMappingResult(None) @@ -438,8 +464,8 @@ class _FakeDB: def test_counter_logic_with_fake_db(monkeypatch: pytest.MonkeyPatch) -> None: """recompute_asking_to_sold_ratios maps the counters row into its return + marks done. - #2002: tiered refresh removed — counters are the 3 legacy keys, FakeDB has 3 - execute() calls (DELETE + REDERIVE INSERT + COUNTERS SELECT). + #3512: FakeDB now sees 2 statements for EKB + 2 per _OTHER_REGION_CODES region + + 1 final COUNTERS SELECT (counters are still the 3 legacy keys, #2002). """ marked: dict[str, Any] = {} monkeypatch.setattr( @@ -461,8 +487,10 @@ def test_counter_logic_with_fake_db(monkeypatch: pytest.MonkeyPatch) -> None: } assert out == expected assert db.committed is True - # Three statements: DELETE + REDERIVE INSERT + COUNTERS SELECT (legacy per-rooms). - assert len(db.executed) == 3 + # EKB (DELETE+INSERT) + 2 per other region + 1 COUNTERS SELECT. + expected_calls = 2 + 2 * len(ratio_mod._OTHER_REGION_CODES) + 1 + assert len(db.executed) == expected_calls + assert db.executed[-1][0] is ratio_mod._COUNTERS_SQL assert marked["run_id"] == 99 assert marked["counters"] == expected @@ -551,3 +579,90 @@ def test_area_bucket_matches_sql_boundaries() -> None: for i, bound in enumerate(sql_bounds): assert ratio_mod.area_bucket(bound - 0.01) == i assert ratio_mod.area_bucket(bound) == i + 1 + + +# ── #3512: per-region ratio (migration 304) ─────────────────────────────────── + +_MIGRATION_304 = _SQL_DIR / "304_asking_to_sold_ratios_region.sql" + + +def test_migration_304_exists() -> None: + assert _MIGRATION_304.is_file(), f"missing migration: {_MIGRATION_304}" + + +def test_migration_304_adds_region_code_and_composite_pk() -> None: + sql = _MIGRATION_304.read_text("utf-8") + assert "ADD COLUMN IF NOT EXISTS region_code int NOT NULL DEFAULT 66" in sql + assert "PRIMARY KEY (region_code, rooms_bucket, district)" in sql + # PK-guard идемпотентен (тот же паттерн, что 298_deal_city_price_bands_region.sql). + assert "pg_get_constraintdef" in sql + assert "DROP CONSTRAINT asking_to_sold_ratios_pkey" in sql + assert "BEGIN;" in sql and "COMMIT;" in sql + assert "SET LOCAL lock_timeout" in sql + + +def test_migration_304_uses_psycopg_safe_sql() -> None: + sql = _MIGRATION_304.read_text("utf-8") + assert not re.search(r":\w+::", sql) + + +def test_other_region_codes_matches_registry_minus_ekb() -> None: + """_OTHER_REGION_CODES — весь реестр регионов (regions_mod.REGIONS) минус 66. + + Новый регион в реестре автоматически подхватывается пересчётом ratio (#3512) — + без правки этого файла. + """ + from app.services import regions as regions_mod + + expected = tuple(sorted(code for code in regions_mod.REGIONS if code != 66)) + assert ratio_mod._OTHER_REGION_CODES == expected + assert 66 not in ratio_mod._OTHER_REGION_CODES + assert 77 in ratio_mod._OTHER_REGION_CODES # Москва — уже в реестре (#3051) + + +def test_rederive_sql_region_scopes_by_region_code_not_city() -> None: + """_REDERIVE_SQL_REGION скоупит ОБЕ стороны по region_code, БЕЗ городской квоты ЕКБ. + + В отличие от _REDERIVE_SQL (byte-identical historical EKB derivation), генерик-путь + для остальных регионов не несёт `city ILIKE` — эта квота была костылём конкретно + ЕКБ-исторического asking-покрытия (#C2), не общим правилом (#3512). + """ + assert "city ILIKE" not in _REDERIVE_SQL_REGION + assert _REDERIVE_SQL_REGION.count("region_code = CAST(:region_code AS int)") >= 4 + assert "FROM deals" in _REDERIVE_SQL_REGION + assert "FROM listings" in _REDERIVE_SQL_REGION + # Тот же порог 30/30 и то же 12-мес окно, что и у ЕКБ-деривации (переиспользуется, + # не изобретается заново — требование задачи). + assert "d.n_deals >= 30" in _REDERIVE_SQL_REGION + assert "a.n_listings >= 30" in _REDERIVE_SQL_REGION + assert "deal_date >= CURRENT_DATE - INTERVAL '12 months'" in _REDERIVE_SQL_REGION + # INSERT пишет параметризованный регион, а не литерал 66. + assert "CAST(:region_code AS int) FROM global_row" in _REDERIVE_SQL_REGION + assert "CAST(:region_code AS int) FROM per_bucket" in _REDERIVE_SQL_REGION + + +def test_delete_sql_region_scopes_by_region_code() -> None: + flat = re.sub(r"\s+", " ", _DELETE_SQL_REGION).strip() + assert "DELETE FROM asking_to_sold_ratios" in flat + assert "WHERE region_code = CAST(:region_code AS int) AND district = ''" in flat + + +def test_rederive_sql_writes_region_code_66_literal() -> None: + """_REDERIVE_SQL (ЕКБ-путь) теперь пишет region_code=66 явно в новую колонку.""" + assert "region_code" in _REDERIVE_SQL + assert "basis, 66 FROM global_row" in _REDERIVE_SQL + assert "basis, 66 FROM per_bucket" in _REDERIVE_SQL + # Числа для 66 не должны были поменяться: городская квота ЕКБ (_ASKING_CITY_PATTERN) + # осталась нетронутой в обоих deal-CTE и обоих ask-CTE. + assert _REDERIVE_SQL.count("AND city ILIKE :asking_city") == 2 + assert _REDERIVE_SQL.count("AND (city IS NULL OR city ILIKE :asking_city)") == 2 + + +def test_task_loops_other_regions_between_ekb_and_counters() -> None: + """recompute_asking_to_sold_ratios: EKB derive -> other-regions loop -> counters.""" + ekb_pos = _TASK_SRC.index("_REDERIVE_SQL,") + loop_pos = _TASK_SRC.index("_OTHER_REGION_CODES") + region_delete_pos = _TASK_SRC.index("_DELETE_SQL_REGION") + region_insert_pos = _TASK_SRC.index("_REDERIVE_SQL_REGION,") + counters_pos = _TASK_SRC.index("_COUNTERS_SQL") + assert ekb_pos < loop_pos < region_delete_pos < region_insert_pos < counters_pos diff --git a/tradein-mvp/backend/tests/test_estimator_ratio_per_region_3512.py b/tradein-mvp/backend/tests/test_estimator_ratio_per_region_3512.py new file mode 100644 index 00000000..73b33652 --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimator_ratio_per_region_3512.py @@ -0,0 +1,140 @@ +"""Tests for per-region asking→sold ratio resolution (#3512). + +ПРОБЛЕМА: asking_to_sold_ratios (migration 080) считался ИСКЛЮЧИТЕЛЬНО по ЕКБ +(city ILIKE '%Екатеринбург%') и применялся estimator'ом к ЛЮБОМУ адресу, включая +Москву — коэффициент рынка на порядок дешевле домножал московскую asking-цену. +Migration 304 добавила region_code в таблицу; этот файл проверяет, что +_get_asking_sold_ratio (app/services/estimator.py) теперь: + 1. Скоупит ОБА SELECT (per-bucket + global -1 fallback) по region_code запроса. + 2. Регион 66 без явного region_code — прежнее поведение (дефолт DEFAULT_REGION_CODE). + 3. Регион без единой строки (ни per-bucket, ни global -1) деградирует ЯВНО: + (None, None), без подмены чужим регионом. + 4. Кэш ключуется на (region_code, bucket), не на голый bucket — иначе Москва и ЕКБ + смешались бы в одном кэш-слоте. + +No DB / network: fake Session (тот же паттерн, что +test_estimator_ratio_savepoint_retry_2265.py). DATABASE_URL нужен только для импорта +app.core.config.Settings. +""" + +import os +from contextlib import contextmanager +from typing import Any + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.services import estimator +from app.services import regions as regions_mod + + +class _Row: + def __init__(self, ratio: float | None, basis: str | None) -> None: + self.ratio = ratio + self.basis = basis + + +class _Result: + def __init__(self, row: _Row | None) -> None: + self._row = row + + def fetchone(self) -> _Row | None: + return self._row + + +class _RegionAwareFakeSession: + """Fake Session: отвечает по params['region_code'], как настоящая БД per-region. + + rows: {(region_code, rooms_bucket): _Row} — rooms_bucket=-1 для global fallback. + Записывает все (sql_text, params) в self.calls для инспекции bind-параметров. + """ + + def __init__(self, rows: dict[tuple[int, int], _Row]) -> None: + self._rows = rows + self.calls: list[dict[str, Any]] = [] + + @contextmanager + def begin_nested(self): # type: ignore[no-untyped-def] + yield None + + def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any: + params = params or {} + self.calls.append(dict(params)) + region_code = params.get("region_code") + bucket = params.get("b", -1) + return _Result(self._rows.get((region_code, bucket))) + + def rollback(self) -> None: + pass + + +def _clear_cache() -> None: + estimator._asking_sold_ratio_cache.clear() + + +def test_ratio_query_scoped_by_region_code_of_request() -> None: + """Оба SELECT (per-bucket + global -1) несут region_code запроса как bind-параметр.""" + _clear_cache() + db = _RegionAwareFakeSession({(77, 1): _Row(0.95, "per_rooms")}) + + ratio, basis = estimator._get_asking_sold_ratio(db, rooms=1, region_code=77) # type: ignore[arg-type] + + assert ratio == 0.95 + assert basis == "per_rooms" + assert len(db.calls) == 1 # per-bucket строка нашлась сразу, global -1 не запрошен + assert db.calls[0]["region_code"] == 77 + assert db.calls[0]["b"] == 1 + + +def test_moscow_and_ekb_get_different_ratios_for_same_bucket() -> None: + """Тот же rooms-бакет (1), два региона — РАЗНЫЕ коэффициенты, не смешиваются.""" + _clear_cache() + db = _RegionAwareFakeSession( + { + (66, 1): _Row(0.84, "per_rooms"), + (77, 1): _Row(0.97, "per_rooms"), + } + ) + + ratio_ekb, basis_ekb = estimator._get_asking_sold_ratio(db, rooms=1, region_code=66) # type: ignore[arg-type] + ratio_msk, basis_msk = estimator._get_asking_sold_ratio(db, rooms=1, region_code=77) # type: ignore[arg-type] + + assert (ratio_ekb, basis_ekb) == (0.84, "per_rooms") + assert (ratio_msk, basis_msk) == (0.97, "per_rooms") + assert ratio_ekb != ratio_msk + # Оба закэшированы РАЗДЕЛЬНО — (region_code, bucket) ключ, не голый bucket (#3512). + assert estimator._asking_sold_ratio_cache[(66, 1)][0] == 0.84 + assert estimator._asking_sold_ratio_cache[(77, 1)][0] == 0.97 + + +def test_default_region_code_is_ekb_previous_behaviour_preserved() -> None: + """Вызов без region_code (легаси call-site) — дефолт 66, прежнее поведение.""" + _clear_cache() + assert regions_mod.DEFAULT_REGION_CODE == 66 + db = _RegionAwareFakeSession({(66, 2): _Row(0.81, "global_fallback")}) + + ratio, basis = estimator._get_asking_sold_ratio(db, rooms=2) # type: ignore[arg-type] + + assert (ratio, basis) == (0.81, "global_fallback") + assert db.calls[0]["region_code"] == 66 + + +def test_region_without_any_row_degrades_explicit_not_silent() -> None: + """Регион без per-bucket И без global -1 строки → (None, None), не чужой регион. + + Falsifiable: до фикса резолвер не нёс region_code вообще, и запрос без фильтра + подхватил бы ЕКБ-глобальную строку молча. Здесь region_code=50 (Московская обл.) + не имеет НИ ОДНОЙ строки в rows — обе попытки (per-bucket + global -1) промахиваются. + """ + _clear_cache() + db = _RegionAwareFakeSession({(66, 3): _Row(0.9, "per_rooms")}) # только ЕКБ + + ratio, basis = estimator._get_asking_sold_ratio(db, rooms=3, region_code=50) # type: ignore[arg-type] + + assert (ratio, basis) == (None, None) + # Обе попытки (per-bucket=3, global=-1) реально ушли под region_code=50 — + # НЕ переключились молча на ЕКБ-данные, которые в rows есть. + assert len(db.calls) == 2 + assert db.calls[0]["region_code"] == 50 + assert db.calls[1]["region_code"] == 50 + # Стабильный факт БД — кэшируется как обычный None-результат (см. #2175 идиома). + assert estimator._asking_sold_ratio_cache[(50, 3)][0] is None diff --git a/tradein-mvp/backend/tests/test_estimator_ratio_savepoint_retry_2265.py b/tradein-mvp/backend/tests/test_estimator_ratio_savepoint_retry_2265.py index 4dfcc76e..a19e99e8 100644 --- a/tradein-mvp/backend/tests/test_estimator_ratio_savepoint_retry_2265.py +++ b/tradein-mvp/backend/tests/test_estimator_ratio_savepoint_retry_2265.py @@ -71,7 +71,8 @@ def test_ratio_retry_recovers_after_first_lookup_fails() -> None: assert basis == "per_rooms" assert db.begin_nested_count == 2 # первый заход упал, второй прошёл assert db.rollback_count >= 1 # чистка tx перед retry - assert 1 in estimator._asking_sold_ratio_cache # успех кэшируется + # #3512: ключ кэша (region_code, bucket); region_code не передан → дефолт 66. + assert (66, 1) in estimator._asking_sold_ratio_cache # успех кэшируется def test_ratio_both_attempts_fail_returns_none_and_not_cached() -> None: @@ -82,7 +83,8 @@ def test_ratio_both_attempts_fail_returns_none_and_not_cached() -> None: assert ratio is None assert basis is None - assert 2 not in estimator._asking_sold_ratio_cache # транзиент НЕ отравляет кэш (#2175) + # транзиент НЕ отравляет кэш (#2175); ключ (region_code, bucket) — #3512. + assert (66, 2) not in estimator._asking_sold_ratio_cache assert db.begin_nested_count == 2 assert db.rollback_count >= 2 @@ -97,7 +99,7 @@ def test_ratio_happy_path_single_attempt_no_rollback() -> None: assert basis == "global_fallback" assert db.begin_nested_count == 1 # ни одного retry assert db.rollback_count == 0 - cached = estimator._asking_sold_ratio_cache.get(0) + cached = estimator._asking_sold_ratio_cache.get((66, 0)) assert cached is not None assert cached[0] == 0.9 assert cached[1] == "global_fallback" diff --git a/tradein-mvp/backend/tests/test_estimator_ratio_tier_fix.py b/tradein-mvp/backend/tests/test_estimator_ratio_tier_fix.py index 2a717175..588b7943 100644 --- a/tradein-mvp/backend/tests/test_estimator_ratio_tier_fix.py +++ b/tradein-mvp/backend/tests/test_estimator_ratio_tier_fix.py @@ -107,9 +107,23 @@ def _run_estimate_with_ratio_spy( ratio_calls: list[Any] = [] - def _spy_ratio(db_inner: Any, rooms: Any, area_m2: Any = None, anchor_ppm2: Any = None) -> Any: + def _spy_ratio( + db_inner: Any, + rooms: Any, + area_m2: Any = None, + anchor_ppm2: Any = None, + region_code: Any = None, + ) -> Any: # #2620-2: signature now carries area_m2 (rekey to area-bucket) before anchor_ppm2. - ratio_calls.append({"rooms": rooms, "area_m2": area_m2, "anchor_ppm2": anchor_ppm2}) + # #3512: region_code — регион цели, теперь тоже часть call-site сигнатуры. + ratio_calls.append( + { + "rooms": rooms, + "area_m2": area_m2, + "anchor_ppm2": anchor_ppm2, + "region_code": region_code, + } + ) return ratio_return async def _run() -> Any: @@ -169,8 +183,15 @@ def test_ratio_tier_uses_final_headline_after_anchor() -> None: # _get_asking_sold_ratio должен получить anchor_ppm2 ≈ 300k (не 105k). captured_anchor_ppm2: list[float | None] = [] - def _spy(db_inner: Any, rooms: Any, area_m2: Any = None, anchor_ppm2: Any = None) -> tuple: + def _spy( + db_inner: Any, + rooms: Any, + area_m2: Any = None, + anchor_ppm2: Any = None, + region_code: Any = None, + ) -> tuple: # #2620-2: signature now carries area_m2 (rekey to area-bucket) before anchor_ppm2. + # #3512: region_code — регион цели, теперь тоже часть call-site сигнатуры. captured_anchor_ppm2.append(anchor_ppm2) return (0.78, "per_rooms")