feat(tradein): estimator additive expected_sold price (#648 S3) (#661)
All checks were successful
Deploy Trade-In / changes (push) Successful in 6s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 42s
Deploy Trade-In / deploy (push) Successful in 34s

This commit is contained in:
Light1YT 2026-05-29 13:37:33 +00:00
parent c7b9565cd6
commit 35bd0238ef
8 changed files with 493 additions and 7 deletions

View file

@ -99,7 +99,10 @@ def get_estimate(
area_m2, rooms, floor, total_floors, area_m2, rooms, floor, total_floors,
year_built, house_type, repair_state, has_balcony, year_built, house_type, repair_state, has_balcony,
canonical_address, house_cadnum, house_fias_id, canonical_address, house_cadnum, house_fias_id,
dadata_qc_geo, dadata_metro dadata_qc_geo, dadata_metro,
expected_sold_price, expected_sold_range_low,
expected_sold_range_high, expected_sold_per_m2,
asking_to_sold_ratio, ratio_basis
FROM trade_in_estimates FROM trade_in_estimates
WHERE id = CAST(:id AS uuid) WHERE id = CAST(:id AS uuid)
AND expires_at > NOW() AND expires_at > NOW()
@ -140,6 +143,16 @@ def get_estimate(
target_lon=row.lon, target_lon=row.lon,
sources_used=row.sources_used or [], sources_used=row.sources_used or [],
data_freshness_minutes=row.data_freshness_minutes, data_freshness_minutes=row.data_freshness_minutes,
# #648 Stage 3 — sold-correction columns rehydrated for shared-link reopen.
# (numeric ratio → float for the Pydantic field.)
expected_sold_price_rub=row.expected_sold_price,
expected_sold_range_low_rub=row.expected_sold_range_low,
expected_sold_range_high_rub=row.expected_sold_range_high,
expected_sold_per_m2=row.expected_sold_per_m2,
asking_to_sold_ratio=(
float(row.asking_to_sold_ratio) if row.asking_to_sold_ratio is not None else None
),
ratio_basis=row.ratio_basis,
area_m2=row.area_m2, area_m2=row.area_m2,
rooms=row.rooms, rooms=row.rooms,
floor=row.floor, floor=row.floor,
@ -178,7 +191,10 @@ def estimate_pdf(
address, lat, lon, area_m2, rooms, floor, total_floors, address, lat, lon, area_m2, rooms, floor, total_floors,
year_built, house_type, repair_state, has_balcony, year_built, house_type, repair_state, has_balcony,
canonical_address, house_cadnum, house_fias_id, canonical_address, house_cadnum, house_fias_id,
dadata_qc_geo, dadata_metro dadata_qc_geo, dadata_metro,
expected_sold_price, expected_sold_range_low,
expected_sold_range_high, expected_sold_per_m2,
asking_to_sold_ratio, ratio_basis
FROM trade_in_estimates FROM trade_in_estimates
WHERE id = CAST(:id AS uuid) WHERE id = CAST(:id AS uuid)
""" """
@ -215,6 +231,15 @@ def estimate_pdf(
target_lon=row.lon, target_lon=row.lon,
sources_used=row.sources_used or [], sources_used=row.sources_used or [],
data_freshness_minutes=row.data_freshness_minutes, data_freshness_minutes=row.data_freshness_minutes,
# #648 Stage 3 — sold-correction columns rehydrated so the PDF carries them.
expected_sold_price_rub=row.expected_sold_price,
expected_sold_range_low_rub=row.expected_sold_range_low,
expected_sold_range_high_rub=row.expected_sold_range_high,
expected_sold_per_m2=row.expected_sold_per_m2,
asking_to_sold_ratio=(
float(row.asking_to_sold_ratio) if row.asking_to_sold_ratio is not None else None
),
ratio_basis=row.ratio_basis,
canonical_address=row.canonical_address, canonical_address=row.canonical_address,
house_cadnum=row.house_cadnum, house_cadnum=row.house_cadnum,
house_fias_id=row.house_fias_id, house_fias_id=row.house_fias_id,

View file

@ -94,6 +94,18 @@ class AggregatedEstimate(BaseModel):
data_freshness_minutes: int | None = None # сколько минут назад был самый свежий парсинг data_freshness_minutes: int | None = None # сколько минут назад был самый свежий парсинг
est_days_on_market: int | None = None # прогноз срока продажи (медиана по аналогам) est_days_on_market: int | None = None # прогноз срока продажи (медиана по аналогам)
cian_valuation: CianValuationSummary | None = None cian_valuation: CianValuationSummary | None = None
# ── Asking→sold correction (#648 Stage 3) — PURELY ADDITIVE ──
# Headline median_price_rub/range_*/median_price_per_m2 остаются ASKING (активные
# объявления). Эти параллельные expected_sold_* = asking × per-rooms ratio
# (asking_to_sold_ratios, migration 080) — релевантная для выкупа цена сделки.
# Backtest (#648 Stage 1) показал, что коррекция убирает bias asking-медианы
# +20% → 4% на held-out ДКП. None если ratio-таблицы нет / бакет пуст (graceful).
expected_sold_price_rub: int | None = None
expected_sold_range_low_rub: int | None = None
expected_sold_range_high_rub: int | None = None
expected_sold_per_m2: int | None = None
asking_to_sold_ratio: float | None = None # =sold/asking, ~0.720.93
ratio_basis: str | None = None # 'per_rooms' | 'global_fallback'
# ── DaData enrichment (PR Q1) — on-demand для target адреса ── # ── DaData enrichment (PR Q1) — on-demand для target адреса ──
# canonical_address — DaData-нормализованная форма (с улицей в short form). # canonical_address — DaData-нормализованная форма (с улицей в short form).
# house_cadnum — кадастровый номер ДОМА (для будущего matching Росреестра). # house_cadnum — кадастровый номер ДОМА (для будущего matching Росреестра).

View file

@ -24,6 +24,7 @@ import json
import logging import logging
import math import math
import re import re
import time
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from typing import Any from typing import Any
from uuid import uuid4 from uuid import uuid4
@ -158,6 +159,81 @@ def _repair_coefficient(repair_state: str | None) -> float:
return _REPAIR_COEF.get(repair_state, 1.0) return _REPAIR_COEF.get(repair_state, 1.0)
# ── Asking→sold correction ratio lookup (#648 Stage 3) ──────────────────────
# Таблица asking_to_sold_ratios (migration 080) хранит per-rooms коэффициент
# ratio = median(SOLD ppm²) / median(ASKING ppm²) (~0.720.93). Estimator
# домножает ASKING-медиану на этот ratio, получая параллельную expected_sold
# цену (релевантную для выкупа). Headline asking-медиана НЕ меняется.
#
# Кэш: tiny in-process dict {bucket: (ratio, basis, fetched_monotonic)} с TTL.
# Ratio дрейфует медленно (refresh-задача раз в сутки, Stage 4), поэтому 300с
# TTL более чем достаточно и снимает по SELECT'у с каждой оценки. Single-worker
# uvicorn/scheduler — GIL делает dict-доступ atomic enough (без явного lock).
_ASKING_SOLD_RATIO_CACHE_TTL_S = 300.0
_asking_sold_ratio_cache: dict[int, tuple[float | None, str | None, float]] = {}
def _get_asking_sold_ratio(db: Session, rooms: int | None) -> tuple[float | None, str | None]:
"""Возвращает (ratio, basis) asking→sold для бакета комнат.
bucket = min(max(rooms or 0, 0), 4). Сначала ищем per-rooms строку
(district=''), при отсутствии global fallback (rooms_bucket=-1). Если
таблицы нет / пуста / любая ошибка (None, None), НЕ raise (graceful:
estimator продолжает без sold-коррекции, headline asking-медиана отдаётся).
Кэшируется на бакет с TTL _ASKING_SOLD_RATIO_CACHE_TTL_S.
"""
bucket = min(max(rooms or 0, 0), 4)
cached = _asking_sold_ratio_cache.get(bucket)
if cached is not None:
ratio, basis, fetched = cached
if (time.monotonic() - fetched) < _ASKING_SOLD_RATIO_CACHE_TTL_S:
return ratio, basis
ratio: float | None = None
basis: str | None = None
try:
row = db.execute(
text(
"""
SELECT ratio, basis FROM asking_to_sold_ratios
WHERE rooms_bucket = CAST(:b AS int) AND district = ''
"""
),
{"b": bucket},
).fetchone()
if row is None:
# Бакет тонкий (n<30 при seed'е) или отсутствует → global fallback (-1).
row = db.execute(
text(
"""
SELECT ratio, basis FROM asking_to_sold_ratios
WHERE rooms_bucket = -1 AND district = ''
"""
),
).fetchone()
if row is not None and row.ratio is not None:
ratio = float(row.ratio)
basis = row.basis
except Exception as exc:
# Таблицы может не быть на свежей/старой БД (миграция 080 не применена),
# либо транзакция в сбойном состоянии — graceful: без sold-коррекции.
# ОБЯЗАТЕЛЬНО rollback (как в sibling-helper'ах _get_or_fetch_*): неудачный
# SELECT помечает транзакцию InFailedSqlTransaction, и без отката следующий
# statement (_fetch_deals) упал бы → 500. Откат держит shared session чистой
# для последующего INSERT. rollback тоже guard'им (соединение могло умереть).
logger.debug("asking_to_sold_ratio lookup skipped (graceful): %s", exc)
try:
db.rollback()
except Exception:
pass
ratio, basis = None, None
_asking_sold_ratio_cache[bucket] = (ratio, basis, time.monotonic())
return ratio, basis
# ── Avito IMV cache lookup (Stage 3) ──────────────────────────────────────── # ── Avito IMV cache lookup (Stage 3) ────────────────────────────────────────
IMV_CACHE_TTL_HOURS = 24 IMV_CACHE_TTL_HOURS = 24
@ -775,6 +851,28 @@ async def estimate_quality(payload: TradeInEstimateInput, db: Session) -> Aggreg
f"({_REPAIR_LABEL.get(payload.repair_state, '')} {pct:+d}%)." f"({_REPAIR_LABEL.get(payload.repair_state, '')} {pct:+d}%)."
) )
# 4c. Asking→sold коррекция (#648 Stage 3) — PURELY ADDITIVE. Headline
# median_price/range_*/median_ppm2 (ASKING активных объявлений) НЕ трогаем;
# вычисляем ПАРАЛЛЕЛЬНУЮ expected_sold цену = asking × per-rooms ratio
# (asking_to_sold_ratios, migration 080). Это релевантная для выкупа цена
# сделки (backtest #648 S1: bias asking-медианы +20% → 4% на held-out ДКП).
# NOTE: actual_deals (#564) остаётся ИНФОРМАЦИОННЫМ и НЕ подмешивается в
# headline — sold-коррекция здесь единственный sold-сигнал (без double-count).
asking_to_sold_ratio, ratio_basis = _get_asking_sold_ratio(db, payload.rooms)
if asking_to_sold_ratio is not None and listings_clean:
expected_sold_per_m2: int | None = round(median_ppm2 * asking_to_sold_ratio)
expected_sold_price: int | None = round(median_price * asking_to_sold_ratio)
expected_sold_range_low: int | None = round(range_low * asking_to_sold_ratio)
expected_sold_range_high: int | None = round(range_high * asking_to_sold_ratio)
else:
expected_sold_per_m2 = None
expected_sold_price = None
expected_sold_range_low = None
expected_sold_range_high = None
# Не было ratio (нет таблицы/бакета) — не вводим в заблуждение пустым basis.
if asking_to_sold_ratio is None:
ratio_basis = None
confidence, explanation = _compute_confidence( confidence, explanation = _compute_confidence(
n_analogs, n_analogs,
median_ppm2, median_ppm2,
@ -924,6 +1022,9 @@ async def estimate_quality(payload: TradeInEstimateInput, db: Session) -> Aggreg
sources_used, data_freshness_minutes, sources_used, data_freshness_minutes,
canonical_address, house_cadnum, house_fias_id, canonical_address, house_cadnum, house_fias_id,
dadata_qc_geo, dadata_qc_house, dadata_metro, dadata_qc_geo, dadata_qc_house, dadata_metro,
expected_sold_price, expected_sold_range_low,
expected_sold_range_high, expected_sold_per_m2,
asking_to_sold_ratio, ratio_basis,
expires_at expires_at
) VALUES ( ) VALUES (
CAST(:id AS uuid), CAST(:id AS uuid),
@ -940,6 +1041,9 @@ async def estimate_quality(payload: TradeInEstimateInput, db: Session) -> Aggreg
:canonical_address, :house_cadnum, :house_fias_id, :canonical_address, :house_cadnum, :house_fias_id,
:dadata_qc_geo, :dadata_qc_house, :dadata_qc_geo, :dadata_qc_house,
CAST(:dadata_metro_json AS jsonb), CAST(:dadata_metro_json AS jsonb),
:expected_sold_price, :expected_sold_range_low,
:expected_sold_range_high, :expected_sold_per_m2,
:asking_to_sold_ratio, :ratio_basis,
:expires_at :expires_at
) )
""" """
@ -982,6 +1086,12 @@ async def estimate_quality(payload: TradeInEstimateInput, db: Session) -> Aggreg
"dadata_qc_geo": dadata.qc_geo if dadata else None, "dadata_qc_geo": dadata.qc_geo if dadata else None,
"dadata_qc_house": dadata.qc_house if dadata else None, "dadata_qc_house": dadata.qc_house if dadata else None,
"dadata_metro_json": dadata_metro_json, "dadata_metro_json": dadata_metro_json,
"expected_sold_price": expected_sold_price,
"expected_sold_range_low": expected_sold_range_low,
"expected_sold_range_high": expected_sold_range_high,
"expected_sold_per_m2": expected_sold_per_m2,
"asking_to_sold_ratio": asking_to_sold_ratio,
"ratio_basis": ratio_basis,
"expires_at": expires_at, "expires_at": expires_at,
}, },
) )
@ -1068,6 +1178,12 @@ async def estimate_quality(payload: TradeInEstimateInput, db: Session) -> Aggreg
if cian_val is not None if cian_val is not None
else None else None
), ),
expected_sold_price_rub=expected_sold_price,
expected_sold_range_low_rub=expected_sold_range_low,
expected_sold_range_high_rub=expected_sold_range_high,
expected_sold_per_m2=expected_sold_per_m2,
asking_to_sold_ratio=asking_to_sold_ratio,
ratio_basis=ratio_basis,
area_m2=payload.area_m2, area_m2=payload.area_m2,
rooms=payload.rooms, rooms=payload.rooms,
floor=payload.floor, floor=payload.floor,

View file

@ -0,0 +1,52 @@
-- 081_trade_in_estimates_expected_sold.sql
-- #648 Stage 3 — persist the asking→sold corrected price on each estimate.
--
-- ПРОБЛЕМА (#648). Estimator (estimator.py) отдаёт headline-медиану по АКТИВНЫМ
-- asking-объявлениям. Backtest (#648 Stage 1) показал систематический bias
-- asking-медианы +20% над реальной ДКП-ценой. Stage 2 (migration 080) сохранил
-- per-rooms коэффициент asking_to_sold_ratios (ratio = sold/asking, ~0.720.93).
-- Stage 3 (estimator) ДОПОЛНИТЕЛЬНО (purely additive — headline НЕ меняется)
-- вычисляет expected_sold = asking × ratio. Эту параллельную цену надо
-- ПЕРСИСТИТЬ на trade_in_estimates, чтобы shared-link reopen (GET /estimate/{id})
-- и PDF (GET /estimate/{id}/pdf) её отдавали (re-SELECT строит AggregatedEstimate
-- из явных колонок — без персиста значения терялись бы при reopen).
--
-- ДОБАВЛЯЕТ (все nullable — backward-safe; старые строки = NULL = «нет коррекции»):
-- expected_sold_price bigint — asking median_price × ratio
-- expected_sold_range_low bigint — asking range_low × ratio
-- expected_sold_range_high bigint — asking range_high × ratio
-- expected_sold_per_m2 int — asking median ₽/м² × ratio
-- asking_to_sold_ratio numeric — применённый ratio (=sold/asking), диагностика
-- ratio_basis text — 'per_rooms' | 'global_fallback'
--
-- GRACEFUL: если asking_to_sold_ratios пуста / бакета нет — estimator пишет NULL
-- в эти колонки (sold-коррекция недоступна), оценка возвращается штатно.
--
-- ЗАВИСИМОСТИ: trade_in_estimates (existing), asking_to_sold_ratios (080).
-- Idempotent (ADD COLUMN IF NOT EXISTS) — безопасно ре-apply / dry-run.
-- Apply after: 080_asking_to_sold_ratios.sql
BEGIN;
ALTER TABLE trade_in_estimates
ADD COLUMN IF NOT EXISTS expected_sold_price bigint,
ADD COLUMN IF NOT EXISTS expected_sold_range_low bigint,
ADD COLUMN IF NOT EXISTS expected_sold_range_high bigint,
ADD COLUMN IF NOT EXISTS expected_sold_per_m2 int,
ADD COLUMN IF NOT EXISTS asking_to_sold_ratio numeric,
ADD COLUMN IF NOT EXISTS ratio_basis text;
COMMENT ON COLUMN trade_in_estimates.expected_sold_price IS
'#648 S3: asking median_price × per-rooms sold/asking ratio. NULL = коррекция недоступна.';
COMMENT ON COLUMN trade_in_estimates.expected_sold_range_low IS
'#648 S3: asking range_low × ratio.';
COMMENT ON COLUMN trade_in_estimates.expected_sold_range_high IS
'#648 S3: asking range_high × ratio.';
COMMENT ON COLUMN trade_in_estimates.expected_sold_per_m2 IS
'#648 S3: asking median ₽/м² × ratio.';
COMMENT ON COLUMN trade_in_estimates.asking_to_sold_ratio IS
'#648 S3: применённый ratio (=sold/asking, ~0.720.93) из asking_to_sold_ratios (080).';
COMMENT ON COLUMN trade_in_estimates.ratio_basis IS
'#648 S3: источник ratio — ''per_rooms'' (свой бакет) | ''global_fallback'' (rooms_bucket=-1).';
COMMIT;

View file

@ -116,6 +116,11 @@ def _common_patches(cian_mock):
"app.services.estimator.estimate_via_cian_valuation", "app.services.estimator.estimate_via_cian_valuation",
new=cian_mock, new=cian_mock,
), ),
# #648 S3: stub asking→sold lookup off (isolates cian-source assertions).
patch(
"app.services.estimator._get_asking_sold_ratio",
return_value=(None, None),
),
] ]
@ -142,6 +147,8 @@ def test_estimator_includes_cian_valuation_when_available() -> None:
new=AsyncMock(return_value=None)), new=AsyncMock(return_value=None)),
patch("app.services.estimator.estimate_via_cian_valuation", patch("app.services.estimator.estimate_via_cian_valuation",
new=cian_mock), new=cian_mock),
patch("app.services.estimator._get_asking_sold_ratio",
return_value=(None, None)),
): ):
result = await estimate_quality(payload, db) result = await estimate_quality(payload, db)
@ -172,6 +179,8 @@ def test_estimator_graceful_when_cian_returns_none() -> None:
new=AsyncMock(return_value=None)), new=AsyncMock(return_value=None)),
patch("app.services.estimator.estimate_via_cian_valuation", patch("app.services.estimator.estimate_via_cian_valuation",
new=cian_mock), new=cian_mock),
patch("app.services.estimator._get_asking_sold_ratio",
return_value=(None, None)),
): ):
result = await estimate_quality(payload, db) result = await estimate_quality(payload, db)
@ -203,6 +212,8 @@ def test_estimator_graceful_when_cian_raises() -> None:
new=AsyncMock(return_value=None)), new=AsyncMock(return_value=None)),
patch("app.services.estimator.estimate_via_cian_valuation", patch("app.services.estimator.estimate_via_cian_valuation",
new=cian_mock), new=cian_mock),
patch("app.services.estimator._get_asking_sold_ratio",
return_value=(None, None)),
): ):
result = await estimate_quality(payload, db) result = await estimate_quality(payload, db)
@ -236,6 +247,8 @@ def test_estimator_cian_result_no_sale_price_not_added() -> None:
new=AsyncMock(return_value=None)), new=AsyncMock(return_value=None)),
patch("app.services.estimator.estimate_via_cian_valuation", patch("app.services.estimator.estimate_via_cian_valuation",
new=cian_mock), new=cian_mock),
patch("app.services.estimator._get_asking_sold_ratio",
return_value=(None, None)),
): ):
result = await estimate_quality(payload, db) result = await estimate_quality(payload, db)

View file

@ -0,0 +1,264 @@
"""Tests for the asking→sold correction (#648 Stage 3).
Two layers:
1. `_get_asking_sold_ratio` lookup helper (DB mocked):
- bucket = min(max(rooms or 0, 0), 4) clamping + None handling
- per-rooms row hit returns (ratio, basis)
- per-rooms miss falls back to the global rooms_bucket=-1 row
- empty / missing table (None, None), never raises (graceful)
- the in-process TTL cache memoises per bucket
2. The apply-logic in `estimate_quality` (every I/O stubbed, no DB, no network
same isolation harness as test_estimator_repair_coef.py):
- when a ratio exists: expected_sold_price_rub round(median_price_rub * ratio)
and expected_sold_per_m2 round(median_price_per_m2 * ratio)
- the headline median_price_rub / ranges / per_m2 stay UNCHANGED (additive)
- graceful path: lookup returns (None, None) all expected_sold_* are None,
no crash, headline still returned
"""
from __future__ import annotations
import os
from datetime import UTC, datetime
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import anyio
# Settings requires DATABASE_URL at init time. Set dummy DSN before any app import.
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
# ── Layer 1: _get_asking_sold_ratio lookup helper ───────────────────────────
class _FakeRow:
"""Stand-in for a SQLAlchemy Row (attribute access .ratio / .basis)."""
def __init__(self, ratio: float | None, basis: str | None) -> None:
self.ratio = ratio
self.basis = basis
def _clear_ratio_cache() -> None:
from app.services import estimator
estimator._asking_sold_ratio_cache.clear()
def _db_returning(rows: list[_FakeRow | None]) -> MagicMock:
"""MagicMock db whose successive .execute(...).fetchone() yield `rows`."""
db = MagicMock()
db.execute.return_value.fetchone.side_effect = rows
return db
def test_bucket_clamping() -> None:
"""rooms None→0, negative→0, >4→4; 0..4 pass through."""
from app.services.estimator import _get_asking_sold_ratio
cases = {None: 0, -3: 0, 0: 0, 1: 1, 2: 2, 3: 3, 4: 4, 5: 4, 99: 4}
for rooms, expected_bucket in cases.items():
_clear_ratio_cache()
db = _db_returning([_FakeRow(0.8, "per_rooms")])
_get_asking_sold_ratio(db, rooms)
# First positional bind param is {"b": bucket}.
bind = db.execute.call_args_list[0].args[1]
assert bind["b"] == expected_bucket, (
f"rooms={rooms} → bucket {bind['b']} != {expected_bucket}"
)
def test_per_rooms_hit_returns_ratio_basis() -> None:
"""Per-rooms row present → returned directly, no fallback query."""
from app.services.estimator import _get_asking_sold_ratio
_clear_ratio_cache()
db = _db_returning([_FakeRow(0.74, "per_rooms")])
ratio, basis = _get_asking_sold_ratio(db, 1)
assert ratio == 0.74
assert basis == "per_rooms"
assert db.execute.call_count == 1 # no global fallback needed
def test_global_fallback_when_per_rooms_missing() -> None:
"""Per-rooms miss (None) → second query for rooms_bucket=-1 global row."""
from app.services.estimator import _get_asking_sold_ratio
_clear_ratio_cache()
db = _db_returning([None, _FakeRow(0.79, "global_fallback")])
ratio, basis = _get_asking_sold_ratio(db, 3)
assert ratio == 0.79
assert basis == "global_fallback"
assert db.execute.call_count == 2
def test_empty_table_returns_none_none() -> None:
"""Both queries miss (empty table) → (None, None), no raise."""
from app.services.estimator import _get_asking_sold_ratio
_clear_ratio_cache()
db = _db_returning([None, None])
assert _get_asking_sold_ratio(db, 2) == (None, None)
def test_missing_table_is_graceful() -> None:
"""db.execute raises (relation does not exist) → (None, None), swallowed."""
from app.services.estimator import _get_asking_sold_ratio
_clear_ratio_cache()
db = MagicMock()
db.execute.side_effect = RuntimeError("relation asking_to_sold_ratios does not exist")
assert _get_asking_sold_ratio(db, 1) == (None, None)
def test_cache_memoises_per_bucket() -> None:
"""Second call for same bucket within TTL → no extra DB query."""
from app.services.estimator import _get_asking_sold_ratio
_clear_ratio_cache()
db = _db_returning([_FakeRow(0.8, "per_rooms")])
first = _get_asking_sold_ratio(db, 2)
second = _get_asking_sold_ratio(db, 2)
assert first == second == (0.8, "per_rooms")
assert db.execute.call_count == 1 # second served from cache
# ── Layer 2: apply-logic inside estimate_quality (all I/O stubbed) ──────────
def _make_listing(*, price_per_m2: float, area_m2: float = 40.0) -> dict[str, Any]:
price_rub = price_per_m2 * area_m2
return {
"source": "cian",
"source_url": "https://cian.ru/offer/1",
"address": "ЕКБ, ул. Учителей, 18",
"lat": 56.838,
"lon": 60.595,
"rooms": 1,
"area_m2": area_m2,
"floor": 4,
"total_floors": 16,
"price_rub": price_rub,
"price_per_m2": price_per_m2,
"listing_date": datetime(2026, 5, 1),
"days_on_market": 10,
"photo_urls": [],
"scraped_at": datetime(2026, 5, 20, tzinfo=UTC),
"distance_m": 100.0,
"relevance_score": 0.1,
}
# Three fixed analogs → deterministic median ppm2 = 150_000 (< 5 ⇒ no outlier drop).
_ANALOGS: list[dict[str, Any]] = [
_make_listing(price_per_m2=140_000.0),
_make_listing(price_per_m2=150_000.0),
_make_listing(price_per_m2=160_000.0),
]
def _make_fake_geo():
from app.services.geocoder import GeocodeResult
return GeocodeResult(
lat=56.838,
lon=60.595,
full_address="Свердловская обл., Екатеринбург, ул. Учителей, 18",
provider="nominatim",
)
def _make_payload():
from app.schemas.trade_in import TradeInEstimateInput
return TradeInEstimateInput(
address="ЕКБ, ул. Учителей, 18",
area_m2=40.0,
rooms=1,
floor=4,
total_floors=16,
)
def _run_estimate(ratio_tuple: tuple[float | None, str | None]):
"""estimate_quality with all deps stubbed; _get_asking_sold_ratio forced."""
from app.services.estimator import estimate_quality
db = MagicMock()
payload = _make_payload()
async def _run():
with (
patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_fake_geo())),
patch("app.services.estimator.dadata_clean_address",
new=AsyncMock(return_value=None)),
patch("app.services.estimator.match_house_readonly", return_value=None),
patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
patch("app.services.estimator._fetch_analogs",
return_value=(list(_ANALOGS), False, "S")),
patch("app.services.estimator._fetch_deals", return_value=[]),
patch("app.services.estimator._get_or_fetch_imv_cached",
new=AsyncMock(return_value=None)),
patch("app.services.estimator._get_or_fetch_yandex_valuation_cached",
new=AsyncMock(return_value=None)),
patch("app.services.estimator.estimate_via_cian_valuation",
new=AsyncMock(return_value=None)),
patch("app.services.estimator._get_asking_sold_ratio", return_value=ratio_tuple),
):
return await estimate_quality(payload, db)
return anyio.run(_run)
def test_expected_sold_applied_when_ratio_present() -> None:
"""ratio present → expected_sold_* ≈ asking × ratio; headline UNCHANGED."""
ratio = 0.74
est = _run_estimate((ratio, "per_rooms"))
# Headline asking-median is the unadjusted market median (additive invariant).
expected_headline = int(150_000.0 * 40.0) # 6_000_000
assert est.median_price_rub == expected_headline
assert est.median_price_per_m2 == 150_000
# Parallel expected_sold = asking × ratio (round()).
assert est.expected_sold_price_rub == round(est.median_price_rub * ratio)
assert est.expected_sold_per_m2 == round(est.median_price_per_m2 * ratio)
assert est.expected_sold_range_low_rub == round(est.range_low_rub * ratio)
assert est.expected_sold_range_high_rub == round(est.range_high_rub * ratio)
assert est.asking_to_sold_ratio == ratio
assert est.ratio_basis == "per_rooms"
def test_headline_unchanged_vs_no_ratio() -> None:
"""median_price_rub / ranges / per_m2 identical with and without a ratio."""
with_ratio = _run_estimate((0.8, "global_fallback"))
without_ratio = _run_estimate((None, None))
assert with_ratio.median_price_rub == without_ratio.median_price_rub
assert with_ratio.range_low_rub == without_ratio.range_low_rub
assert with_ratio.range_high_rub == without_ratio.range_high_rub
assert with_ratio.median_price_per_m2 == without_ratio.median_price_per_m2
def test_graceful_when_ratio_none() -> None:
"""Lookup returns (None, None) → all expected_sold_* None, no crash."""
est = _run_estimate((None, None))
assert est.expected_sold_price_rub is None
assert est.expected_sold_range_low_rub is None
assert est.expected_sold_range_high_rub is None
assert est.expected_sold_per_m2 is None
assert est.asking_to_sold_ratio is None
assert est.ratio_basis is None
# Headline still produced normally.
assert est.median_price_rub == int(150_000.0 * 40.0)
def test_global_fallback_basis_carried_through() -> None:
"""basis='global_fallback' propagates to the returned estimate."""
est = _run_estimate((0.79, "global_fallback"))
assert est.ratio_basis == "global_fallback"
assert est.asking_to_sold_ratio == 0.79

View file

@ -109,6 +109,10 @@ def _run_estimate(repair_state: str | None):
new=AsyncMock(return_value=None)), new=AsyncMock(return_value=None)),
patch("app.services.estimator.estimate_via_cian_valuation", patch("app.services.estimator.estimate_via_cian_valuation",
new=AsyncMock(return_value=None)), new=AsyncMock(return_value=None)),
# #648 S3: stub asking→sold lookup off so this test isolates the
# repair coefficient (no sold-correction, no DB).
patch("app.services.estimator._get_asking_sold_ratio",
return_value=(None, None)),
): ):
return await estimate_quality(payload, db) return await estimate_quality(payload, db)

View file

@ -77,7 +77,7 @@ def test_address_cap_limits_per_address_listings() -> None:
] ]
db = _make_db_mock(sql_rows) db = _make_db_mock(sql_rows)
result, fallback_used = _fetch_analogs( result, fallback_used, _tier = _fetch_analogs(
db, lat=56.838, lon=60.595, rooms=1, area=38.0, radius_m=1000 db, lat=56.838, lon=60.595, rooms=1, area=38.0, radius_m=1000
) )
@ -116,7 +116,7 @@ def test_source_quota_prevents_cian_starvation() -> None:
sql_rows = avito_rows + cian_rows sql_rows = avito_rows + cian_rows
db = _make_db_mock(sql_rows) db = _make_db_mock(sql_rows)
result, _ = _fetch_analogs( result, _, _ = _fetch_analogs(
db, lat=56.838, lon=60.595, rooms=1, area=38.0, radius_m=1000 db, lat=56.838, lon=60.595, rooms=1, area=38.0, radius_m=1000
) )
@ -150,7 +150,7 @@ def test_source_quota_includes_all_when_supply_below_min() -> None:
sql_rows = avito_rows + cian_rows sql_rows = avito_rows + cian_rows
db = _make_db_mock(sql_rows) db = _make_db_mock(sql_rows)
result, _ = _fetch_analogs( result, _, _ = _fetch_analogs(
db, lat=56.838, lon=60.595, rooms=1, area=38.0, radius_m=1000 db, lat=56.838, lon=60.595, rooms=1, area=38.0, radius_m=1000
) )
@ -177,13 +177,13 @@ def test_fallback_signal_reflects_radius() -> None:
] ]
db_default = _make_db_mock(rows) db_default = _make_db_mock(rows)
_, fallback_default = _fetch_analogs( _, fallback_default, _ = _fetch_analogs(
db_default, lat=56.838, lon=60.595, rooms=1, area=38.0, radius_m=DEFAULT_RADIUS_M db_default, lat=56.838, lon=60.595, rooms=1, area=38.0, radius_m=DEFAULT_RADIUS_M
) )
assert fallback_default is False, "radius == DEFAULT should produce fallback_used=False" assert fallback_default is False, "radius == DEFAULT should produce fallback_used=False"
db_fallback = _make_db_mock(rows) db_fallback = _make_db_mock(rows)
_, fallback_wide = _fetch_analogs( _, fallback_wide, _ = _fetch_analogs(
db_fallback, lat=56.838, lon=60.595, rooms=1, area=38.0, radius_m=FALLBACK_RADIUS_M db_fallback, lat=56.838, lon=60.595, rooms=1, area=38.0, radius_m=FALLBACK_RADIUS_M
) )
assert fallback_wide is True, "radius == FALLBACK should produce fallback_used=True" assert fallback_wide is True, "radius == FALLBACK should produce fallback_used=True"