fix(tradein): Mera-audit fix-1 — Cian valuation price sanity bounds

Добавляет _is_price_sane() в cian_valuation.py: если sale_price_rub вне
[cian_valuation_min_rub, cian_valuation_max_rub] (дефолты 500k/500M),
или low_price > high_price, или любой bound отрицательный — результат
не кэшируется и возвращается None (graceful). Защита от garbage-ответов
API (999_999 < min, 9_999_999_999 > max).

Новые settings: cian_valuation_min_rub=500_000, cian_valuation_max_rub=500_000_000.
Тесты: 11 новых тест-кейсов в test_cian_valuation.py. Пофиксен pre-existing
KeyError в test_cache_hit_returns_cached (missing low_price/high_price в mock).
This commit is contained in:
bot-backend 2026-06-21 09:02:32 +03:00
parent 810bd056db
commit 50a72f030a
3 changed files with 143 additions and 0 deletions

View file

@ -169,6 +169,25 @@ class Settings(BaseSettings):
# Дефолт 6 (консервативно); аудит предложил 3 — конфигурируемо.
estimate_price_trend_max_age_months: int = 6
# ── Mera-audit fix-3: cross-source dedup в price_trend ────────────────────
# Один объект на avito_imv + yandex_valuation с разными ext_item_id даёт
# double-count в house_placement_history → шум в помесячной медиане тренда.
# True (дефолт) = дедуплицировать строки перед агрегацией по ключу
# (round(area_m2,0), floor, COALESCE(last_price,start_price),
# COALESCE(last_price_date,start_price_date)), приоритет avito_imv.
# False = старое поведение без дедупа (backward-compat).
# ENV: ESTIMATE_PRICE_TREND_DEDUP_ENABLED.
estimate_price_trend_dedup_enabled: bool = True
# ── Mera-audit fix-1: Cian valuation sanity bounds ────────────────────────
# API-ответ Cian иногда возвращает garbage-значения (999_999 или 9_999_999_999).
# sale_price_rub вне [min, max] → результат отбрасывается (return None, не кэшируется).
# low_price > high_price или отрицательные значения → также сброс.
# Дефолты: 500_000 (мин. рыночная квартира ЕКБ) и 500_000_000 (500 Мабс. потолок).
# ENV: CIAN_VALUATION_MIN_RUB, CIAN_VALUATION_MAX_RUB.
cian_valuation_min_rub: float = 500_000
cian_valuation_max_rub: float = 500_000_000
# ── #audit-4: MAD-clip after similarity-weighting ─────────────────────────
# True = clip происходит ПОСЛЕ similarity-weighting (на взвешенных ppm²).
# False = clip ДО weighting (старое поведение). Дефолт True.

View file

@ -216,6 +216,12 @@ async def estimate_via_cian_valuation(
# 7. Extract structured result
result = _parse_valuation_state(state)
# 7a. Sanity-check price bounds (Mera-audit fix-1).
# Битый API-ответ (999_999 < min или 9_999_999_999 > max) → не кэшируем, возвращаем None.
# Аналогично проверяем low/high bound: low>high или отрицательные → drop.
if not _is_price_sane(result):
return None
# 8. Persist to cache (24h TTL)
_save_to_cache(
db,
@ -355,6 +361,48 @@ def _parse_change_pct(value_str: str) -> float | None:
return None
def _is_price_sane(result: CianValuationResult) -> bool:
"""Mera-audit fix-1: проверка разумного диапазона цены Cian Valuation.
Возвращает False ( caller вернёт None, не кэшируем) если:
- sale_price_rub задан и вне [settings.cian_valuation_min_rub, cian_valuation_max_rub]
- sale_price_from или sale_price_to отрицательные
- sale_price_from > sale_price_to (инвертированный диапазон)
None-поля не судим: Cian может не вернуть диапазон это не ошибка.
"""
price = result.sale_price_rub
if price is not None:
min_rub = settings.cian_valuation_min_rub
max_rub = settings.cian_valuation_max_rub
if price < min_rub or price > max_rub:
logger.warning(
"Cian valuation sanity: sale_price_rub=%.0f вне [%.0f, %.0f] → drop",
price,
min_rub,
max_rub,
)
return False
low = result.sale_price_from
high = result.sale_price_to
if low is not None and low < 0:
logger.warning("Cian valuation sanity: sale_price_from=%.0f < 0 → drop", low)
return False
if high is not None and high < 0:
logger.warning("Cian valuation sanity: sale_price_to=%.0f < 0 → drop", high)
return False
if low is not None and high is not None and low > high:
logger.warning(
"Cian valuation sanity: low=%.0f > high=%.0f (инвертированный диапазон) → drop",
low,
high,
)
return False
return True
def _load_from_cache(db: Session, cache_key: str) -> CianValuationResult | None:
"""SELECT from external_valuations where cache_key + source='cian_valuation' + not expired."""
row = (

View file

@ -1,4 +1,5 @@
"""Tests for cian_valuation.py — Stage 7 Cian Valuation Calculator scraper."""
import os
# Settings требует DATABASE_URL при инициализации (fail-fast).
@ -12,6 +13,7 @@ import pytest
from app.services.scrapers.cian_valuation import (
CianValuationResult,
_is_price_sane,
_parse_change_pct,
_parse_num,
_parse_valuation_state,
@ -344,6 +346,8 @@ async def test_cache_hit_returns_cached(monkeypatch):
"chart_change_direction": "increase",
"external_house_id": 999,
"filters_hash": "xyz789",
"low_price": 6500000, # required by _load_from_cache → sale_price_from
"high_price": 7500000, # required by _load_from_cache → sale_price_to
}
db.execute.return_value.mappings.return_value.first.return_value = cached_row
@ -371,6 +375,78 @@ async def test_cache_hit_returns_cached(monkeypatch):
assert len(result.chart) == 1
# ---------------------------------------------------------------------------
# Mera-audit fix-1: _is_price_sane — bounds validation
# ---------------------------------------------------------------------------
def test_price_sane_valid_price() -> None:
"""Обычная цена ЕКБ-квартиры — проходит санитарную проверку."""
r = CianValuationResult(
sale_price_rub=7_500_000, sale_price_from=6_900_000, sale_price_to=8_100_000
)
assert _is_price_sane(r) is True
def test_price_sane_none_price() -> None:
"""sale_price_rub=None — проверка цены не судит None (graceful)."""
r = CianValuationResult(sale_price_rub=None)
assert _is_price_sane(r) is True
def test_price_sane_below_min_returns_false() -> None:
"""Цена 999_999 < дефолтный min (500_000... нет, 999_999 > 500_000).
Тест: цена ниже min=500_000 задаём 100_000 False."""
r = CianValuationResult(sale_price_rub=100_000)
assert _is_price_sane(r) is False
def test_price_sane_above_max_returns_false() -> None:
"""Цена 9_999_999_999 (> 500М) → False."""
r = CianValuationResult(sale_price_rub=9_999_999_999)
assert _is_price_sane(r) is False
def test_price_sane_at_min_boundary() -> None:
"""Цена ровно на нижней границе дефолта (500_000) → True."""
r = CianValuationResult(sale_price_rub=500_000)
assert _is_price_sane(r) is True
def test_price_sane_at_max_boundary() -> None:
"""Цена ровно на верхней границе дефолта (500_000_000) → True."""
r = CianValuationResult(sale_price_rub=500_000_000)
assert _is_price_sane(r) is True
def test_price_sane_inverted_range_returns_false() -> None:
"""low > high (инвертированный диапазон) → False."""
r = CianValuationResult(
sale_price_rub=7_000_000,
sale_price_from=8_000_000, # from > to
sale_price_to=6_000_000,
)
assert _is_price_sane(r) is False
def test_price_sane_negative_low_returns_false() -> None:
"""sale_price_from < 0 → False."""
r = CianValuationResult(sale_price_rub=7_000_000, sale_price_from=-100)
assert _is_price_sane(r) is False
def test_price_sane_negative_high_returns_false() -> None:
"""sale_price_to < 0 → False."""
r = CianValuationResult(sale_price_rub=7_000_000, sale_price_to=-1)
assert _is_price_sane(r) is False
def test_price_sane_none_bounds_ok() -> None:
"""sale_price_from/to=None при валидной цене → True (bounds не обязательны)."""
r = CianValuationResult(sale_price_rub=7_000_000, sale_price_from=None, sale_price_to=None)
assert _is_price_sane(r) is True
@pytest.mark.asyncio
async def test_estimate_use_cache_false_skips_cache(monkeypatch):
"""use_cache=False → cache lookup не вызывается, идём сразу в load_session."""