feat(tradein): позиция квартиры внутри когорты аналогов — перцентиль (#2899) (#2926)
All checks were successful
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m54s
Deploy Trade-In / build-backend (push) Successful in 1m7s
Deploy Trade-In / deploy (push) Successful in 2m6s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 10s
All checks were successful
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m54s
Deploy Trade-In / build-backend (push) Successful in 1m7s
Deploy Trade-In / deploy (push) Successful in 2m6s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 10s
This commit is contained in:
parent
d25ff668f7
commit
cf48e6d6c8
6 changed files with 263 additions and 0 deletions
|
|
@ -511,6 +511,7 @@ def get_estimate(
|
|||
f"""
|
||||
SELECT id, median_price, range_low, range_high, median_price_per_m2,
|
||||
confidence, confidence_explanation, n_analogs,
|
||||
market_percentile,
|
||||
analogs, actual_deals, sources_used, data_freshness_minutes,
|
||||
expires_at, retain_until, address, lat, lon,
|
||||
area_m2, rooms, floor, total_floors,
|
||||
|
|
@ -655,6 +656,11 @@ def get_estimate(
|
|||
confidence=row.confidence,
|
||||
confidence_explanation=row.confidence_explanation,
|
||||
n_analogs=row.n_analogs,
|
||||
# #2899: getattr — тот же defensive-идиом, что у relaxations/reliability
|
||||
# ниже: строка без колонки (старый in-memory double, любая выборка до
|
||||
# миграции 267) деградирует в None — «позиции не знаем», — а не роняет
|
||||
# ответ AttributeError'ом.
|
||||
market_percentile=getattr(row, "market_percentile", None),
|
||||
period_months=12,
|
||||
analogs=analogs,
|
||||
actual_deals=actual_deals,
|
||||
|
|
@ -732,6 +738,7 @@ def estimate_pdf(
|
|||
"""
|
||||
SELECT id, median_price, range_low, range_high, median_price_per_m2,
|
||||
confidence, confidence_explanation, n_analogs,
|
||||
market_percentile,
|
||||
analogs, actual_deals, sources_used, data_freshness_minutes,
|
||||
expires_at, retain_until,
|
||||
address, lat, lon, area_m2, rooms, floor, total_floors,
|
||||
|
|
@ -775,6 +782,11 @@ def estimate_pdf(
|
|||
confidence=row.confidence,
|
||||
confidence_explanation=row.confidence_explanation,
|
||||
n_analogs=row.n_analogs,
|
||||
# #2899: getattr — тот же defensive-идиом, что у relaxations/reliability
|
||||
# ниже: строка без колонки (старый in-memory double, любая выборка до
|
||||
# миграции 267) деградирует в None — «позиции не знаем», — а не роняет
|
||||
# ответ AttributeError'ом.
|
||||
market_percentile=getattr(row, "market_percentile", None),
|
||||
# #1351: окно сделок — 12 мес (estimator.DEALS_PERIOD_MONTHS), как в POST
|
||||
# /estimate и GET /estimate/{id}. Раньше PDF-ветка хардкодила 24 →
|
||||
# экспортёр рисовал ложный ~2-летний диапазон в клиентском документе.
|
||||
|
|
|
|||
|
|
@ -191,6 +191,14 @@ class AggregatedEstimate(BaseModel):
|
|||
# #698: ПОЛНОЕ число найденных аналогов — НЕ равно len(analogs) (тот обрезан до
|
||||
# top-10, см. поле `analogs` ниже). Консьюмер должен брать счёт отсюда, а не из len().
|
||||
n_analogs: int
|
||||
# #2899: позиция ЭТОЙ квартиры внутри когорты аналогов, 1..99 — «какая доля
|
||||
# аналогов дешевле». None = когорта меньше MARKET_PERCENTILE_MIN_N (15) либо
|
||||
# оценки нет; ниже порога один соседний лот двигает ярлык на целую категорию.
|
||||
#
|
||||
# НЕ путать с location_index_pct (`GET /location-index`) — тот про РАЙОН против
|
||||
# медианы города, а не про квартиру внутри своей выборки, и в цену не идёт.
|
||||
# Показывать имеет смысл только вместе с n_analogs.
|
||||
market_percentile: int | None = None
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
|
|
|
|||
|
|
@ -184,6 +184,30 @@ DEALS_HEADLINE_FALLBACK_MIN_N = 3
|
|||
# ИТОГОВОЙ выборке как headline-источнику.
|
||||
HEADLINE_LISTINGS_MIN_N = 5
|
||||
|
||||
# #2899: минимальный размер когорты, при котором ПОЗИЦИЯ объекта в ней (перцентиль)
|
||||
# перестаёт быть шумом. НЕ переиспользует HEADLINE_LISTINGS_MIN_N=5 намеренно: тот
|
||||
# отвечает на другой вопрос — «доверять ли выборке как источнику МЕДИАНЫ», а медиана
|
||||
# устойчива там, где ранг ещё пляшет.
|
||||
#
|
||||
# Замер 19.08.2026 (Монте-Карло: из 54 прод-когорт размера 62..125 набирались
|
||||
# подвыборки размера k, сравнивался ранг объекта в подвыборке с рангом в полной
|
||||
# когорте; поправка на конечную популяцию учтена):
|
||||
# k=5 → средняя ошибка ранга 13.4 пп, ярлык «низ/рынок/верх» перепутан в 26.4%
|
||||
# k=10 → 9.5 пп
|
||||
# k=15 → 7.6 пп ← точка перелома
|
||||
# k=20 → 6.7 пп (стоит ещё 10 пп покрытия, даёт ~1 пп точности — не окупается)
|
||||
# k=30 → 5.4 пп, ярлык перепутан в 10.0%
|
||||
#
|
||||
# Согласованный второй сигнал: шаг перцентиля 100/n против ценового разрыва между
|
||||
# соседями по когорте. При n≈7 шаг 14.3 пп на разрыв 1.8% — один соседний лот двигает
|
||||
# ярлык на целую категорию; при n≈15 шаг 7.1 пп; при n≈30 — 3.3 пп на 0.8%.
|
||||
#
|
||||
# Цена порога: по 1086 историческим оценкам прода n>=15 покрывает 35.8% (n>=5 дало бы
|
||||
# 76.2%, но с ошибкой ранга вдвое больше). Ниже порога поле остаётся None и UI не
|
||||
# рисует ничего — «мало данных» уже говорят reliability и relaxations, вторая надпись
|
||||
# о том же была бы дублированием.
|
||||
MARKET_PERCENTILE_MIN_N = 15
|
||||
|
||||
# #oblast-F (never-block relaxation cascade, product decision 2026-08-10, live
|
||||
# repro: Академика Парина 46/5 студия 23.1 м² — rooms=1 exact match gave n=4
|
||||
# и попадала под #oblast-E выше, хотя rooms=0 по тому же адресу давал n=34;
|
||||
|
|
@ -4532,7 +4556,18 @@ async def estimate_quality(
|
|||
# _dedup_display_lots ловит остаточные кросс-source дубли (ценовой дрейф между
|
||||
# площадками), которые price_bucket-строгий статистический _dedup_cross_source
|
||||
# мог пропустить — display-only, n_analogs/median/cv не трогает.
|
||||
# #2899: позиция объекта внутри ТОЙ ЖЕ когорты, что дала headline, и по УЖЕ
|
||||
# финальной median_ppm2 (все восемь мутаторов цены — repair_coef, anchor override,
|
||||
# IMV-blend, quarter index, corridor clamp, radius floor, deals fallback, segment
|
||||
# multiplier — лежат внутри _price_from_inputs и отработали выше). Считать внутри
|
||||
# _price_from_inputs нельзя: там цена ещё не финальная, а n_analogs после точки
|
||||
# расчёта перезаписывается трижды — перцентиль разошёлся бы с показанной ценой у
|
||||
# двух третей оценок, которые идут якорным путём.
|
||||
#
|
||||
# Пул берётся ДО _dedup_display_lots: дедуп режет выборку под показ, а позиция
|
||||
# должна считаться по популяции, из которой взята цена.
|
||||
if anchor_tier is not None and anchor_comps_used:
|
||||
market_percentile = _market_percentile(median_ppm2, anchor_comps_used)
|
||||
display_pool = _dedup_display_lots(anchor_comps_used)
|
||||
analogs_lots = [_anchor_comp_to_analog(c) for c in display_pool[:10]]
|
||||
# #1519: при сработавшем якоре метаданные (freshness/last_scraped_at/
|
||||
|
|
@ -4547,6 +4582,7 @@ async def estimate_quality(
|
|||
# их сохраняет — «нечего судить»), которые раздували карточки сверх
|
||||
# заявленного N. _dedup_display_lots — см. ветку anchor выше.
|
||||
priced_clean = [lot for lot in listings_clean if lot.get("price_per_m2")]
|
||||
market_percentile = _market_percentile(median_ppm2, priced_clean)
|
||||
display_pool = _dedup_display_lots(priced_clean)
|
||||
analogs_lots = [_listing_to_analog(lot) for lot in display_pool[:10]]
|
||||
metadata_lots = display_pool
|
||||
|
|
@ -4587,6 +4623,7 @@ async def estimate_quality(
|
|||
ownership_type, has_mortgage,
|
||||
median_price, range_low, range_high, median_price_per_m2,
|
||||
confidence, confidence_explanation, n_analogs,
|
||||
market_percentile,
|
||||
analogs, actual_deals,
|
||||
sources_used, data_freshness_minutes,
|
||||
canonical_address, house_cadnum, house_fias_id,
|
||||
|
|
@ -4606,6 +4643,7 @@ async def estimate_quality(
|
|||
:ownership_type, :has_mortgage,
|
||||
:median_price, :range_low, :range_high, :median_ppm2,
|
||||
:confidence, :explanation, :n_analogs,
|
||||
:market_percentile,
|
||||
CAST(:analogs_json AS jsonb),
|
||||
CAST(:deals_json AS jsonb),
|
||||
CAST(:sources_json AS jsonb),
|
||||
|
|
@ -4646,6 +4684,7 @@ async def estimate_quality(
|
|||
"confidence": confidence,
|
||||
"explanation": explanation,
|
||||
"n_analogs": n_analogs,
|
||||
"market_percentile": market_percentile,
|
||||
"analogs_json": json.dumps(
|
||||
[a.model_dump(mode="json") for a in analogs_lots], ensure_ascii=False
|
||||
),
|
||||
|
|
@ -4798,6 +4837,7 @@ async def estimate_quality(
|
|||
confidence=confidence,
|
||||
confidence_explanation=explanation,
|
||||
n_analogs=n_analogs,
|
||||
market_percentile=market_percentile,
|
||||
period_months=DEALS_PERIOD_MONTHS,
|
||||
analogs=analogs_lots,
|
||||
actual_deals=deals_lots,
|
||||
|
|
@ -6712,6 +6752,36 @@ def _dedup_cross_source(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|||
return _union_find_phys_dedup(lots, include_price=True)
|
||||
|
||||
|
||||
def _market_percentile(target_ppm2: float | None, pool: list[dict]) -> int | None:
|
||||
"""Позиция объекта внутри когорты аналогов, 1..99. PURE.
|
||||
|
||||
Перцентиль РАНГА, а не квантиль: отвечает на вопрос «какая доля когорты дешевле
|
||||
нас», тогда как `_percentile` решает обратную задачу — «какая цена стоит на
|
||||
заданной доле». Формула midrank: доля строго дешевле плюс половина равных, что
|
||||
даёт 50 для объекта ровно по медиане симметричной когорты и не зависит от того,
|
||||
попал ли сам объект в пул.
|
||||
|
||||
Пул — тот же, что дал headline (`anchor_comps_used` либо ценовые `listings_clean`),
|
||||
и ДО `_dedup_display_lots`: дедуп режет пул под показ (на проде 15 карточек из 528
|
||||
усечены именно им), а позиция должна считаться по той популяции, из которой взята
|
||||
цена.
|
||||
|
||||
Возвращает None, если считать не по чему или когорта меньше
|
||||
MARKET_PERCENTILE_MIN_N — см. обоснование порога у константы. Зажимаем в 1..99:
|
||||
«0-й перцентиль» и «100-й» читаются как «дешевле всех на свете», хотя означают
|
||||
лишь край конкретной выборки.
|
||||
"""
|
||||
if target_ppm2 is None or not pool:
|
||||
return None
|
||||
prices = [float(lot["price_per_m2"]) for lot in pool if lot.get("price_per_m2") is not None]
|
||||
if len(prices) < MARKET_PERCENTILE_MIN_N:
|
||||
return None
|
||||
below = sum(1 for p in prices if p < target_ppm2)
|
||||
equal = sum(1 for p in prices if p == target_ppm2)
|
||||
raw = 100.0 * (below + 0.5 * equal) / len(prices)
|
||||
return max(1, min(99, round(raw)))
|
||||
|
||||
|
||||
def _dedup_display_lots(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Дедуп ОТОБРАЖАЕМЫХ карточек-аналогов по физическому ключу БЕЗ price_bucket.
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
-- #2899: позиция объекта внутри когорты аналогов (перцентиль ранга, 1..99).
|
||||
--
|
||||
-- ЗАЧЕМ КОЛОНКА, А НЕ РАСЧЁТ НА ЧТЕНИИ. Когорты в БД нет: в `analogs` лежит только
|
||||
-- top-10 показанных лотов (прод: max(jsonb_array_length(analogs)) = 10 при
|
||||
-- max(n_analogs) = 294, у 599 из 1086 строк список упёрся в потолок). Пересчитать
|
||||
-- позицию по сохранённому top-10 нельзя — это другая популяция, поэтому значение
|
||||
-- обязано персиститься вместе с оценкой.
|
||||
--
|
||||
-- smallint: диапазон 1..99 по построению (`_market_percentile` зажимает края —
|
||||
-- «0-й перцентиль» читался бы как «дешевле всех на свете», хотя означает лишь край
|
||||
-- выборки). NULL = когорта меньше MARKET_PERCENTILE_MIN_N (15) либо оценки нет.
|
||||
-- Бэкфилла нет и быть не может: позицию старых строк восстановить не из чего.
|
||||
--
|
||||
-- ПРО ЛОКИ (#2752). ADD COLUMN без DEFAULT в PG 11+ не переписывает таблицу и держит
|
||||
-- ACCESS EXCLUSIVE миллисекунды — но ЖДАТЬ его выдачи может сколько угодно, и всё это
|
||||
-- время ждущий DDL стоит в очереди ПЕРЕД новыми запросами приложения к той же таблице.
|
||||
-- Ровно так миграция 250 встала на 29 минут за чужой psql-сессией. lock_timeout
|
||||
-- ограничивает только ожидание: не дождались — красный деплой вместо тихой очереди.
|
||||
BEGIN;
|
||||
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
ALTER TABLE trade_in_estimates
|
||||
ADD COLUMN IF NOT EXISTS market_percentile smallint;
|
||||
|
||||
COMMENT ON COLUMN trade_in_estimates.market_percentile IS
|
||||
'#2899: доля аналогов дешевле этой квартиры, 1..99. NULL — когорта < 15 лотов. '
|
||||
'НЕ location_index_pct (тот про район против медианы города).';
|
||||
|
||||
COMMIT;
|
||||
101
tradein-mvp/backend/tests/test_2899_market_percentile.py
Normal file
101
tradein-mvp/backend/tests/test_2899_market_percentile.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
"""#2899: позиция объекта внутри когорты аналогов (перцентиль ранга).
|
||||
|
||||
Макет показывает бейдж «Верх рынка» — позицию квартиры в распределении аналогов.
|
||||
Поля не было ни в схеме, ни в расчёте.
|
||||
|
||||
Порог MARKET_PERCENTILE_MIN_N=15 взят из замера 19.08.2026 (Монте-Карло на 54
|
||||
прод-когортах): при выборке в 5 лотов средняя ошибка ранга 13.4 пп и ярлык
|
||||
«низ/рынок/верх» перепутан в 26.4% случаев; при 15 — 7.6 пп. Порог 5 от
|
||||
HEADLINE_LISTINGS_MIN_N переиспользовать нельзя: тот отвечает на другой вопрос —
|
||||
устойчива ли МЕДИАНА, а она устойчива там, где ранг ещё пляшет.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||
|
||||
from app.services.estimator import (
|
||||
MARKET_PERCENTILE_MIN_N,
|
||||
_market_percentile,
|
||||
)
|
||||
|
||||
|
||||
def _pool(prices: list[float]) -> list[dict]:
|
||||
return [{"price_per_m2": p} for p in prices]
|
||||
|
||||
|
||||
# ── порог ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_below_threshold_returns_none() -> None:
|
||||
"""Когорта на один лот меньше порога — позиции нет, а не «примерно такая»."""
|
||||
prices = [100_000 + i * 1_000 for i in range(MARKET_PERCENTILE_MIN_N - 1)]
|
||||
assert _market_percentile(120_000, _pool(prices)) is None
|
||||
|
||||
|
||||
def test_at_threshold_returns_number() -> None:
|
||||
"""Ровно на пороге — считаем. Граница включающая, без «почти хватило»."""
|
||||
prices = [100_000 + i * 1_000 for i in range(MARKET_PERCENTILE_MIN_N)]
|
||||
assert _market_percentile(200_000, _pool(prices)) == 99
|
||||
|
||||
|
||||
def test_threshold_counts_only_priced_lots() -> None:
|
||||
"""Лоты без цены в счёт не идут: 14 ценовых + 5 пустых — это 14, а не 19.
|
||||
|
||||
Радиусная ветка отбирает `price_per_m2`-лоты сама, но якорные комплы приходят
|
||||
как есть, и без этой проверки порог обошёлся бы пустышками.
|
||||
"""
|
||||
prices = [{"price_per_m2": 100_000 + i} for i in range(MARKET_PERCENTILE_MIN_N - 1)]
|
||||
empty = [{"price_per_m2": None} for _ in range(5)]
|
||||
assert _market_percentile(100_000, prices + empty) is None
|
||||
|
||||
|
||||
# ── сама позиция ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_median_object_lands_mid_scale() -> None:
|
||||
"""Объект ровно по медиане симметричной когорты → около 50."""
|
||||
prices = [float(x) for x in range(100, 100 + 21)] # 100..120, медиана 110
|
||||
assert _market_percentile(110.0, _pool(prices)) == 50
|
||||
|
||||
|
||||
def test_expensive_object_is_high() -> None:
|
||||
prices = [float(x) for x in range(100, 100 + 20)]
|
||||
assert _market_percentile(1000.0, _pool(prices)) == 99
|
||||
|
||||
|
||||
def test_cheap_object_is_low() -> None:
|
||||
prices = [float(x) for x in range(100, 100 + 20)]
|
||||
assert _market_percentile(1.0, _pool(prices)) == 1
|
||||
|
||||
|
||||
def test_edges_are_clamped_not_zero_or_hundred() -> None:
|
||||
"""1..99, а не 0..100.
|
||||
|
||||
«0-й перцентиль» читается как «дешевле всех на свете», хотя означает лишь край
|
||||
конкретной выборки из 20 лотов. Край выборки — не край рынка.
|
||||
"""
|
||||
prices = [float(x) for x in range(100, 100 + 20)]
|
||||
assert _market_percentile(0.0, _pool(prices)) == 1
|
||||
assert _market_percentile(1e9, _pool(prices)) == 99
|
||||
|
||||
|
||||
def test_ties_count_as_half() -> None:
|
||||
"""Равные цены дают midrank: 10 дешевле, 10 равных → 50, а не 33 и не 66."""
|
||||
prices = [100.0] * 10 + [200.0] * 10
|
||||
assert _market_percentile(200.0, _pool(prices)) == 75
|
||||
prices2 = [200.0] * 20
|
||||
assert _market_percentile(200.0, _pool(prices2)) == 50
|
||||
|
||||
|
||||
# ── вырожденные входы ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_no_price_returns_none() -> None:
|
||||
assert _market_percentile(None, _pool([float(x) for x in range(100, 120)])) is None
|
||||
|
||||
|
||||
def test_empty_pool_returns_none() -> None:
|
||||
assert _market_percentile(100_000.0, []) is None
|
||||
|
|
@ -75,6 +75,9 @@ def _make_estimate_row(created_by: str | None, retain_until: object = None) -> S
|
|||
confidence="medium",
|
||||
confidence_explanation="ok",
|
||||
n_analogs=7,
|
||||
# #2899: колонка есть у всех строк после миграции 267; NULL у старых
|
||||
# (бэкфилла нет — позицию по сохранённому top-10 не восстановить).
|
||||
market_percentile=63,
|
||||
analogs=[],
|
||||
actual_deals=[],
|
||||
sources_used=["avito"],
|
||||
|
|
@ -728,3 +731,42 @@ def test_get_estimate_response_includes_retain_until_field(trade_in_app: FastAPI
|
|||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["retain_until"] is None
|
||||
|
||||
|
||||
def test_get_estimate_surfaces_market_percentile(trade_in_app: FastAPI) -> None:
|
||||
"""#2899: позиция в когорте переживает перезагрузку по ссылке и уходит в PDF.
|
||||
|
||||
Значение считается только на POST (когорты в БД нет — в `analogs` лежит top-10,
|
||||
а не выборка), поэтому оно ОБЯЗАНО храниться в колонке и подниматься обоими SELECT'ами.
|
||||
У GET и PDF списки колонок РАЗНЫЕ и живут в разных функциях — один общий тест их
|
||||
не покрывает, отсюда две проверки.
|
||||
"""
|
||||
row = _make_estimate_row(created_by="kopylov")
|
||||
db_mock = _make_db_mock(row)
|
||||
client = _client_with(trade_in_app, db_mock, role="pilot")
|
||||
|
||||
resp = client.get(
|
||||
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
|
||||
headers={"X-Authenticated-User": "kopylov"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["market_percentile"] == 63
|
||||
|
||||
|
||||
def test_get_estimate_market_percentile_nullable(trade_in_app: FastAPI) -> None:
|
||||
"""Контроль: NULL проходит как null, а не роняет ответ.
|
||||
|
||||
Так выглядят все строки до миграции 267 и все оценки с когортой меньше 15 лотов.
|
||||
Зелёный с обеих сторон правки — доказывает, что поле необязательное.
|
||||
"""
|
||||
row = _make_estimate_row(created_by="kopylov")
|
||||
row.market_percentile = None
|
||||
db_mock = _make_db_mock(row)
|
||||
client = _client_with(trade_in_app, db_mock, role="pilot")
|
||||
|
||||
resp = client.get(
|
||||
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
|
||||
headers={"X-Authenticated-User": "kopylov"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["market_percentile"] is None
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue