diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index 81301e69..af8e0321 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -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-летний диапазон в клиентском документе. diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index 8f245bb0..c25db60f 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -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 diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 987a0dfc..5d42d82f 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -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. diff --git a/tradein-mvp/backend/data/sql/267_trade_in_estimates_market_percentile.sql b/tradein-mvp/backend/data/sql/267_trade_in_estimates_market_percentile.sql new file mode 100644 index 00000000..90be92ad --- /dev/null +++ b/tradein-mvp/backend/data/sql/267_trade_in_estimates_market_percentile.sql @@ -0,0 +1,20 @@ +-- #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) либо оценки нет. +-- +-- Идемпотентно: IF NOT EXISTS. Блокировки не берёт (ADD COLUMN без DEFAULT в PG 11+ +-- не переписывает таблицу). +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 (тот про район против медианы города).'; diff --git a/tradein-mvp/backend/tests/test_2899_market_percentile.py b/tradein-mvp/backend/tests/test_2899_market_percentile.py new file mode 100644 index 00000000..4985ea0c --- /dev/null +++ b/tradein-mvp/backend/tests/test_2899_market_percentile.py @@ -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 diff --git a/tradein-mvp/backend/tests/test_estimate_idor.py b/tradein-mvp/backend/tests/test_estimate_idor.py index 099d39d0..2abbd7ac 100644 --- a/tradein-mvp/backend/tests/test_estimate_idor.py +++ b/tradein-mvp/backend/tests/test_estimate_idor.py @@ -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