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:
parent
810bd056db
commit
50a72f030a
3 changed files with 143 additions and 0 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue