From a6ef2c73e1fa0db6d57ad7718c5df4667f08bc02 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 20 Jun 2026 15:03:19 +0300 Subject: [PATCH] =?UTF-8?q?fix(estimator):=20data-age=20guards=20=E2=80=94?= =?UTF-8?q?=20sber=20staleness=20+=20IMV=20thin-market=20(#audit-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5a: _load_sber_index_series логирует warning если latest месяц серии старее sber_index_max_age_days (дефолт 35) — расчёт не блокируется. 5b: AvitoImvSummary.thin_market (bool, дефолт False) — True когда market_count < avito_imv_thin_market_threshold (дефолт 10). Все три точки конструирования AvitoImvSummary обновлены. Warning при thin. --- tradein-mvp/backend/app/schemas/trade_in.py | 4 ++ tradein-mvp/backend/app/services/estimator.py | 53 ++++++++++++++++--- 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index 9173d75d..ff9e444f 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -98,6 +98,10 @@ class AvitoImvSummary(BaseModel): lower_price: int | None = None # нижняя граница IMV-коридора, ₽ higher_price: int | None = None # верхняя граница IMV-коридора, ₽ market_count: int | None = None # объём рынка, на котором построена оценка + # #audit-5b: тонкий рынок — market_count < avito_imv_thin_market_threshold. + # True = IMV построен на малой выборке → reliability ↓. Фронт/estimator могут + # использовать для понижения уверенности или отображения предупреждения. + thin_market: bool = False class DkpCorridor(BaseModel): diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 59a86f47..0cff624b 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -1102,6 +1102,7 @@ def _load_sber_index_series(db: Session, *, region: str) -> dict[date, float]: """#794: monthly {period_month: index_value} for region from sber_price_index. Tries SBER_COEFF_DASHBOARDS in order; returns first non-empty series. {} on any error. + #audit-5a: если latest месяц серии старее sber_index_max_age_days → warning. """ for dash in SBER_COEFF_DASHBOARDS: try: @@ -1123,7 +1124,23 @@ def _load_sber_index_series(db: Session, *, region: str) -> dict[date, float]: logger.warning("sber_price_index lookup failed for %s (graceful): %s", dash, exc) continue if rows: - return {r["period_month"]: float(r["index_value_rub_m2"]) for r in rows} + series = {r["period_month"]: float(r["index_value_rub_m2"]) for r in rows} + # #audit-5a: data-age guard — предупреждаем о stale СберИндексе. + latest = max(series) + today = datetime.now(tz=UTC).date() + age_days = (today - latest).days + if age_days > settings.sber_index_max_age_days: + logger.warning( + "sber_index stale #audit-5a: latest=%s age=%d days" + " (> sber_index_max_age_days=%d) region=%s dash=%s" + " — time-adjustment may be outdated", + latest.isoformat(), + age_days, + settings.sber_index_max_age_days, + region, + dash, + ) + return series return {} @@ -2492,14 +2509,17 @@ async def estimate_quality( int(imv_anchor["higher_price"]) if imv_anchor.get("higher_price") else None ) anchor_label = "оценке Avito IMV" + _imv_mc = int(imv_anchor["market_count"]) if imv_anchor.get("market_count") else None avito_imv_summary = AvitoImvSummary( recommended_price=anchor_total, lower_price=( int(imv_anchor["lower_price"]) if imv_anchor.get("lower_price") else None ), higher_price=anchor_higher, - market_count=( - int(imv_anchor["market_count"]) if imv_anchor.get("market_count") else None + market_count=_imv_mc, + # #audit-5b: тонкий рынок — малая выборка для IMV. + thin_market=( + _imv_mc is not None and _imv_mc < settings.avito_imv_thin_market_threshold ), ) elif imv_eval is not None and imv_eval.recommended_price: @@ -2512,6 +2532,20 @@ async def estimate_quality( lower_price=int(imv_eval.lower_price) if imv_eval.lower_price else None, higher_price=anchor_higher, market_count=imv_eval.market_count, + # #audit-5b: тонкий рынок — market_count < threshold. + thin_market=( + imv_eval.market_count is not None + and imv_eval.market_count < settings.avito_imv_thin_market_threshold + ), + ) + + # #audit-5b: thin-market warning — IMV на малой выборке → reliability ↓. + if avito_imv_summary is not None and avito_imv_summary.thin_market: + logger.warning( + "avito_imv thin_market #audit-5b: market_count=%s" + " (< avito_imv_thin_market_threshold=%d) — IMV reliability low", + avito_imv_summary.market_count, + settings.avito_imv_thin_market_threshold, ) if anchor_total is not None: @@ -2556,6 +2590,11 @@ async def estimate_quality( area=payload.area_m2, ) if imv_anchor_disp is not None and imv_anchor_disp.get("recommended_price"): + _disp_mc = ( + int(imv_anchor_disp["market_count"]) + if imv_anchor_disp.get("market_count") + else None + ) avito_imv_summary = AvitoImvSummary( recommended_price=int(imv_anchor_disp["recommended_price"]), lower_price=( @@ -2568,10 +2607,10 @@ async def estimate_quality( if imv_anchor_disp.get("higher_price") else None ), - market_count=( - int(imv_anchor_disp["market_count"]) - if imv_anchor_disp.get("market_count") - else None + market_count=_disp_mc, + # #audit-5b: тонкий рынок. + thin_market=( + _disp_mc is not None and _disp_mc < settings.avito_imv_thin_market_threshold ), )