feat(tradein): sell-time sensitivity block (4 цены × медиана срока продажи)
Backend: GET /api/v1/trade-in/estimate/{id}/sell-time-sensitivity
- 4 фиксированных бакета по premium к медиане года: −5% / медиана (±3%) / +5% / +10%
- Median exposure_days + p25/p75 per bucket
- Filter outliers: last_price > start_price * 0.7 (отбрасываем подозрительно дешёвые)
- Benchmark: median ₽/м² за последние 2 года
Frontend: новый SellTimeSensitivity component (4-card grid)
- color-coded buckets (green→blue→yellow→red)
- median highlighted синей рамкой
- показывает "~31 дн" + "обычно 31-61 дн" + count лотов
- встроен в HouseAnalyticsSection между KpiRow и PriceHistoryChart
Использует существующий house_placement_history (15k+ rows).
This commit is contained in:
parent
596842df8d
commit
0ac54d1de0
6 changed files with 325 additions and 1 deletions
|
|
@ -27,6 +27,8 @@ from app.schemas.trade_in import (
|
||||||
PlacementHistoryEntry,
|
PlacementHistoryEntry,
|
||||||
PriceHistoryYearPoint,
|
PriceHistoryYearPoint,
|
||||||
RecentSoldEntry,
|
RecentSoldEntry,
|
||||||
|
SellTimeBucket,
|
||||||
|
SellTimeSensitivityResponse,
|
||||||
TradeInEstimateInput,
|
TradeInEstimateInput,
|
||||||
)
|
)
|
||||||
from app.services.exporters.trade_in_pdf import generate_trade_in_pdf
|
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]
|
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)
|
@router.get("/estimate/{estimate_id}/imv-benchmark", response_model=IMVBenchmarkResponse)
|
||||||
def get_estimate_imv_benchmark(
|
def get_estimate_imv_benchmark(
|
||||||
estimate_id: UUID,
|
estimate_id: UUID,
|
||||||
|
|
|
||||||
|
|
@ -263,3 +263,26 @@ class HouseAnalyticsResponse(BaseModel):
|
||||||
price_history: list[PriceHistoryYearPoint]
|
price_history: list[PriceHistoryYearPoint]
|
||||||
recent_sold: list[RecentSoldEntry]
|
recent_sold: list[RecentSoldEntry]
|
||||||
kpi: HouseAnalyticsKpi
|
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]
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
"use client";
|
"use client";
|
||||||
import { useEstimateHouseAnalytics } from "@/lib/trade-in-api";
|
import { useEstimateHouseAnalytics, useEstimateSellTimeSensitivity } from "@/lib/trade-in-api";
|
||||||
import { PriceHistoryChart } from "./PriceHistoryChart";
|
import { PriceHistoryChart } from "./PriceHistoryChart";
|
||||||
import { HouseAnalyticsKpiRow } from "./HouseAnalyticsKpiRow";
|
import { HouseAnalyticsKpiRow } from "./HouseAnalyticsKpiRow";
|
||||||
import { RecentSoldList } from "./RecentSoldList";
|
import { RecentSoldList } from "./RecentSoldList";
|
||||||
|
import { SellTimeSensitivity } from "./SellTimeSensitivity";
|
||||||
|
|
||||||
type Props = { estimateId: string };
|
type Props = { estimateId: string };
|
||||||
|
|
||||||
export function HouseAnalyticsSection({ estimateId }: Props) {
|
export function HouseAnalyticsSection({ estimateId }: Props) {
|
||||||
const { data, isPending, isError } = useEstimateHouseAnalytics(estimateId);
|
const { data, isPending, isError } = useEstimateHouseAnalytics(estimateId);
|
||||||
|
const sellTime = useEstimateSellTimeSensitivity(estimateId);
|
||||||
|
|
||||||
if (isPending || isError || !data) return null;
|
if (isPending || isError || !data) return null;
|
||||||
if (data.kpi.total_lots === 0) return null;
|
if (data.kpi.total_lots === 0) return null;
|
||||||
|
|
@ -33,6 +35,7 @@ export function HouseAnalyticsSection({ estimateId }: Props) {
|
||||||
</small>
|
</small>
|
||||||
</header>
|
</header>
|
||||||
<HouseAnalyticsKpiRow kpi={data.kpi} />
|
<HouseAnalyticsKpiRow kpi={data.kpi} />
|
||||||
|
{sellTime.data && <SellTimeSensitivity data={sellTime.data} />}
|
||||||
{data.price_history.length >= 2 && (
|
{data.price_history.length >= 2 && (
|
||||||
<PriceHistoryChart points={data.price_history} />
|
<PriceHistoryChart points={data.price_history} />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { SellTimeSensitivityResponse, SellTimeBucket } from "@/types/trade-in";
|
||||||
|
|
||||||
|
type Props = { data: SellTimeSensitivityResponse };
|
||||||
|
|
||||||
|
const BUCKET_LABELS: Record<string, string> = {
|
||||||
|
cheap: "−5% от рынка",
|
||||||
|
median: "По медиане",
|
||||||
|
plus5: "+5%",
|
||||||
|
plus10: "+10%",
|
||||||
|
};
|
||||||
|
|
||||||
|
const BUCKET_COLORS: Record<string, string> = {
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className="card"
|
||||||
|
style={{
|
||||||
|
padding: 12,
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
background: BUCKET_COLORS[bucket.price_premium_label] ?? "#f9fafb",
|
||||||
|
border:
|
||||||
|
bucket.price_premium_label === "median"
|
||||||
|
? "2px solid #2563eb"
|
||||||
|
: "1px solid var(--border, #e5e7eb)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 11, color: "#374151", marginBottom: 4, fontWeight: 500 }}>
|
||||||
|
{BUCKET_LABELS[bucket.price_premium_label]}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 22, fontWeight: 700, lineHeight: 1.1 }}>
|
||||||
|
{hasData ? `~${bucket.median_exposure_days} дн.` : "—"}
|
||||||
|
</div>
|
||||||
|
{hasData && bucket.p25_days != null && bucket.p75_days != null && (
|
||||||
|
<div style={{ fontSize: 10, color: "#6b7280", marginTop: 4 }}>
|
||||||
|
обычно {bucket.p25_days}–{bucket.p75_days} дн.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ fontSize: 10, color: "#6b7280", marginTop: 2 }}>
|
||||||
|
{bucket.n_lots > 0
|
||||||
|
? `${bucket.n_lots} аналог${bucket.n_lots === 1 ? "" : bucket.n_lots < 5 ? "а" : "ов"}`
|
||||||
|
: "нет данных"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SellTimeSensitivity({ data }: Props) {
|
||||||
|
if (data.buckets.every((b) => b.n_lots === 0)) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="card" style={{ marginTop: 12, padding: 16 }}>
|
||||||
|
<header style={{ marginBottom: 12 }}>
|
||||||
|
<h4 style={{ margin: 0, fontSize: 14, fontWeight: 600 }}>
|
||||||
|
Срок продажи в зависимости от цены
|
||||||
|
</h4>
|
||||||
|
<small style={{ color: "var(--muted, #6b7280)" }}>
|
||||||
|
Медиана экспозиции по архивным лотам · benchmark{" "}
|
||||||
|
{data.target_median_price_per_m2
|
||||||
|
? `${data.target_median_price_per_m2.toLocaleString("ru-RU")} ₽/м²`
|
||||||
|
: "—"}
|
||||||
|
</small>
|
||||||
|
</header>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 8 }}>
|
||||||
|
{data.buckets.map((b) => (
|
||||||
|
<BucketCard key={b.price_premium_label} bucket={b} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ import type {
|
||||||
HouseInfoForEstimate,
|
HouseInfoForEstimate,
|
||||||
IMVBenchmarkResponse,
|
IMVBenchmarkResponse,
|
||||||
PlacementHistoryItem,
|
PlacementHistoryItem,
|
||||||
|
SellTimeSensitivityResponse,
|
||||||
TradeInEstimateInput,
|
TradeInEstimateInput,
|
||||||
} from "@/types/trade-in";
|
} from "@/types/trade-in";
|
||||||
|
|
||||||
|
|
@ -118,3 +119,19 @@ export function useEstimateHouseAnalytics(estimate_id: string | null) {
|
||||||
staleTime: 10 * 60_000,
|
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<SellTimeSensitivityResponse>({
|
||||||
|
queryKey: ["trade-in", "estimate", estimate_id, "sell-time-sensitivity"],
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<SellTimeSensitivityResponse>(
|
||||||
|
`${BASE}/estimate/${estimate_id}/sell-time-sensitivity`,
|
||||||
|
),
|
||||||
|
enabled: estimate_id !== null && estimate_id.length > 0,
|
||||||
|
staleTime: 10 * 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -191,3 +191,21 @@ export interface HouseAnalyticsResponse {
|
||||||
recent_sold: RecentSoldEntry[];
|
recent_sold: RecentSoldEntry[];
|
||||||
kpi: HouseAnalyticsKpi;
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue