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:
parent
5b2a39cd5e
commit
a6ef2c73e1
2 changed files with 50 additions and 7 deletions
|
|
@ -98,6 +98,10 @@ class AvitoImvSummary(BaseModel):
|
||||||
lower_price: int | None = None # нижняя граница IMV-коридора, ₽
|
lower_price: int | None = None # нижняя граница IMV-коридора, ₽
|
||||||
higher_price: int | None = None # верхняя граница IMV-коридора, ₽
|
higher_price: int | None = None # верхняя граница IMV-коридора, ₽
|
||||||
market_count: int | None = None # объём рынка, на котором построена оценка
|
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):
|
class DkpCorridor(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -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.
|
"""#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.
|
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:
|
for dash in SBER_COEFF_DASHBOARDS:
|
||||||
try:
|
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)
|
logger.warning("sber_price_index lookup failed for %s (graceful): %s", dash, exc)
|
||||||
continue
|
continue
|
||||||
if rows:
|
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 {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -2492,14 +2509,17 @@ async def estimate_quality(
|
||||||
int(imv_anchor["higher_price"]) if imv_anchor.get("higher_price") else None
|
int(imv_anchor["higher_price"]) if imv_anchor.get("higher_price") else None
|
||||||
)
|
)
|
||||||
anchor_label = "оценке Avito IMV"
|
anchor_label = "оценке Avito IMV"
|
||||||
|
_imv_mc = int(imv_anchor["market_count"]) if imv_anchor.get("market_count") else None
|
||||||
avito_imv_summary = AvitoImvSummary(
|
avito_imv_summary = AvitoImvSummary(
|
||||||
recommended_price=anchor_total,
|
recommended_price=anchor_total,
|
||||||
lower_price=(
|
lower_price=(
|
||||||
int(imv_anchor["lower_price"]) if imv_anchor.get("lower_price") else None
|
int(imv_anchor["lower_price"]) if imv_anchor.get("lower_price") else None
|
||||||
),
|
),
|
||||||
higher_price=anchor_higher,
|
higher_price=anchor_higher,
|
||||||
market_count=(
|
market_count=_imv_mc,
|
||||||
int(imv_anchor["market_count"]) if imv_anchor.get("market_count") else None
|
# #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:
|
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,
|
lower_price=int(imv_eval.lower_price) if imv_eval.lower_price else None,
|
||||||
higher_price=anchor_higher,
|
higher_price=anchor_higher,
|
||||||
market_count=imv_eval.market_count,
|
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:
|
if anchor_total is not None:
|
||||||
|
|
@ -2556,6 +2590,11 @@ async def estimate_quality(
|
||||||
area=payload.area_m2,
|
area=payload.area_m2,
|
||||||
)
|
)
|
||||||
if imv_anchor_disp is not None and imv_anchor_disp.get("recommended_price"):
|
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(
|
avito_imv_summary = AvitoImvSummary(
|
||||||
recommended_price=int(imv_anchor_disp["recommended_price"]),
|
recommended_price=int(imv_anchor_disp["recommended_price"]),
|
||||||
lower_price=(
|
lower_price=(
|
||||||
|
|
@ -2568,10 +2607,10 @@ async def estimate_quality(
|
||||||
if imv_anchor_disp.get("higher_price")
|
if imv_anchor_disp.get("higher_price")
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
market_count=(
|
market_count=_disp_mc,
|
||||||
int(imv_anchor_disp["market_count"])
|
# #audit-5b: тонкий рынок.
|
||||||
if imv_anchor_disp.get("market_count")
|
thin_market=(
|
||||||
else None
|
_disp_mc is not None and _disp_mc < settings.avito_imv_thin_market_threshold
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue