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
Owner

Summary

Foundation Phase 1 of #564 — backend для sales-vs-asks comparison: связь ДКП-сделок Росреестра с listings avito/cian/yandex/n1.

Implementation

DDL (migration 067):

  • street_sales_vs_listings(street, area, rooms, window_days, area_tol) — table-valued function для filtered query (использует indexes)
  • v_street_sales_vs_listings view — full join для analytics
  • JOIN logic: street ILIKE + area ±15% + listing_date в окне [deal_date - 180d, deal_date]
  • Computed: days_listing_to_deal, discount_pct = (deal - listing) / listing * 100

API GET /api/v1/sales-vs-listings:

  • Params: address, area_m2, rooms, window_days=180, area_tolerance=0.15
  • Response: total_deals, deals_with_listings, linkage_rate_pct, median_discount_pct, pairs[]
  • Use existing extract_street_name() из estimator

Production smoke verification

Migration applied. View total 50,139 pairs:

Sample случай: «Бисертская»

  • ДКП: 6.3 млн (86 м²) на 2025-01-01
  • listing: 8.5 млн (79 м²) на 2025-01-05 (cian) — за 4 дня после сделки
  • discount_pct = −25.88% (продали на 25% дешевле выставленного)

Это пример реального insight который теперь доступен через API.

Files (+828 LOC)

  • data/sql/067_v_street_sales_vs_listings.sql (new, 224 LOC) — TVF + view
  • app/api/v1/trade_in.py (+148 LOC) — endpoint
  • app/schemas/trade_in.py (+53 LOC) — Pydantic models
  • tests/test_sales_vs_listings.py (new, 403 LOC) — 8 tests

Tests

8/8 pass: happy path, no matches, LEFT JOIN behavior, median discount, response shape, defaults. Ruff clean.

Out of scope (отдельные PR L+M)

  • Frontend extend StreetDealsCard «Связанные ASK»
  • Estimator revert NOTE 2026-05-24 — добавить rosreestr_deals обратно в actual_deals

Refs #564 (Phase 1 — backend foundation).

## Summary **Foundation Phase 1 of #564** — backend для sales-vs-asks comparison: связь ДКП-сделок Росреестра с listings avito/cian/yandex/n1. ## Implementation **DDL** (migration 067): - `street_sales_vs_listings(street, area, rooms, window_days, area_tol)` — table-valued function для filtered query (использует indexes) - `v_street_sales_vs_listings` view — full join для analytics - JOIN logic: street ILIKE + area ±15% + listing_date в окне `[deal_date - 180d, deal_date]` - Computed: `days_listing_to_deal`, `discount_pct = (deal - listing) / listing * 100` **API** `GET /api/v1/sales-vs-listings`: - Params: `address`, `area_m2`, `rooms`, `window_days=180`, `area_tolerance=0.15` - Response: `total_deals`, `deals_with_listings`, `linkage_rate_pct`, `median_discount_pct`, `pairs[]` - Use existing `extract_street_name()` из estimator ## Production smoke verification Migration applied. View total **50,139 pairs**: Sample случай: «Бисертская» - ДКП: 6.3 млн (86 м²) на 2025-01-01 - listing: 8.5 млн (79 м²) на 2025-01-05 (cian) — за 4 дня после сделки - `discount_pct = −25.88%` (продали на 25% дешевле выставленного) Это пример реального insight который теперь доступен через API. ## Files (+828 LOC) - `data/sql/067_v_street_sales_vs_listings.sql` (new, 224 LOC) — TVF + view - `app/api/v1/trade_in.py` (+148 LOC) — endpoint - `app/schemas/trade_in.py` (+53 LOC) — Pydantic models - `tests/test_sales_vs_listings.py` (new, 403 LOC) — 8 tests ## Tests 8/8 pass: happy path, no matches, LEFT JOIN behavior, median discount, response shape, defaults. Ruff clean. ## Out of scope (отдельные PR L+M) - Frontend extend StreetDealsCard «Связанные ASK» - Estimator revert NOTE 2026-05-24 — добавить rosreestr_deals обратно в `actual_deals` Refs #564 (Phase 1 — backend foundation).
Light1YT added 1 commit 2026-05-27 11:55:43 +00:00
Связь ДКП Росреестра с listings (avito/cian/yandex/n1) через street
normalization + area window + listing-date window. Foundation для
sales-vs-asks comparison (discount_pct per pair).

DDL:
- data/sql/067_v_street_sales_vs_listings.sql:
  * table-valued function street_sales_vs_listings(street, area, rooms,
    window_days, area_tol) для filtered query с indexes
  * view v_street_sales_vs_listings — full crossjoin для analytical queries
    (used в reporting; для transactional path use TVF)
  * Encapsulated JOIN logic: street ILIKE + area ±15% + listing_date в окне
    [deal_date - window_days, deal_date]
  * Computed columns: days_listing_to_deal, discount_pct

API:
- GET /api/v1/sales-vs-listings?address=...&area_m2=...&rooms=...
  * Pydantic SalesVsListingsResponse с street/period/total_deals/
    deals_with_listings/linkage_rate_pct/median_discount_pct/pairs list
  * Использует extract_street_name() из existing estimator code

Schema:
- schemas/trade_in.py: SalesListingPair + SalesVsListingsResponse

Production verification:
- Migration applied на проде: CREATE FUNCTION + CREATE VIEW
- View total: 50,139 (deal, listing) pairs
- Sample case: «Бисертская» — ДКП 6.3 млн (86 м²) vs listing 8.5 млн (79 м²)
  с listing_date за 4 дня до сделки → discount_pct = −25.88%

Tests: 8/8 pass в test_sales_vs_listings.py — happy path, no matches,
LEFT JOIN behavior, median discount, defaults, response shape.
Ruff clean.

Out of scope (separate PR L+M):
- Frontend extend StreetDealsCard «Связанные ASK»
- Estimator revert NOTE 2026-05-24 (rosreestr_deals в actual_deals)

Refs #564 (Phase 1 — backend foundation).
Light1YT merged commit 39da0f0644 into main 2026-05-27 11:55:45 +00:00
Light1YT deleted branch feat/tradein-sales-vs-listings-endpoint 2026-05-27 11:55:45 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#597
No description provided.