diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index 28494859..7cb513e1 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, + SellTimeBucket, + SellTimeSensitivityResponse, TradeInEstimateInput, ) from app.services.exporters.trade_in_pdf import generate_trade_in_pdf @@ -796,6 +798,187 @@ def get_estimate_cian_price_changes( return [CianPriceChangeStats(**dict(r)) for r in rows] +@router.get( + "/estimate/{estimate_id}/sell-time-sensitivity", + response_model=SellTimeSensitivityResponse, +) +def get_estimate_sell_time_sensitivity( + estimate_id: UUID, + db: Annotated[Session, Depends(get_db)], +) -> SellTimeSensitivityResponse: + """Срок продажи в зависимости от цены к медиане дома/района. + + 4 бакета: -5% / медиана (±3%) / +5% / +10%. Median exposure_days + p25/p75. + Filter last_price > start_price * 0.7 — отбрасываем подозрительно + заниженные лоты (выбросы, ошибки парсинга). + """ + # 1. Resolve house_ids (same logic as house-analytics) + target = db.execute( + text("SELECT lat, lon, address FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"), + {"id": str(estimate_id)}, + ).fetchone() + if target is None: + raise HTTPException(status_code=404, detail="estimate not found") + + house_ids: list[int] = [] + if target.address: + rows = db.execute( + text( + "SELECT id FROM houses WHERE short_address = tradein_normalize_short_addr(:addr) " + "OR tradein_normalize_short_addr(address) = tradein_normalize_short_addr(:addr)" + ), + {"addr": target.address}, + ).all() + house_ids = [r.id for r in rows] + if not house_ids and target.lat is not None and target.lon is not None: + rows = db.execute( + text( + "SELECT id FROM houses WHERE geom IS NOT NULL AND ST_DWithin(" + "geom::geography, ST_MakePoint(:lon, :lat)::geography, 100) LIMIT 3" + ), + {"lat": target.lat, "lon": target.lon}, + ).all() + house_ids = [r.id for r in rows] + + # Expand to 300m if too few rows (same threshold as house-analytics) + radius_used = 0 + n_in_house = 0 + if house_ids: + n_in_house = ( + db.execute( + text("SELECT COUNT(*) FROM house_placement_history WHERE house_id = ANY(:ids)"), + {"ids": house_ids}, + ).scalar() + or 0 + ) + if n_in_house < 8 and target.lat is not None and target.lon is not None: + rows = db.execute( + text( + "SELECT id FROM houses WHERE geom IS NOT NULL AND ST_DWithin(" + "geom::geography, ST_MakePoint(:lon, :lat)::geography, 300) LIMIT 30" + ), + {"lat": target.lat, "lon": target.lon}, + ).all() + house_ids = sorted(set(house_ids) | {r.id for r in rows}) + radius_used = 300 + + if not house_ids: + return SellTimeSensitivityResponse( + house_ids=[], + radius_m=0, + target_median_price_per_m2=None, + buckets=[], + ) + + # 2. Compute benchmark median ₽/м² for last 2 years + target_median = db.execute( + text( + """ + SELECT percentile_cont(0.5) WITHIN GROUP ( + ORDER BY last_price / NULLIF(area_m2, 0) + )::int AS median_ppm2 + FROM house_placement_history + WHERE house_id = ANY(:ids) + AND last_price IS NOT NULL AND last_price > 100000 + AND area_m2 > 10 + AND COALESCE(last_price_date, start_price_date) > (NOW() - INTERVAL '2 years')::date + AND (start_price = 0 OR last_price > start_price * 0.7) + """ + ), + {"ids": house_ids}, + ).scalar() + + # 3. Per-year median (для расчёта premium per lot); используем CTE для bucket-расчёта + bucket_rows = db.execute( + text( + """ + WITH year_medians AS ( + SELECT + EXTRACT(YEAR FROM COALESCE(last_price_date, start_price_date))::int AS year, + percentile_cont(0.5) WITHIN GROUP ( + ORDER BY last_price / NULLIF(area_m2, 0) + ) AS median_ppm2 + FROM house_placement_history + WHERE house_id = ANY(:ids) + AND last_price IS NOT NULL AND area_m2 > 10 + AND (start_price = 0 OR last_price > start_price * 0.7) + GROUP BY year + ), + lots_with_premium AS ( + SELECT + hph.exposure_days, + CASE + WHEN ym.median_ppm2 IS NULL OR ym.median_ppm2 = 0 THEN NULL + ELSE ((hph.last_price / NULLIF(hph.area_m2, 0)) - ym.median_ppm2) + / ym.median_ppm2 * 100 + END AS premium_pct + FROM house_placement_history hph + JOIN year_medians ym ON ym.year = EXTRACT(YEAR FROM + COALESCE(hph.last_price_date, hph.start_price_date))::int + WHERE hph.house_id = ANY(:ids) + AND hph.removed_date IS NOT NULL + AND hph.exposure_days IS NOT NULL + AND hph.area_m2 > 10 + AND (hph.start_price = 0 OR hph.last_price > hph.start_price * 0.7) + ), + bucketed AS ( + SELECT + CASE + WHEN premium_pct BETWEEN -10 AND -3 THEN 'cheap' + WHEN premium_pct BETWEEN -3 AND 3 THEN 'median' + WHEN premium_pct BETWEEN 3 AND 8 THEN 'plus5' + WHEN premium_pct BETWEEN 8 AND 15 THEN 'plus10' + ELSE NULL + END AS bucket, + exposure_days + FROM lots_with_premium + WHERE premium_pct IS NOT NULL + ) + SELECT + bucket, + COUNT(*) AS n_lots, + percentile_cont(0.5) WITHIN GROUP (ORDER BY exposure_days)::int + AS median_exposure_days, + percentile_cont(0.25) WITHIN GROUP (ORDER BY exposure_days)::int AS p25_days, + percentile_cont(0.75) WITHIN GROUP (ORDER BY exposure_days)::int AS p75_days + FROM bucketed + WHERE bucket IS NOT NULL + GROUP BY bucket + """ + ), + {"ids": house_ids}, + ).mappings().all() + + # 4. Build buckets — гарантируем все 4 даже если данных нет в bucket + bucket_map = {r["bucket"]: dict(r) for r in bucket_rows} + bucket_definitions = [ + ("cheap", -5.0), + ("median", 0.0), + ("plus5", 5.0), + ("plus10", 10.0), + ] + buckets: list[SellTimeBucket] = [] + for label, pct in bucket_definitions: + r = bucket_map.get(label) + buckets.append( + SellTimeBucket( + price_premium_label=label, + price_premium_pct=pct, + median_exposure_days=r["median_exposure_days"] if r else None, + p25_days=r["p25_days"] if r else None, + p75_days=r["p75_days"] if r else None, + n_lots=r["n_lots"] if r else 0, + ) + ) + + return SellTimeSensitivityResponse( + house_ids=house_ids, + radius_m=radius_used, + target_median_price_per_m2=int(target_median) if target_median else None, + buckets=buckets, + ) + + @router.get("/estimate/{estimate_id}/imv-benchmark", response_model=IMVBenchmarkResponse) def get_estimate_imv_benchmark( estimate_id: UUID, diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index 9d772845..675f3de8 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -263,3 +263,26 @@ class HouseAnalyticsResponse(BaseModel): price_history: list[PriceHistoryYearPoint] recent_sold: list[RecentSoldEntry] kpi: HouseAnalyticsKpi + + +# ── Sell-time sensitivity (срок продажи по бакетам цены) ───────────────────── + + +class SellTimeBucket(BaseModel): + """Один бакет срока продажи для данного ценового диапазона.""" + + price_premium_label: str # 'cheap' | 'median' | 'plus5' | 'plus10' + price_premium_pct: float # -5.0, 0.0, 5.0, 10.0 для UI + median_exposure_days: int | None + p25_days: int | None + p75_days: int | None + n_lots: int + + +class SellTimeSensitivityResponse(BaseModel): + """Ответ GET /estimate/{id}/sell-time-sensitivity.""" + + house_ids: list[int] + radius_m: int + target_median_price_per_m2: int | None # benchmark — медиана ₽/м² за последние 2 года + buckets: list[SellTimeBucket] diff --git a/tradein-mvp/frontend/src/components/trade-in/HouseAnalyticsSection.tsx b/tradein-mvp/frontend/src/components/trade-in/HouseAnalyticsSection.tsx index 941fdaef..5ee1f808 100644 --- a/tradein-mvp/frontend/src/components/trade-in/HouseAnalyticsSection.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/HouseAnalyticsSection.tsx @@ -1,13 +1,15 @@ "use client"; -import { useEstimateHouseAnalytics } from "@/lib/trade-in-api"; +import { useEstimateHouseAnalytics, useEstimateSellTimeSensitivity } from "@/lib/trade-in-api"; import { PriceHistoryChart } from "./PriceHistoryChart"; import { HouseAnalyticsKpiRow } from "./HouseAnalyticsKpiRow"; import { RecentSoldList } from "./RecentSoldList"; +import { SellTimeSensitivity } from "./SellTimeSensitivity"; type Props = { estimateId: string }; export function HouseAnalyticsSection({ estimateId }: Props) { const { data, isPending, isError } = useEstimateHouseAnalytics(estimateId); + const sellTime = useEstimateSellTimeSensitivity(estimateId); if (isPending || isError || !data) return null; if (data.kpi.total_lots === 0) return null; @@ -33,6 +35,7 @@ export function HouseAnalyticsSection({ estimateId }: Props) { + {sellTime.data && } {data.price_history.length >= 2 && ( )} diff --git a/tradein-mvp/frontend/src/components/trade-in/SellTimeSensitivity.tsx b/tradein-mvp/frontend/src/components/trade-in/SellTimeSensitivity.tsx new file mode 100644 index 00000000..aa7e1fa3 --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/SellTimeSensitivity.tsx @@ -0,0 +1,80 @@ +"use client"; + +import type { SellTimeSensitivityResponse, SellTimeBucket } from "@/types/trade-in"; + +type Props = { data: SellTimeSensitivityResponse }; + +const BUCKET_LABELS: Record = { + cheap: "−5% от рынка", + median: "По медиане", + plus5: "+5%", + plus10: "+10%", +}; + +const BUCKET_COLORS: Record = { + cheap: "#dcfce7", // light green + median: "#dbeafe", // light blue + plus5: "#fef3c7", // light yellow + plus10: "#fee2e2", // light red +}; + +function BucketCard({ bucket }: { bucket: SellTimeBucket }) { + const hasData = bucket.median_exposure_days !== null && bucket.n_lots > 0; + return ( +
+
+ {BUCKET_LABELS[bucket.price_premium_label]} +
+
+ {hasData ? `~${bucket.median_exposure_days} дн.` : "—"} +
+ {hasData && bucket.p25_days != null && bucket.p75_days != null && ( +
+ обычно {bucket.p25_days}–{bucket.p75_days} дн. +
+ )} +
+ {bucket.n_lots > 0 + ? `${bucket.n_lots} аналог${bucket.n_lots === 1 ? "" : bucket.n_lots < 5 ? "а" : "ов"}` + : "нет данных"} +
+
+ ); +} + +export function SellTimeSensitivity({ data }: Props) { + if (data.buckets.every((b) => b.n_lots === 0)) return null; + + return ( +
+
+

+ Срок продажи в зависимости от цены +

+ + Медиана экспозиции по архивным лотам · benchmark{" "} + {data.target_median_price_per_m2 + ? `${data.target_median_price_per_m2.toLocaleString("ru-RU")} ₽/м²` + : "—"} + +
+
+ {data.buckets.map((b) => ( + + ))} +
+
+ ); +} diff --git a/tradein-mvp/frontend/src/lib/trade-in-api.ts b/tradein-mvp/frontend/src/lib/trade-in-api.ts index e1aa234c..3536f2bb 100644 --- a/tradein-mvp/frontend/src/lib/trade-in-api.ts +++ b/tradein-mvp/frontend/src/lib/trade-in-api.ts @@ -10,6 +10,7 @@ import type { HouseInfoForEstimate, IMVBenchmarkResponse, PlacementHistoryItem, + SellTimeSensitivityResponse, TradeInEstimateInput, } from "@/types/trade-in"; @@ -118,3 +119,19 @@ export function useEstimateHouseAnalytics(estimate_id: string | null) { staleTime: 10 * 60_000, }); } + +/** + * GET /api/v1/trade-in/estimate/{id}/sell-time-sensitivity + * Median exposure days bucketed by price premium (-5%, 0, +5%, +10%). + */ +export function useEstimateSellTimeSensitivity(estimate_id: string | null) { + return useQuery({ + queryKey: ["trade-in", "estimate", estimate_id, "sell-time-sensitivity"], + queryFn: () => + apiFetch( + `${BASE}/estimate/${estimate_id}/sell-time-sensitivity`, + ), + enabled: estimate_id !== null && estimate_id.length > 0, + staleTime: 10 * 60_000, + }); +} diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts index 55552b0e..6d1d5822 100644 --- a/tradein-mvp/frontend/src/types/trade-in.ts +++ b/tradein-mvp/frontend/src/types/trade-in.ts @@ -191,3 +191,21 @@ export interface HouseAnalyticsResponse { recent_sold: RecentSoldEntry[]; kpi: HouseAnalyticsKpi; } + +// ── Sell-time sensitivity (endpoint: GET /estimate/{id}/sell-time-sensitivity) ── + +export interface SellTimeBucket { + price_premium_label: string; // 'cheap' | 'median' | 'plus5' | 'plus10' + price_premium_pct: number; // -5, 0, 5, 10 + median_exposure_days: number | null; + p25_days: number | null; + p75_days: number | null; + n_lots: number; +} + +export interface SellTimeSensitivityResponse { + house_ids: number[]; + radius_m: number; + target_median_price_per_m2: number | null; + buckets: SellTimeBucket[]; // ровно 4 элемента: cheap, median, plus5, plus10 +}