feat(tradein): house-match аналогов + поправка на ремонт (#6,#7)
#6: аналоги сортируются по relevance — расстояние + близость года постройки + совпадение типа дома (соразмерные квартиры из встречи Птицы). #7: медиана корректируется коэффициентом по состоянию ремонта (требует ремонта 0.92 ... евроремонт 1.08).
This commit is contained in:
parent
8383ec0ed2
commit
a7d010173f
1 changed files with 59 additions and 2 deletions
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue