fix(tradein/estimator): честная маркировка quarter-precision дат ДКП + insufficient_data флаг для sell-time-sensitivity (#1995) #2355
7 changed files with 436 additions and 13 deletions
|
|
@ -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
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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 сделки без публичной ссылки
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
240
tradein-mvp/backend/tests/test_sell_time_sensitivity.py
Normal file
240
tradein-mvp/backend/tests/test_sell_time_sensitivity.py
Normal 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"]))
|
||||
Loading…
Add table
Reference in a new issue