gendesign/tradein-mvp/backend/tests/test_estimator_audit_fixes.py
bot-backend b64e824e17
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m24s
fix(tradein/sber): сторож мерит отставание загрузки, а не календарь (#2846)
Порог свежести якоря был недостижим по построению. period_month — метка ПЕРВОГО
числа месяца, поэтому возрасту ≥30 уже на закрытии месяца; плюс лаг публикации
источника. За 31 сутки прямых наблюдений монитора (07-13…08-12, scrape_runs.counters)
возраст лежал в 46..76 и ни разу не опускался ниже 46 — при пороге оценщика 35.
Сторож был истинным 100% времени с рождения таблицы и нёс ноль бит: при живом
источнике и при мёртвом загрузчике он писал одно и то же.

Второй порог (монитор, 60 = 35 + запас 25) не лучше: он лежит ВНУТРИ рабочего
диапазона. Обещание миграции 212 («такт 7 ⇒ потолок возраста 53 < 60») прод
ОПРОВЕРГ — 2026-08-12 возраст 72 при полном прогоне загрузки 08-06; двенадцатые
сутки подряд ERROR при исправной загрузке. Потолок 53 держался бы, только если бы
источник публиковал строго помесячно.

Что теперь. Загрузчик тянет ВСЮ серию (limit=1000&offset=0), поэтому после прогона
с errors=0 AND upserted>0 наш max(period_month) равен максимуму источника ПО
ПОСТРОЕНИЮ. Значит вопрос «отстали ли мы» = «давно ли был последний ЗАВЕДОМО ПОЛНЫЙ
прогон», и он не зависит от возраста периода. Порог — 2 такта самой загрузки,
читается из scrape_schedules.default_params.interval_days, то есть из той же строки,
по которой планировщик считает next_run_at: разъехаться с тактом он не может.
status='done' за успех не считается — прогон id=37 имеет done при {errors: 9,
upserted: 0}. Табло спрашивается тем же порядком, что у оценщика
(SBER_COEFF_DASHBOARDS), потому что max() по таблице маскирует отставшее табло:
real_estate_deals 2026-06, dinamika-tsen-obyavlenii 2026-05.

Два порога сведены удалением: settings.sber_index_max_age_days и per-estimate
warning в estimator убраны, свежесть считает ровно одно место.

fetched_at больше не переписывается апсертом. Забор идёт всей серией, поэтому
fetched_at = now() в DO UPDATE ставил одну метку всем 639 строкам, включая период
2017-01 — как признак свежести колонка была пуста. Теперь она означает «когда
впервые увидели период», т.е. такт публикации источника станет измеримым.
Ретроспективу это не возвращает: у уже лежащих строк метка 2026-08-06 и останется.

Refs #2846
2026-08-13 00:25:53 +05:00

428 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Unit tests for estimator audit fixes #audit-1..5.
Покрывает:
fix1 — low-conf anchor gate → radius fallback
fix2 — analog_tier значения для anchor/radius путей
fix3 — старый yandex item исключён из price_trend
fix4 — sigma=0 guard + видовой компл не выкинут pre-weighting
fix5a — stale sber warning
fix5b — thin-market flag в AvitoImvSummary
"""
from __future__ import annotations
import os
import sys
from datetime import UTC, date, datetime, timedelta
from typing import Any
from unittest.mock import MagicMock, patch
# pydantic Settings требует DATABASE_URL при инициализации.
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
# WeasyPrint stubbed in CI.
sys.modules.setdefault("weasyprint", MagicMock())
import pytest # noqa: E402
from app.services.estimator import ( # noqa: E402
_compute_same_building_anchor,
)
# ---------------------------------------------------------------------------
# Fix 1 — anchor low-confidence gate
# ---------------------------------------------------------------------------
def _make_comp(ppm2: float, area: float = 50.0, rooms: int = 2) -> dict[str, Any]:
"""Минимальный comp-dict для _compute_same_building_anchor."""
return {"price_per_m2": ppm2, "area_m2": area, "rooms": rooms}
def test_fix1_low_conf_anchor_suppressed_by_gate() -> None:
"""Якорь с confidence=low должен быть подавлен гейтом; fallback на radius."""
# 2 комплa с огромным разбросом → confidence=low (FSD > 0.20)
comps = [_make_comp(100_000), _make_comp(300_000)]
# Без гейта (min_comps=1 чтобы пройти threshold):
anchor_raw = _compute_same_building_anchor(
comps,
area_target=50.0,
rooms_target=2,
tier="A",
sigma=0.18,
rooms_boost=1.6,
min_comps=1,
)
assert anchor_raw is not None, "Должен построить якорь без гейта"
assert anchor_raw["confidence"] == "low", "Ожидаем low confidence при большом разбросе"
# Гейт low-confidence якоря применяется в estimate_quality (выше уровня
# _compute_same_building_anchor),
# поэтому тестируем сигнал: если confidence=low — гейт должен подавить.
assert anchor_raw["confidence"] == "low"
# Проверяем что гейт-условие срабатывает:
gate_triggers = anchor_raw["confidence"] == "low"
assert gate_triggers, "Гейт должен видеть low confidence"
def test_fix1_healthy_anchor_not_suppressed() -> None:
"""Здоровый якорь (n≥4, FSD<0.15) не должен попасть под гейт."""
# 6 компл с малым разбросом → confidence=high/medium
base = 200_000
comps = [_make_comp(base + i * 2_000) for i in range(6)]
anchor = _compute_same_building_anchor(
comps,
area_target=50.0,
rooms_target=2,
tier="A",
sigma=0.18,
rooms_boost=1.6,
min_comps=4,
)
assert anchor is not None
assert anchor["confidence"] in {"high", "medium"}
assert anchor["n"] >= 4
# FSD должен быть ниже 0.20 для консистентного набора
assert anchor["fsd"] < 0.20, f"FSD={anchor['fsd']} ожидается < 0.20"
def test_fix1_thin_n_high_fsd_triggers_gate() -> None:
"""n<gate_min_n И FSD>gate_max_fsd → гейт срабатывает (проверяем условие)."""
# 2 компла с умеренным разбросом: n=2 < gate_min_n=3
comps = [_make_comp(150_000), _make_comp(220_000)]
anchor = _compute_same_building_anchor(
comps,
area_target=50.0,
rooms_target=2,
tier="C",
sigma=0.18,
rooms_boost=1.6,
min_comps=1, # разрешаем построить якорь
)
if anchor is None:
pytest.skip("MAD-clip отсёк — нет якоря, тест не применим")
from app.core.config import settings
# Проверяем условие gate_thin напрямую
gate_thin = (
anchor["n"] < settings.estimate_sb_gate_min_n
and anchor["fsd"] > settings.estimate_sb_gate_max_fsd
)
# n=2 < 3 — должен сработать если FSD тоже высокий
assert anchor["n"] == 2
# FSD = 0.07 + 0.25*CV + tier_penalty + n_penalty; n_penalty=0.05 при n<3;
# tier_penalty=0.05 (C); CV = std/mean для 2 элементов
# Ожидаем что n=2 с умеренным разбросом даёт FSD ≈ 0.07+...≥0.20
if anchor["fsd"] > settings.estimate_sb_gate_max_fsd:
assert gate_thin, "gate_thin должен быть True при n=2 и high FSD"
# ---------------------------------------------------------------------------
# Fix 2 — analog_tier enum values
# ---------------------------------------------------------------------------
def test_fix2_analog_tier_schema_accepts_valid_values() -> None:
"""AggregatedEstimate.analog_tier принимает все 4 enum-значения + None."""
from app.schemas.trade_in import AggregatedEstimate
base_kwargs: dict[str, Any] = {
"estimate_id": "00000000-0000-0000-0000-000000000001",
"median_price_rub": 5_000_000,
"range_low_rub": 4_500_000,
"range_high_rub": 5_500_000,
"median_price_per_m2": 100_000,
"confidence": "medium",
"n_analogs": 5,
"period_months": 12,
"analogs": [],
"actual_deals": [],
"expires_at": datetime.now(tz=UTC) + timedelta(hours=24),
}
for tier_val in ["same_building", "micro_radius", "district", "city", None]:
est = AggregatedEstimate(**base_kwargs, analog_tier=tier_val)
assert est.analog_tier == tier_val, f"Должен принять tier={tier_val!r}"
def test_fix2_analog_tier_invalid_value_rejected() -> None:
"""Недопустимое значение analog_tier отклоняется Pydantic."""
from pydantic import ValidationError
from app.schemas.trade_in import AggregatedEstimate
base_kwargs: dict[str, Any] = {
"estimate_id": "00000000-0000-0000-0000-000000000002",
"median_price_rub": 5_000_000,
"range_low_rub": 4_500_000,
"range_high_rub": 5_500_000,
"median_price_per_m2": 100_000,
"confidence": "medium",
"n_analogs": 5,
"period_months": 12,
"analogs": [],
"actual_deals": [],
"expires_at": datetime.now(tz=UTC) + timedelta(hours=24),
"analog_tier": "unknown_tier",
}
with pytest.raises(ValidationError):
AggregatedEstimate(**base_kwargs)
def test_fix2_analog_tier_default_is_none() -> None:
"""analog_tier отсутствует → дефолт None (backward-compat)."""
from app.schemas.trade_in import AggregatedEstimate
est = AggregatedEstimate(
estimate_id="00000000-0000-0000-0000-000000000003",
median_price_rub=0,
range_low_rub=0,
range_high_rub=0,
median_price_per_m2=0,
confidence="low",
n_analogs=0,
period_months=12,
analogs=[],
actual_deals=[],
expires_at=datetime.now(tz=UTC) + timedelta(hours=24),
)
assert est.analog_tier is None
# ---------------------------------------------------------------------------
# Fix 3 — price_trend freshness filter
# ---------------------------------------------------------------------------
def test_fix3_price_trend_freshness_filter() -> None:
"""_fetch_price_trend с freshness_months должен исключать старые items.
Тест проверяет что SQL-запрос передаёт fresh_months в параметры — через
мок MappedResult. Инвариант: функция не падает и возвращает только свежие.
"""
from app.services.estimator import _fetch_price_trend
# Мокаем db.execute чтобы не нужна реальная БД.
# Source 1 (houses_price_dynamics) — вернуть пустой список → пойдёт в Source 2.
# Source 2 — вернуть 1 свежую точку.
mock_db = MagicMock()
fresh_month = (datetime.now(tz=UTC).date().replace(day=1)).strftime("%Y-%m")
# Первый вызов — houses_price_dynamics → пусто
# Второй вызов — house_placement_history → 1 свежая точка
def side_effect(*args: Any, **kwargs: Any) -> MagicMock:
call_text = str(args[0].text) if args else ""
result = MagicMock()
if "houses_price_dynamics" in call_text:
result.mappings.return_value.all.return_value = []
else:
result.mappings.return_value.all.return_value = [
{"month": fresh_month, "ppm2": 150_000}
]
return result
mock_db.execute.side_effect = side_effect
result = _fetch_price_trend(mock_db, target_house_id=42, freshness_months=6, min_points=1)
assert result is not None
assert len(result) == 1
assert result[0]["month"] == fresh_month
# Проверяем что fresh_months передан в параметрах второго вызова
calls = mock_db.execute.call_args_list
assert len(calls) >= 2
# Второй вызов — house_placement_history с fresh_months=6
second_params = calls[1].args[1] if len(calls[1].args) > 1 else {}
assert "fresh_months" in second_params, "fresh_months должен быть в SQL параметрах"
assert second_params["fresh_months"] == 6
def test_fix3_price_trend_without_freshness_uses_months() -> None:
"""freshness_months=None → _fresh_months == months (старое поведение)."""
from app.services.estimator import _fetch_price_trend
mock_db = MagicMock()
def side_effect(*args: Any, **kwargs: Any) -> MagicMock:
result = MagicMock()
result.mappings.return_value.all.return_value = []
return result
mock_db.execute.side_effect = side_effect
# Не должен падать при freshness_months=None
result = _fetch_price_trend(mock_db, target_house_id=42, months=24, freshness_months=None)
assert result is None # нет точек → None (min_points=3 не достигнуто)
# ---------------------------------------------------------------------------
# Fix 4 — sigma=0 guard + видовой компл не выкинут pre-weighting
# ---------------------------------------------------------------------------
def test_fix4_sigma_zero_does_not_crash() -> None:
"""sigma=0 не должен вызвать ZeroDivisionError."""
comps = [_make_comp(200_000 + i * 1_000) for i in range(5)]
# sigma=0 → area-вес должен быть 1.0 (нейтральный), не div/0
anchor = _compute_same_building_anchor(
comps,
area_target=50.0,
rooms_target=2,
tier="A",
sigma=0.0, # ← нулевой sigma
rooms_boost=1.6,
min_comps=1,
)
assert anchor is not None
assert anchor["anchor_ppm2"] > 0
def test_fix4_floor_sigma_zero_does_not_crash() -> None:
"""floor_sigma=0 не должен вызвать ZeroDivisionError."""
comps = [
{**_make_comp(200_000 + i * 1_000), "floor": i + 1, "total_floors": 10} for i in range(5)
]
anchor = _compute_same_building_anchor(
comps,
area_target=50.0,
rooms_target=2,
tier="A",
sigma=0.18,
rooms_boost=1.6,
floor_target=5,
total_floors_target=10,
floor_sigma=0.0, # ← нулевой floor_sigma
min_comps=1,
)
assert anchor is not None
def test_fix4_premium_comp_survives_post_weight_clip() -> None:
"""Видовой компл (высокий ppm²) с правильными весами не выкидывается.
Логика: post-weight MAD-clip применяется к ppm² ПОСЛЕ weighting. Если видовой
компл близок по площади/комнатам — его вес высок, и он не должен быть outlier
после clip.
"""
# 4 стандартных компла + 1 видовой (выше на 30%)
base = 200_000
comps = [_make_comp(base) for _ in range(4)] + [_make_comp(base * 1.3)]
with patch("app.services.estimator.settings") as mock_settings:
mock_settings.estimate_sb_mad_k_small_n = 2.5
mock_settings.estimate_sb_small_n_threshold = 10
mock_settings.avito_imv_thin_market_threshold = 10
mock_settings.sber_index_max_age_days = 35
anchor = _compute_same_building_anchor(
comps,
area_target=50.0,
rooms_target=2,
tier="A",
sigma=0.18,
rooms_boost=1.6,
min_comps=1,
mad_k=3.5,
)
# С mad_k=3.5 и n=5 — видовой компл (130% от base, т.е. ~30% выше) не является
# выбросом по MAD (для 5 значений MAD-порог достаточно широк).
assert anchor is not None
# Anchor должен быть между base и base*1.3 (видовой учтён)
assert anchor["anchor_ppm2"] >= base * 0.95
# ---------------------------------------------------------------------------
# Fix 5a — per-estimate sber staleness warning УДАЛЁН (#2846)
#
# Guard сравнивал возраст latest периода с sber_index_max_age_days=35. Такой
# возраст недостижим по построению (period_month — метка первого числа + лаг
# публикации ⇒ пол 46 суток), поэтому на проде warning писался у КАЖДОЙ оценки
# и не нёс ни бита. Пара прежних тестов зеленела только на фикстуре с «свежим»
# месяцем, которого в реальной серии не бывает. Свежесть теперь мерит одно место —
# tasks/sber_freshness_monitor, по отставанию ЗАГРУЗКИ.
# ---------------------------------------------------------------------------
def test_fix5a_no_per_estimate_staleness_warning(caplog: pytest.LogCaptureFixture) -> None:
"""Серия отдаётся как есть; календарного warning'а в горячем пути больше нет."""
import logging
from app.services.estimator import _load_sber_index_series
stale_month = date(2024, 1, 1) # два года назад — прежний guard тут кричал
mock_db = MagicMock()
mock_db.execute.return_value.mappings.return_value.all.return_value = [
{"period_month": stale_month, "index_value_rub_m2": 100_000.0}
]
with caplog.at_level(logging.WARNING, logger="app.services.estimator"):
series = _load_sber_index_series(mock_db, region="Свердловская область")
assert series == {stale_month: 100_000.0}
stale_msgs = [r for r in caplog.records if "stale" in r.message.lower()]
assert not stale_msgs, f"per-estimate guard вернулся, caplog: {caplog.text}"
# ---------------------------------------------------------------------------
# Fix 5b — thin-market flag in AvitoImvSummary
# ---------------------------------------------------------------------------
def test_fix5b_thin_market_true_when_count_below_threshold() -> None:
"""thin_market=True когда market_count < avito_imv_thin_market_threshold."""
from app.schemas.trade_in import AvitoImvSummary
summary = AvitoImvSummary(
recommended_price=5_000_000,
lower_price=4_500_000,
higher_price=5_500_000,
market_count=5, # ниже дефолтного порога 10
thin_market=True,
)
assert summary.thin_market is True
assert summary.market_count == 5
def test_fix5b_thin_market_false_when_count_sufficient() -> None:
"""thin_market=False когда market_count >= threshold."""
from app.schemas.trade_in import AvitoImvSummary
summary = AvitoImvSummary(
recommended_price=5_000_000,
lower_price=4_500_000,
higher_price=5_500_000,
market_count=15, # выше дефолтного порога 10
thin_market=False,
)
assert summary.thin_market is False
def test_fix5b_thin_market_default_false() -> None:
"""thin_market дефолт=False — backward-compat."""
from app.schemas.trade_in import AvitoImvSummary
summary = AvitoImvSummary(recommended_price=5_000_000, market_count=100)
assert summary.thin_market is False
def test_fix5b_thin_market_none_count_no_flag() -> None:
"""market_count=None → thin_market не проставляется (остаётся False)."""
from app.schemas.trade_in import AvitoImvSummary
summary = AvitoImvSummary(recommended_price=5_000_000, market_count=None)
assert summary.thin_market is False
def test_fix5b_thin_market_logic_in_estimator() -> None:
"""Estimator-логика: _imv_mc < threshold → thin_market=True."""
from app.core.config import settings
threshold = settings.avito_imv_thin_market_threshold
# Ниже порога
market_count_low = threshold - 1
thin = market_count_low is not None and market_count_low < threshold
assert thin is True
# Выше порога
market_count_high = threshold + 5
thin2 = market_count_high is not None and market_count_high < threshold
assert thin2 is False