feat(tradein): cian price-change badges + split chart Avito/Yandex #547
7 changed files with 248 additions and 17 deletions
|
|
@ -18,6 +18,7 @@ from app.core.db import get_db
|
||||||
from app.schemas.trade_in import (
|
from app.schemas.trade_in import (
|
||||||
AggregatedEstimate,
|
AggregatedEstimate,
|
||||||
AnalogLot,
|
AnalogLot,
|
||||||
|
CianPriceChangeStats,
|
||||||
HouseAnalyticsKpi,
|
HouseAnalyticsKpi,
|
||||||
HouseAnalyticsResponse,
|
HouseAnalyticsResponse,
|
||||||
HouseInfoForEstimate,
|
HouseInfoForEstimate,
|
||||||
|
|
@ -611,12 +612,13 @@ def get_estimate_house_analytics(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# 3. Price history by year (median ₽/м²)
|
# 3. Price history by year × source (median ₽/м²)
|
||||||
price_history_rows = db.execute(
|
price_history_rows = db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
SELECT
|
SELECT
|
||||||
EXTRACT(YEAR FROM COALESCE(last_price_date, start_price_date))::int AS year,
|
EXTRACT(YEAR FROM COALESCE(last_price_date, start_price_date))::int AS year,
|
||||||
|
source,
|
||||||
COUNT(*) AS n_lots,
|
COUNT(*) AS n_lots,
|
||||||
percentile_cont(0.5) WITHIN GROUP (ORDER BY last_price / NULLIF(area_m2, 0))::int
|
percentile_cont(0.5) WITHIN GROUP (ORDER BY last_price / NULLIF(area_m2, 0))::int
|
||||||
AS median_price_per_m2,
|
AS median_price_per_m2,
|
||||||
|
|
@ -626,8 +628,8 @@ def get_estimate_house_analytics(
|
||||||
AND last_price IS NOT NULL AND last_price > 100000
|
AND last_price IS NOT NULL AND last_price > 100000
|
||||||
AND area_m2 IS NOT NULL AND area_m2 > 10
|
AND area_m2 IS NOT NULL AND area_m2 > 10
|
||||||
AND COALESCE(last_price_date, start_price_date) IS NOT NULL
|
AND COALESCE(last_price_date, start_price_date) IS NOT NULL
|
||||||
GROUP BY year
|
GROUP BY year, source
|
||||||
ORDER BY year ASC
|
ORDER BY year ASC, source ASC
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{"ids": house_ids},
|
{"ids": house_ids},
|
||||||
|
|
@ -709,6 +711,91 @@ def get_estimate_house_analytics(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/estimate/{estimate_id}/cian-price-changes",
|
||||||
|
response_model=list[CianPriceChangeStats],
|
||||||
|
)
|
||||||
|
def get_estimate_cian_price_changes(
|
||||||
|
estimate_id: UUID,
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
) -> list[CianPriceChangeStats]:
|
||||||
|
"""История изменений цены для Cian-аналогов из estimate.
|
||||||
|
|
||||||
|
Для каждого cian-аналога в estimate.analogs:
|
||||||
|
- extract cian_id из URL (/sale/flat/<id>/)
|
||||||
|
- JOIN listings (source='cian', source_id IN cian_ids)
|
||||||
|
- JOIN offer_price_history per listing_id
|
||||||
|
- Aggregate: n_changes, last_change_time, last_diff_percent, total_change_pct
|
||||||
|
|
||||||
|
Возвращает только аналоги с хотя бы одним изменением цены.
|
||||||
|
Пустой список если нет cian-аналогов или нет истории.
|
||||||
|
"""
|
||||||
|
rows = db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
WITH cian_analogs AS (
|
||||||
|
SELECT DISTINCT
|
||||||
|
substring(a->>'source_url' from '/sale/flat/(\\d+)/') AS cian_id
|
||||||
|
FROM trade_in_estimates, jsonb_array_elements(analogs) a
|
||||||
|
WHERE id = CAST(:eid AS uuid)
|
||||||
|
AND a->>'source' = 'cian'
|
||||||
|
AND a->>'source_url' IS NOT NULL
|
||||||
|
AND substring(a->>'source_url' from '/sale/flat/(\\d+)/') IS NOT NULL
|
||||||
|
),
|
||||||
|
listings_resolved AS (
|
||||||
|
SELECT l.id AS listing_id, l.source_id AS cian_id,
|
||||||
|
l.price_rub::int AS current_price
|
||||||
|
FROM listings l
|
||||||
|
JOIN cian_analogs c ON l.source_id = c.cian_id
|
||||||
|
WHERE l.source = 'cian'
|
||||||
|
),
|
||||||
|
changes_agg AS (
|
||||||
|
SELECT
|
||||||
|
oph.listing_id,
|
||||||
|
COUNT(*) AS n_changes,
|
||||||
|
MAX(oph.change_time) AS last_change_time,
|
||||||
|
(array_agg(oph.diff_percent ORDER BY oph.change_time DESC))[1]
|
||||||
|
AS last_diff_percent,
|
||||||
|
(
|
||||||
|
SELECT oph2.price_rub::int
|
||||||
|
FROM offer_price_history oph2
|
||||||
|
WHERE oph2.listing_id = oph.listing_id
|
||||||
|
ORDER BY oph2.change_time ASC
|
||||||
|
LIMIT 1
|
||||||
|
) AS first_seen_price
|
||||||
|
FROM offer_price_history oph
|
||||||
|
WHERE oph.listing_id IN (SELECT listing_id FROM listings_resolved)
|
||||||
|
GROUP BY oph.listing_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
lr.cian_id,
|
||||||
|
lr.listing_id,
|
||||||
|
ca.n_changes::int,
|
||||||
|
ca.last_change_time,
|
||||||
|
ca.last_diff_percent::float AS last_diff_percent,
|
||||||
|
ca.first_seen_price,
|
||||||
|
lr.current_price,
|
||||||
|
CASE
|
||||||
|
WHEN ca.first_seen_price IS NOT NULL AND ca.first_seen_price > 0
|
||||||
|
THEN ROUND(
|
||||||
|
(lr.current_price - ca.first_seen_price)::numeric
|
||||||
|
/ ca.first_seen_price * 100,
|
||||||
|
1
|
||||||
|
)::float
|
||||||
|
ELSE NULL
|
||||||
|
END AS total_change_pct
|
||||||
|
FROM listings_resolved lr
|
||||||
|
JOIN changes_agg ca ON ca.listing_id = lr.listing_id
|
||||||
|
WHERE ca.n_changes > 0
|
||||||
|
ORDER BY ca.last_change_time DESC
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"eid": str(estimate_id)},
|
||||||
|
).mappings().all()
|
||||||
|
|
||||||
|
return [CianPriceChangeStats(**dict(r)) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
@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,
|
||||||
|
|
|
||||||
|
|
@ -205,14 +205,31 @@ class ScheduleConfigUpdate(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class PriceHistoryYearPoint(BaseModel):
|
class PriceHistoryYearPoint(BaseModel):
|
||||||
"""Медианная цена ₽/м² и число лотов за год."""
|
"""Медианная цена ₽/м² и число лотов за год, разбитая по источнику."""
|
||||||
|
|
||||||
year: int
|
year: int
|
||||||
|
source: str # 'avito_imv' | 'yandex_valuation'
|
||||||
median_price_per_m2: int
|
median_price_per_m2: int
|
||||||
n_lots: int
|
n_lots: int
|
||||||
median_price_rub: int
|
median_price_rub: int
|
||||||
|
|
||||||
|
|
||||||
|
class CianPriceChangeStats(BaseModel):
|
||||||
|
"""Статистика изменений цены для одного Cian-аналога.
|
||||||
|
|
||||||
|
Источник: offer_price_history JOIN listings (source='cian').
|
||||||
|
"""
|
||||||
|
|
||||||
|
cian_id: str
|
||||||
|
listing_id: int
|
||||||
|
n_changes: int # COUNT(*) из offer_price_history
|
||||||
|
last_change_time: datetime | None
|
||||||
|
last_diff_percent: float | None # последняя дельта (-5% если цена снизилась)
|
||||||
|
total_change_pct: float | None # суммарно (current - first) / first * 100
|
||||||
|
first_seen_price: int | None
|
||||||
|
current_price: int # из listings.price_rub
|
||||||
|
|
||||||
|
|
||||||
class RecentSoldEntry(BaseModel):
|
class RecentSoldEntry(BaseModel):
|
||||||
"""Лот из house_placement_history снятый с продажи за последние 12 мес."""
|
"""Лот из house_placement_history снятый с продажи за последние 12 мес."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -180,7 +180,7 @@ export default function TradeInPage() {
|
||||||
<HouseAnalyticsSection estimateId={currentEstimateId} />
|
<HouseAnalyticsSection estimateId={currentEstimateId} />
|
||||||
)}
|
)}
|
||||||
<PhotoUpload estimateId={resultData.estimate.estimate_id} />
|
<PhotoUpload estimateId={resultData.estimate.estimate_id} />
|
||||||
<ListingsCard estimate={resultData.estimate} />
|
<ListingsCard estimate={resultData.estimate} estimateId={currentEstimateId ?? undefined} />
|
||||||
<DealsCard estimate={resultData.estimate} />
|
<DealsCard estimate={resultData.estimate} />
|
||||||
<OfferCard estimate={resultData.estimate} />
|
<OfferCard estimate={resultData.estimate} />
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,14 @@
|
||||||
* ListingsCard — Секция 2 «Рынок» из mockup tradein.html.
|
* ListingsCard — Секция 2 «Рынок» из mockup tradein.html.
|
||||||
* Counts strip + filters + price bar + table.
|
* Counts strip + filters + price bar + table.
|
||||||
*/
|
*/
|
||||||
import type { AggregatedEstimate, AnalogLot } from "@/types/trade-in";
|
import { useMemo } from "react";
|
||||||
|
import type { AggregatedEstimate, AnalogLot, CianPriceChangeStats } from "@/types/trade-in";
|
||||||
import { safeUrl } from "@/lib/safeUrl";
|
import { safeUrl } from "@/lib/safeUrl";
|
||||||
|
import { useEstimateCianPriceChanges } from "@/lib/trade-in-api";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
estimate: AggregatedEstimate;
|
estimate: AggregatedEstimate;
|
||||||
|
estimateId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SOURCE_DOTS: Record<string, string> = {
|
const SOURCE_DOTS: Record<string, string> = {
|
||||||
|
|
@ -37,7 +40,24 @@ function fmtArea(v: number): string {
|
||||||
return v.toFixed(1);
|
return v.toFixed(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ListingsCard({ estimate }: Props) {
|
/** Extract Cian numeric ID from a source_url like https://ekb.cian.ru/sale/flat/123456789/ */
|
||||||
|
function extractCianId(url: string | null): string | null {
|
||||||
|
if (!url) return null;
|
||||||
|
const m = url.match(/\/sale\/flat\/(\d+)/);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ListingsCard({ estimate, estimateId }: Props) {
|
||||||
|
const priceChangesQuery = useEstimateCianPriceChanges(estimateId ?? null);
|
||||||
|
|
||||||
|
const priceChangeMap = useMemo<Map<string, CianPriceChangeStats>>(() => {
|
||||||
|
const map = new Map<string, CianPriceChangeStats>();
|
||||||
|
for (const stats of priceChangesQuery.data ?? []) {
|
||||||
|
map.set(stats.cian_id, stats);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [priceChangesQuery.data]);
|
||||||
|
|
||||||
const lots = estimate.analogs;
|
const lots = estimate.analogs;
|
||||||
if (lots.length === 0) {
|
if (lots.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -224,7 +244,7 @@ export function ListingsCard({ estimate }: Props) {
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{lots.map((lot, i) => (
|
{lots.map((lot, i) => (
|
||||||
<AnalogRow key={i} lot={lot} />
|
<AnalogRow key={i} lot={lot} priceChangeMap={priceChangeMap} />
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
@ -239,7 +259,13 @@ export function ListingsCard({ estimate }: Props) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AnalogRow({ lot }: { lot: AnalogLot }) {
|
function AnalogRow({
|
||||||
|
lot,
|
||||||
|
priceChangeMap,
|
||||||
|
}: {
|
||||||
|
lot: AnalogLot;
|
||||||
|
priceChangeMap: Map<string, CianPriceChangeStats>;
|
||||||
|
}) {
|
||||||
const src = lot.source ?? "";
|
const src = lot.source ?? "";
|
||||||
const dot = SOURCE_DOTS[src] ?? "dom";
|
const dot = SOURCE_DOTS[src] ?? "dom";
|
||||||
const label = SOURCE_LABELS[src] ?? src;
|
const label = SOURCE_LABELS[src] ?? src;
|
||||||
|
|
@ -251,6 +277,9 @@ function AnalogRow({ lot }: { lot: AnalogLot }) {
|
||||||
: `${(lot.distance_m / 1000).toFixed(1)} км`;
|
: `${(lot.distance_m / 1000).toFixed(1)} км`;
|
||||||
const externalUrl = safeUrl(lot.source_url);
|
const externalUrl = safeUrl(lot.source_url);
|
||||||
|
|
||||||
|
const cianId = src === "cian" ? extractCianId(lot.source_url) : null;
|
||||||
|
const priceStats = cianId ? priceChangeMap.get(cianId) : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
|
|
@ -269,8 +298,35 @@ function AnalogRow({ lot }: { lot: AnalogLot }) {
|
||||||
<span className={`src-dot ${dot}`} /> {label}
|
<span className={`src-dot ${dot}`} /> {label}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="num">{fmtRub(lot.price_per_m2)}</td>
|
<td className="num">
|
||||||
<td className="num">{fmtRub(lot.price_rub)}</td>
|
{fmtRub(lot.price_per_m2)}
|
||||||
|
</td>
|
||||||
|
<td className="num">
|
||||||
|
{fmtRub(lot.price_rub)}
|
||||||
|
{priceStats && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
padding: "2px 6px",
|
||||||
|
borderRadius: 4,
|
||||||
|
background:
|
||||||
|
priceStats.last_diff_percent != null && priceStats.last_diff_percent < 0
|
||||||
|
? "#dcfce7"
|
||||||
|
: "#fee2e2",
|
||||||
|
color:
|
||||||
|
priceStats.last_diff_percent != null && priceStats.last_diff_percent < 0
|
||||||
|
? "#166534"
|
||||||
|
: "#991b1b",
|
||||||
|
marginLeft: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{priceStats.n_changes > 1 ? `↓${priceStats.n_changes}` : "↓1"}
|
||||||
|
{priceStats.last_diff_percent != null
|
||||||
|
? ` ${priceStats.last_diff_percent > 0 ? "+" : ""}${priceStats.last_diff_percent.toFixed(1)}%`
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td className="num">{distance}</td>
|
<td className="num">{distance}</td>
|
||||||
<td>
|
<td>
|
||||||
{externalUrl && (
|
{externalUrl && (
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
import { useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
LineChart,
|
LineChart,
|
||||||
Line,
|
Line,
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
Legend,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
|
|
@ -14,7 +16,30 @@ type Props = { points: PriceHistoryYearPoint[] };
|
||||||
|
|
||||||
const fmtK = (n: number) => `${Math.round(n / 1000)}k`;
|
const fmtK = (n: number) => `${Math.round(n / 1000)}k`;
|
||||||
|
|
||||||
|
interface PivotRow {
|
||||||
|
year: number;
|
||||||
|
avito_imv?: number;
|
||||||
|
yandex_valuation?: number;
|
||||||
|
n_avito?: number;
|
||||||
|
n_yandex?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export function PriceHistoryChart({ points }: Props) {
|
export function PriceHistoryChart({ points }: Props) {
|
||||||
|
const pivoted = useMemo<PivotRow[]>(() => {
|
||||||
|
const byYear: Record<number, PivotRow> = {};
|
||||||
|
for (const p of points) {
|
||||||
|
if (!byYear[p.year]) byYear[p.year] = { year: p.year };
|
||||||
|
if (p.source === "avito_imv") {
|
||||||
|
byYear[p.year].avito_imv = p.median_price_per_m2;
|
||||||
|
byYear[p.year].n_avito = p.n_lots;
|
||||||
|
} else if (p.source === "yandex_valuation") {
|
||||||
|
byYear[p.year].yandex_valuation = p.median_price_per_m2;
|
||||||
|
byYear[p.year].n_yandex = p.n_lots;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.values(byYear).sort((a, b) => a.year - b.year);
|
||||||
|
}, [points]);
|
||||||
|
|
||||||
const totalLots = points.reduce((s, p) => s + p.n_lots, 0);
|
const totalLots = points.reduce((s, p) => s + p.n_lots, 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -24,13 +49,13 @@ export function PriceHistoryChart({ points }: Props) {
|
||||||
Динамика цен в доме
|
Динамика цен в доме
|
||||||
</h4>
|
</h4>
|
||||||
<small style={{ color: "var(--muted, #6b7280)" }}>
|
<small style={{ color: "var(--muted, #6b7280)" }}>
|
||||||
Медиана ₽/м² по году (по {totalLots} лотам)
|
Медиана ₽/м² по году · Avito + Яндекс (по {totalLots} лотам)
|
||||||
</small>
|
</small>
|
||||||
</header>
|
</header>
|
||||||
<div style={{ width: "100%", height: 240 }}>
|
<div style={{ width: "100%", height: 240 }}>
|
||||||
<ResponsiveContainer>
|
<ResponsiveContainer>
|
||||||
<LineChart
|
<LineChart
|
||||||
data={points}
|
data={pivoted}
|
||||||
margin={{ top: 8, right: 16, left: 8, bottom: 8 }}
|
margin={{ top: 8, right: 16, left: 8, bottom: 8 }}
|
||||||
>
|
>
|
||||||
<CartesianGrid stroke="#eee" strokeDasharray="3 3" />
|
<CartesianGrid stroke="#eee" strokeDasharray="3 3" />
|
||||||
|
|
@ -38,18 +63,35 @@ export function PriceHistoryChart({ points }: Props) {
|
||||||
<YAxis tickFormatter={fmtK} tick={{ fontSize: 11 }} />
|
<YAxis tickFormatter={fmtK} tick={{ fontSize: 11 }} />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
formatter={(value: number, name: string) =>
|
formatter={(value: number, name: string) =>
|
||||||
name === "median_price_per_m2"
|
[
|
||||||
? [`${value.toLocaleString("ru-RU")} ₽/м²`, "Медиана"]
|
`${value.toLocaleString("ru-RU")} ₽/м²`,
|
||||||
: [value, name]
|
name === "avito_imv"
|
||||||
|
? "Avito"
|
||||||
|
: name === "yandex_valuation"
|
||||||
|
? "Яндекс"
|
||||||
|
: name,
|
||||||
|
]
|
||||||
}
|
}
|
||||||
labelFormatter={(year) => `${year} год`}
|
labelFormatter={(year) => `${year} год`}
|
||||||
/>
|
/>
|
||||||
|
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="median_price_per_m2"
|
dataKey="avito_imv"
|
||||||
stroke="#2563eb"
|
stroke="#2563eb"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
dot={{ r: 3 }}
|
dot={{ r: 3 }}
|
||||||
|
name="Avito"
|
||||||
|
connectNulls
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="yandex_valuation"
|
||||||
|
stroke="#ea580c"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ r: 3 }}
|
||||||
|
name="Яндекс"
|
||||||
|
connectNulls
|
||||||
/>
|
/>
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { apiFetch } from "./api";
|
import { apiFetch } from "./api";
|
||||||
import type {
|
import type {
|
||||||
AggregatedEstimate,
|
AggregatedEstimate,
|
||||||
|
CianPriceChangeStats,
|
||||||
HouseAnalyticsResponse,
|
HouseAnalyticsResponse,
|
||||||
HouseInfoForEstimate,
|
HouseInfoForEstimate,
|
||||||
IMVBenchmarkResponse,
|
IMVBenchmarkResponse,
|
||||||
|
|
@ -86,6 +87,22 @@ export function useEstimateImvBenchmark(estimate_id: string | null) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/trade-in/estimate/{id}/cian-price-changes
|
||||||
|
* Price change history for Cian analog listings (only those with >0 changes).
|
||||||
|
*/
|
||||||
|
export function useEstimateCianPriceChanges(estimate_id: string | null) {
|
||||||
|
return useQuery<CianPriceChangeStats[]>({
|
||||||
|
queryKey: ["trade-in", "estimate", estimate_id, "cian-price-changes"],
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<CianPriceChangeStats[]>(
|
||||||
|
`${BASE}/estimate/${estimate_id}/cian-price-changes`,
|
||||||
|
),
|
||||||
|
enabled: estimate_id !== null && estimate_id.length > 0,
|
||||||
|
staleTime: 10 * 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/v1/trade-in/estimate/{id}/house-analytics
|
* GET /api/v1/trade-in/estimate/{id}/house-analytics
|
||||||
* Price history, KPI and recent sold lots for the target house.
|
* Price history, KPI and recent sold lots for the target house.
|
||||||
|
|
|
||||||
|
|
@ -146,11 +146,23 @@ export interface IMVBenchmarkResponse {
|
||||||
|
|
||||||
export interface PriceHistoryYearPoint {
|
export interface PriceHistoryYearPoint {
|
||||||
year: number;
|
year: number;
|
||||||
|
source: string; // 'avito_imv' | 'yandex_valuation'
|
||||||
median_price_per_m2: number;
|
median_price_per_m2: number;
|
||||||
n_lots: number;
|
n_lots: number;
|
||||||
median_price_rub: number;
|
median_price_rub: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CianPriceChangeStats {
|
||||||
|
cian_id: string;
|
||||||
|
listing_id: number;
|
||||||
|
n_changes: number;
|
||||||
|
last_change_time: string | null;
|
||||||
|
last_diff_percent: number | null;
|
||||||
|
total_change_pct: number | null;
|
||||||
|
first_seen_price: number | null;
|
||||||
|
current_price: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RecentSoldEntry {
|
export interface RecentSoldEntry {
|
||||||
id: number;
|
id: number;
|
||||||
source: string; // 'avito_imv' | 'yandex_valuation'
|
source: string; // 'avito_imv' | 'yandex_valuation'
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue