From 56929d64808475e5bc614a875b3e667394839b52 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 4 Jul 2026 03:10:19 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein/estimator):=20=D1=87=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=BD=D0=B0=D1=8F=20=D0=BC=D0=B0=D1=80=D0=BA=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=BA=D0=B0=20quarter-precision=20=D0=B4=D0=B0?= =?UTF-8?q?=D1=82=20=D0=94=D0=9A=D0=9F=20+=20insufficient=5Fdata=20=D1=84?= =?UTF-8?q?=D0=BB=D0=B0=D0=B3=20=D0=B4=D0=BB=D1=8F=20sell-time-sensitivity?= =?UTF-8?q?=20(#1995)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "01.01.26 у всех сделок" — не ETL-баг, а честный предел источника: rosreestr open dataset публикует только квартальную гранулярность (period_start_date = первый день квартала), не точную дату сделки (задокументировано в 01_schema_rosreestr_deals.sql ещё на моменте создания схемы). Живой SQL подтвердил: 9 distinct дат на 9 кварталов, 5510 сделок все на 2026-01-01. Фикс — честная маркировка, без фабрикации данных: - AnalogLot.date_precision / SalesListingPair.deal_date_precision ("day"/"quarter") — фронт может показать "Q1 2026" вместо ложной точной даты. - SellTimeBucket.insufficient_data (n_lots < sell_time_sensitivity_min_n_lots=10, паттерн 1-в-1 с существующим AvitoImvSummary.thin_market) — немонотонность на малых бакетах помечается, не сглаживается статистически. --- tradein-mvp/backend/app/api/v1/trade_in.py | 12 +- tradein-mvp/backend/app/core/config.py | 11 + tradein-mvp/backend/app/schemas/trade_in.py | 26 ++ tradein-mvp/backend/app/services/estimator.py | 21 +- .../backend/tests/test_estimator_enrich.py | 62 +++++ .../backend/tests/test_sales_vs_listings.py | 77 +++++- .../tests/test_sell_time_sensitivity.py | 240 ++++++++++++++++++ 7 files changed, 436 insertions(+), 13 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_sell_time_sensitivity.py diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index 7bfd369d..71d1b094 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -15,6 +15,7 @@ from fastapi import APIRouter, Depends, File, Header, HTTPException, Response, U from sqlalchemy import text from sqlalchemy.orm import Session +from app.core.config import settings from app.core.db import get_db from app.schemas.trade_in import ( AggregatedEstimate, @@ -1414,6 +1415,7 @@ def get_estimate_sell_time_sensitivity( buckets: list[SellTimeBucket] = [] for label, pct in bucket_definitions: r = bucket_map.get(label) + n_lots = r["n_lots"] if r else 0 buckets.append( SellTimeBucket( price_premium_label=label, @@ -1421,7 +1423,11 @@ def get_estimate_sell_time_sensitivity( median_exposure_days=r["median_exposure_days"] if r else None, p25_days=r["p25_days"] if r else None, p75_days=r["p75_days"] if r else None, - n_lots=r["n_lots"] if r else 0, + n_lots=n_lots, + # #1995: малая выборка → median/p25/p75 шумные (наблюдалась + # немонотонность между бакетами, напр. +10% быстрее +5%). Честный + # флаг вместо тихого шума — фронт решает, как показать. + insufficient_data=n_lots < settings.sell_time_sensitivity_min_n_lots, ) ) @@ -1851,6 +1857,10 @@ def get_sales_vs_listings( ), days_listing_to_deal=r["days_listing_to_deal"], discount_pct=(float(r["discount_pct"]) if r["discount_pct"] is not None else None), + # #1995: street_sales_vs_listings() фильтрует ТОЛЬКО source='rosreestr' + # (067_v_street_sales_vs_listings.sql) → все pairs сейчас квартальной + # precision (deal_date = period_start_date). Честная маркировка, не баг. + deal_date_precision="quarter", ) for r in rows ] diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index c08cf6b0..582f41cb 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -633,5 +633,16 @@ class Settings(BaseSettings): # ENV: BROWSER_WAIT_MS. browser_wait_ms: int = 6000 + # ── #1995: sell-time-sensitivity — честная маркировка малой выборки ──────── + # /estimate/{id}/sell-time-sensitivity бьёт лоты на 4 price-бакета (cheap/ + # median/plus5/plus10) и считает median/p25/p75 exposure_days по каждому. + # При n_lots ниже порога результат бакета шумный (замечена немонотонность: + # +10% продаётся быстрее +5% — артефакт малой выборки, не data-баг). Вместо + # тихого шума бакет с n_lots < порога помечается insufficient_data=True + # (SellTimeBucket) — тот же паттерн, что и AvitoImvSummary.thin_market + # (avito_imv_thin_market_threshold). НЕ сглаживаем/не выдумываем статистику — + # честная маркировка. + sell_time_sensitivity_min_n_lots: int = 10 + settings = Settings() diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index 0cabae3d..07524fd0 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -71,6 +71,17 @@ class AnalogLot(BaseModel): # kadastr_num — все ДКП-сделки сейчас T1. Поле зарезервировано на случай # будущего enrichment data feed (ЕГРН direct). tier: str | None = None + # ── Честность даты (#1995) ── + # Rosreestr open dataset публикует ДКП-сделки с точностью до КВАРТАЛА + # (listing_date = deals.deal_date = period_start_date, первый день квартала — + # см. data/sql/01_schema_rosreestr_deals.sql, комментарий "Excluded: ... exact + # deal date"). Поэтому ВСЕ rosreestr-сделки одного квартала несут ОДИНАКОВЫЙ + # listing_date — это честное отражение granularity источника, а НЕ баг/заглушка + # (подтверждено live-аудитом prod: 9 кварталов, ровно 1 distinct deal_date на + # квартал). listings (avito/cian/yandex/domklik) несут реальную day-level дату + # скрапинга/парсинга. None — источник не задан (напр. устаревшая persisted-запись + # до этого поля — rehydrate default). + date_precision: Literal["day", "quarter"] | None = None class CianChartPoint(BaseModel): @@ -452,6 +463,11 @@ class SellTimeBucket(BaseModel): p25_days: int | None p75_days: int | None n_lots: int + # #1995: n_lots < settings.sell_time_sensitivity_min_n_lots → малая выборка, + # median/p25/p75 exposure_days шумные (немонотонность между бакетами — типичный + # артефакт, не data-баг). Фронт должен явно показать "недостаточно данных" + # вместо тихого шума. Не сглаживаем/не переоцениваем статистику — честный флаг. + insufficient_data: bool = False class SellTimeSensitivityResponse(BaseModel): @@ -518,6 +534,16 @@ class SalesListingPair(BaseModel): # discount_pct = (deal_price - listing_price) / listing_price * 100. # Отрицательный = продали дешевле выставленного (торг). discount_pct: float | None = None + # #1995: честность precision deal_date. Rosreestr open dataset публикует ДКП с + # точностью до КВАРТАЛА (deal_date = period_start_date, 1-е число квартала — + # см. data/sql/01_schema_rosreestr_deals.sql), НЕ реальную дату регистрации. + # street_sales_vs_listings() фильтрует ТОЛЬКО source='rosreestr' → сейчас + # precision одинаковая ("quarter") для всех pairs этого endpoint'а. Live-аудит + # prod (2026-07): 9 загруженных кварталов — ровно 1 distinct deal_date на + # квартал, подтверждает НЕ баг, а granularity источника. Per-pair (не + # response-level) — задел на случай будущего source с exact-датой (etazhi/ + # domklik_history, см. deals table comment). + deal_date_precision: Literal["day", "quarter"] = "quarter" class SalesVsListingsResponse(BaseModel): diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 97ecdff2..952c90f4 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -29,7 +29,7 @@ import time from collections.abc import Callable, Iterable from dataclasses import dataclass from datetime import UTC, date, datetime, timedelta -from typing import Any +from typing import Any, Literal from uuid import uuid4 from scraper_kit.providers.avito.imv import ( @@ -5259,6 +5259,22 @@ def _compute_confidence( return base, explanation +def _date_precision_for_source(source: str | None) -> Literal["day", "quarter"] | None: + """Честная маркировка precision listing_date/deal_date (#1995). + + Rosreestr open dataset публикует ДКП-сделки с точностью до КВАРТАЛА + (deals.deal_date = period_start_date, первый день квартала — см. data/sql/ + 01_schema_rosreestr_deals.sql, комментарий "Excluded: ... exact deal date"). + Live-аудит prod (2026-07): 9 загруженных кварталов, ровно 1 distinct deal_date + на квартал — подтверждает granularity источника, а НЕ баг/заглушку. + listings (avito/cian/yandex/domklik) несут реальную day-level дату + скрапинга/парсинга. source=None (неизвестен) → None (precision не заявляем). + """ + if source is None: + return None + return "quarter" if source == "rosreestr" else "day" + + def _listing_to_analog(row: dict[str, Any]) -> AnalogLot: return AnalogLot( address=row.get("address") or "", @@ -5270,6 +5286,7 @@ def _listing_to_analog(row: dict[str, Any]) -> AnalogLot: price_per_m2=int(row.get("price_per_m2") or 0), listing_date=row.get("listing_date"), days_on_market=row.get("days_on_market"), + date_precision=_date_precision_for_source(row.get("source")), photo_url=(row["photo_urls"] or [None])[0] if row.get("photo_urls") else None, source=row.get("source"), source_url=row.get("source_url"), @@ -5310,6 +5327,7 @@ def _anchor_comp_to_analog(c: dict[str, Any]) -> AnalogLot: price_per_m2=ppm2, listing_date=c.get("listing_date"), days_on_market=c.get("days_on_market"), + date_precision=_date_precision_for_source(c.get("source")), photo_url=(photo_urls or [None])[0] if photo_urls else None, source=c.get("source"), source_url=c.get("source_url"), @@ -5340,6 +5358,7 @@ def _deal_to_analog(row: dict[str, Any]) -> AnalogLot: price_per_m2=int(row.get("price_per_m2") or 0), listing_date=row.get("deal_date"), days_on_market=row.get("days_on_market"), + date_precision=_date_precision_for_source(row.get("source")), photo_url=None, source=row.get("source"), source_url=None, # rosreestr сделки без публичной ссылки diff --git a/tradein-mvp/backend/tests/test_estimator_enrich.py b/tradein-mvp/backend/tests/test_estimator_enrich.py index c05e44a7..643145db 100644 --- a/tradein-mvp/backend/tests/test_estimator_enrich.py +++ b/tradein-mvp/backend/tests/test_estimator_enrich.py @@ -83,6 +83,68 @@ def test_deal_to_analog_carries_lat_lon() -> None: assert lot.tier == "T0_per_house" # kadastr with участок → per-house tier +# --------------------------------------------------------------------------- # +# date_precision honesty flag (#1995) — rosreestr deals are quarter-granular, +# real listings are day-granular. See _date_precision_for_source docstring + +# data/sql/01_schema_rosreestr_deals.sql for the underlying data-source reason. +# --------------------------------------------------------------------------- # + + +def test_deal_to_analog_marks_rosreestr_source_as_quarter_precision() -> None: + """#1995: deals.source='rosreestr' → date_precision='quarter' — deal_date is + period_start_date (первый день квартала), НЕ реальная дата регистрации. + Это честная маркировка данных, а не баг (live-аудит prod: 9 кварталов, + ровно 1 distinct deal_date на квартал).""" + row = { + "address": "ул. Тестовая, 2", + "area_m2": 60.0, + "rooms": 2, + "floor": 5, + "total_floors": 10, + "price_rub": 11_000_000, + "price_per_m2": 183_333, + "deal_date": None, + "days_on_market": None, + "cadastral_number": "66:41:0204016:10", + "source": "rosreestr", + "distance_m": 80.0, + "lat": 56.84, + "lon": 60.61, + } + lot = estimator._deal_to_analog(row) + assert lot.date_precision == "quarter" + + +def test_listing_to_analog_marks_real_source_as_day_precision() -> None: + """Listings (avito/cian/yandex/domklik) carry a real scrape/parse date → + date_precision='day'.""" + row = { + "address": "ул. Тестовая, 1", + "area_m2": 50.0, + "rooms": 2, + "floor": 3, + "total_floors": 9, + "price_rub": 10_000_000, + "price_per_m2": 200_000, + "listing_date": None, + "days_on_market": 14, + "photo_urls": None, + "source": "cian", + "source_url": "https://example.test/1", + "distance_m": 120.0, + "lat": 56.8389, + "lon": 60.6057, + } + lot = estimator._listing_to_analog(row) + assert lot.date_precision == "day" + + +def test_date_precision_none_when_source_unknown() -> None: + """source отсутствует (устаревшая/неполная строка) → date_precision=None — + не заявляем precision, которую не можем подтвердить.""" + assert estimator._date_precision_for_source(None) is None + + def test_analog_lat_lon_optional_none_when_missing() -> None: # Tier S address-only Avito lots can lack geom → lat/lon absent → None (graceful). row = { diff --git a/tradein-mvp/backend/tests/test_sales_vs_listings.py b/tradein-mvp/backend/tests/test_sales_vs_listings.py index adc1abde..39472da2 100644 --- a/tradein-mvp/backend/tests/test_sales_vs_listings.py +++ b/tradein-mvp/backend/tests/test_sales_vs_listings.py @@ -341,9 +341,7 @@ def test_sales_vs_listings_passes_proper_params(trade_in_app: FastAPI) -> None: def test_sales_vs_listings_response_shape(trade_in_app: FastAPI) -> None: """Все required fields присутствуют в JSON response.""" fixture_rows = [ - _make_pair_row( - deal_id=1, listing_id=11, discount_pct=-3.0, listing_source="cian" - ), + _make_pair_row(deal_id=1, listing_id=11, discount_pct=-3.0, listing_source="cian"), ] db_mock = _make_db_mock(fixture_rows) _override_db(trade_in_app, db_mock) @@ -361,24 +359,81 @@ def test_sales_vs_listings_response_shape(trade_in_app: FastAPI) -> None: data = resp.json() # Top-level keys expected_keys = { - "street", "period_months", "window_days", "area_tolerance", - "total_deals", "deals_with_listings", "linkage_rate_pct", - "median_discount_pct", "pairs", + "street", + "period_months", + "window_days", + "area_tolerance", + "total_deals", + "deals_with_listings", + "linkage_rate_pct", + "median_discount_pct", + "pairs", } assert expected_keys.issubset(data.keys()) # Pair keys pair = data["pairs"][0] pair_keys = { - "deal_id", "deal_date", "deal_price_rub", "deal_price_per_m2", - "deal_area_m2", "deal_rooms", "deal_floor", "deal_address", - "listing_id", "listing_source", "listing_source_url", "listing_date", - "listing_price_rub", "listing_price_per_m2", "listing_area_m2", - "days_listing_to_deal", "discount_pct", + "deal_id", + "deal_date", + "deal_price_rub", + "deal_price_per_m2", + "deal_area_m2", + "deal_rooms", + "deal_floor", + "deal_address", + "listing_id", + "listing_source", + "listing_source_url", + "listing_date", + "listing_price_rub", + "listing_price_per_m2", + "listing_area_m2", + "days_listing_to_deal", + "discount_pct", } assert pair_keys.issubset(pair.keys()) assert pair["listing_source"] == "cian" +# ── Test: honest deal_date_precision marking (#1995) ───────────────────────── + + +def test_sales_vs_listings_marks_deal_date_precision_quarter( + trade_in_app: FastAPI, +) -> None: + """#1995: street_sales_vs_listings() возвращает ТОЛЬКО rosreestr-сделки — + deal_date = period_start_date (первый день квартала), не реальная дата + регистрации (см. data/sql/01_schema_rosreestr_deals.sql). Live-аудит prod + подтвердил: 9 загруженных кварталов, ровно 1 distinct deal_date на квартал — + это НЕ баг/заглушка, а granularity источника. Каждая пара должна нести + честный deal_date_precision="quarter", даже когда (как в prod) МНОГО разных + сделок совпадают по deal_date.""" + same_quarter_date = date(2026, 1, 1) + fixture_rows = [ + _make_pair_row(deal_id=1, deal_date=same_quarter_date, listing_id=11), + _make_pair_row(deal_id=2, deal_date=same_quarter_date, listing_id=12), + _make_pair_row(deal_id=3, deal_date=same_quarter_date, listing_id=13), + ] + db_mock = _make_db_mock(fixture_rows) + _override_db(trade_in_app, db_mock) + + client = TestClient(trade_in_app) + resp = client.get( + "/api/v1/trade-in/sales-vs-listings", + params={ + "address": "г. Екатеринбург, ул. Космонавтов, 50", + "area_m2": 50.0, + "rooms": 2, + }, + ) + assert resp.status_code == 200 + data = resp.json() + assert len(data["pairs"]) == 3 + for pair in data["pairs"]: + assert pair["deal_date"] == "2026-01-01" + assert pair["deal_date_precision"] == "quarter" + + # ── Test: default param values ─────────────────────────────────────────────── diff --git a/tradein-mvp/backend/tests/test_sell_time_sensitivity.py b/tradein-mvp/backend/tests/test_sell_time_sensitivity.py new file mode 100644 index 00000000..313cf83e --- /dev/null +++ b/tradein-mvp/backend/tests/test_sell_time_sensitivity.py @@ -0,0 +1,240 @@ +"""Tests for GET /estimate/{id}/sell-time-sensitivity (#1995). + +Regression coverage for the "недостаточно данных" honesty flag: buckets built +from a thin sample (n_lots below settings.sell_time_sensitivity_min_n_lots) +must carry insufficient_data=True instead of silently exposing noisy +median/p25/p75 exposure_days numbers. We deliberately do NOT try to smooth or +"fix" a non-monotonic result (e.g. +10% selling faster than +5%) — see +test_sell_time_sensitivity_does_not_smooth_non_monotonic_result below. +""" + +from __future__ import annotations + +import os +import sys +from types import SimpleNamespace +from unittest.mock import MagicMock + +# psycopg v3 driver required; stub DATABASE_URL before any app import +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +# WeasyPrint requires GTK — not present in CI/Windows. Stub before any app import. +_wp_mock = MagicMock() +sys.modules.setdefault("weasyprint", _wp_mock) +sys.modules.setdefault("weasyprint.CSS", _wp_mock) +sys.modules.setdefault("weasyprint.HTML", _wp_mock) + +import pytest # noqa: E402 +from fastapi import FastAPI # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 + +_ESTIMATE_ID = "22222222-2222-2222-2222-222222222222" + + +@pytest.fixture(autouse=True) +def _restore_get_role(): + """Restore app.core.auth.get_role after each test (mirror test_estimate_idor).""" + from app.core import auth as auth_mod + + original = auth_mod.get_role + yield + auth_mod.get_role = original + + +def _make_app() -> FastAPI: + """Minimal FastAPI app mounting only the trade-in router.""" + from app.api.v1 import trade_in as trade_in_module + + application = FastAPI() + application.include_router(trade_in_module.router, prefix="/api/v1/trade-in") + return application + + +def _exec_result( + *, + fetchone: object | None = None, + all_rows: list | None = None, + scalar: object | None = None, + mapping_rows: list | None = None, +) -> MagicMock: + """Build a single db.execute(...) return value supporting whichever chain + the endpoint calls next (.fetchone() / .all() / .scalar() / .mappings().all()).""" + result = MagicMock() + result.fetchone.return_value = fetchone + result.all.return_value = all_rows if all_rows is not None else [] + result.scalar.return_value = scalar + mapping_mock = MagicMock() + mapping_mock.all.return_value = mapping_rows if mapping_rows is not None else [] + result.mappings.return_value = mapping_mock + return result + + +def _make_db_mock( + *, + created_by: str | None, + bucket_rows: list[dict], + target_median: int | None = 120_000, +) -> MagicMock: + """Sequential db.execute() mock covering the endpoint's fixed 6-call path + (explicit radius_m, address resolves ≥1 house_id): + 1. _assert_estimate_access_by_id → fetchone(created_by) + 2. target lat/lon/address → fetchone + 3. address-based house lookup → all() + 4. radius_m-based house expansion → all() + 5. target_median benchmark → scalar() + 6. bucket_rows → mappings().all() + """ + db = MagicMock() + db.execute.side_effect = [ + _exec_result(fetchone=SimpleNamespace(created_by=created_by)), + _exec_result( + fetchone=SimpleNamespace(lat=56.8, lon=60.6, address="Екатеринбург, ул. Тестовая, 1") + ), + _exec_result(all_rows=[SimpleNamespace(id=1)]), + _exec_result(all_rows=[SimpleNamespace(id=2)]), + _exec_result(scalar=target_median), + _exec_result(mapping_rows=bucket_rows), + ] + return db + + +def _client_with(app: FastAPI, db_mock: MagicMock, role: str) -> TestClient: + from app.core.db import get_db + + def _override_db(): + yield db_mock + + app.dependency_overrides[get_db] = _override_db + + auth_mod = sys.modules["app.core.auth"] + auth_mod.get_role = lambda _u: role # type: ignore[assignment] + return TestClient(app) + + +def _bucket_row(bucket: str, n_lots: int, median_exposure_days: int) -> dict: + return { + "bucket": bucket, + "n_lots": n_lots, + "median_exposure_days": median_exposure_days, + "p25_days": median_exposure_days - 5, + "p75_days": median_exposure_days + 5, + } + + +def _get(client: TestClient, *, radius_m: int = 500) -> dict: + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}/sell-time-sensitivity", + params={"radius_m": radius_m}, + headers={"X-Authenticated-User": "admin"}, + ) + assert resp.status_code == 200, resp.text + return resp.json() + + +# ── Test: thin bucket (n_lots < threshold) flagged insufficient_data ──────── + + +def test_sell_time_sensitivity_flags_thin_bucket_insufficient() -> None: + """n_lots below settings.sell_time_sensitivity_min_n_lots (default 10) → + insufficient_data=True; buckets with plenty of lots stay False.""" + from app.core.config import settings + + bucket_rows = [ + _bucket_row("cheap", 20, 30), + _bucket_row("median", 18, 35), + # plus5 — thin sample (3 lots). + _bucket_row("plus5", 3, 60), + _bucket_row("plus10", 15, 40), + ] + db_mock = _make_db_mock(created_by="kopylov", bucket_rows=bucket_rows) + app = _make_app() + client = _client_with(app, db_mock, role="admin") + data = _get(client) + + by_label = {b["price_premium_label"]: b for b in data["buckets"]} + assert by_label["cheap"]["n_lots"] == 20 + assert by_label["cheap"]["insufficient_data"] is False + assert by_label["plus5"]["n_lots"] == 3 + assert by_label["plus5"]["insufficient_data"] is True + assert by_label["plus5"]["n_lots"] < settings.sell_time_sensitivity_min_n_lots + + +# ── Test: bucket at exactly the threshold is NOT flagged (strict <) ───────── + + +def test_sell_time_sensitivity_bucket_at_threshold_not_flagged() -> None: + """n_lots == threshold (10) is considered sufficient (strict less-than check).""" + from app.core.config import settings + + bucket_rows = [ + _bucket_row("cheap", settings.sell_time_sensitivity_min_n_lots, 25), + _bucket_row("median", 20, 30), + _bucket_row("plus5", 20, 35), + _bucket_row("plus10", 20, 40), + ] + db_mock = _make_db_mock(created_by="kopylov", bucket_rows=bucket_rows) + app = _make_app() + client = _client_with(app, db_mock, role="admin") + data = _get(client) + + by_label = {b["price_premium_label"]: b for b in data["buckets"]} + assert by_label["cheap"]["n_lots"] == settings.sell_time_sensitivity_min_n_lots + assert by_label["cheap"]["insufficient_data"] is False + + +# ── Test: missing bucket (no DB rows at all) defaults n_lots=0 → flagged ──── + + +def test_sell_time_sensitivity_missing_bucket_flagged() -> None: + """A price bucket absent from the DB result (no matching lots) still comes + back with n_lots=0 and MUST be flagged insufficient_data — never a bare 0 + presented as if it were a real, confident median.""" + bucket_rows = [ + _bucket_row("cheap", 20, 30), + _bucket_row("median", 18, 35), + _bucket_row("plus10", 15, 40), + # 'plus5' entirely missing from bucket_rows. + ] + db_mock = _make_db_mock(created_by="kopylov", bucket_rows=bucket_rows) + app = _make_app() + client = _client_with(app, db_mock, role="admin") + data = _get(client) + + by_label = {b["price_premium_label"]: b for b in data["buckets"]} + assert by_label["plus5"]["n_lots"] == 0 + assert by_label["plus5"]["median_exposure_days"] is None + assert by_label["plus5"]["insufficient_data"] is True + + +# ── Test: non-monotonic result is surfaced as-is, not smoothed ────────────── + + +def test_sell_time_sensitivity_does_not_smooth_non_monotonic_result() -> None: + """#1995: +10% selling (median_exposure_days=20) FASTER than +5% + (median_exposure_days=60) is a real, honestly-reported small-sample + artifact — the endpoint must NOT invent smoothing/interpolation. Both + thin buckets are simply flagged insufficient_data so the frontend can + choose to de-emphasize them, and the raw (non-monotonic) numbers pass + through unchanged.""" + bucket_rows = [ + _bucket_row("cheap", 20, 30), + _bucket_row("median", 18, 35), + _bucket_row("plus5", 4, 60), # thin — slower + _bucket_row("plus10", 3, 20), # thin — faster (non-monotonic vs plus5) + ] + db_mock = _make_db_mock(created_by="kopylov", bucket_rows=bucket_rows) + app = _make_app() + client = _client_with(app, db_mock, role="admin") + data = _get(client) + + by_label = {b["price_premium_label"]: b for b in data["buckets"]} + # Raw numbers unchanged — no smoothing/reordering applied. + assert by_label["plus5"]["median_exposure_days"] == 60 + assert by_label["plus10"]["median_exposure_days"] == 20 + # Both thin buckets honestly flagged instead of silently shown. + assert by_label["plus5"]["insufficient_data"] is True + assert by_label["plus10"]["insufficient_data"] is True + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(pytest.main([__file__, "-q"]))