Merge remote-tracking branch 'origin/fix/audit-dkp-price' into fix/audit-all
This commit is contained in:
commit
e3f3e8f3b1
5 changed files with 133 additions and 26 deletions
|
|
@ -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). Перепутать местами — это точка в другой стране, и никакой
|
||||
|
|
|
|||
|
|
@ -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)} активных объявлений города — "
|
||||
"остальные в расчёт не входят"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue