Merge remote-tracking branch 'origin/fix/audit-dkp-price' into fix/audit-all

This commit is contained in:
bot-backend 2026-08-30 00:14:23 +05:00
commit e3f3e8f3b1
5 changed files with 133 additions and 26 deletions

View file

@ -118,7 +118,8 @@ REJECTION_RULE = (
NOTE = ( NOTE = (
"Прогноз посчитан по активным объявлениям на дату пересчёта, сделка — прошлая: " "Прогноз посчитан по активным объявлениям на дату пересчёта, сделка — прошлая: "
"это не point-in-time проверка, дрейф рынка за период входит в отклонение целиком. " "это не point-in-time проверка, дрейф рынка за период входит в отклонение целиком. "
"Факт — цена ДКП, заявленная в Росреестр: она бывает занижена сторонами, и тогда " "Факт — цена ДКП из договора (поле price_rub Росреестра, не пересчёт из ₽/м²): "
"она бывает занижена сторонами, и тогда "
"строка выглядит как промах оценщика, хотя врёт документ. " "строка выглядит как промах оценщика, хотя врёт документ. "
"Схема на карточке — улица сделки, а не её дом: в адресе Росреестра номер дома " "Схема на карточке — улица сделки, а не её дом: в адресе Росреестра номер дома "
"есть у 2.7% строк, поэтому дом не показан и показан быть не может." "есть у 2.7% строк, поэтому дом не показан и показан быть не может."
@ -192,7 +193,7 @@ def build_row(
total_floors: int | None, total_floors: int | None,
deal_date: date | None, deal_date: date | None,
predicted_rub: float | None, predicted_rub: float | None,
fact_ppm2: float, fact_rub: float | None,
n_analogs: int, n_analogs: int,
lat: float | None = None, lat: float | None = None,
lon: float | None = None, lon: float | None = None,
@ -204,18 +205,33 @@ def build_row(
(делить не на что). Величина отклонения причиной НЕ является ни при каких (делить не на что). Величина отклонения причиной НЕ является ни при каких
значениях см. «ФИЛЬТРА ПО ОШИБКЕ ТОЖЕ НЕТ» в докстринге модуля. значениях см. «ФИЛЬТРА ПО ОШИБКЕ ТОЖЕ НЕТ» в докстринге модуля.
ФАКТ ЭТО `deals.price_rub`, ЦЕНА ИЗ ДОГОВОРА, А НЕ ПРОИЗВЕДЕНИЕ. Колонка на
витрине называется «Цена ДКП», и подпись обязана называть ту величину, которая
показана. До 2026-08-30 здесь считалось `price_per_m2 * area_m2`, а
`deals.price_rub` лежала рядом и не использовалась: `price_per_m2` в базе
integer, поэтому произведение промахивалось на единицы рублей (на проде
4 799 995 вместо 4 800 000, 3 649 995 вместо 3 650 000). Расхождение
копеечное, но показывалась реконструкция под именем документа.
Строка без `price_rub` НЕ ПОКАЗЫВАЕТСЯ это «данных нет», и подставить туда
реконструкцию значило бы вернуть дефект в одной строке из двадцати, где его
уже никто не найдёт. Замер на проде 2026-08-30: в выборке витрины (ЕКБ,
rosreestr, с 2025-01-01, санитарный диапазон) price_rub заполнен у 33 555 из
33 555 сделок, так что отказ по этой причине защита, а не рабочий путь.
ОТСУТСТВИЕ КООРДИНАТЫ ПРИЧИНОЙ ТОЖЕ НЕ ЯВЛЯЕТСЯ. Строка без точки едет на ОТСУТСТВИЕ КООРДИНАТЫ ПРИЧИНОЙ ТОЖЕ НЕ ЯВЛЯЕТСЯ. Строка без точки едет на
витрину с lat=lon=None: карта переживёт сделку без точки, а выбрасывание витрину с lat=lon=None: карта переживёт сделку без точки, а выбрасывание
сделки из-за отсутствия координаты отбор по признаку, не связанному с сделки из-за отсутствия координаты отбор по признаку, не связанному с
качеством оценки, то есть та же порча витрины, что и отбор по ошибке. качеством оценки, то есть та же порча витрины, что и отбор по ошибке.
""" """
if predicted_rub is None or predicted_rub <= 0 or area_m2 <= 0 or fact_ppm2 <= 0: if predicted_rub is None or predicted_rub <= 0 or area_m2 <= 0:
return None
if fact_rub is None or fact_rub <= 0:
return None return None
quarter = quarter_label(deal_date) quarter = quarter_label(deal_date)
if quarter is None: if quarter is None:
return None return None
fact_rub = fact_ppm2 * area_m2
# Знак ошибки — как в бэктесте: (прогноз факт) / факт. Плюс = МЕРА # Знак ошибки — как в бэктесте: (прогноз факт) / факт. Плюс = МЕРА
# назвала дороже, чем ушло по ДКП. # назвала дороже, чем ушло по ДКП.
err_pct = 100.0 * (predicted_rub - fact_rub) / fact_rub err_pct = 100.0 * (predicted_rub - fact_rub) / fact_rub
@ -385,7 +401,7 @@ def refresh_landing_showcase_deals(
total_floors=deal.total_floors, total_floors=deal.total_floors,
deal_date=deal.deal_date, deal_date=deal.deal_date,
predicted_rub=pr.expected_sold_price, predicted_rub=pr.expected_sold_price,
fact_ppm2=deal.sold_ppm2, fact_rub=deal.price_rub,
n_analogs=len(capture[0]["kwargs"]["listings"]) if capture else 0, n_analogs=len(capture[0]["kwargs"]["listings"]) if capture else 0,
# Порядок ровно такой: lat — широта (~56.8 для ЕКБ), lon — долгота # Порядок ровно такой: lat — широта (~56.8 для ЕКБ), lon — долгота
# (~60.6). Перепутать местами — это точка в другой стране, и никакой # (~60.6). Перепутать местами — это точка в другой стране, и никакой

View file

@ -24,7 +24,10 @@
Точность считает бэктест (своя задача, свои допущения), а срок продажи требует Точность считает бэктест (своя задача, свои допущения), а срок продажи требует
пары «объявление снято сделка», которой у нас нет: снятие объявления не пары «объявление снято сделка», которой у нас нет: снятие объявления не
означает продажу. `listing_age_median_days` НЕ является сроком продажи и назван означает продажу. `listing_age_median_days` НЕ является сроком продажи и назван
экспозицией активного объявления см. note метрики. экспозицией активного объявления см. note метрики. Считается от даты
публикации у источника (`listing_date`, иначе `publish_date` это одна и та же
величина в двух колонках, замер в комментарии к `_LISTING_AGE_SQL`), и note
называет охват: у 1500 из 31 068 активных объявлений ЕКБ даты публикации нет.
ПОЧЕМУ ТОЛЬКО DOMKLIK В ЦЕНОВЫХ МЕТРИКАХ ПОЧЕМУ ТОЛЬКО DOMKLIK В ЦЕНОВЫХ МЕТРИКАХ
---------------------------------------- ----------------------------------------
@ -90,16 +93,33 @@ _ANALOGS_SQL = text("""
# Возраст АКТИВНОГО объявления = экспозиция на сегодня, а не срок продажи: # Возраст АКТИВНОГО объявления = экспозиция на сегодня, а не срок продажи:
# знаменатель — те, кто ещё висит, поэтому величина по построению занижена # знаменатель — те, кто ещё висит, поэтому величина по построению занижена
# относительно «сколько в итоге продавалось». Это ограничение уезжает в note. # относительно «сколько в итоге продавалось». Это ограничение уезжает в note.
#
# ДАТА ПУБЛИКАЦИИ ЛЕЖИТ В ДВУХ КОЛОНКАХ, И ОБЕ ЗНАЧАТ ОДНО. `listing_date`
# наполняют cian (`added_ts`), yandex (`creationDate`) и avito (дата карточки
# выдачи); `publish_date` — yandex (тем же значением) и Домклик
# (`publishedDate`). Замер на проде 2026-08-30 по активным ЕКБ: там, где
# заполнены ОБЕ, они совпадают (yandex 10 761 из 10 903, avito 474 из 569,
# медиана разницы 0 дней) — то есть это не «когда увидели мы» против «когда
# выставили», а одна величина в двух полях.
#
# Поэтому COALESCE: по одному `listing_date` Домклик выпадал ЦЕЛИКОМ (0 из
# 3061 активных строк с датой), и метрика считалась по 25 982 из 31 068
# активных объявлений — 83.6%, о чём подпись молчала. С COALESCE охват
# 29 568 из 31 068 (95.2%), а медиана осталась той же: 26 дней. Охват едет в
# note, потому что 1500 объявлений без даты публикации — это не ноль.
_LISTING_AGE_SQL = text(""" _LISTING_AGE_SQL = text("""
SELECT count(*) AS n, WITH active AS (
percentile_cont(0.5) WITHIN GROUP ( SELECT (CURRENT_DATE - COALESCE(listing_date, publish_date)) AS age_days
ORDER BY (CURRENT_DATE - listing_date)
) AS median
FROM listings FROM listings
WHERE is_active WHERE is_active
AND city = CAST(:city AS text) AND city = CAST(:city AS text)
AND listing_date IS NOT NULL )
AND listing_date <= CURRENT_DATE SELECT count(*) FILTER (WHERE age_days >= 0) AS n,
count(*) AS n_active,
percentile_cont(0.5) WITHIN GROUP (
ORDER BY age_days
) FILTER (WHERE age_days >= 0) AS median
FROM active
""") """)
# ── Динамика цены объявлений ──────────────────────────────────────────────── # ── Динамика цены объявлений ────────────────────────────────────────────────
@ -270,9 +290,11 @@ def collect_landing_metrics(db: Session) -> list[dict[str, Any]]:
"sample_n": int(row.n), "sample_n": int(row.n),
"note": ( "note": (
"Медианная ЭКСПОЗИЦИЯ активного объявления в Екатеринбурге " "Медианная ЭКСПОЗИЦИЯ активного объявления в Екатеринбурге "
"(сколько дней висит на сегодня). Это НЕ срок продажи: " "(сколько дней висит на сегодня, от даты публикации у источника). "
"считается по тем, кто ещё продаётся, и снятие объявления " "Это НЕ срок продажи: считается по тем, кто ещё продаётся, и "
"не означает сделку" "снятие объявления не означает сделку. Дата публикации известна "
f"у {int(row.n)} из {int(row.n_active)} активных объявлений города — "
"остальные в расчёт не входят"
), ),
} }
) )

View file

@ -264,6 +264,12 @@ class DealSample:
sold_ppm2: float sold_ppm2: float
deal_date: Any # datetime.date | None — carried through for reporting only deal_date: Any # datetime.date | None — carried through for reporting only
area_m2: float = 0.0 area_m2: float = 0.0
# Цена ДКП как она записана в договоре. НЕ sold_ppm2 * area_m2: price_per_m2
# в базе integer, и произведение промахивается на единицы рублей
# (4 799 995 против 4 800 000). Витрине нужна цена документа, поэтому поле
# едет из выборки, а не восстанавливается. Бэктест считает в ₽/м² и его не
# использует. None только если в строке нет цены — в выборке ЕКБ таких нет.
price_rub: float | None = None
address: str | None = None address: str | None = None
floor: int | None = None floor: int | None = None
total_floors: int | None = None total_floors: int | None = None
@ -1013,6 +1019,7 @@ _SAMPLE_SQL = text(
ST_Y(geom::geometry) AS lat, ST_Y(geom::geometry) AS lat,
rooms, rooms,
price_per_m2 AS sold_ppm2, price_per_m2 AS sold_ppm2,
price_rub,
deal_date, deal_date,
area_m2, area_m2,
address, address,
@ -1091,6 +1098,7 @@ def _sample_sql(city: str | None) -> Any:
ST_Y(geom::geometry) AS lat, ST_Y(geom::geometry) AS lat,
rooms, rooms,
price_per_m2 AS sold_ppm2, price_per_m2 AS sold_ppm2,
price_rub,
deal_date, deal_date,
area_m2, area_m2,
address, address,
@ -1176,6 +1184,7 @@ def _load_sample(
lat=float(r["lat"]), lat=float(r["lat"]),
rooms=int(r["rooms"]), rooms=int(r["rooms"]),
sold_ppm2=float(r["sold_ppm2"]), sold_ppm2=float(r["sold_ppm2"]),
price_rub=(float(r["price_rub"]) if r["price_rub"] is not None else None),
deal_date=r["deal_date"], deal_date=r["deal_date"],
area_m2=float(r["area_m2"]), area_m2=float(r["area_m2"]),
address=r["address"], address=r["address"],

View file

@ -91,7 +91,7 @@ def _build(**over: object) -> ShowcaseRow | None:
"total_floors": 9, "total_floors": 9,
"deal_date": date(2026, 4, 1), "deal_date": date(2026, 4, 1),
"predicted_rub": 5_000_000.0, "predicted_rub": 5_000_000.0,
"fact_ppm2": 100_000.0, # → факт 5 000 000 ₽, ошибка 0% "fact_rub": 5_000_000.0, # цена ДКП из договора, ошибка 0%
"n_analogs": 30, "n_analogs": 30,
"lat": 56.8386, # ЕКБ: широта ~56.8, долгота ~60.6 — величины НЕ похожи, "lat": 56.8386, # ЕКБ: широта ~56.8, долгота ~60.6 — величины НЕ похожи,
"lon": 60.6055, # поэтому перестановка ловится по значению. "lon": 60.6055, # поэтому перестановка ловится по значению.
@ -108,6 +108,27 @@ def test_plain_row_survives_and_carries_signed_error() -> None:
assert row.deal_quarter == "II квартал 2026" assert row.deal_quarter == "II квартал 2026"
def test_fact_is_the_contract_price_not_the_reconstruction() -> None:
"""«Цена ДКП» — это `deals.price_rub`, а не `price_per_m2 × area_m2`.
Числа взяты с прода (сделка 5777343): в договоре 4 800 000 , а
произведение даёт 4 799 995 `price_per_m2` в базе integer. Витрина
показывала произведение под подписью «Цена ДКП».
Ломать так: вернуть в `build_row` реконструкцию (`fact_ppm2 * area_m2`,
то есть 185 328 × 25.9) тест покраснеет ПО ЗНАЧЕНИЮ: 4 799 995 вместо
4 800 000, и вместе с ним поедет err_pct.
"""
contract_rub = 4_800_000.0
reconstruction = 185_328 * 25.9 # 4 799 995.2 — то, что показывалось раньше
assert round(reconstruction) != contract_rub
row = _build(area_m2=25.9, fact_rub=contract_rub, predicted_rub=contract_rub)
assert row is not None
assert row.fact_rub == 4_800_000, "на витрину уехала реконструкция, а не цена договора"
assert row.err_pct == 0.0, "отклонение считается от той же величины, что показана"
def test_no_error_magnitude_is_ever_rejected() -> None: def test_no_error_magnitude_is_ever_rejected() -> None:
"""Промах оценщика ЛЮБОГО размера остаётся на витрине. """Промах оценщика ЛЮБОГО размера остаётся на витрине.
@ -139,7 +160,7 @@ def test_underdeclared_dkp_is_shown_not_hidden() -> None:
строки в `note`, а не за счёт отсева. строки в `note`, а не за счёт отсева.
""" """
# Факт 2 000 000 ₽ против прогноза 5 000 000 — отклонение +150%. # Факт 2 000 000 ₽ против прогноза 5 000 000 — отклонение +150%.
row = _build(fact_ppm2=40_000.0) row = _build(fact_rub=2_000_000.0)
assert row is not None assert row is not None
assert row.err_pct == 150.0 assert row.err_pct == 150.0
@ -152,13 +173,20 @@ def test_ppm2_band_is_not_duplicated_here() -> None:
отбраковок срабатывала ровно одна по ошибке. Ломать так: вернуть любую отбраковок срабатывала ровно одна по ошибке. Ломать так: вернуть любую
из границ покраснеет соответствующая половина. из границ покраснеет соответствующая половина.
""" """
assert _build(fact_ppm2=20_000.0, predicted_rub=1_000_000.0) is not None assert _build(fact_rub=1_000_000.0, predicted_rub=1_000_000.0) is not None
assert _build(fact_ppm2=2_000_000.0, predicted_rub=100_000_000.0) is not None assert _build(fact_rub=100_000_000.0, predicted_rub=100_000_000.0) is not None
def test_missing_fact_price_is_rejected() -> None: def test_missing_fact_price_is_rejected() -> None:
"""Нулевая цена сделки — это «данных нет», а не «число некрасивое»: делить не на что.""" """Нет цены ДКП — строки нет. Реконструкция вместо неё запрещена.
assert _build(fact_ppm2=0.0) is None
Подставить `price_per_m2 * area_m2` в строку без `price_rub` значило бы
вернуть тот самый дефект в одну строку из двадцати, где его уже не найти.
На проде price_rub заполнен у 33 555 из 33 555 сделок выборки, так что это
защита, а не рабочий путь.
"""
assert _build(fact_rub=None) is None
assert _build(fact_rub=0.0) is None
assert _build(area_m2=0.0) is None assert _build(area_m2=0.0) is None

View file

@ -108,7 +108,7 @@ def _rows(**overrides: Any) -> list[Any]:
base: dict[str, Any] = { base: dict[str, Any] = {
"estimates": SimpleNamespace(total=1123, period_days=94.0), "estimates": SimpleNamespace(total=1123, period_days=94.0),
"analogs": SimpleNamespace(n=975, median=Decimal("12")), "analogs": SimpleNamespace(n=975, median=Decimal("12")),
"listing_age": SimpleNamespace(n=25943, median=Decimal("26")), "listing_age": SimpleNamespace(n=29568, n_active=31068, median=Decimal("26")),
"price": SimpleNamespace(n=6276, n_cut=3018, median_pct_per_month=Decimal("-2.174")), "price": SimpleNamespace(n=6276, n_cut=3018, median_pct_per_month=Decimal("-2.174")),
"deals": SimpleNamespace(n=18657), "deals": SimpleNamespace(n=18657),
} }
@ -171,6 +171,38 @@ def test_listing_age_note_says_exposure_not_time_to_sell() -> None:
assert "НЕ срок продажи" in note assert "НЕ срок продажи" in note
def test_listing_age_note_names_its_coverage() -> None:
"""Знаменатель обязан быть в подписи: метрика видит не все активные.
Дата публикации есть у 29 568 из 31 068 активных объявлений ЕКБ (замер
2026-08-30). Полторы тысячи без даты это не ноль, и «медиана по активным
объявлениям» без охвата читается как «по всем».
Ломать так: убрать из note подстановку n/n_active тест покраснеет.
"""
got = _by_metric(ls.collect_landing_metrics(_FakeSession(_rows())))
note = got["listing_age_median_days"]["note"]
assert "29568" in note and "31068" in note, f"охват не назван: {note}"
def test_listing_age_counts_both_publication_date_columns() -> None:
"""Дата публикации лежит в двух колонках, и обе значат одно.
`listing_date` пишут cian/yandex/avito, `publish_date` yandex (тем же
значением) и Домклик. Там, где заполнены обе, они совпадают (прод
2026-08-30: yandex 10 761 из 10 903, avito 474 из 569). По одному
`listing_date` Домклик выпадал целиком 3061 активное объявление, 0 с
датой, охват 83.6% вместо 95.2%.
Ломать так: вернуть `ORDER BY (CURRENT_DATE - listing_date)` без COALESCE
тест покраснеет.
"""
sql = str(ls._LISTING_AGE_SQL)
assert "COALESCE(listing_date, publish_date)" in sql, (
"метрика снова считает по одной колонке — Домклик выпадает целиком"
)
def test_forecast_accuracy_and_time_to_sell_are_never_produced() -> None: def test_forecast_accuracy_and_time_to_sell_are_never_produced() -> None:
"""Этих величин в данных нет; их считает бэктест со своими допущениями.""" """Этих величин в данных нет; их считает бэктест со своими допущениями."""
names = {m["metric"] for m in ls.collect_landing_metrics(_FakeSession(_rows()))} names = {m["metric"] for m in ls.collect_landing_metrics(_FakeSession(_rows()))}
@ -185,7 +217,7 @@ def test_forecast_accuracy_and_time_to_sell_are_never_produced() -> None:
[ [
({"estimates": SimpleNamespace(total=0, period_days=None)}, "estimates_total"), ({"estimates": SimpleNamespace(total=0, period_days=None)}, "estimates_total"),
({"analogs": SimpleNamespace(n=0, median=None)}, "analogs_median"), ({"analogs": SimpleNamespace(n=0, median=None)}, "analogs_median"),
({"listing_age": SimpleNamespace(n=0, median=None)}, "listing_age_median_days"), ({"listing_age": SimpleNamespace(n=0, n_active=0, median=None)}, "listing_age_median_days"),
( (
{"price": SimpleNamespace(n=0, n_cut=0, median_pct_per_month=None)}, {"price": SimpleNamespace(n=0, n_cut=0, median_pct_per_month=None)},
"price_cut_share_pct", "price_cut_share_pct",
@ -260,7 +292,7 @@ def test_totally_empty_run_keeps_the_showcase_instead_of_wiping_it() -> None:
empty = _rows( empty = _rows(
estimates=SimpleNamespace(total=0, period_days=None), estimates=SimpleNamespace(total=0, period_days=None),
analogs=SimpleNamespace(n=0, median=None), analogs=SimpleNamespace(n=0, median=None),
listing_age=SimpleNamespace(n=0, median=None), listing_age=SimpleNamespace(n=0, n_active=0, median=None),
price=SimpleNamespace(n=0, n_cut=0, median_pct_per_month=None), price=SimpleNamespace(n=0, n_cut=0, median_pct_per_month=None),
deals=SimpleNamespace(n=0), deals=SimpleNamespace(n=0),
) )