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

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:
bot-backend 2026-09-13 13:46:02 +03:00
parent 3218190a89
commit 35c8ef4445
8 changed files with 620 additions and 51 deletions

View file

@ -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(

View file

@ -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:

View file

@ -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;

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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")