Merge pull request 'feat(tradein): sell-time sensitivity (4 цены × срок продажи)' (#548) from feat/tradein-sell-time-backend into main
Reviewed-on: #548
This commit is contained in:
commit
5394713bc7
6 changed files with 325 additions and 1 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
</small>
|
||||
</header>
|
||||
<HouseAnalyticsKpiRow kpi={data.kpi} />
|
||||
{sellTime.data && <SellTimeSensitivity data={sellTime.data} />}
|
||||
{data.price_history.length >= 2 && (
|
||||
<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,
|
||||
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<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[];
|
||||
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