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,
|
||||
PriceHistoryYearPoint,
|
||||
RecentSoldEntry,
|
||||
SalesListingPair,
|
||||
SalesVsListingsResponse,
|
||||
SellTimeBucket,
|
||||
SellTimeSensitivityResponse,
|
||||
StreetDealsResponse,
|
||||
|
|
@ -1184,3 +1186,149 @@ def get_street_deals(
|
|||
range_high_rub=range_high_rub,
|
||||
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_high_rub: int
|
||||
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