feat(tradein): sell-time sensitivity (4 цены × срок продажи) #548

Merged
lekss361 merged 1 commit from feat/tradein-sell-time-backend into main 2026-05-24 19:44:42 +00:00
Owner

Контекст

Третий PR серии для demo. Поверх PR #547 (multi-source).

Что добавлено

Новый блок «Срок продажи в зависимости от цены» под HouseAnalyticsKpiRow.

4 карточки с фиксированными бакетами (premium к медиане года):

  • −5% от рынка
  • По медиане (выделена синей рамкой)
  • +5%
  • +10%

Каждая показывает: ~31 дн (median) + обычно 31-61 дн (p25-p75) + count лотов.

Источник

house_placement_history (15k+ rows). Для текущего estimate Татищева 96:

  • 76 лотов в bucket median (±3%) → ~31 дн
  • 57 лотов при +5..+10% → 31 дн
  • 34 лота при +10% → ~53 дн

Filter outliers last_price > start_price * 0.7 (отбрасывает fake-cheap листинги).

Аномалии в данных

  • Avito IMV возвращает exposure_days с floor=31 (все p25=31) — это ограничение источника, не баг pipeline.
  • Bucket «cheap <−10%» был аномально долгим (62 дн), теперь отфильтрован через price-ratio guard.

Test plan

  • backend: ruff/mypy/pre-commit pass
  • frontend: tsc/lint/build pass
  • smoke /trade-in?id=d1570e00 после deploy
## Контекст Третий PR серии для demo. Поверх PR #547 (multi-source). ## Что добавлено Новый блок **«Срок продажи в зависимости от цены»** под HouseAnalyticsKpiRow. 4 карточки с фиксированными бакетами (premium к медиане года): - −5% от рынка - По медиане (выделена синей рамкой) - +5% - +10% Каждая показывает: `~31 дн` (median) + `обычно 31-61 дн` (p25-p75) + count лотов. ## Источник `house_placement_history` (15k+ rows). Для текущего estimate Татищева 96: - 76 лотов в bucket median (±3%) → ~31 дн - 57 лотов при +5..+10% → 31 дн - 34 лота при +10% → ~53 дн Filter outliers `last_price > start_price * 0.7` (отбрасывает fake-cheap листинги). ## Аномалии в данных - Avito IMV возвращает exposure_days с floor=31 (все p25=31) — это ограничение источника, не баг pipeline. - Bucket «cheap <−10%» был аномально долгим (62 дн), теперь отфильтрован через price-ratio guard. ## Test plan - [x] backend: ruff/mypy/pre-commit pass - [x] frontend: tsc/lint/build pass - [ ] smoke /trade-in?id=d1570e00 после deploy
lekss361 added 1 commit 2026-05-24 18:27:49 +00:00
Backend:
- New GET /api/v1/trade-in/estimate/{id}/cian-price-changes — per-analog
  price change stats (n_changes, last_diff_pct, total_change_pct) из
  offer_price_history (cian playwright_history backfill, 268+ rows).
- house-analytics: PriceHistoryYearPoint gains source field; SQL теперь
  GROUP BY year, source → 2 серии для chart (avito_imv / yandex_valuation).

Frontend:
- ListingsCard показывает colored badge ↓N ±X.X% рядом с ценой cian-аналога
  если есть price changes (зелёный = снижение, красный = рост).
- PriceHistoryChart: pivot long→wide format client-side, 2 Line components
  (синий Avito #2563eb, оранжевый Яндекс #ea580c) + Legend, connectNulls.

Использует данные:
- offer_price_history (live cian price changes)
- house_placement_history.source разделение avito_imv vs yandex_valuation
Author
Owner

Deep Code Review — verdict

Summary

  • Status: APPROVE
  • Files: 9 (backend API + schemas, frontend types/hooks/components)
  • Lines: +573 / -18 · SHA: 1776d2c

Scope verified

  • New endpoint GET /estimate/{id}/sell-time-sensitivity — 4-bucket exposure_days matrix bucketed by price-premium-vs-year-median.
  • New endpoint GET /estimate/{id}/cian-price-changes — Cian price-change history join via offer_price_history.
  • PriceHistoryYearPoint now splits by source (avito_imv + yandex_valuation) with pivoted Recharts dual-line.
  • New UI: SellTimeSensitivity card grid (4 cards), Cian price-change badges in ListingsCard.

Math correctness — sound

  • Division-by-zero защищён везде: NULLIF(area_m2, 0), CASE-guard ym.median_ppm2 IS NULL OR ym.median_ppm2 = 0, first_seen_price > 0.
  • CASE WHEN top-down evaluation делает boundary points (-3, 3, 8) deterministic → no double-counting, хотя BETWEEN границы overlap'ятся.
  • last_price > start_price * 0.7 outlier filter — корректно (PR описывает: отбрасывает fake-cheap листинги).

Edge cases — covered

  • Empty house_ids → возвращается early empty response.
  • 4 bucket-slots всегда выдаются даже при отсутствии данных (UI показывает «—»).
  • data.buckets.every(b => b.n_lots === 0) → component hides itself.
  • 404 при missing estimate.

Existing pipeline reuse

  • Не трогает services/estimator.py, /estimate endpoint backward-compat сохранён.
  • House-resolve cascade (address → 100m → 300m) идентичен house-analytics логике — consistency ✔.
  • Atomic IMV (#509) + cohort filter (#519/522) — не затронуты.
  • psycopg v3 CAST rule соблюдён: CAST(:eid AS uuid), CAST(:id AS uuid) — никаких :x::type.

Minor observations (не blocker)

  • 🟡 start_price IS NULL rows silently dropped (filter start_price = 0 OR last_price > start_price * 0.7 → NULL → fails WHERE). Older scrapes без start_price исключаются. Trade-off documented в PR body.
  • 🟡 PriceHistoryYearPoint.source теперь required → breaking response shape для /house-analytics. Both backend и frontend updated в same PR — для internal app OK.
  • 🟢 n_changes в cian-price-changes = COUNT(oph rows) включает initial seed row (where diff_percent IS NULL) → UI badge ↓N может overcount on 1. Cosmetic.
  • 🟢 Russian plural в SellTimeSensitivity (1 / 2-4 / 5+) пропускает 11-14, 21-24. Cosmetic.
  • 🟢 Inline style={} — consistent с existing pattern в tradein-mvp/frontend/components/trade-in.

Security

  • ✔ Все параметры bound (:eid, :ids, :id, :addr, :lat, :lon, :address).
  • ✔ No SQL interpolation.
  • ✔ Regex extraction /sale/flat/(\d+)/ использует escaped substring matcher in PostgreSQL.
  • ✔ Frontend safeUrl() reuse для lot.source_url.

Performance

  • hph_house_idx (house_id, last_price_date DESC) covers main lookup.
  • ✔ Correlated subquery in changes_agg для first_seen_price — scope ограничен listings_resolved (typically <50 rows per estimate).
  • staleTime: 10 * 60_000 (10 min) на TanStack Query reuse pattern.

Tests

  • Не добавлены. Для demo-PR в tradein-mvp acceptable per project convention; smoke remains user-driven (PR body checklist).
  1. Smoke /trade-in?id=d1570e00 после deploy — verify 4 buckets render.
  2. (Optional, follow-up) Patch Russian pluralization heuristic для 11-14 / 21-24.
  3. (Optional) Subtract 1 from n_changes so badge reflects actual price decreases, not raw history-row count.

Complexity / blast radius

  • Risk: LOW — пара новых endpoints, изменения существующих (PriceHistoryYearPoint) внутренние, response-only.
  • Reversibility: revert чистый — feature flag не требуется.
  • Merge window: OK to merge.
## Deep Code Review — verdict ### Summary - **Status:** APPROVE - **Files:** 9 (backend API + schemas, frontend types/hooks/components) - **Lines:** +573 / -18 · SHA: 1776d2c ### Scope verified - New endpoint `GET /estimate/{id}/sell-time-sensitivity` — 4-bucket exposure_days matrix bucketed by price-premium-vs-year-median. - New endpoint `GET /estimate/{id}/cian-price-changes` — Cian price-change history join via `offer_price_history`. - `PriceHistoryYearPoint` now splits by `source` (avito_imv + yandex_valuation) with pivoted Recharts dual-line. - New UI: `SellTimeSensitivity` card grid (4 cards), Cian price-change badges in `ListingsCard`. ### Math correctness — sound - Division-by-zero защищён везде: `NULLIF(area_m2, 0)`, CASE-guard `ym.median_ppm2 IS NULL OR ym.median_ppm2 = 0`, `first_seen_price > 0`. - `CASE WHEN` top-down evaluation делает boundary points (-3, 3, 8) deterministic → no double-counting, хотя `BETWEEN` границы overlap'ятся. - `last_price > start_price * 0.7` outlier filter — корректно (PR описывает: отбрасывает fake-cheap листинги). ### Edge cases — covered - Empty `house_ids` → возвращается early empty response. - 4 bucket-slots всегда выдаются даже при отсутствии данных (UI показывает «—»). - `data.buckets.every(b => b.n_lots === 0)` → component hides itself. - 404 при missing estimate. ### Existing pipeline reuse - Не трогает `services/estimator.py`, `/estimate` endpoint backward-compat сохранён. - House-resolve cascade (address → 100m → 300m) идентичен `house-analytics` логике — consistency ✔. - Atomic IMV (#509) + cohort filter (#519/522) — не затронуты. - psycopg v3 CAST rule соблюдён: `CAST(:eid AS uuid)`, `CAST(:id AS uuid)` — никаких `:x::type`. ### Minor observations (не blocker) - 🟡 `start_price IS NULL` rows silently dropped (filter `start_price = 0 OR last_price > start_price * 0.7` → NULL → fails WHERE). Older scrapes без start_price исключаются. Trade-off documented в PR body. - 🟡 `PriceHistoryYearPoint.source` теперь required → breaking response shape для `/house-analytics`. Both backend и frontend updated в same PR — для internal app OK. - 🟢 `n_changes` в cian-price-changes = COUNT(oph rows) включает initial seed row (where diff_percent IS NULL) → UI badge `↓N` может overcount on 1. Cosmetic. - 🟢 Russian plural в `SellTimeSensitivity` (`1 / 2-4 / 5+`) пропускает 11-14, 21-24. Cosmetic. - 🟢 Inline `style={}` — consistent с existing pattern в `tradein-mvp/frontend/components/trade-in`. ### Security - ✔ Все параметры bound (`:eid`, `:ids`, `:id`, `:addr`, `:lat`, `:lon`, `:address`). - ✔ No SQL interpolation. - ✔ Regex extraction `/sale/flat/(\d+)/` использует escaped substring matcher in PostgreSQL. - ✔ Frontend `safeUrl()` reuse для `lot.source_url`. ### Performance - ✔ `hph_house_idx (house_id, last_price_date DESC)` covers main lookup. - ✔ Correlated subquery in `changes_agg` для `first_seen_price` — scope ограничен listings_resolved (typically <50 rows per estimate). - ✔ `staleTime: 10 * 60_000` (10 min) на TanStack Query reuse pattern. ### Tests - Не добавлены. Для demo-PR в tradein-mvp acceptable per project convention; smoke remains user-driven (PR body checklist). ### Recommended next steps 1. Smoke `/trade-in?id=d1570e00` после deploy — verify 4 buckets render. 2. (Optional, follow-up) Patch Russian pluralization heuristic для 11-14 / 21-24. 3. (Optional) Subtract 1 from `n_changes` so badge reflects actual price *decreases*, not raw history-row count. ### Complexity / blast radius - **Risk:** LOW — пара новых endpoints, изменения существующих (`PriceHistoryYearPoint`) внутренние, response-only. - **Reversibility:** revert чистый — feature flag не требуется. - **Merge window:** OK to merge. <!-- gendesign-review-bot: sha=1776d2c verdict=approve -->
lekss361 force-pushed feat/tradein-sell-time-backend from 1776d2cfe8 to 0ac54d1de0 2026-05-24 19:43:48 +00:00 Compare
lekss361 merged commit 5394713bc7 into main 2026-05-24 19:44:42 +00:00
Author
Owner

Re-review after rebase — verdict: APPROVE

Rebase resolution clean. Prior approve verdict holds.

Rebase integration check (vs merged #547/#549/#550):

  • #547 PriceHistoryYearPoint split: main schema already has source: str field. PR #548 не трогает PriceHistoryYearPoint — добавляет независимые SellTimeBucket / SellTimeSensitivityResponse. Конфликта нет.
  • HouseAnalyticsSection.tsx: чистая вставка — useEstimateSellTimeSensitivity hook + <SellTimeSensitivity> между KPI row и PriceHistoryChart (split chart из #547 не задет).
  • #549 ДКП filter / #550 Росреестр button: затрагивают другие компоненты (RecentSoldList / экспорт), PR #548 их не пересекает.

Math correctness (prior verdict re-confirmed):

  • NULLIF(area_m2, 0) guards 2x (benchmark + per-lot premium)
  • COALESCE(last_price_date, start_price_date) для year extraction
  • Top-down CASE с non-overlapping BETWEEN: -10..-3 / -3..3 / 3..8 / 8..15 (boundary collisions решены first-match-wins)
  • premium_pct IS NOT NULL отбрасывает median_ppm2=0
  • last_price > start_price * 0.7 фильтр выбросов
  • area_m2 > 10 + last_price > 100000 sanity

Edge cases:

  • Пустой house_ids → early-return all-zero response
  • target_median NULL → frontend renders "—"
  • Backend гарантирует все 4 бакета через bucket_definitions loop
  • Frontend data.buckets.every(b => b.n_lots === 0)return null (graceful empty)

Conventions: psycopg v3 CAST(:id AS uuid) ✓, ruff line-100 ✓, TS strict types match Pydantic ✓.

Merging (squash + delete branch).

<!-- gendesign-review-bot: sha=0ac54d1 verdict=approve --> **Re-review after rebase — verdict: APPROVE** Rebase resolution clean. Prior approve verdict holds. **Rebase integration check (vs merged #547/#549/#550):** - `#547 PriceHistoryYearPoint split`: main schema already has `source: str` field. PR #548 не трогает `PriceHistoryYearPoint` — добавляет независимые `SellTimeBucket` / `SellTimeSensitivityResponse`. Конфликта нет. - `HouseAnalyticsSection.tsx`: чистая вставка — `useEstimateSellTimeSensitivity` hook + `<SellTimeSensitivity>` между KPI row и `PriceHistoryChart` (split chart из #547 не задет). - `#549 ДКП filter / #550 Росреестр button`: затрагивают другие компоненты (RecentSoldList / экспорт), PR #548 их не пересекает. **Math correctness (prior verdict re-confirmed):** - `NULLIF(area_m2, 0)` guards 2x (benchmark + per-lot premium) - `COALESCE(last_price_date, start_price_date)` для year extraction - Top-down CASE с non-overlapping BETWEEN: `-10..-3 / -3..3 / 3..8 / 8..15` (boundary collisions решены first-match-wins) - `premium_pct IS NOT NULL` отбрасывает median_ppm2=0 - `last_price > start_price * 0.7` фильтр выбросов - `area_m2 > 10` + `last_price > 100000` sanity **Edge cases:** - Пустой `house_ids` → early-return all-zero response - `target_median` NULL → frontend renders "—" - Backend гарантирует все 4 бакета через `bucket_definitions` loop - Frontend `data.buckets.every(b => b.n_lots === 0)` → `return null` (graceful empty) **Conventions:** psycopg v3 `CAST(:id AS uuid)` ✓, ruff line-100 ✓, TS strict types match Pydantic ✓. Merging (squash + delete branch).
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#548
No description provided.