From c467584de37df2215d9cdd999602c014c1dbba21 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 30 Aug 2026 00:10:36 +0500 Subject: [PATCH] =?UTF-8?q?fix(mera/landing):=20=C2=AB=D0=A6=D0=B5=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=94=D0=9A=D0=9F=C2=BB=20=E2=80=94=20=D1=86=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=B4=D0=BE=D0=B3=D0=BE=D0=B2=D0=BE=D1=80=D0=B0?= =?UTF-8?q?,=20=D0=B0=20=D0=BD=D0=B5=20=D0=BF=D1=80=D0=BE=D0=B8=D0=B7?= =?UTF-8?q?=D0=B2=D0=B5=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5;=20=D1=8D=D0=BA?= =?UTF-8?q?=D1=81=D0=BF=D0=BE=D0=B7=D0=B8=D1=86=D0=B8=D1=8F=20=D1=81=D1=87?= =?UTF-8?q?=D0=B8=D1=82=D0=B0=D0=B5=D1=82=20=D0=B8=20=D0=94=D0=BE=D0=BC?= =?UTF-8?q?=D0=BA=D0=BB=D0=B8=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Витрина показывала fact_rub = price_per_m2 * area_m2, хотя deals.price_rub лежит в той же строке и не использовалась. price_per_m2 в базе integer, поэтому под подписью «Цена ДКП» ехала реконструкция: 4 799 995 вместо 4 800 000, 3 649 995 вместо 3 650 000 (прод, сделки 5777343 и др.). Теперь price_rub едет из выборки (DealSample.price_rub) и показывается как есть; err_pct считается от той же величины. Строка без price_rub НЕ показывается — подставлять реконструкцию в одну строку из двадцати значило бы спрятать тот же дефект (на проде price_rub заполнен у 33 555 из 33 555 сделок выборки витрины). Вторая находка аудита (listing_date якобы «когда увидели МЫ», экспозиция занижена втрое) НЕ ПОДТВЕРДИЛАСЬ. listing_date пишут cian (added_ts), yandex (creationDate) и avito (дата карточки выдачи) — это дата публикации у источника. Там, где заполнены и listing_date, и publish_date, они совпадают: yandex 10 761 из 10 903, avito 474 из 569, медиана разницы 0 дней. 75 дней у аудитора — эффект другой ВЫБОРКИ: publish_date есть у 15 058 активных строк (yandex + Домклик, оба старые), listing_date — у 25 982 (плюс cian с медианой 17 дней и 87% avito с медианой 19). Настоящий дефект рядом: по одному listing_date Домклик выпадал целиком (0 из 3061 активной строки), метрика считалась по 83.6% активных объявлений, и подпись об этом молчала. COALESCE(listing_date, publish_date) → охват 95.2% (29 568 из 31 068), медиана та же — 26 дней; охват теперь назван в note. Co-Authored-By: Claude Opus 5 --- .../app/tasks/landing_showcase_deals.py | 26 +++++++++-- .../backend/app/tasks/landing_stats.py | 46 ++++++++++++++----- .../backend/scripts/backtest_estimator.py | 9 ++++ .../tests/test_landing_showcase_deals.py | 40 +++++++++++++--- .../backend/tests/test_landing_stats.py | 38 +++++++++++++-- 5 files changed, 133 insertions(+), 26 deletions(-) diff --git a/tradein-mvp/backend/app/tasks/landing_showcase_deals.py b/tradein-mvp/backend/app/tasks/landing_showcase_deals.py index 40aaed0d..ec69b54a 100644 --- a/tradein-mvp/backend/app/tasks/landing_showcase_deals.py +++ b/tradein-mvp/backend/app/tasks/landing_showcase_deals.py @@ -118,7 +118,8 @@ REJECTION_RULE = ( NOTE = ( "Прогноз посчитан по активным объявлениям на дату пересчёта, сделка — прошлая: " "это не point-in-time проверка, дрейф рынка за период входит в отклонение целиком. " - "Факт — цена ДКП, заявленная в Росреестр: она бывает занижена сторонами, и тогда " + "Факт — цена ДКП из договора (поле price_rub Росреестра, не пересчёт из ₽/м²): " + "она бывает занижена сторонами, и тогда " "строка выглядит как промах оценщика, хотя врёт документ. " "Схема на карточке — улица сделки, а не её дом: в адресе Росреестра номер дома " "есть у 2.7% строк, поэтому дом не показан и показан быть не может." @@ -192,7 +193,7 @@ def build_row( total_floors: int | None, deal_date: date | None, predicted_rub: float | None, - fact_ppm2: float, + fact_rub: float | None, n_analogs: int, lat: 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: карта переживёт сделку без точки, а выбрасывание сделки из-за отсутствия координаты — отбор по признаку, не связанному с качеством оценки, то есть та же порча витрины, что и отбор по ошибке. """ - 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 quarter = quarter_label(deal_date) if quarter is None: return None - fact_rub = fact_ppm2 * area_m2 # Знак ошибки — как в бэктесте: (прогноз − факт) / факт. Плюс = МЕРА # назвала дороже, чем ушло по ДКП. err_pct = 100.0 * (predicted_rub - fact_rub) / fact_rub @@ -385,7 +401,7 @@ def refresh_landing_showcase_deals( total_floors=deal.total_floors, deal_date=deal.deal_date, 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, # Порядок ровно такой: lat — широта (~56.8 для ЕКБ), lon — долгота # (~60.6). Перепутать местами — это точка в другой стране, и никакой diff --git a/tradein-mvp/backend/app/tasks/landing_stats.py b/tradein-mvp/backend/app/tasks/landing_stats.py index b6821e93..4a9cdfc8 100644 --- a/tradein-mvp/backend/app/tasks/landing_stats.py +++ b/tradein-mvp/backend/app/tasks/landing_stats.py @@ -24,7 +24,10 @@ Точность считает бэктест (своя задача, свои допущения), а срок продажи требует пары «объявление снято → сделка», которой у нас нет: снятие объявления не означает продажу. `listing_age_median_days` НЕ является сроком продажи и назван -экспозицией активного объявления — см. note метрики. +экспозицией активного объявления — см. note метрики. Считается от даты +публикации у источника (`listing_date`, иначе `publish_date` — это одна и та же +величина в двух колонках, замер в комментарии к `_LISTING_AGE_SQL`), и note +называет охват: у 1500 из 31 068 активных объявлений ЕКБ даты публикации нет. ПОЧЕМУ ТОЛЬКО DOMKLIK В ЦЕНОВЫХ МЕТРИКАХ ---------------------------------------- @@ -90,16 +93,33 @@ _ANALOGS_SQL = text(""" # Возраст АКТИВНОГО объявления = экспозиция на сегодня, а не срок продажи: # знаменатель — те, кто ещё висит, поэтому величина по построению занижена # относительно «сколько в итоге продавалось». Это ограничение уезжает в 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(""" - SELECT count(*) AS n, + WITH active AS ( + SELECT (CURRENT_DATE - COALESCE(listing_date, publish_date)) AS age_days + FROM listings + WHERE is_active + AND city = CAST(:city AS text) + ) + SELECT count(*) FILTER (WHERE age_days >= 0) AS n, + count(*) AS n_active, percentile_cont(0.5) WITHIN GROUP ( - ORDER BY (CURRENT_DATE - listing_date) - ) AS median - FROM listings - WHERE is_active - AND city = CAST(:city AS text) - AND listing_date IS NOT NULL - AND listing_date <= CURRENT_DATE + 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), "note": ( "Медианная ЭКСПОЗИЦИЯ активного объявления в Екатеринбурге " - "(сколько дней висит на сегодня). Это НЕ срок продажи: " - "считается по тем, кто ещё продаётся, и снятие объявления " - "не означает сделку" + "(сколько дней висит на сегодня, от даты публикации у источника). " + "Это НЕ срок продажи: считается по тем, кто ещё продаётся, и " + "снятие объявления не означает сделку. Дата публикации известна " + f"у {int(row.n)} из {int(row.n_active)} активных объявлений города — " + "остальные в расчёт не входят" ), } ) diff --git a/tradein-mvp/backend/scripts/backtest_estimator.py b/tradein-mvp/backend/scripts/backtest_estimator.py index 2c272a42..e0fe8ef4 100644 --- a/tradein-mvp/backend/scripts/backtest_estimator.py +++ b/tradein-mvp/backend/scripts/backtest_estimator.py @@ -264,6 +264,12 @@ class DealSample: sold_ppm2: float deal_date: Any # datetime.date | None — carried through for reporting only 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 floor: int | None = None total_floors: int | None = None @@ -1013,6 +1019,7 @@ _SAMPLE_SQL = text( ST_Y(geom::geometry) AS lat, rooms, price_per_m2 AS sold_ppm2, + price_rub, deal_date, area_m2, address, @@ -1091,6 +1098,7 @@ def _sample_sql(city: str | None) -> Any: ST_Y(geom::geometry) AS lat, rooms, price_per_m2 AS sold_ppm2, + price_rub, deal_date, area_m2, address, @@ -1176,6 +1184,7 @@ def _load_sample( lat=float(r["lat"]), rooms=int(r["rooms"]), 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"], area_m2=float(r["area_m2"]), address=r["address"], diff --git a/tradein-mvp/backend/tests/test_landing_showcase_deals.py b/tradein-mvp/backend/tests/test_landing_showcase_deals.py index 42bde9bd..702e42f7 100644 --- a/tradein-mvp/backend/tests/test_landing_showcase_deals.py +++ b/tradein-mvp/backend/tests/test_landing_showcase_deals.py @@ -91,7 +91,7 @@ def _build(**over: object) -> ShowcaseRow | None: "total_floors": 9, "deal_date": date(2026, 4, 1), "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, "lat": 56.8386, # ЕКБ: широта ~56.8, долгота ~60.6 — величины НЕ похожи, "lon": 60.6055, # поэтому перестановка ловится по значению. @@ -108,6 +108,27 @@ def test_plain_row_survives_and_carries_signed_error() -> None: 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: """Промах оценщика ЛЮБОГО размера остаётся на витрине. @@ -139,7 +160,7 @@ def test_underdeclared_dkp_is_shown_not_hidden() -> None: строки в `note`, а не за счёт отсева. """ # Факт 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.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_ppm2=2_000_000.0, predicted_rub=100_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_rub=100_000_000.0, predicted_rub=100_000_000.0) is not 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 diff --git a/tradein-mvp/backend/tests/test_landing_stats.py b/tradein-mvp/backend/tests/test_landing_stats.py index f6b71772..7c561d76 100644 --- a/tradein-mvp/backend/tests/test_landing_stats.py +++ b/tradein-mvp/backend/tests/test_landing_stats.py @@ -108,7 +108,7 @@ def _rows(**overrides: Any) -> list[Any]: base: dict[str, Any] = { "estimates": SimpleNamespace(total=1123, period_days=94.0), "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")), "deals": SimpleNamespace(n=18657), } @@ -171,6 +171,38 @@ def test_listing_age_note_says_exposure_not_time_to_sell() -> None: 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: """Этих величин в данных нет; их считает бэктест со своими допущениями.""" 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"), ({"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_cut_share_pct", @@ -260,7 +292,7 @@ def test_totally_empty_run_keeps_the_showcase_instead_of_wiping_it() -> None: empty = _rows( estimates=SimpleNamespace(total=0, period_days=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), deals=SimpleNamespace(n=0), )