fix(tradein): коэффициент цена предложения -> цена сделки стал региональным (#3512)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 18s
CI / changes (pull_request) Successful in 20s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m28s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 18s
CI / changes (pull_request) Successful in 20s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m28s
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.
This commit is contained in:
parent
3218190a89
commit
35c8ef4445
8 changed files with 620 additions and 51 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue