fix(tradein/estimator): честная маркировка quarter-precision дат ДКП + insufficient_data флаг для sell-time-sensitivity (#1995)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 1m34s

"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) —
  немонотонность на малых бакетах помечается, не сглаживается статистически.
This commit is contained in:
bot-backend 2026-07-04 03:10:19 +03:00
parent c4201f7d2a
commit 56929d6480
7 changed files with 436 additions and 13 deletions

View file

@ -15,6 +15,7 @@ from fastapi import APIRouter, Depends, File, Header, HTTPException, Response, U
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.db import get_db from app.core.db import get_db
from app.schemas.trade_in import ( from app.schemas.trade_in import (
AggregatedEstimate, AggregatedEstimate,
@ -1414,6 +1415,7 @@ def get_estimate_sell_time_sensitivity(
buckets: list[SellTimeBucket] = [] buckets: list[SellTimeBucket] = []
for label, pct in bucket_definitions: for label, pct in bucket_definitions:
r = bucket_map.get(label) r = bucket_map.get(label)
n_lots = r["n_lots"] if r else 0
buckets.append( buckets.append(
SellTimeBucket( SellTimeBucket(
price_premium_label=label, 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, median_exposure_days=r["median_exposure_days"] if r else None,
p25_days=r["p25_days"] if r else None, p25_days=r["p25_days"] if r else None,
p75_days=r["p75_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"], days_listing_to_deal=r["days_listing_to_deal"],
discount_pct=(float(r["discount_pct"]) if r["discount_pct"] is not None else None), 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 for r in rows
] ]

View file

@ -633,5 +633,16 @@ class Settings(BaseSettings):
# ENV: BROWSER_WAIT_MS. # ENV: BROWSER_WAIT_MS.
browser_wait_ms: int = 6000 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() settings = Settings()

View file

@ -71,6 +71,17 @@ class AnalogLot(BaseModel):
# kadastr_num — все ДКП-сделки сейчас T1. Поле зарезервировано на случай # kadastr_num — все ДКП-сделки сейчас T1. Поле зарезервировано на случай
# будущего enrichment data feed (ЕГРН direct). # будущего enrichment data feed (ЕГРН direct).
tier: str | None = None 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): class CianChartPoint(BaseModel):
@ -452,6 +463,11 @@ class SellTimeBucket(BaseModel):
p25_days: int | None p25_days: int | None
p75_days: int | None p75_days: int | None
n_lots: int 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): class SellTimeSensitivityResponse(BaseModel):
@ -518,6 +534,16 @@ class SalesListingPair(BaseModel):
# discount_pct = (deal_price - listing_price) / listing_price * 100. # discount_pct = (deal_price - listing_price) / listing_price * 100.
# Отрицательный = продали дешевле выставленного (торг). # Отрицательный = продали дешевле выставленного (торг).
discount_pct: float | None = None 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): class SalesVsListingsResponse(BaseModel):

View file

@ -29,7 +29,7 @@ import time
from collections.abc import Callable, Iterable from collections.abc import Callable, Iterable
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, date, datetime, timedelta from datetime import UTC, date, datetime, timedelta
from typing import Any from typing import Any, Literal
from uuid import uuid4 from uuid import uuid4
from scraper_kit.providers.avito.imv import ( from scraper_kit.providers.avito.imv import (
@ -5259,6 +5259,22 @@ def _compute_confidence(
return base, explanation 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: def _listing_to_analog(row: dict[str, Any]) -> AnalogLot:
return AnalogLot( return AnalogLot(
address=row.get("address") or "", 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), price_per_m2=int(row.get("price_per_m2") or 0),
listing_date=row.get("listing_date"), listing_date=row.get("listing_date"),
days_on_market=row.get("days_on_market"), 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, photo_url=(row["photo_urls"] or [None])[0] if row.get("photo_urls") else None,
source=row.get("source"), source=row.get("source"),
source_url=row.get("source_url"), source_url=row.get("source_url"),
@ -5310,6 +5327,7 @@ def _anchor_comp_to_analog(c: dict[str, Any]) -> AnalogLot:
price_per_m2=ppm2, price_per_m2=ppm2,
listing_date=c.get("listing_date"), listing_date=c.get("listing_date"),
days_on_market=c.get("days_on_market"), 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, photo_url=(photo_urls or [None])[0] if photo_urls else None,
source=c.get("source"), source=c.get("source"),
source_url=c.get("source_url"), 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), price_per_m2=int(row.get("price_per_m2") or 0),
listing_date=row.get("deal_date"), listing_date=row.get("deal_date"),
days_on_market=row.get("days_on_market"), days_on_market=row.get("days_on_market"),
date_precision=_date_precision_for_source(row.get("source")),
photo_url=None, photo_url=None,
source=row.get("source"), source=row.get("source"),
source_url=None, # rosreestr сделки без публичной ссылки source_url=None, # rosreestr сделки без публичной ссылки

View file

@ -83,6 +83,68 @@ def test_deal_to_analog_carries_lat_lon() -> None:
assert lot.tier == "T0_per_house" # kadastr with участок → per-house tier 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: def test_analog_lat_lon_optional_none_when_missing() -> None:
# Tier S address-only Avito lots can lack geom → lat/lon absent → None (graceful). # Tier S address-only Avito lots can lack geom → lat/lon absent → None (graceful).
row = { row = {

View file

@ -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: def test_sales_vs_listings_response_shape(trade_in_app: FastAPI) -> None:
"""Все required fields присутствуют в JSON response.""" """Все required fields присутствуют в JSON response."""
fixture_rows = [ fixture_rows = [
_make_pair_row( _make_pair_row(deal_id=1, listing_id=11, discount_pct=-3.0, listing_source="cian"),
deal_id=1, listing_id=11, discount_pct=-3.0, listing_source="cian"
),
] ]
db_mock = _make_db_mock(fixture_rows) db_mock = _make_db_mock(fixture_rows)
_override_db(trade_in_app, db_mock) _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() data = resp.json()
# Top-level keys # Top-level keys
expected_keys = { expected_keys = {
"street", "period_months", "window_days", "area_tolerance", "street",
"total_deals", "deals_with_listings", "linkage_rate_pct", "period_months",
"median_discount_pct", "pairs", "window_days",
"area_tolerance",
"total_deals",
"deals_with_listings",
"linkage_rate_pct",
"median_discount_pct",
"pairs",
} }
assert expected_keys.issubset(data.keys()) assert expected_keys.issubset(data.keys())
# Pair keys # Pair keys
pair = data["pairs"][0] pair = data["pairs"][0]
pair_keys = { pair_keys = {
"deal_id", "deal_date", "deal_price_rub", "deal_price_per_m2", "deal_id",
"deal_area_m2", "deal_rooms", "deal_floor", "deal_address", "deal_date",
"listing_id", "listing_source", "listing_source_url", "listing_date", "deal_price_rub",
"listing_price_rub", "listing_price_per_m2", "listing_area_m2", "deal_price_per_m2",
"days_listing_to_deal", "discount_pct", "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_keys.issubset(pair.keys())
assert pair["listing_source"] == "cian" 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 ─────────────────────────────────────────────── # ── Test: default param values ───────────────────────────────────────────────

View file

@ -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"]))