fix(estimator): data-age guards — sber staleness + IMV thin-market (#audit-5)

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.
This commit is contained in:
bot-backend 2026-06-20 15:03:19 +03:00
parent 5b2a39cd5e
commit a6ef2c73e1
2 changed files with 50 additions and 7 deletions

View file

@ -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):

View file

@ -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
),
)