Merge pull request 'feat(tradein): house-match аналогов + поправка на ремонт (#6,#7)' (#385) from feat/tradein-house-match-repair into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 48s
Deploy Trade-In / deploy (push) Successful in 20s

This commit is contained in:
lekss361 2026-05-21 17:01:03 +00:00
commit 9ac4f35abc

View file

@ -40,6 +40,29 @@ AREA_TOLERANCE = 0.15 # ±15% площади
LISTINGS_FRESH_DAYS = 14 # объявления не старше 14 дней
DEALS_PERIOD_MONTHS = 12 # сделки за последний год
# Поправочные коэффициенты на состояние ремонта. Аналоги в выборке — микс
# состояний (≈ "стандартный/косметический"), коэффициент сдвигает медиану под
# конкретный ремонт целевой квартиры. Встреча Птицы: ремонт влияет на цену.
_REPAIR_COEF: dict[str, float] = {
"needs_repair": 0.92, # требует ремонта — ниже рынка
"standard": 0.98,
"good": 1.03,
"excellent": 1.08, # евроремонт — выше рынка
}
_REPAIR_LABEL: dict[str | None, str] = {
"needs_repair": "требует ремонта",
"standard": "стандартный ремонт",
"good": "хороший ремонт",
"excellent": "евроремонт",
}
def _repair_coefficient(repair_state: str | None) -> float:
"""Множитель к медиане по состоянию ремонта. None → 1.0 (без поправки)."""
if not repair_state:
return 1.0
return _REPAIR_COEF.get(repair_state, 1.0)
# ── Public ───────────────────────────────────────────────────────────────────
async def estimate_quality(
@ -67,6 +90,7 @@ async def estimate_quality(
listings, fallback_used = _fetch_analogs(
db, lat=geo.lat, lon=geo.lon, rooms=payload.rooms, area=payload.area_m2,
radius_m=DEFAULT_RADIUS_M,
year_built=payload.year_built, house_type=payload.house_type,
)
area_widened = False
@ -74,6 +98,7 @@ async def estimate_quality(
listings_wide, _ = _fetch_analogs(
db, lat=geo.lat, lon=geo.lon, rooms=payload.rooms, area=payload.area_m2,
radius_m=FALLBACK_RADIUS_M,
year_built=payload.year_built, house_type=payload.house_type,
)
if len(listings_wide) > len(listings):
listings = listings_wide
@ -85,6 +110,7 @@ async def estimate_quality(
listings_widearea, _ = _fetch_analogs(
db, lat=geo.lat, lon=geo.lon, rooms=payload.rooms, area=payload.area_m2,
radius_m=FALLBACK_RADIUS_M, area_tolerance=0.25,
year_built=payload.year_built, house_type=payload.house_type,
)
if len(listings_widearea) > len(listings):
listings = listings_widearea
@ -111,10 +137,26 @@ async def estimate_quality(
range_high = 0
n_analogs = 0
# 4b. Поправка на состояние ремонта (встреча Птицы: ремонт влияет на цену).
# Аналоги — микс состояний; коэффициент сдвигает оценку под ремонт клиента.
repair_coef = _repair_coefficient(payload.repair_state)
repair_note = ""
if listings_clean and repair_coef != 1.0:
median_price = int(median_price * repair_coef)
range_low = int(range_low * repair_coef)
range_high = int(range_high * repair_coef)
median_ppm2 = median_ppm2 * repair_coef
pct = int(round((repair_coef - 1.0) * 100))
repair_note = (
f" Цена скорректирована на состояние ремонта "
f"({_REPAIR_LABEL.get(payload.repair_state, '')} {pct:+d}%)."
)
confidence, explanation = _compute_confidence(
n_analogs, median_ppm2, q1_ppm2 if listings_clean else 0,
q3_ppm2 if listings_clean else 0, fallback_used, area_widened,
)
explanation = (explanation or "") + repair_note
# 5. Deals — фактические сделки за период
deals = _fetch_deals(
@ -251,8 +293,14 @@ def _compute_freshness_minutes(lots: list[dict[str, Any]]) -> int | None:
def _fetch_analogs(
db: Session, *, lat: float, lon: float, rooms: int, area: float, radius_m: int,
area_tolerance: float = AREA_TOLERANCE,
year_built: int | None = None, house_type: str | None = None,
) -> tuple[list[dict[str, Any]], bool]:
"""SELECT аналогов с PostGIS distance + фильтры.
"""SELECT аналогов с PostGIS distance + house-match relevance.
House-match (встреча Птицы «соразмерные квартиры»): сортировка не просто
по расстоянию, а по relevance-скору, где учитывается близость года постройки
и совпадение типа дома. Так аналог «рядом + та же эпоха дома» побеждает
аналог «чуть ближе, но дом на 30 лет старше».
Returns:
(list_of_listings_as_dicts, fallback_radius_used_flag)
@ -274,7 +322,14 @@ def _fetch_analogs(
AND is_active = true
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
AND price_rub > 0
ORDER BY distance_m
ORDER BY (
distance_m / 1000.0
+ CASE WHEN :target_year IS NOT NULL AND year_built IS NOT NULL
THEN abs(year_built - :target_year) / 12.0 ELSE 0 END
+ CASE WHEN :target_house_type IS NOT NULL AND house_type IS NOT NULL
AND house_type <> :target_house_type
THEN 1.5 ELSE 0 END
)
LIMIT 50
"""
),
@ -286,6 +341,8 @@ def _fetch_analogs(
"area_min": area * (1 - area_tolerance),
"area_max": area * (1 + area_tolerance),
"fresh_days": LISTINGS_FRESH_DAYS,
"target_year": year_built,
"target_house_type": house_type,
},
).mappings().all()