This commit is contained in:
parent
1d8cfb967f
commit
39da0f0644
4 changed files with 828 additions and 0 deletions
|
|
@ -27,6 +27,8 @@ from app.schemas.trade_in import (
|
||||||
PlacementHistoryEntry,
|
PlacementHistoryEntry,
|
||||||
PriceHistoryYearPoint,
|
PriceHistoryYearPoint,
|
||||||
RecentSoldEntry,
|
RecentSoldEntry,
|
||||||
|
SalesListingPair,
|
||||||
|
SalesVsListingsResponse,
|
||||||
SellTimeBucket,
|
SellTimeBucket,
|
||||||
SellTimeSensitivityResponse,
|
SellTimeSensitivityResponse,
|
||||||
StreetDealsResponse,
|
StreetDealsResponse,
|
||||||
|
|
@ -1184,3 +1186,149 @@ def get_street_deals(
|
||||||
range_high_rub=range_high_rub,
|
range_high_rub=range_high_rub,
|
||||||
deals=top10,
|
deals=top10,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Sales vs Listings (PR K — Foundation Phase 1 of issue #564) ──────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sales-vs-listings", response_model=SalesVsListingsResponse)
|
||||||
|
def get_sales_vs_listings(
|
||||||
|
address: str,
|
||||||
|
area_m2: float,
|
||||||
|
rooms: int,
|
||||||
|
db: Annotated[Session, Depends(get_db)] = None, # type: ignore[assignment]
|
||||||
|
window_days: int = 180,
|
||||||
|
area_tolerance: float = 0.15,
|
||||||
|
period_months: int = 24,
|
||||||
|
) -> SalesVsListingsResponse:
|
||||||
|
"""Pairs (ДКП-сделка, listing) для улицы целевого адреса (PR K / #564).
|
||||||
|
|
||||||
|
Для каждой ДКП-сделки Росреестра в окне `period_months` пытаемся найти
|
||||||
|
matching listing на той же улице с такими же rooms / близкой area_m2 /
|
||||||
|
listing_date в окне [deal_date - window_days, deal_date + 30d grace].
|
||||||
|
|
||||||
|
Возвращаем LEFT JOIN: сделки без listing match сохраняются (listing_* = None),
|
||||||
|
чтобы вычислить linkage_rate.
|
||||||
|
|
||||||
|
discount_pct = (deal_price - listing_price) / listing_price * 100.
|
||||||
|
Отрицательный = продали дешевле asking → reasoned discount от торга.
|
||||||
|
|
||||||
|
Per-street view: Росреестр open dataset агрегирует адреса до улицы.
|
||||||
|
"""
|
||||||
|
from app.services.estimator import _percentile, extract_street_name
|
||||||
|
|
||||||
|
def _empty(reason_street: str | None = None) -> SalesVsListingsResponse:
|
||||||
|
return SalesVsListingsResponse(
|
||||||
|
street=reason_street,
|
||||||
|
period_months=period_months,
|
||||||
|
window_days=window_days,
|
||||||
|
area_tolerance=area_tolerance,
|
||||||
|
total_deals=0,
|
||||||
|
deals_with_listings=0,
|
||||||
|
linkage_rate_pct=0.0,
|
||||||
|
median_discount_pct=None,
|
||||||
|
pairs=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
street_name = extract_street_name(address)
|
||||||
|
if not street_name:
|
||||||
|
logger.warning("sales-vs-listings: cannot extract street from %r", address)
|
||||||
|
return _empty()
|
||||||
|
|
||||||
|
rows = db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
deal_id, deal_date, deal_price_rub, deal_price_per_m2,
|
||||||
|
deal_area_m2, deal_rooms, deal_floor, deal_address,
|
||||||
|
listing_id, listing_source, listing_source_url,
|
||||||
|
listing_date, listing_price_rub, listing_price_per_m2,
|
||||||
|
listing_area_m2, days_listing_to_deal, discount_pct
|
||||||
|
FROM street_sales_vs_listings(
|
||||||
|
CAST(:street_pattern AS text),
|
||||||
|
CAST(:area_m2 AS numeric),
|
||||||
|
CAST(:rooms AS integer),
|
||||||
|
CAST(:window_days AS integer),
|
||||||
|
CAST(:area_tolerance AS numeric),
|
||||||
|
CAST(:period_months AS integer)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"street_pattern": "%" + street_name + "%",
|
||||||
|
"area_m2": area_m2,
|
||||||
|
"rooms": rooms,
|
||||||
|
"window_days": window_days,
|
||||||
|
"area_tolerance": area_tolerance,
|
||||||
|
"period_months": period_months,
|
||||||
|
},
|
||||||
|
).mappings().all()
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
logger.info(
|
||||||
|
"sales-vs-listings: no deals street=%r rooms=%d area=%.1f period_months=%d",
|
||||||
|
street_name, rooms, area_m2, period_months,
|
||||||
|
)
|
||||||
|
return _empty(reason_street=street_name)
|
||||||
|
|
||||||
|
pairs = [
|
||||||
|
SalesListingPair(
|
||||||
|
deal_id=r["deal_id"],
|
||||||
|
deal_date=r["deal_date"],
|
||||||
|
deal_price_rub=int(r["deal_price_rub"]),
|
||||||
|
deal_price_per_m2=int(r["deal_price_per_m2"] or 0),
|
||||||
|
deal_area_m2=float(r["deal_area_m2"]),
|
||||||
|
deal_rooms=int(r["deal_rooms"]),
|
||||||
|
deal_floor=r["deal_floor"],
|
||||||
|
deal_address=r["deal_address"],
|
||||||
|
listing_id=r["listing_id"],
|
||||||
|
listing_source=r["listing_source"],
|
||||||
|
listing_source_url=r["listing_source_url"],
|
||||||
|
listing_date=r["listing_date"],
|
||||||
|
listing_price_rub=(
|
||||||
|
int(r["listing_price_rub"]) if r["listing_price_rub"] is not None else None
|
||||||
|
),
|
||||||
|
listing_price_per_m2=(
|
||||||
|
int(r["listing_price_per_m2"])
|
||||||
|
if r["listing_price_per_m2"] is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
listing_area_m2=(
|
||||||
|
float(r["listing_area_m2"]) if r["listing_area_m2"] is not None else None
|
||||||
|
),
|
||||||
|
days_listing_to_deal=r["days_listing_to_deal"],
|
||||||
|
discount_pct=(
|
||||||
|
float(r["discount_pct"]) if r["discount_pct"] is not None else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
total_deals = len(pairs)
|
||||||
|
deals_with_listings = sum(1 for p in pairs if p.listing_id is not None)
|
||||||
|
linkage_rate_pct = (
|
||||||
|
round(deals_with_listings / total_deals * 100, 1) if total_deals else 0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
discounts = sorted(p.discount_pct for p in pairs if p.discount_pct is not None)
|
||||||
|
median_discount = (
|
||||||
|
round(_percentile(discounts, 0.5), 2) if discounts else None
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"sales-vs-listings: street=%r deals=%d with_listings=%d linkage=%.1f%% median_disc=%s",
|
||||||
|
street_name, total_deals, deals_with_listings, linkage_rate_pct,
|
||||||
|
f"{median_discount:+.2f}%" if median_discount is not None else "n/a",
|
||||||
|
)
|
||||||
|
|
||||||
|
return SalesVsListingsResponse(
|
||||||
|
street=street_name,
|
||||||
|
period_months=period_months,
|
||||||
|
window_days=window_days,
|
||||||
|
area_tolerance=area_tolerance,
|
||||||
|
total_deals=total_deals,
|
||||||
|
deals_with_listings=deals_with_listings,
|
||||||
|
linkage_rate_pct=linkage_rate_pct,
|
||||||
|
median_discount_pct=median_discount,
|
||||||
|
pairs=pairs,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -308,3 +308,56 @@ class StreetDealsResponse(BaseModel):
|
||||||
range_low_rub: int
|
range_low_rub: int
|
||||||
range_high_rub: int
|
range_high_rub: int
|
||||||
deals: list[AnalogLot] # последние 10 по deal_date DESC
|
deals: list[AnalogLot] # последние 10 по deal_date DESC
|
||||||
|
|
||||||
|
|
||||||
|
# ── Sales vs Listings (PR K, issue #564 Foundation Phase 1) ─────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class SalesListingPair(BaseModel):
|
||||||
|
"""Пара (ДКП-сделка, listing того же ассортимента).
|
||||||
|
|
||||||
|
Возвращается из street_sales_vs_listings() SQL-function. Если для сделки
|
||||||
|
listing не нашёлся — все listing_* поля = None (LEFT JOIN).
|
||||||
|
"""
|
||||||
|
|
||||||
|
deal_id: int
|
||||||
|
deal_date: date
|
||||||
|
deal_price_rub: int
|
||||||
|
deal_price_per_m2: int
|
||||||
|
deal_area_m2: float
|
||||||
|
deal_rooms: int
|
||||||
|
deal_floor: int | None = None
|
||||||
|
deal_address: str | None = None
|
||||||
|
|
||||||
|
listing_id: int | None = None
|
||||||
|
listing_source: str | None = None # 'avito' / 'cian' / 'yandex' / 'n1' / 'domklik'
|
||||||
|
listing_source_url: str | None = None
|
||||||
|
listing_date: date | None = None
|
||||||
|
listing_price_rub: int | None = None
|
||||||
|
listing_price_per_m2: int | None = None
|
||||||
|
listing_area_m2: float | None = None
|
||||||
|
|
||||||
|
# Положительный = listing date раньше сделки (типичный кейс).
|
||||||
|
# Отрицательный = listing появился позже (отложенный парсинг).
|
||||||
|
days_listing_to_deal: int | None = None
|
||||||
|
# discount_pct = (deal_price - listing_price) / listing_price * 100.
|
||||||
|
# Отрицательный = продали дешевле выставленного (торг).
|
||||||
|
discount_pct: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SalesVsListingsResponse(BaseModel):
|
||||||
|
"""Ответ GET /api/v1/trade-in/sales-vs-listings.
|
||||||
|
|
||||||
|
Per-street pairs ДКП-сделок и matching listings. Aggregate KPIs показывают
|
||||||
|
linkage rate и медианный discount.
|
||||||
|
"""
|
||||||
|
|
||||||
|
street: str | None # извлечённое имя улицы, None если не извлеклось
|
||||||
|
period_months: int # окно поиска сделок
|
||||||
|
window_days: int # окно matching listing → deal
|
||||||
|
area_tolerance: float # 0.15 = ±15% по area_m2
|
||||||
|
total_deals: int # количество всех matching ДКП в улице/период
|
||||||
|
deals_with_listings: int # сколько имеют связанный listing
|
||||||
|
linkage_rate_pct: float # deals_with_listings / total_deals * 100
|
||||||
|
median_discount_pct: float | None # медиана по парам с listing
|
||||||
|
pairs: list[SalesListingPair] # все пары, sorted by deal_date DESC
|
||||||
|
|
|
||||||
224
tradein-mvp/backend/data/sql/067_v_street_sales_vs_listings.sql
Normal file
224
tradein-mvp/backend/data/sql/067_v_street_sales_vs_listings.sql
Normal file
|
|
@ -0,0 +1,224 @@
|
||||||
|
-- 067_v_street_sales_vs_listings.sql
|
||||||
|
-- Purpose: PR K (Foundation Phase 1 of issue #564) — pairs ДКП-сделок (deals,
|
||||||
|
-- source='rosreestr') с listings (avito/cian/yandex/n1/domklik) для
|
||||||
|
-- comparison sales-vs-asks per-street.
|
||||||
|
--
|
||||||
|
-- Open dataset Росреестра агрегирует адреса до уровня улицы (без номера дома).
|
||||||
|
-- Поэтому matching работает per-street + area/rooms — а не per-house.
|
||||||
|
--
|
||||||
|
-- JOIN logic (encapsulated в view):
|
||||||
|
-- 1. address ILIKE '%street_pattern%' — фильтрация per-street происходит в
|
||||||
|
-- endpoint (extract_street_name → pattern → передаётся обоим JOIN-sides).
|
||||||
|
-- 2. Area: deals.area_m2 BETWEEN listings.area_m2 * (1 - tolerance)
|
||||||
|
-- AND listings.area_m2 * (1 + tolerance).
|
||||||
|
-- 3. Listing date: listings.listing_date BETWEEN
|
||||||
|
-- (deals.deal_date - window_days) AND deals.deal_date.
|
||||||
|
-- Допускаем listing немного после сделки (отложенный закрепляющий парсинг).
|
||||||
|
-- 4. Rooms: exact match.
|
||||||
|
--
|
||||||
|
-- Result row: (deal_id, deal_date, deal_price, deal_area_m2, listing_id,
|
||||||
|
-- listing_source, listing_url, listing_date, listing_price,
|
||||||
|
-- listing_price_per_m2, days_listing_to_deal, discount_pct).
|
||||||
|
--
|
||||||
|
-- discount_pct = (deal_price - listing_price) / listing_price * 100
|
||||||
|
-- Положительный = сделка дороже asking → 'переплата' (раритет, чаще ошибка).
|
||||||
|
-- Отрицательный = торг (продали дешевле выставленного).
|
||||||
|
--
|
||||||
|
-- Реализация — table-valued function street_sales_vs_listings(...). View поверх
|
||||||
|
-- всех data слишком тяжёлый: deals × listings cross-product взорвёт RAM.
|
||||||
|
-- Function позволяет filter early по улице, area, rooms, window_days и
|
||||||
|
-- использовать существующие индексы (deals_rooms_area_idx, listings_rooms_area_idx).
|
||||||
|
--
|
||||||
|
-- Dependencies:
|
||||||
|
-- - deals (002_core_tables.sql) — source='rosreestr' filtered to ДКП в PR #549.
|
||||||
|
-- - listings (002_core_tables.sql) — все 4 источника объявлений.
|
||||||
|
--
|
||||||
|
-- Deploy order: после 066. Idempotent (CREATE OR REPLACE FUNCTION).
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION street_sales_vs_listings(
|
||||||
|
p_street_pattern text,
|
||||||
|
p_area_m2 numeric,
|
||||||
|
p_rooms integer,
|
||||||
|
p_window_days integer DEFAULT 180,
|
||||||
|
p_area_tolerance numeric DEFAULT 0.15,
|
||||||
|
p_period_months integer DEFAULT 24
|
||||||
|
)
|
||||||
|
RETURNS TABLE (
|
||||||
|
deal_id bigint,
|
||||||
|
deal_date date,
|
||||||
|
deal_price_rub bigint,
|
||||||
|
deal_price_per_m2 integer,
|
||||||
|
deal_area_m2 numeric,
|
||||||
|
deal_rooms integer,
|
||||||
|
deal_floor integer,
|
||||||
|
deal_address text,
|
||||||
|
listing_id bigint,
|
||||||
|
listing_source text,
|
||||||
|
listing_source_url text,
|
||||||
|
listing_date date,
|
||||||
|
listing_price_rub bigint,
|
||||||
|
listing_price_per_m2 integer,
|
||||||
|
listing_area_m2 numeric,
|
||||||
|
days_listing_to_deal integer,
|
||||||
|
discount_pct numeric
|
||||||
|
)
|
||||||
|
LANGUAGE sql
|
||||||
|
STABLE
|
||||||
|
AS $$
|
||||||
|
WITH window_deals AS (
|
||||||
|
-- Сделки в улице + период. Фильтр по rooms + area.
|
||||||
|
SELECT
|
||||||
|
d.id AS deal_id,
|
||||||
|
d.deal_date AS deal_date,
|
||||||
|
d.price_rub AS deal_price_rub,
|
||||||
|
d.price_per_m2 AS deal_price_per_m2,
|
||||||
|
d.area_m2 AS deal_area_m2,
|
||||||
|
d.rooms AS deal_rooms,
|
||||||
|
d.floor AS deal_floor,
|
||||||
|
d.address AS deal_address
|
||||||
|
FROM deals d
|
||||||
|
WHERE d.source = 'rosreestr'
|
||||||
|
AND d.address ILIKE p_street_pattern
|
||||||
|
AND d.rooms = p_rooms
|
||||||
|
AND d.area_m2 BETWEEN p_area_m2 * (1.0 - p_area_tolerance)
|
||||||
|
AND p_area_m2 * (1.0 + p_area_tolerance)
|
||||||
|
AND d.deal_date > NOW() - (p_period_months || ' months')::interval
|
||||||
|
AND d.price_rub > 0
|
||||||
|
),
|
||||||
|
window_listings AS (
|
||||||
|
-- Кандидаты-listings на той же улице, rooms exact, area ±tolerance.
|
||||||
|
SELECT
|
||||||
|
l.id AS listing_id,
|
||||||
|
l.source AS listing_source,
|
||||||
|
l.source_url AS listing_source_url,
|
||||||
|
l.listing_date AS listing_date,
|
||||||
|
l.price_rub AS listing_price_rub,
|
||||||
|
l.price_per_m2 AS listing_price_per_m2,
|
||||||
|
l.area_m2 AS listing_area_m2,
|
||||||
|
l.rooms AS listing_rooms,
|
||||||
|
COALESCE(l.listing_date, l.scraped_at::date) AS listing_event_date
|
||||||
|
FROM listings l
|
||||||
|
WHERE l.address ILIKE p_street_pattern
|
||||||
|
AND l.rooms = p_rooms
|
||||||
|
AND l.area_m2 BETWEEN p_area_m2 * (1.0 - p_area_tolerance)
|
||||||
|
AND p_area_m2 * (1.0 + p_area_tolerance)
|
||||||
|
AND l.price_rub > 0
|
||||||
|
AND COALESCE(l.listing_date, l.scraped_at::date)
|
||||||
|
> NOW() - ((p_period_months + 6) || ' months')::interval
|
||||||
|
),
|
||||||
|
paired AS (
|
||||||
|
-- LEFT JOIN: сохраняем все сделки даже если нет listing match.
|
||||||
|
-- Для каждой сделки выбираем listing с listing_date ближайший
|
||||||
|
-- к deal_date (предпочтительно перед сделкой).
|
||||||
|
SELECT DISTINCT ON (wd.deal_id)
|
||||||
|
wd.deal_id,
|
||||||
|
wd.deal_date,
|
||||||
|
wd.deal_price_rub,
|
||||||
|
wd.deal_price_per_m2,
|
||||||
|
wd.deal_area_m2,
|
||||||
|
wd.deal_rooms,
|
||||||
|
wd.deal_floor,
|
||||||
|
wd.deal_address,
|
||||||
|
wl.listing_id,
|
||||||
|
wl.listing_source,
|
||||||
|
wl.listing_source_url,
|
||||||
|
wl.listing_date,
|
||||||
|
wl.listing_price_rub,
|
||||||
|
wl.listing_price_per_m2,
|
||||||
|
wl.listing_area_m2,
|
||||||
|
(wd.deal_date - wl.listing_event_date)::integer AS days_listing_to_deal,
|
||||||
|
CASE
|
||||||
|
WHEN wl.listing_price_rub IS NOT NULL AND wl.listing_price_rub > 0
|
||||||
|
THEN ROUND(
|
||||||
|
(wd.deal_price_rub - wl.listing_price_rub)::numeric
|
||||||
|
/ wl.listing_price_rub * 100,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
ELSE NULL
|
||||||
|
END AS discount_pct
|
||||||
|
FROM window_deals wd
|
||||||
|
LEFT JOIN window_listings wl
|
||||||
|
ON wl.listing_event_date
|
||||||
|
BETWEEN (wd.deal_date - (p_window_days || ' days')::interval)::date
|
||||||
|
AND (wd.deal_date + interval '30 days')::date
|
||||||
|
ORDER BY
|
||||||
|
wd.deal_id,
|
||||||
|
-- prefer listing event дата перед сделкой и ближе к ней
|
||||||
|
CASE WHEN wl.listing_event_date IS NULL THEN 1 ELSE 0 END,
|
||||||
|
CASE WHEN wl.listing_event_date <= wd.deal_date THEN 0 ELSE 1 END,
|
||||||
|
ABS((wd.deal_date - wl.listing_event_date))
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
deal_id,
|
||||||
|
deal_date,
|
||||||
|
deal_price_rub,
|
||||||
|
deal_price_per_m2,
|
||||||
|
deal_area_m2,
|
||||||
|
deal_rooms,
|
||||||
|
deal_floor,
|
||||||
|
deal_address,
|
||||||
|
listing_id,
|
||||||
|
listing_source,
|
||||||
|
listing_source_url,
|
||||||
|
listing_date,
|
||||||
|
listing_price_rub,
|
||||||
|
listing_price_per_m2,
|
||||||
|
listing_area_m2,
|
||||||
|
days_listing_to_deal,
|
||||||
|
discount_pct
|
||||||
|
FROM paired
|
||||||
|
ORDER BY deal_date DESC;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION street_sales_vs_listings(text, numeric, integer, integer, numeric, integer) IS
|
||||||
|
'Pairs (ДКП-сделка, listing) для улицы. PR K / issue #564 Foundation Phase 1. '
|
||||||
|
'Per-street matching: address ILIKE, area ±tolerance, rooms exact, window_days '
|
||||||
|
'до даты сделки (+30д grace). Возвращает LEFT JOIN — сделки без listing match '
|
||||||
|
'имеют listing_* = NULL. discount_pct = (deal - listing) / listing * 100.';
|
||||||
|
|
||||||
|
-- ─────────────────────────────────────────────────────────────────────
|
||||||
|
-- v_street_sales_vs_listings — convenience view (без параметров)
|
||||||
|
-- Используется для prod-аудита linkage_rate. НЕ для hot API path —
|
||||||
|
-- view сканирует все deals × listings, поэтому только для analytics.
|
||||||
|
-- ─────────────────────────────────────────────────────────────────────
|
||||||
|
CREATE OR REPLACE VIEW v_street_sales_vs_listings AS
|
||||||
|
SELECT
|
||||||
|
d.id AS deal_id,
|
||||||
|
d.deal_date,
|
||||||
|
d.price_rub AS deal_price_rub,
|
||||||
|
d.price_per_m2 AS deal_price_per_m2,
|
||||||
|
d.area_m2 AS deal_area_m2,
|
||||||
|
d.rooms AS deal_rooms,
|
||||||
|
d.address AS deal_address,
|
||||||
|
l.id AS listing_id,
|
||||||
|
l.source AS listing_source,
|
||||||
|
l.source_url AS listing_source_url,
|
||||||
|
l.listing_date,
|
||||||
|
l.price_rub AS listing_price_rub,
|
||||||
|
l.price_per_m2 AS listing_price_per_m2,
|
||||||
|
l.area_m2 AS listing_area_m2,
|
||||||
|
(d.deal_date - COALESCE(l.listing_date, l.scraped_at::date))::integer
|
||||||
|
AS days_listing_to_deal,
|
||||||
|
CASE
|
||||||
|
WHEN l.price_rub IS NOT NULL AND l.price_rub > 0
|
||||||
|
THEN ROUND((d.price_rub - l.price_rub)::numeric / l.price_rub * 100, 2)
|
||||||
|
ELSE NULL
|
||||||
|
END AS discount_pct
|
||||||
|
FROM deals d
|
||||||
|
LEFT JOIN listings l
|
||||||
|
ON l.rooms = d.rooms
|
||||||
|
AND l.area_m2 BETWEEN d.area_m2 * 0.85 AND d.area_m2 * 1.15
|
||||||
|
AND COALESCE(l.listing_date, l.scraped_at::date)
|
||||||
|
BETWEEN (d.deal_date - interval '180 days')::date
|
||||||
|
AND (d.deal_date + interval '30 days')::date
|
||||||
|
AND l.price_rub > 0
|
||||||
|
WHERE d.source = 'rosreestr'
|
||||||
|
AND d.price_rub > 0;
|
||||||
|
|
||||||
|
COMMENT ON VIEW v_street_sales_vs_listings IS
|
||||||
|
'Analytical view над deals × listings для prod linkage_rate audit. PR K / #564. '
|
||||||
|
'НЕ для hot API path — слишком тяжёлый. Используется только для ad-hoc query.';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
403
tradein-mvp/backend/tests/test_sales_vs_listings.py
Normal file
403
tradein-mvp/backend/tests/test_sales_vs_listings.py
Normal file
|
|
@ -0,0 +1,403 @@
|
||||||
|
"""Tests for GET /api/v1/trade-in/sales-vs-listings endpoint (PR K, issue #564).
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- Happy path: deal + matching listing → pair with discount_pct.
|
||||||
|
- Empty result: extracted street, no DB rows.
|
||||||
|
- Multiple deals: ordered by deal_date DESC.
|
||||||
|
- LEFT JOIN semantics: deal без listing match → listing_* поля = None.
|
||||||
|
- linkage_rate_pct computation.
|
||||||
|
- median_discount_pct on subset с listing_id != None.
|
||||||
|
- extract_street_name failure → returns empty response with street=None.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
# psycopg v3 driver (psycopg2 not installed)
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
# WeasyPrint requires GTK — not present in CI/Windows. Stub before any app import.
|
||||||
|
_wp_mock = MagicMock()
|
||||||
|
sys.modules.setdefault("weasyprint", _wp_mock)
|
||||||
|
sys.modules.setdefault("weasyprint.CSS", _wp_mock)
|
||||||
|
sys.modules.setdefault("weasyprint.HTML", _wp_mock)
|
||||||
|
|
||||||
|
from datetime import date # noqa: E402
|
||||||
|
|
||||||
|
import pytest # noqa: E402
|
||||||
|
from fastapi import FastAPI # noqa: E402
|
||||||
|
from fastapi.testclient import TestClient # noqa: E402
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def trade_in_app() -> FastAPI:
|
||||||
|
"""Minimal FastAPI app mounting only the trade-in router with DB overridden."""
|
||||||
|
from app.api.v1 import trade_in as trade_in_module
|
||||||
|
from app.core.db import get_db
|
||||||
|
|
||||||
|
application = FastAPI()
|
||||||
|
application.include_router(trade_in_module.router, prefix="/api/v1/trade-in")
|
||||||
|
|
||||||
|
def _override_db():
|
||||||
|
yield MagicMock()
|
||||||
|
|
||||||
|
application.dependency_overrides[get_db] = _override_db
|
||||||
|
return application
|
||||||
|
|
||||||
|
|
||||||
|
def _make_db_mock(rows) -> MagicMock:
|
||||||
|
"""DB session mock returning *rows* from .mappings().all()."""
|
||||||
|
db = MagicMock()
|
||||||
|
mapping_result = MagicMock()
|
||||||
|
mapping_result.all.return_value = rows
|
||||||
|
execute_result = MagicMock()
|
||||||
|
execute_result.mappings.return_value = mapping_result
|
||||||
|
db.execute.return_value = execute_result
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
def _make_pair_row(
|
||||||
|
*,
|
||||||
|
deal_id: int = 100,
|
||||||
|
deal_date: date | None = None,
|
||||||
|
deal_price_rub: int = 5_000_000,
|
||||||
|
deal_price_per_m2: int = 100_000,
|
||||||
|
deal_area_m2: float = 50.0,
|
||||||
|
deal_rooms: int = 2,
|
||||||
|
deal_floor: int | None = 5,
|
||||||
|
deal_address: str = "г. Екатеринбург, ул. Малышева",
|
||||||
|
listing_id: int | None = 200,
|
||||||
|
listing_source: str | None = "avito",
|
||||||
|
listing_source_url: str | None = "https://avito.ru/spb/123",
|
||||||
|
listing_date: date | None = None,
|
||||||
|
listing_price_rub: int | None = 5_200_000,
|
||||||
|
listing_price_per_m2: int | None = 104_000,
|
||||||
|
listing_area_m2: float | None = 50.0,
|
||||||
|
days_listing_to_deal: int | None = 45,
|
||||||
|
discount_pct: float | None = -3.85,
|
||||||
|
) -> dict:
|
||||||
|
"""Mock row из SQL-function street_sales_vs_listings()."""
|
||||||
|
return {
|
||||||
|
"deal_id": deal_id,
|
||||||
|
"deal_date": deal_date or date(2025, 6, 1),
|
||||||
|
"deal_price_rub": deal_price_rub,
|
||||||
|
"deal_price_per_m2": deal_price_per_m2,
|
||||||
|
"deal_area_m2": deal_area_m2,
|
||||||
|
"deal_rooms": deal_rooms,
|
||||||
|
"deal_floor": deal_floor,
|
||||||
|
"deal_address": deal_address,
|
||||||
|
"listing_id": listing_id,
|
||||||
|
"listing_source": listing_source,
|
||||||
|
"listing_source_url": listing_source_url,
|
||||||
|
"listing_date": listing_date,
|
||||||
|
"listing_price_rub": listing_price_rub,
|
||||||
|
"listing_price_per_m2": listing_price_per_m2,
|
||||||
|
"listing_area_m2": listing_area_m2,
|
||||||
|
"days_listing_to_deal": days_listing_to_deal,
|
||||||
|
"discount_pct": discount_pct,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _override_db(trade_in_app: FastAPI, db_mock: MagicMock) -> None:
|
||||||
|
from app.core.db import get_db
|
||||||
|
|
||||||
|
def _override():
|
||||||
|
yield db_mock
|
||||||
|
|
||||||
|
trade_in_app.dependency_overrides[get_db] = _override
|
||||||
|
|
||||||
|
|
||||||
|
# ── Test: street extraction failure → empty response ─────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_sales_vs_listings_returns_empty_when_no_street(trade_in_app: FastAPI) -> None:
|
||||||
|
"""Address без recognisable улицы → street=None, total_deals=0, pairs=[]."""
|
||||||
|
db_mock = _make_db_mock([])
|
||||||
|
_override_db(trade_in_app, db_mock)
|
||||||
|
|
||||||
|
client = TestClient(trade_in_app)
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/trade-in/sales-vs-listings",
|
||||||
|
params={"address": "Industrial zone X", "area_m2": 50.0, "rooms": 2},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["street"] is None
|
||||||
|
assert data["total_deals"] == 0
|
||||||
|
assert data["deals_with_listings"] == 0
|
||||||
|
assert data["linkage_rate_pct"] == 0.0
|
||||||
|
assert data["pairs"] == []
|
||||||
|
# DB не должна быть вызвана — early return при пустой улице
|
||||||
|
db_mock.execute.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Test: empty DB result for valid street ───────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_sales_vs_listings_no_matches(trade_in_app: FastAPI) -> None:
|
||||||
|
"""Valid street, но DB вернул 0 строк → пустой ответ с известной street."""
|
||||||
|
db_mock = _make_db_mock([])
|
||||||
|
_override_db(trade_in_app, db_mock)
|
||||||
|
|
||||||
|
client = TestClient(trade_in_app)
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/trade-in/sales-vs-listings",
|
||||||
|
params={
|
||||||
|
"address": "г. Екатеринбург, ул. Космонавтов, 50",
|
||||||
|
"area_m2": 50.0,
|
||||||
|
"rooms": 2,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["street"] == "Космонавтов"
|
||||||
|
assert data["total_deals"] == 0
|
||||||
|
assert data["deals_with_listings"] == 0
|
||||||
|
assert data["linkage_rate_pct"] == 0.0
|
||||||
|
assert data["median_discount_pct"] is None
|
||||||
|
assert data["pairs"] == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── Test: happy path — one deal + one matching listing ───────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_sales_vs_listings_happy_path(trade_in_app: FastAPI) -> None:
|
||||||
|
"""Single (deal, listing) пара возвращается с правильным discount_pct."""
|
||||||
|
fixture_rows = [
|
||||||
|
_make_pair_row(
|
||||||
|
deal_id=1001,
|
||||||
|
deal_date=date(2025, 8, 15),
|
||||||
|
deal_price_rub=4_900_000,
|
||||||
|
deal_area_m2=50.0,
|
||||||
|
listing_id=2001,
|
||||||
|
listing_source="avito",
|
||||||
|
listing_date=date(2025, 6, 30),
|
||||||
|
listing_price_rub=5_200_000,
|
||||||
|
days_listing_to_deal=46,
|
||||||
|
discount_pct=-5.77,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
db_mock = _make_db_mock(fixture_rows)
|
||||||
|
_override_db(trade_in_app, db_mock)
|
||||||
|
|
||||||
|
client = TestClient(trade_in_app)
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/trade-in/sales-vs-listings",
|
||||||
|
params={
|
||||||
|
"address": "г. Екатеринбург, ул. Малышева, 125",
|
||||||
|
"area_m2": 50.0,
|
||||||
|
"rooms": 2,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["street"] == "Малышева"
|
||||||
|
assert data["total_deals"] == 1
|
||||||
|
assert data["deals_with_listings"] == 1
|
||||||
|
assert data["linkage_rate_pct"] == 100.0
|
||||||
|
assert data["median_discount_pct"] == -5.77
|
||||||
|
assert len(data["pairs"]) == 1
|
||||||
|
pair = data["pairs"][0]
|
||||||
|
assert pair["deal_id"] == 1001
|
||||||
|
assert pair["listing_id"] == 2001
|
||||||
|
assert pair["listing_source"] == "avito"
|
||||||
|
assert pair["days_listing_to_deal"] == 46
|
||||||
|
assert pair["discount_pct"] == -5.77
|
||||||
|
|
||||||
|
|
||||||
|
# ── Test: LEFT JOIN — deal без listing match ─────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_sales_vs_listings_left_join_no_listing(trade_in_app: FastAPI) -> None:
|
||||||
|
"""Сделка без matching listing → listing_* поля None, linkage_rate counted."""
|
||||||
|
fixture_rows = [
|
||||||
|
_make_pair_row(
|
||||||
|
deal_id=1001,
|
||||||
|
listing_id=2001,
|
||||||
|
listing_source="avito",
|
||||||
|
discount_pct=-5.0,
|
||||||
|
),
|
||||||
|
_make_pair_row(
|
||||||
|
deal_id=1002,
|
||||||
|
deal_date=date(2025, 7, 1),
|
||||||
|
listing_id=None,
|
||||||
|
listing_source=None,
|
||||||
|
listing_source_url=None,
|
||||||
|
listing_date=None,
|
||||||
|
listing_price_rub=None,
|
||||||
|
listing_price_per_m2=None,
|
||||||
|
listing_area_m2=None,
|
||||||
|
days_listing_to_deal=None,
|
||||||
|
discount_pct=None,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
db_mock = _make_db_mock(fixture_rows)
|
||||||
|
_override_db(trade_in_app, db_mock)
|
||||||
|
|
||||||
|
client = TestClient(trade_in_app)
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/trade-in/sales-vs-listings",
|
||||||
|
params={
|
||||||
|
"address": "г. Екатеринбург, ул. Малышева, 125",
|
||||||
|
"area_m2": 50.0,
|
||||||
|
"rooms": 2,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["total_deals"] == 2
|
||||||
|
assert data["deals_with_listings"] == 1
|
||||||
|
assert data["linkage_rate_pct"] == 50.0
|
||||||
|
# median считается только по парам с discount_pct
|
||||||
|
assert data["median_discount_pct"] == -5.0
|
||||||
|
# Pair without listing
|
||||||
|
pair_no_listing = next(p for p in data["pairs"] if p["deal_id"] == 1002)
|
||||||
|
assert pair_no_listing["listing_id"] is None
|
||||||
|
assert pair_no_listing["listing_source"] is None
|
||||||
|
assert pair_no_listing["listing_price_rub"] is None
|
||||||
|
assert pair_no_listing["discount_pct"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Test: median discount with multiple pairs ────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_sales_vs_listings_median_discount(trade_in_app: FastAPI) -> None:
|
||||||
|
"""Median считается через _percentile(0.5) только по парам c discount_pct."""
|
||||||
|
# Discounts: [-10, -5, 0, 3, 7] → median = 0
|
||||||
|
fixture_rows = [
|
||||||
|
_make_pair_row(deal_id=1, listing_id=11, discount_pct=-10.0),
|
||||||
|
_make_pair_row(deal_id=2, listing_id=12, discount_pct=-5.0),
|
||||||
|
_make_pair_row(deal_id=3, listing_id=13, discount_pct=0.0),
|
||||||
|
_make_pair_row(deal_id=4, listing_id=14, discount_pct=3.0),
|
||||||
|
_make_pair_row(deal_id=5, listing_id=15, discount_pct=7.0),
|
||||||
|
# Сделка без listing — не учитывается в median.
|
||||||
|
_make_pair_row(
|
||||||
|
deal_id=6,
|
||||||
|
listing_id=None,
|
||||||
|
listing_price_rub=None,
|
||||||
|
discount_pct=None,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
db_mock = _make_db_mock(fixture_rows)
|
||||||
|
_override_db(trade_in_app, db_mock)
|
||||||
|
|
||||||
|
client = TestClient(trade_in_app)
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/trade-in/sales-vs-listings",
|
||||||
|
params={
|
||||||
|
"address": "г. Екатеринбург, ул. Космонавтов, 50",
|
||||||
|
"area_m2": 50.0,
|
||||||
|
"rooms": 2,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["total_deals"] == 6
|
||||||
|
assert data["deals_with_listings"] == 5
|
||||||
|
assert round(data["linkage_rate_pct"], 1) == 83.3
|
||||||
|
assert data["median_discount_pct"] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ── Test: SQL function called with proper params ─────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_sales_vs_listings_passes_proper_params(trade_in_app: FastAPI) -> None:
|
||||||
|
"""Endpoint должен вызвать street_sales_vs_listings() c кастомными window_days,
|
||||||
|
area_tolerance, period_months."""
|
||||||
|
db_mock = _make_db_mock([])
|
||||||
|
_override_db(trade_in_app, db_mock)
|
||||||
|
|
||||||
|
client = TestClient(trade_in_app)
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/trade-in/sales-vs-listings",
|
||||||
|
params={
|
||||||
|
"address": "г. Екатеринбург, ул. Малышева, 125",
|
||||||
|
"area_m2": 65.5,
|
||||||
|
"rooms": 3,
|
||||||
|
"window_days": 90,
|
||||||
|
"area_tolerance": 0.05,
|
||||||
|
"period_months": 12,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
# Проверяем что DB вызвалась с правильными params
|
||||||
|
assert db_mock.execute.called
|
||||||
|
args, kwargs = db_mock.execute.call_args
|
||||||
|
params = args[1] if len(args) > 1 else kwargs.get("parameters", {})
|
||||||
|
assert params["street_pattern"] == "%Малышева%"
|
||||||
|
assert params["area_m2"] == 65.5
|
||||||
|
assert params["rooms"] == 3
|
||||||
|
assert params["window_days"] == 90
|
||||||
|
assert params["area_tolerance"] == 0.05
|
||||||
|
assert params["period_months"] == 12
|
||||||
|
|
||||||
|
|
||||||
|
# ── Test: response shape (Pydantic validation) ───────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_sales_vs_listings_response_shape(trade_in_app: FastAPI) -> None:
|
||||||
|
"""Все required fields присутствуют в JSON response."""
|
||||||
|
fixture_rows = [
|
||||||
|
_make_pair_row(
|
||||||
|
deal_id=1, listing_id=11, discount_pct=-3.0, listing_source="cian"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
db_mock = _make_db_mock(fixture_rows)
|
||||||
|
_override_db(trade_in_app, db_mock)
|
||||||
|
|
||||||
|
client = TestClient(trade_in_app)
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/trade-in/sales-vs-listings",
|
||||||
|
params={
|
||||||
|
"address": "г. Екатеринбург, ул. Космонавтов, 50",
|
||||||
|
"area_m2": 50.0,
|
||||||
|
"rooms": 2,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
# Top-level keys
|
||||||
|
expected_keys = {
|
||||||
|
"street", "period_months", "window_days", "area_tolerance",
|
||||||
|
"total_deals", "deals_with_listings", "linkage_rate_pct",
|
||||||
|
"median_discount_pct", "pairs",
|
||||||
|
}
|
||||||
|
assert expected_keys.issubset(data.keys())
|
||||||
|
# Pair keys
|
||||||
|
pair = data["pairs"][0]
|
||||||
|
pair_keys = {
|
||||||
|
"deal_id", "deal_date", "deal_price_rub", "deal_price_per_m2",
|
||||||
|
"deal_area_m2", "deal_rooms", "deal_floor", "deal_address",
|
||||||
|
"listing_id", "listing_source", "listing_source_url", "listing_date",
|
||||||
|
"listing_price_rub", "listing_price_per_m2", "listing_area_m2",
|
||||||
|
"days_listing_to_deal", "discount_pct",
|
||||||
|
}
|
||||||
|
assert pair_keys.issubset(pair.keys())
|
||||||
|
assert pair["listing_source"] == "cian"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Test: default param values ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_sales_vs_listings_defaults(trade_in_app: FastAPI) -> None:
|
||||||
|
"""window_days=180, area_tolerance=0.15, period_months=24 — defaults."""
|
||||||
|
db_mock = _make_db_mock([])
|
||||||
|
_override_db(trade_in_app, db_mock)
|
||||||
|
|
||||||
|
client = TestClient(trade_in_app)
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/trade-in/sales-vs-listings",
|
||||||
|
params={
|
||||||
|
"address": "г. Екатеринбург, ул. Малышева, 125",
|
||||||
|
"area_m2": 50.0,
|
||||||
|
"rooms": 2,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["window_days"] == 180
|
||||||
|
assert data["area_tolerance"] == 0.15
|
||||||
|
assert data["period_months"] == 24
|
||||||
Loading…
Add table
Reference in a new issue