Compare commits
No commits in common. "6a81e91a83e800de4f56ab84be83a4a7395353fe" and "6508023c296a62ddbd2e80cc1a57ce8c222ec3a0" have entirely different histories.
6a81e91a83
...
6508023c29
6 changed files with 0 additions and 573 deletions
|
|
@ -47,18 +47,5 @@ class Settings(BaseSettings):
|
||||||
dadata_api_token: str | None = None
|
dadata_api_token: str | None = None
|
||||||
dadata_api_secret: str | None = None
|
dadata_api_secret: str | None = None
|
||||||
|
|
||||||
# ── #651: IMV / Yandex blend (killer accuracy fix) ──────────────────────
|
|
||||||
# Радиусная медиана ₽/м² системно недооценивает премиум/видовые квартиры
|
|
||||||
# (нет class/segment/IMV-коррекции → premium ~2x underestimate, case 50М vs
|
|
||||||
# факт ~100М). Если внешний якорь (Avito IMV recommended_price из
|
|
||||||
# house_imv_evaluations, либо Yandex sale) выше нашей медианы более чем в
|
|
||||||
# `threshold` раз — подмешиваем якорь к медиане с весом `weight` и
|
|
||||||
# расширяем верх диапазона. ОДНОНАПРАВЛЕННО: только повышаем (баг — занижение).
|
|
||||||
# Полностью за флагами — безопасно выкатить до демо; при отсутствии IMV/Yandex
|
|
||||||
# no-op (медиана не меняется).
|
|
||||||
estimate_imv_blend_enabled: bool = True
|
|
||||||
estimate_imv_blend_weight: float = 0.5 # вес якоря в blend: median*(1-w)+A*w
|
|
||||||
estimate_imv_blend_threshold: float = 1.15 # якорь должен быть > медианы ×1.15
|
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
|
||||||
|
|
@ -73,38 +73,6 @@ class CianValuationSummary(BaseModel):
|
||||||
chart_change_direction: Literal["increase", "decrease", "neutral"] | None = None
|
chart_change_direction: Literal["increase", "decrease", "neutral"] | None = None
|
||||||
|
|
||||||
|
|
||||||
class AvitoImvSummary(BaseModel):
|
|
||||||
"""Avito IMV (Индекс Market Value) якорь для target-дома (#651).
|
|
||||||
|
|
||||||
Источник: `house_imv_evaluations` (per house_id, обновляется регулярно).
|
|
||||||
Это РЕАЛЬНАЯ рыночная оценка Avito по дому — служит anchor'ом для blend'а
|
|
||||||
(см. estimate_imv_blend_*). Сурфейсится в UI как референсный маркер на
|
|
||||||
ценовой шкале. None если для дома нет свежей IMV-записи.
|
|
||||||
"""
|
|
||||||
|
|
||||||
recommended_price: int | None = None # рекомендованная цена Avito, ₽
|
|
||||||
lower_price: int | None = None # нижняя граница IMV-коридора, ₽
|
|
||||||
higher_price: int | None = None # верхняя граница IMV-коридора, ₽
|
|
||||||
market_count: int | None = None # объём рынка, на котором построена оценка
|
|
||||||
|
|
||||||
|
|
||||||
class DkpCorridor(BaseModel):
|
|
||||||
"""Коридор реальных ДКП-сделок Росреестра для target (#652).
|
|
||||||
|
|
||||||
Источник: `deals` (source='rosreestr', ДКП-only), агрегированные по улице +
|
|
||||||
rooms + площади ±15% за период. ADVISORY: показывается как тонкая референсная
|
|
||||||
линия «коридор реальных сделок: X–Y млн»; если итоговая медиана ₽/м² выходит
|
|
||||||
за [low,high]×slack — добавляется текстовая пометка. НЕ хард-клампит оценку.
|
|
||||||
None / count=0 если по улице нет сопоставимых сделок.
|
|
||||||
"""
|
|
||||||
|
|
||||||
count: int # число ДКП-сделок в выборке
|
|
||||||
low_ppm2: int # min ₽/м² по сделкам (P10-ish — берём минимум)
|
|
||||||
median_ppm2: int # медиана ₽/м²
|
|
||||||
high_ppm2: int # max ₽/м²
|
|
||||||
period_months: int # окно поиска сделок
|
|
||||||
|
|
||||||
|
|
||||||
class AggregatedEstimate(BaseModel):
|
class AggregatedEstimate(BaseModel):
|
||||||
estimate_id: UUID
|
estimate_id: UUID
|
||||||
median_price_rub: int
|
median_price_rub: int
|
||||||
|
|
@ -126,13 +94,6 @@ 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
|
||||||
# ── #651/#652: внешние якоря (Avito IMV) + коридор реальных сделок (ДКП) ──
|
|
||||||
# avito_imv — реальная IMV-оценка дома (house_imv_evaluations). Используется
|
|
||||||
# как anchor для blend'а медианы (см. confidence_explanation).
|
|
||||||
# dkp_corridor — коридор ₽/м² по ДКП-сделкам Росреестра (advisory, не клампит).
|
|
||||||
# Оба nullable — None при отсутствии данных (graceful, common case без регрессий).
|
|
||||||
avito_imv: AvitoImvSummary | None = None
|
|
||||||
dkp_corridor: DkpCorridor | None = None
|
|
||||||
# ── Asking→sold correction (#648 Stage 3) — PURELY ADDITIVE ──
|
# ── Asking→sold correction (#648 Stage 3) — PURELY ADDITIVE ──
|
||||||
# Headline median_price_rub/range_*/median_price_per_m2 остаются ASKING (активные
|
# Headline median_price_rub/range_*/median_price_per_m2 остаются ASKING (активные
|
||||||
# объявления). Эти параллельные expected_sold_* = asking × per-rooms ratio
|
# объявления). Эти параллельные expected_sold_* = asking × per-rooms ratio
|
||||||
|
|
|
||||||
|
|
@ -32,13 +32,10 @@ from uuid import uuid4
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.schemas.trade_in import (
|
from app.schemas.trade_in import (
|
||||||
AggregatedEstimate,
|
AggregatedEstimate,
|
||||||
AnalogLot,
|
AnalogLot,
|
||||||
AvitoImvSummary,
|
|
||||||
CianValuationSummary,
|
CianValuationSummary,
|
||||||
DkpCorridor,
|
|
||||||
TradeInEstimateInput,
|
TradeInEstimateInput,
|
||||||
)
|
)
|
||||||
from app.services.dadata import DadataAddressResult
|
from app.services.dadata import DadataAddressResult
|
||||||
|
|
@ -661,173 +658,6 @@ def _save_yandex_history_items(
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
# ── #651: IMV / Yandex blend (killer accuracy fix) ─────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_house_imv_anchor(
|
|
||||||
db: Session,
|
|
||||||
*,
|
|
||||||
target_house_id: int | None,
|
|
||||||
rooms: int | None,
|
|
||||||
area: float | None,
|
|
||||||
) -> dict[str, Any] | None:
|
|
||||||
"""Достаёт РЕАЛЬНУЮ Avito IMV-оценку target-дома из `house_imv_evaluations`.
|
|
||||||
|
|
||||||
В отличие от `avito_imv_evaluations` (keyed estimate_id — пустая, on-demand
|
|
||||||
скрейп), `house_imv_evaluations` популирована (~2951 домов, fresh) и keyed по
|
|
||||||
house_id. Резолвим строку: WHERE house_id = target_house_id, предпочитаем
|
|
||||||
запись с ближайшими rooms+area (минимизируем |Δrooms|*10 + |Δarea%|), иначе
|
|
||||||
самую свежую (fetched_at DESC). Best-effort: None при любой ошибке / отсутствии
|
|
||||||
house_id / пустой таблице — estimator продолжает на гео-tier'ах (no regress).
|
|
||||||
|
|
||||||
Returns dict {recommended_price, lower_price, higher_price, market_count,
|
|
||||||
rooms, area_m2} или None.
|
|
||||||
"""
|
|
||||||
if target_house_id is None:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
row = (
|
|
||||||
db.execute(
|
|
||||||
text(
|
|
||||||
"""
|
|
||||||
SELECT recommended_price, lower_price, higher_price,
|
|
||||||
market_count, rooms, area_m2
|
|
||||||
FROM house_imv_evaluations
|
|
||||||
WHERE house_id = CAST(:hid AS bigint)
|
|
||||||
AND recommended_price > 0
|
|
||||||
ORDER BY
|
|
||||||
-- ближе по комнатам и площади → меньше score; NULL target → 0
|
|
||||||
(CASE WHEN CAST(:rooms AS integer) IS NOT NULL AND rooms IS NOT NULL
|
|
||||||
THEN abs(rooms - CAST(:rooms AS integer)) * 10 ELSE 0 END)
|
|
||||||
+ (CASE WHEN CAST(:area AS double precision) IS NOT NULL
|
|
||||||
AND area_m2 IS NOT NULL AND area_m2 > 0
|
|
||||||
THEN abs(area_m2 - CAST(:area AS double precision))
|
|
||||||
/ area_m2 * 100 ELSE 0 END) ASC,
|
|
||||||
fetched_at DESC
|
|
||||||
LIMIT 1
|
|
||||||
"""
|
|
||||||
),
|
|
||||||
{"hid": target_house_id, "rooms": rooms, "area": area},
|
|
||||||
)
|
|
||||||
.mappings()
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
except Exception as exc: # pragma: no cover — defensive
|
|
||||||
logger.warning("house_imv anchor lookup failed (graceful): %s", exc)
|
|
||||||
return None
|
|
||||||
return dict(row) if row is not None else None
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_imv_blend(
|
|
||||||
*,
|
|
||||||
median_price: int,
|
|
||||||
range_high: int,
|
|
||||||
median_ppm2: float,
|
|
||||||
area: float,
|
|
||||||
anchor_total: int | None,
|
|
||||||
anchor_higher: int | None,
|
|
||||||
weight: float,
|
|
||||||
threshold: float,
|
|
||||||
) -> tuple[int, int, float, bool, int | None]:
|
|
||||||
"""Чистая (testable без БД) blend-трансформация для #651.
|
|
||||||
|
|
||||||
Если есть надёжный якорь A (`anchor_total`, ПОЛНАЯ цена за квартиру) и он
|
|
||||||
выше median_price × threshold (сигнал занижения) → поднимаем медиану до
|
|
||||||
blend = median*(1-w) + A*w и расширяем верх диапазона до max(range_high,
|
|
||||||
anchor_higher или A). ОДНОНАПРАВЛЕННО: только повышаем (баг — занижение).
|
|
||||||
Если A ниже медианы — медиану НЕ трогаем, но диапазон можем расширить, чтобы
|
|
||||||
включить A (информативность). Null-guard: при anchor_total=None — no-op.
|
|
||||||
|
|
||||||
Returns (new_median_price, new_range_high, new_median_ppm2, blended,
|
|
||||||
anchor_used_total).
|
|
||||||
"""
|
|
||||||
if anchor_total is None or anchor_total <= 0 or median_price <= 0 or area <= 0:
|
|
||||||
return median_price, range_high, median_ppm2, False, None
|
|
||||||
|
|
||||||
blended = False
|
|
||||||
new_median = median_price
|
|
||||||
new_ppm2 = median_ppm2
|
|
||||||
if anchor_total > median_price * threshold:
|
|
||||||
w = max(0.0, min(1.0, weight))
|
|
||||||
new_median = round(median_price * (1.0 - w) + anchor_total * w)
|
|
||||||
new_ppm2 = new_median / area
|
|
||||||
blended = True
|
|
||||||
|
|
||||||
# Расширяем верх диапазона: предпочитаем верхнюю границу IMV-коридора, иначе сам
|
|
||||||
# якорь. Только вверх (никогда не сужаем).
|
|
||||||
range_top_candidate = anchor_higher if (anchor_higher and anchor_higher > 0) else anchor_total
|
|
||||||
new_range_high = max(range_high, range_top_candidate, new_median)
|
|
||||||
|
|
||||||
return new_median, new_range_high, new_ppm2, blended, anchor_total
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_dkp_corridor(
|
|
||||||
db: Session,
|
|
||||||
*,
|
|
||||||
address: str | None,
|
|
||||||
rooms: int | None,
|
|
||||||
area: float | None,
|
|
||||||
period_months: int = DEALS_PERIOD_MONTHS,
|
|
||||||
area_tolerance: float = AREA_TOLERANCE,
|
|
||||||
) -> dict[str, Any] | None:
|
|
||||||
"""#652: коридор ₽/м² по реальным ДКП-сделкам Росреестра для target.
|
|
||||||
|
|
||||||
Reuse паттерна street-deals (api/v1/trade_in.py): извлекаем улицу из адреса,
|
|
||||||
фильтруем `deals` (source='rosreestr', та же rooms, площадь ±tolerance, окно
|
|
||||||
period_months) и нормализуем per-m². Возвращаем low/median/high ₽/м².
|
|
||||||
ADVISORY — caller не клампит, только сурфейсит + опциональная пометка.
|
|
||||||
Best-effort: None при отсутствии улицы / сделок / любой ошибке.
|
|
||||||
"""
|
|
||||||
if not address or rooms is None or not area:
|
|
||||||
return None
|
|
||||||
street_name = extract_street_name(address)
|
|
||||||
if not street_name:
|
|
||||||
return None
|
|
||||||
area_min = area * (1.0 - area_tolerance)
|
|
||||||
area_max = area * (1.0 + area_tolerance)
|
|
||||||
try:
|
|
||||||
rows = (
|
|
||||||
db.execute(
|
|
||||||
text(
|
|
||||||
"""
|
|
||||||
SELECT price_per_m2
|
|
||||||
FROM deals
|
|
||||||
WHERE source = 'rosreestr'
|
|
||||||
AND address ILIKE :street_pattern
|
|
||||||
AND rooms = CAST(:rooms AS integer)
|
|
||||||
AND area_m2 BETWEEN :area_min AND :area_max
|
|
||||||
AND deal_date > NOW()
|
|
||||||
- (CAST(:period_months AS integer) || ' months')::interval
|
|
||||||
AND price_per_m2 > 0
|
|
||||||
"""
|
|
||||||
),
|
|
||||||
{
|
|
||||||
"street_pattern": "%" + street_name + "%",
|
|
||||||
"rooms": rooms,
|
|
||||||
"area_min": area_min,
|
|
||||||
"area_max": area_max,
|
|
||||||
"period_months": period_months,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.mappings()
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
except Exception as exc: # pragma: no cover — defensive
|
|
||||||
logger.warning("dkp_corridor lookup failed (graceful): %s", exc)
|
|
||||||
return None
|
|
||||||
|
|
||||||
ppm2_values = sorted(float(r["price_per_m2"]) for r in rows if r["price_per_m2"])
|
|
||||||
if not ppm2_values:
|
|
||||||
return None
|
|
||||||
return {
|
|
||||||
"count": len(ppm2_values),
|
|
||||||
"low_ppm2": int(ppm2_values[0]),
|
|
||||||
"median_ppm2": int(_percentile(ppm2_values, 0.5)),
|
|
||||||
"high_ppm2": int(ppm2_values[-1]),
|
|
||||||
"period_months": period_months,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ── Public ───────────────────────────────────────────────────────────────────
|
# ── Public ───────────────────────────────────────────────────────────────────
|
||||||
async def estimate_quality(payload: TradeInEstimateInput, db: Session) -> AggregatedEstimate:
|
async def estimate_quality(payload: TradeInEstimateInput, db: Session) -> AggregatedEstimate:
|
||||||
"""Главная функция — оценка квартиры по реальным данным.
|
"""Главная функция — оценка квартиры по реальным данным.
|
||||||
|
|
@ -1151,103 +981,6 @@ async def estimate_quality(payload: TradeInEstimateInput, db: Session) -> Aggreg
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("cian_valuation: lookup failed (graceful): %s", exc)
|
logger.warning("cian_valuation: lookup failed (graceful): %s", exc)
|
||||||
|
|
||||||
# ── #651: IMV / Yandex BLEND (killer accuracy fix) ──────────────────────
|
|
||||||
# Радиусная медиана системно занижает премиум/видовые квартиры (нет class/
|
|
||||||
# segment-коррекции). Берём РЕАЛЬНЫЙ Avito IMV target-дома из house_imv_evaluations
|
|
||||||
# (avito_imv_evaluations пуст — keyed estimate_id, on-demand), используем как
|
|
||||||
# anchor: если IMV recommended_price > median × threshold — поднимаем медиану
|
|
||||||
# blend'ом и расширяем верх диапазона. Всё за флагом + null-guard (no-op без IMV).
|
|
||||||
avito_imv_summary: AvitoImvSummary | None = None
|
|
||||||
if settings.estimate_imv_blend_enabled and listings_clean and median_price > 0:
|
|
||||||
imv_anchor = _fetch_house_imv_anchor(
|
|
||||||
db,
|
|
||||||
target_house_id=target_house_id,
|
|
||||||
rooms=payload.rooms,
|
|
||||||
area=payload.area_m2,
|
|
||||||
)
|
|
||||||
# Anchor chain: prefer Avito IMV recommended; fall back to on-demand imv_eval.
|
|
||||||
anchor_total: int | None = None
|
|
||||||
anchor_higher: int | None = None
|
|
||||||
anchor_label: str | None = None
|
|
||||||
if imv_anchor is not None and imv_anchor.get("recommended_price"):
|
|
||||||
anchor_total = int(imv_anchor["recommended_price"])
|
|
||||||
anchor_higher = (
|
|
||||||
int(imv_anchor["higher_price"]) if imv_anchor.get("higher_price") else None
|
|
||||||
)
|
|
||||||
anchor_label = "оценке Avito IMV"
|
|
||||||
avito_imv_summary = AvitoImvSummary(
|
|
||||||
recommended_price=anchor_total,
|
|
||||||
lower_price=(
|
|
||||||
int(imv_anchor["lower_price"]) if imv_anchor.get("lower_price") else None
|
|
||||||
),
|
|
||||||
higher_price=anchor_higher,
|
|
||||||
market_count=(
|
|
||||||
int(imv_anchor["market_count"]) if imv_anchor.get("market_count") else None
|
|
||||||
),
|
|
||||||
)
|
|
||||||
elif imv_eval is not None and imv_eval.recommended_price:
|
|
||||||
# on-demand IMV (avito_imv_evaluations) — fallback, обычно пуст
|
|
||||||
anchor_total = int(imv_eval.recommended_price)
|
|
||||||
anchor_higher = int(imv_eval.higher_price) if imv_eval.higher_price else None
|
|
||||||
anchor_label = "оценке Avito IMV"
|
|
||||||
avito_imv_summary = AvitoImvSummary(
|
|
||||||
recommended_price=anchor_total,
|
|
||||||
lower_price=int(imv_eval.lower_price) if imv_eval.lower_price else None,
|
|
||||||
higher_price=anchor_higher,
|
|
||||||
market_count=imv_eval.market_count,
|
|
||||||
)
|
|
||||||
|
|
||||||
if anchor_total is not None:
|
|
||||||
new_median, new_range_high, new_ppm2, blended, anchor_used = _apply_imv_blend(
|
|
||||||
median_price=median_price,
|
|
||||||
range_high=range_high,
|
|
||||||
median_ppm2=median_ppm2,
|
|
||||||
area=payload.area_m2,
|
|
||||||
anchor_total=anchor_total,
|
|
||||||
anchor_higher=anchor_higher,
|
|
||||||
weight=settings.estimate_imv_blend_weight,
|
|
||||||
threshold=settings.estimate_imv_blend_threshold,
|
|
||||||
)
|
|
||||||
if blended:
|
|
||||||
logger.info(
|
|
||||||
"imv_blend: median %d → %d (anchor=%d w=%.2f) range_high %d → %d",
|
|
||||||
median_price, new_median, anchor_used,
|
|
||||||
settings.estimate_imv_blend_weight, range_high, new_range_high,
|
|
||||||
)
|
|
||||||
median_price = new_median
|
|
||||||
median_ppm2 = new_ppm2
|
|
||||||
explanation = (explanation or "") + (
|
|
||||||
f" Оценка скорректирована по {anchor_label} "
|
|
||||||
f"({anchor_used / 1_000_000:.1f} млн ₽)."
|
|
||||||
)
|
|
||||||
sources_used_pre = sorted(set(sources_used_pre) | {"avito_imv"})
|
|
||||||
# Диапазон расширяем даже если медиану не двигали (информативность).
|
|
||||||
range_high = new_range_high
|
|
||||||
|
|
||||||
# ── #652: ДКП-коридор реальных сделок (ADVISORY + soft sanity-bound) ─────
|
|
||||||
dkp_corridor: DkpCorridor | None = None
|
|
||||||
dkp_raw = _fetch_dkp_corridor(
|
|
||||||
db,
|
|
||||||
address=geo.full_address,
|
|
||||||
rooms=payload.rooms,
|
|
||||||
area=payload.area_m2,
|
|
||||||
)
|
|
||||||
if dkp_raw is not None:
|
|
||||||
dkp_corridor = DkpCorridor(**dkp_raw)
|
|
||||||
# Soft sanity-bound: если итоговая медиана ₽/м² заметно вне коридора —
|
|
||||||
# текстовая пометка (без хард-клампа). slack ±25% — широко, чтобы не
|
|
||||||
# шуметь на тонких выборках.
|
|
||||||
slack = 0.25
|
|
||||||
if median_ppm2 and dkp_raw["count"] >= 3:
|
|
||||||
if median_ppm2 > dkp_raw["high_ppm2"] * (1.0 + slack):
|
|
||||||
explanation = (explanation or "") + (
|
|
||||||
" Оценка выше коридора реальных сделок Росреестра по улице."
|
|
||||||
)
|
|
||||||
elif median_ppm2 < dkp_raw["low_ppm2"] * (1.0 - slack):
|
|
||||||
explanation = (explanation or "") + (
|
|
||||||
" Оценка ниже коридора реальных сделок Росреестра по улице."
|
|
||||||
)
|
|
||||||
|
|
||||||
# 5. Deals — ДКП-only sales (вторичка) из rosreestr_deals.
|
# 5. Deals — ДКП-only sales (вторичка) из rosreestr_deals.
|
||||||
# Importer фильтрует doc_type='ДКП' (PR-A 2026-05-24), ДДУ застройщиков
|
# Importer фильтрует doc_type='ДКП' (PR-A 2026-05-24), ДДУ застройщиков
|
||||||
# исключены — больше не скёюят median вторички ~110-120 К/м².
|
# исключены — больше не скёюят median вторички ~110-120 К/м².
|
||||||
|
|
@ -1445,8 +1178,6 @@ 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
|
||||||
),
|
),
|
||||||
avito_imv=avito_imv_summary,
|
|
||||||
dkp_corridor=dkp_corridor,
|
|
||||||
expected_sold_price_rub=expected_sold_price,
|
expected_sold_price_rub=expected_sold_price,
|
||||||
expected_sold_range_low_rub=expected_sold_range_low,
|
expected_sold_range_low_rub=expected_sold_range_low,
|
||||||
expected_sold_range_high_rub=expected_sold_range_high,
|
expected_sold_range_high_rub=expected_sold_range_high,
|
||||||
|
|
|
||||||
|
|
@ -1,190 +0,0 @@
|
||||||
"""Unit tests for #651 IMV blend (killer accuracy fix) + #652 DKP corridor.
|
|
||||||
|
|
||||||
Тестируем чистую blend-трансформацию `_apply_imv_blend` (без БД) и DB-helpers
|
|
||||||
с замоканной Session — premium-blend поднимает медиану + расширяет диапазон,
|
|
||||||
no-anchor / sub-threshold случаи — no-op.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
# Settings требует DATABASE_URL при инициализации (fail-fast, C-3).
|
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db")
|
|
||||||
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
from app.services.estimator import (
|
|
||||||
_apply_imv_blend,
|
|
||||||
_fetch_dkp_corridor,
|
|
||||||
_fetch_house_imv_anchor,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── #651: pure blend transform ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_blend_premium_raises_median_and_extends_range() -> None:
|
|
||||||
"""IMV ≫ median → median поднимается blend'ом, range_high расширяется."""
|
|
||||||
# median 50М, IMV recommended 100М (≈2x — premium/view unit underestimate),
|
|
||||||
# IMV higher 110М. weight 0.5, threshold 1.15.
|
|
||||||
area = 80.0
|
|
||||||
median_price = 50_000_000
|
|
||||||
range_high = 60_000_000
|
|
||||||
median_ppm2 = median_price / area
|
|
||||||
|
|
||||||
new_median, new_range_high, new_ppm2, blended, anchor_used = _apply_imv_blend(
|
|
||||||
median_price=median_price,
|
|
||||||
range_high=range_high,
|
|
||||||
median_ppm2=median_ppm2,
|
|
||||||
area=area,
|
|
||||||
anchor_total=100_000_000,
|
|
||||||
anchor_higher=110_000_000,
|
|
||||||
weight=0.5,
|
|
||||||
threshold=1.15,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert blended is True
|
|
||||||
# blend = 50М*0.5 + 100М*0.5 = 75М
|
|
||||||
assert new_median == 75_000_000
|
|
||||||
# range_high расширен до IMV higher (110М)
|
|
||||||
assert new_range_high == 110_000_000
|
|
||||||
# ppm2 пересчитан консистентно
|
|
||||||
assert new_ppm2 == 75_000_000 / area
|
|
||||||
assert anchor_used == 100_000_000
|
|
||||||
|
|
||||||
|
|
||||||
def test_blend_no_op_when_anchor_below_median() -> None:
|
|
||||||
"""A < median → медиану НЕ понижаем (однонаправленность), но диапазон может расшириться."""
|
|
||||||
area = 50.0
|
|
||||||
median_price = 10_000_000
|
|
||||||
range_high = 11_000_000
|
|
||||||
median_ppm2 = median_price / area
|
|
||||||
|
|
||||||
new_median, new_range_high, new_ppm2, blended, anchor_used = _apply_imv_blend(
|
|
||||||
median_price=median_price,
|
|
||||||
range_high=range_high,
|
|
||||||
median_ppm2=median_ppm2,
|
|
||||||
area=area,
|
|
||||||
anchor_total=8_000_000, # ниже медианы
|
|
||||||
anchor_higher=8_500_000,
|
|
||||||
weight=0.5,
|
|
||||||
threshold=1.15,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert blended is False
|
|
||||||
assert new_median == median_price # не понижаем
|
|
||||||
assert new_ppm2 == median_ppm2
|
|
||||||
assert anchor_used == 8_000_000
|
|
||||||
# range_high не сужается (anchor ниже текущего high)
|
|
||||||
assert new_range_high == range_high
|
|
||||||
|
|
||||||
|
|
||||||
def test_blend_no_op_below_threshold() -> None:
|
|
||||||
"""A выше median, но в пределах threshold → blend не срабатывает (медиана та же)."""
|
|
||||||
area = 40.0
|
|
||||||
median_price = 10_000_000
|
|
||||||
range_high = 11_000_000
|
|
||||||
median_ppm2 = median_price / area
|
|
||||||
|
|
||||||
new_median, _, new_ppm2, blended, _ = _apply_imv_blend(
|
|
||||||
median_price=median_price,
|
|
||||||
range_high=range_high,
|
|
||||||
median_ppm2=median_ppm2,
|
|
||||||
area=area,
|
|
||||||
anchor_total=11_000_000, # ×1.1 < threshold 1.15
|
|
||||||
anchor_higher=None,
|
|
||||||
weight=0.5,
|
|
||||||
threshold=1.15,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert blended is False
|
|
||||||
assert new_median == median_price
|
|
||||||
assert new_ppm2 == median_ppm2
|
|
||||||
|
|
||||||
|
|
||||||
def test_blend_no_op_when_no_anchor() -> None:
|
|
||||||
"""anchor_total=None → graceful no-op (common case без IMV-данных)."""
|
|
||||||
new_median, new_range_high, new_ppm2, blended, anchor_used = _apply_imv_blend(
|
|
||||||
median_price=12_000_000,
|
|
||||||
range_high=14_000_000,
|
|
||||||
median_ppm2=200_000.0,
|
|
||||||
area=60.0,
|
|
||||||
anchor_total=None,
|
|
||||||
anchor_higher=None,
|
|
||||||
weight=0.5,
|
|
||||||
threshold=1.15,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert blended is False
|
|
||||||
assert anchor_used is None
|
|
||||||
assert new_median == 12_000_000
|
|
||||||
assert new_range_high == 14_000_000
|
|
||||||
assert new_ppm2 == 200_000.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_blend_extends_range_to_anchor_when_no_higher() -> None:
|
|
||||||
"""Нет higher_price → диапазон расширяется до самого якоря."""
|
|
||||||
_, new_range_high, _, blended, _ = _apply_imv_blend(
|
|
||||||
median_price=50_000_000,
|
|
||||||
range_high=60_000_000,
|
|
||||||
median_ppm2=625_000.0,
|
|
||||||
area=80.0,
|
|
||||||
anchor_total=100_000_000,
|
|
||||||
anchor_higher=None,
|
|
||||||
weight=0.5,
|
|
||||||
threshold=1.15,
|
|
||||||
)
|
|
||||||
assert blended is True
|
|
||||||
assert new_range_high == 100_000_000 # до якоря, т.к. higher отсутствует
|
|
||||||
|
|
||||||
|
|
||||||
# ── #651: house_imv anchor lookup (mocked Session) ─────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_house_imv_anchor_none_when_no_house_id() -> None:
|
|
||||||
"""target_house_id=None → None без обращения к БД."""
|
|
||||||
mock_db = MagicMock()
|
|
||||||
assert _fetch_house_imv_anchor(mock_db, target_house_id=None, rooms=2, area=50.0) is None
|
|
||||||
mock_db.execute.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_house_imv_anchor_returns_row() -> None:
|
|
||||||
"""Возвращает dict из house_imv_evaluations при наличии строки."""
|
|
||||||
mock_db = MagicMock()
|
|
||||||
mock_db.execute.return_value.mappings.return_value.first.return_value = {
|
|
||||||
"recommended_price": 31_950_000,
|
|
||||||
"lower_price": 30_352_500,
|
|
||||||
"higher_price": 33_547_500,
|
|
||||||
"market_count": 800,
|
|
||||||
"rooms": 2,
|
|
||||||
"area_m2": 80.0,
|
|
||||||
}
|
|
||||||
res = _fetch_house_imv_anchor(mock_db, target_house_id=11308, rooms=2, area=80.0)
|
|
||||||
assert res is not None
|
|
||||||
assert res["recommended_price"] == 31_950_000
|
|
||||||
assert res["higher_price"] == 33_547_500
|
|
||||||
|
|
||||||
|
|
||||||
# ── #652: DKP corridor (mocked Session) ────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_dkp_corridor_none_without_street() -> None:
|
|
||||||
"""Адрес без распознаваемой улицы → None."""
|
|
||||||
mock_db = MagicMock()
|
|
||||||
assert _fetch_dkp_corridor(mock_db, address="", rooms=2, area=50.0) is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_dkp_corridor_aggregates_ppm2() -> None:
|
|
||||||
"""Считает low/median/high ₽/м² по сделкам."""
|
|
||||||
mock_db = MagicMock()
|
|
||||||
mock_db.execute.return_value.mappings.return_value.all.return_value = [
|
|
||||||
{"price_per_m2": 100_000},
|
|
||||||
{"price_per_m2": 120_000},
|
|
||||||
{"price_per_m2": 140_000},
|
|
||||||
]
|
|
||||||
res = _fetch_dkp_corridor(
|
|
||||||
mock_db, address="улица Ленина, 5", rooms=2, area=50.0
|
|
||||||
)
|
|
||||||
assert res is not None
|
|
||||||
assert res["count"] == 3
|
|
||||||
assert res["low_ppm2"] == 100_000
|
|
||||||
assert res["median_ppm2"] == 120_000
|
|
||||||
assert res["high_ppm2"] == 140_000
|
|
||||||
|
|
@ -92,18 +92,6 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
|
||||||
|
|
||||||
const showMockDisclaimer = isMockAddress(estimate);
|
const showMockDisclaimer = isMockAddress(estimate);
|
||||||
|
|
||||||
// ── #651: Avito IMV anchor — реальная рыночная оценка дома. Маркер у цены. ──
|
|
||||||
const imv = estimate.avito_imv;
|
|
||||||
const imvPrice =
|
|
||||||
imv && typeof imv.recommended_price === "number" && imv.recommended_price > 0
|
|
||||||
? imv.recommended_price
|
|
||||||
: null;
|
|
||||||
// ── #652: коридор реальных ДКП-сделок (advisory). ₽/м² → млн через площадь. ──
|
|
||||||
const dkp = estimate.dkp_corridor;
|
|
||||||
const dkpArea = estimate.area_m2 ?? input.area_m2 ?? 0;
|
|
||||||
const showDkp =
|
|
||||||
!!dkp && dkp.count > 0 && dkpArea > 0 && dkp.low_ppm2 > 0 && dkp.high_ppm2 > 0;
|
|
||||||
|
|
||||||
// Progressive enrichment state
|
// Progressive enrichment state
|
||||||
const needsHouseType = estimate.house_type === null;
|
const needsHouseType = estimate.house_type === null;
|
||||||
const needsRepairState = estimate.repair_state === null;
|
const needsRepairState = estimate.repair_state === null;
|
||||||
|
|
@ -276,15 +264,6 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
|
||||||
<div className="price-figure-perm2 mono">
|
<div className="price-figure-perm2 mono">
|
||||||
{estimate.median_price_per_m2.toLocaleString("ru-RU")} ₽/м²
|
{estimate.median_price_per_m2.toLocaleString("ru-RU")} ₽/м²
|
||||||
</div>
|
</div>
|
||||||
{imvPrice !== null && (
|
|
||||||
<div
|
|
||||||
className="price-figure-imv mono"
|
|
||||||
title="Индекс Market Value Avito по этому дому — реальный рыночный якорь"
|
|
||||||
style={{ marginTop: 6, fontSize: 12, color: "var(--muted)" }}
|
|
||||||
>
|
|
||||||
Avito IMV: {formatMln(imvPrice)} ₽
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasSold && (
|
{hasSold && (
|
||||||
|
|
@ -320,20 +299,6 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showDkp && dkp && (
|
|
||||||
<p
|
|
||||||
className="hero-prices-note"
|
|
||||||
title="Коридор реальных ₽/м² по ДКП-сделкам Росреестра (справочно)"
|
|
||||||
>
|
|
||||||
<b>Коридор реальных сделок:</b>{" "}
|
|
||||||
<span className="mono">
|
|
||||||
{formatMln(dkp.low_ppm2 * dkpArea)} – {formatMln(dkp.high_ppm2 * dkpArea)} ₽
|
|
||||||
</span>{" "}
|
|
||||||
· <span className="mono">{dkp.median_ppm2.toLocaleString("ru-RU")} ₽/м²</span> ·{" "}
|
|
||||||
{dkp.count} ДКП (Росреестр)
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="hero-bars">
|
<div className="hero-bars">
|
||||||
<div className="bar-block">
|
<div className="bar-block">
|
||||||
<div className="bar-head">
|
<div className="bar-head">
|
||||||
|
|
|
||||||
|
|
@ -72,27 +72,6 @@ export interface CianValuationSummary {
|
||||||
// Базис коэффициента asking→sold (S3): по группе комнат либо городской fallback.
|
// Базис коэффициента asking→sold (S3): по группе комнат либо городской fallback.
|
||||||
export type RatioBasis = "per_rooms" | "global_fallback";
|
export type RatioBasis = "per_rooms" | "global_fallback";
|
||||||
|
|
||||||
// ── #651: Avito IMV (Индекс Market Value) якорь для target-дома ──
|
|
||||||
// Реальная рыночная оценка Avito по дому (house_imv_evaluations) — anchor для
|
|
||||||
// blend'а медианы. Сурфейсится как референсный маркер. null при отсутствии записи.
|
|
||||||
export interface AvitoImvSummary {
|
|
||||||
recommended_price: number | null; // рекомендованная цена Avito, ₽
|
|
||||||
lower_price: number | null; // нижняя граница IMV-коридора, ₽
|
|
||||||
higher_price: number | null; // верхняя граница IMV-коридора, ₽
|
|
||||||
market_count: number | null; // объём рынка, на котором построена оценка
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── #652: коридор реальных ДКП-сделок Росреестра (advisory, не клампит) ──
|
|
||||||
// Агрегаты ₽/м² по сопоставимым сделкам за период. null / count=0 если по улице
|
|
||||||
// нет сделок. count..period_months — required (бэкенд не отдаёт частичный объект).
|
|
||||||
export interface DkpCorridor {
|
|
||||||
count: number; // число ДКП-сделок в выборке
|
|
||||||
low_ppm2: number; // min ₽/м² по сделкам
|
|
||||||
median_ppm2: number; // медиана ₽/м²
|
|
||||||
high_ppm2: number; // max ₽/м²
|
|
||||||
period_months: number; // окно поиска сделок
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AggregatedEstimate {
|
export interface AggregatedEstimate {
|
||||||
estimate_id: string; // UUID
|
estimate_id: string; // UUID
|
||||||
median_price_rub: number; // ASKING-ориентир: по чему выставлены аналоги
|
median_price_rub: number; // ASKING-ориентир: по чему выставлены аналоги
|
||||||
|
|
@ -138,12 +117,6 @@ export interface AggregatedEstimate {
|
||||||
has_balcony: boolean | null;
|
has_balcony: boolean | null;
|
||||||
// ── Cian valuation surface ──
|
// ── Cian valuation surface ──
|
||||||
cian_valuation?: CianValuationSummary | null;
|
cian_valuation?: CianValuationSummary | null;
|
||||||
// ── #651/#652: внешние якоря ──
|
|
||||||
// avito_imv — реальная Avito IMV-оценка дома, anchor для blend'а медианы.
|
|
||||||
// dkp_corridor — коридор ₽/м² по ДКП-сделкам Росреестра (advisory). Оба nullable:
|
|
||||||
// null при отсутствии данных (graceful, common case без регрессий).
|
|
||||||
avito_imv?: AvitoImvSummary | null;
|
|
||||||
dkp_corridor?: DkpCorridor | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Stage 4a/4b response types ──
|
// ── Stage 4a/4b response types ──
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue