feat(tradein): sales-vs-listings endpoint (PR K, foundation #564) #597

Merged
Light1YT merged 1 commit from feat/tradein-sales-vs-listings-endpoint into main 2026-05-27 11:55:45 +00:00
4 changed files with 828 additions and 0 deletions

View file

@ -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,
)

View file

@ -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

View 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;

View 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