feat(tradein): GET /street-deals — ДКП-сделки Росреестра по улице target адреса #555

Merged
lekss361 merged 1 commit from feat/tradein-street-deals-backend into main 2026-05-24 20:05:49 +00:00
Owner

What

Новый endpoint GET /api/v1/trade-in/street-deals возвращает ДКП-сделки Росреестра, матчящиеся по улице целевого адреса + rooms + area BETWEEN target ± 15%.

Питает frontend секцию «По вашей улице» (PR-C-front идёт следом).

Why per-street (не per-house)

Open dataset Росреестра агрегирует адреса до УЛИЦЫ, без номера дома. Проверено: tradein.deals.address для rosreestr = 'Екатеринбург, Космонавтов' (890 сделок), 'Екатеринбург, 8 Марта' (690), etc. См. Fix_Rosreestr_Dkp_Filter_May24 для constraints.

Использует чистые ДКП-сделки после PR-A (#549). До merge PR-A endpoint вернул бы mixed ДКП+ДДУ-первичку с искажённой median — теперь корректно.

API

GET /api/v1/trade-in/street-deals
  ?address=Екатеринбург, ул. Космонавтов, 50
  &area_m2=55.0
  &rooms=2
  &period_months=12   (optional)
  &area_tolerance=0.15 (optional)

Response: StreetDealsResponse
  street: str | None            # 'Космонавтов' / None если не извлёкся
  period_from: date
  period_to: date
  count: int                     # all matching
  median_price_rub: int
  median_price_per_m2: int
  range_low_rub: int
  range_high_rub: int
  deals: list[AnalogLot]         # top-10 по deal_date DESC

Empty response (count=0, deals=[]) при street is None или нет совпадений — frontend gracefully скроет секцию.

Changes (4 файла)

  1. schemas/trade_in.py — добавлен StreetDealsResponse после SellTimeSensitivityResponse
  2. services/estimator.py — public extract_street_name(full_address) -> str | None с двумя regex (_TRAILING_HOUSE_RE, _STREET_PREFIX_RE), переиспользует существующий _extract_short_addr и _STREET_START_RE
  3. api/v1/trade_in.pyGET /street-deals endpoint с inline SQL (text + bind params per psycopg3 rules); reuse _deal_to_analog и _percentile из estimator
  4. tests/test_street_deals_endpoint.py — NEW, 5 тестов: 3× extract_street_name parsing, empty case, aggregation

Verification

  • ruff check — All checks passed
  • pytest — 5/5 новых + 14/14 existing estimator/_extract_short_addr тестов = 19/19 pass

При тесте на prod после merge: curl 'http://localhost:8002/api/v1/trade-in/street-deals?address=...&area_m2=55&rooms=2' должен вернуть ~5-30 сделок по большинству ЕКБ-улиц.

Refs: PR-C-back из 4-PR roadmap (PR-C-front следом). Fix_Rosreestr_Dkp_Filter_May24 для контекста.

## What Новый endpoint `GET /api/v1/trade-in/street-deals` возвращает ДКП-сделки Росреестра, матчящиеся по улице целевого адреса + rooms + area BETWEEN target ± 15%. Питает frontend секцию «По вашей улице» (PR-C-front идёт следом). ## Why per-street (не per-house) Open dataset Росреестра агрегирует адреса до УЛИЦЫ, без номера дома. Проверено: `tradein.deals.address` для rosreestr = `'Екатеринбург, Космонавтов'` (890 сделок), `'Екатеринбург, 8 Марта'` (690), etc. См. [[Fix_Rosreestr_Dkp_Filter_May24]] для constraints. Использует чистые ДКП-сделки после PR-A (#549). До merge PR-A endpoint вернул бы mixed ДКП+ДДУ-первичку с искажённой median — теперь корректно. ## API ``` GET /api/v1/trade-in/street-deals ?address=Екатеринбург, ул. Космонавтов, 50 &area_m2=55.0 &rooms=2 &period_months=12 (optional) &area_tolerance=0.15 (optional) Response: StreetDealsResponse street: str | None # 'Космонавтов' / None если не извлёкся period_from: date period_to: date count: int # all matching median_price_rub: int median_price_per_m2: int range_low_rub: int range_high_rub: int deals: list[AnalogLot] # top-10 по deal_date DESC ``` Empty response (count=0, deals=[]) при street is None или нет совпадений — frontend gracefully скроет секцию. ## Changes (4 файла) 1. **`schemas/trade_in.py`** — добавлен `StreetDealsResponse` после `SellTimeSensitivityResponse` 2. **`services/estimator.py`** — public `extract_street_name(full_address) -> str | None` с двумя regex (`_TRAILING_HOUSE_RE`, `_STREET_PREFIX_RE`), переиспользует существующий `_extract_short_addr` и `_STREET_START_RE` 3. **`api/v1/trade_in.py`** — `GET /street-deals` endpoint с inline SQL (text + bind params per psycopg3 rules); reuse `_deal_to_analog` и `_percentile` из estimator 4. **`tests/test_street_deals_endpoint.py`** — NEW, 5 тестов: 3× extract_street_name parsing, empty case, aggregation ## Verification - `ruff check` — All checks passed - `pytest` — 5/5 новых + 14/14 existing estimator/_extract_short_addr тестов = 19/19 pass При тесте на prod после merge: `curl 'http://localhost:8002/api/v1/trade-in/street-deals?address=...&area_m2=55&rooms=2'` должен вернуть ~5-30 сделок по большинству ЕКБ-улиц. Refs: PR-C-back из 4-PR roadmap (PR-C-front следом). [[Fix_Rosreestr_Dkp_Filter_May24]] для контекста.
lekss361 added 1 commit 2026-05-24 19:57:37 +00:00
Новый endpoint для секции «По вашей улице» в trade-in UI. Возвращает
ДКП-сделки Росреестра матчащиеся по улице (open dataset агрегирует
до street level — номера дома нет) + rooms + area BETWEEN target ± 15%.

Использует rosreestr_deals после PR-A (#549) — только ДКП, без ДДУ-первички.

Response: median/range за period_months (default 12) + top-10 последних
сделок. Empty StreetDealsResponse если street не извлёкся или count=0.

Tests: extract_street_name parsing, empty case, aggregation.
Author
Owner

Deep Code Review — verdict: APPROVE

Files: 4 (P1: api/trade_in.py, schemas/trade_in.py, services/estimator.py; P2: tests/test_street_deals_endpoint.py) · +398 / -1

Checked SHA: 23aebea812 (head matches prompt).

Security

  • Parametrized SQL — no injection. street_pattern = % + street + % is bound via psycopg, not concatenated into SQL text.
  • ILIKE wildcards (%/_) in street_name would only widen the SELECT (read-only). Low risk — see suggestions.
  • Auth: matches peer trade-in endpoints (no auth on any route in this router). Not a new gap.
  • Logging uses %r for user input — safe.

Correctness

  • extract_street_name regex passes for the 5 listed test cases including «ул. 8 Марта». Traced: _TRAILING_HOUSE_RE strips , 18, then _STREET_PREFIX_RE strips ул. 8 Марта.
  • ДКП-only invariant preserved via PR #549 importer-level filter (source = 'rosreestr').
  • period_from uses days*30 approximation; SQL uses 'N months'::interval — minor drift, only affects the echoed period_from field. Not a real bug.
  • CAST for :rooms / :period_months — correct psycopg v3 pattern.
  • _empty() is defined inside the handler but never called — dead code (the if not street_name branch inlines its own empty response). Minor cleanup.
  • extract_street_name does not strip д. N form (e.g. «ул. Малышева, д. 1»«Малышева, д. 1»). All explicit test cases use , N. Real-data addresses commonly use д. N. Low impact: only widens ILIKE pattern, doesn't break results.
  • _TRAILING_HOUSE_RE = r",\s*\d+.*$" requires comma before digits. Addresses like «Космонавтов 12» (no comma) keep the number → wider ILIKE. Low impact.

Performance

  • ILIKE on deals.address — no functional index. Acceptable for one-off endpoint call; deals table is partitioned and the source = 'rosreestr' predicate narrows aggressively.
  • No LIMIT on the main query (existing _fetch_deals uses LIMIT 30). For very common streets (Космонавтов ≈ 890 rows) the full scan + sort is fine; consider LIMIT 1000 cap before percentile math if it grows.
  • No upper bound on period_months / area_tolerance — a user can pass period_months=10000 to force a long scan. Recommend Query(12, ge=1, le=60) and bounds on area_tolerance.

Project conventions

  • psycopg v3 CAST · ruff line-100 · Pydantic v2 strict (StreetDealsResponse, no list[dict]) · Annotated[Depends].
  • Ergonomic wart: db: Annotated[..., Depends(get_db)] = None, # type: ignore[assignment] — needed because required-no-default params precede defaulted ones. Works at runtime; cleaner would be Query() annotations on period_months / area_tolerance, or move db last.

Architecture & maintenance

  • Function-local from app.services.estimator import … — likely to avoid circular import. Fine.
  • Reuses _deal_to_analog, _percentile, _extract_short_addr. Good DRY.
  • extract_street_name is public (no underscore) — appropriate for cross-module use.

Tests

  • Unit tests for extract_street_name cover 5 positive + 2 edge cases.
  • Endpoint tests: empty rows + 5-row aggregation. Median math verified: ppm² median = 100k × area = 50 → 5_000_000.
  • Tests use MagicMock for the DB session — no real SQL execution, but the SQL string is canonical with _fetch_deals and the parametrization is identical.

Cross-file impact

  • Additive endpoint, no shared mutation. extract_street_name is a new symbol — no existing callers affected.
  • Schema addition StreetDealsResponse — no breaking change. Frontend consumer (PR-C-front) follows.
  • No DB migration, no Alembic touch.

Vault cross-check

  • PR body references [[Fix_Rosreestr_Dkp_Filter_May24]] — consistent with the deals invariant from PR #549 (merged: 596842d).
  1. Add Query(ge=…, le=…) bounds on period_months, area_tolerance, rooms, area_m2.
  2. Remove unused _empty() helper or use it consistently.
  3. Extend _TRAILING_HOUSE_RE to handle , д.\s*\d+ and bare \s+\d+ forms.
  4. Optional LIMIT 1000 cap on the main query as a safety bound.

Complexity / blast radius

  • Risk: Low — new read-only endpoint, additive only.
  • Reversibility: Trivial — revert one commit.
  • Merge window: OK any time.

Auto-merging (squash + branch delete).

<!-- gendesign-review-bot: sha=23aebea verdict=approve --> ## Deep Code Review — verdict: APPROVE **Files**: 4 (P1: api/trade_in.py, schemas/trade_in.py, services/estimator.py; P2: tests/test_street_deals_endpoint.py) · +398 / -1 Checked SHA: 23aebea8123f10e0385c5aa7543dbc6e868d54c7 (head matches prompt). ### Security - Parametrized SQL — no injection. `street_pattern` = `%` + street + `%` is bound via psycopg, not concatenated into SQL text. - ILIKE wildcards (`%`/`_`) in `street_name` would only widen the SELECT (read-only). Low risk — see suggestions. - Auth: matches peer trade-in endpoints (no auth on any route in this router). Not a new gap. - Logging uses `%r` for user input — safe. ### Correctness - `extract_street_name` regex passes for the 5 listed test cases including `«ул. 8 Марта»`. Traced: `_TRAILING_HOUSE_RE` strips `, 18`, then `_STREET_PREFIX_RE` strips `ул. ` → `8 Марта`. - ДКП-only invariant preserved via PR #549 importer-level filter (`source = 'rosreestr'`). - `period_from` uses `days*30` approximation; SQL uses `'N months'::interval` — minor drift, only affects the echoed `period_from` field. Not a real bug. - CAST for `:rooms` / `:period_months` — correct psycopg v3 pattern. - `_empty()` is defined inside the handler but never called — dead code (the `if not street_name` branch inlines its own empty response). Minor cleanup. - `extract_street_name` does not strip `д. N` form (e.g. `«ул. Малышева, д. 1»` → `«Малышева, д. 1»`). All explicit test cases use `, N`. Real-data addresses commonly use `д. N`. Low impact: only widens ILIKE pattern, doesn't break results. - `_TRAILING_HOUSE_RE = r",\s*\d+.*$"` requires comma before digits. Addresses like `«Космонавтов 12»` (no comma) keep the number → wider ILIKE. Low impact. ### Performance - ILIKE on `deals.address` — no functional index. Acceptable for one-off endpoint call; deals table is partitioned and the `source = 'rosreestr'` predicate narrows aggressively. - No `LIMIT` on the main query (existing `_fetch_deals` uses LIMIT 30). For very common streets (Космонавтов ≈ 890 rows) the full scan + sort is fine; consider `LIMIT 1000` cap before percentile math if it grows. - No upper bound on `period_months` / `area_tolerance` — a user can pass `period_months=10000` to force a long scan. **Recommend** `Query(12, ge=1, le=60)` and bounds on `area_tolerance`. ### Project conventions - psycopg v3 CAST · ruff line-100 · Pydantic v2 strict (StreetDealsResponse, no `list[dict]`) · Annotated[Depends]. - Ergonomic wart: `db: Annotated[..., Depends(get_db)] = None, # type: ignore[assignment]` — needed because required-no-default params precede defaulted ones. Works at runtime; cleaner would be `Query()` annotations on `period_months` / `area_tolerance`, or move `db` last. ### Architecture & maintenance - Function-local `from app.services.estimator import …` — likely to avoid circular import. Fine. - Reuses `_deal_to_analog`, `_percentile`, `_extract_short_addr`. Good DRY. - `extract_street_name` is public (no underscore) — appropriate for cross-module use. ### Tests - Unit tests for `extract_street_name` cover 5 positive + 2 edge cases. - Endpoint tests: empty rows + 5-row aggregation. Median math verified: ppm² median = 100k × area = 50 → 5_000_000. - Tests use `MagicMock` for the DB session — no real SQL execution, but the SQL string is canonical with `_fetch_deals` and the parametrization is identical. ### Cross-file impact - Additive endpoint, no shared mutation. `extract_street_name` is a new symbol — no existing callers affected. - Schema addition `StreetDealsResponse` — no breaking change. Frontend consumer (PR-C-front) follows. - No DB migration, no Alembic touch. ### Vault cross-check - PR body references `[[Fix_Rosreestr_Dkp_Filter_May24]]` — consistent with the deals invariant from PR #549 (merged: 596842d). ### Recommended follow-ups (non-blocking) 1. Add `Query(ge=…, le=…)` bounds on `period_months`, `area_tolerance`, `rooms`, `area_m2`. 2. Remove unused `_empty()` helper or use it consistently. 3. Extend `_TRAILING_HOUSE_RE` to handle `, д.\s*\d+` and bare `\s+\d+` forms. 4. Optional `LIMIT 1000` cap on the main query as a safety bound. ### Complexity / blast radius - Risk: **Low** — new read-only endpoint, additive only. - Reversibility: **Trivial** — revert one commit. - Merge window: **OK any time**. Auto-merging (squash + branch delete).
lekss361 merged commit a63e459e25 into main 2026-05-24 20:05:49 +00:00
lekss361 deleted branch feat/tradein-street-deals-backend 2026-05-24 20:05:49 +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#555
No description provided.