diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index fd013f0c..d22f157e 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -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, + ) diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index 614383cd..8a600752 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -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 diff --git a/tradein-mvp/backend/data/sql/067_v_street_sales_vs_listings.sql b/tradein-mvp/backend/data/sql/067_v_street_sales_vs_listings.sql new file mode 100644 index 00000000..8c2f48c4 --- /dev/null +++ b/tradein-mvp/backend/data/sql/067_v_street_sales_vs_listings.sql @@ -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; diff --git a/tradein-mvp/backend/tests/test_sales_vs_listings.py b/tradein-mvp/backend/tests/test_sales_vs_listings.py new file mode 100644 index 00000000..adc1abde --- /dev/null +++ b/tradein-mvp/backend/tests/test_sales_vs_listings.py @@ -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