This commit is contained in:
parent
577ddc0654
commit
63756c3c29
4 changed files with 340 additions and 41 deletions
|
|
@ -1,13 +1,20 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* StreetDealsCard — «По вашей улице» — ДКП-сделки Росреестра по улице target адреса.
|
* StreetDealsCard — «По вашей улице» — ДКП-сделки Росреестра по улице target адреса
|
||||||
* Питается от GET /api/v1/trade-in/street-deals (PR #555).
|
* с привязкой к историческим listings (Avito / Cian / Yandex / N1).
|
||||||
* Не рендерится если street не извлёкся или count = 0.
|
*
|
||||||
|
* Питается от GET /api/v1/trade-in/sales-vs-listings (PR L / #564 Phase 2).
|
||||||
|
* Endpoint = superset предыдущего /street-deals: возвращает все pairs (LEFT JOIN
|
||||||
|
* deal → listing) + linkage_rate. Aggregates (count / median / range) считаются
|
||||||
|
* client-side по pairs[].deal_price_rub.
|
||||||
|
*
|
||||||
|
* Не рендерится если street не извлёкся или pairs.length = 0.
|
||||||
*/
|
*/
|
||||||
import { useStreetDeals } from "@/lib/trade-in-api";
|
import { useSalesVsListings } from "@/lib/trade-in-api";
|
||||||
import { openRosreestrWithAddress } from "@/lib/rosreestr";
|
import { openRosreestrWithAddress } from "@/lib/rosreestr";
|
||||||
import type { AggregatedEstimate, AnalogLot } from "@/types/trade-in";
|
import { safeUrl } from "@/lib/safeUrl";
|
||||||
|
import type { AggregatedEstimate, SalesListingPair } from "@/types/trade-in";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
estimate: AggregatedEstimate;
|
estimate: AggregatedEstimate;
|
||||||
|
|
@ -30,18 +37,54 @@ function fmtDate(iso: string | null): string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Median по числовому массиву (linear interpolation between centre items). */
|
||||||
|
function median(values: number[]): number {
|
||||||
|
if (values.length === 0) return 0;
|
||||||
|
const sorted = [...values].sort((a, b) => a - b);
|
||||||
|
const mid = sorted.length / 2;
|
||||||
|
if (Number.isInteger(mid)) {
|
||||||
|
return (sorted[mid - 1] + sorted[mid]) / 2;
|
||||||
|
}
|
||||||
|
return sorted[Math.floor(mid)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Срок «за N дней до» / «через N дней после» сделки. */
|
||||||
|
function fmtDaysToDeal(days: number | null): string {
|
||||||
|
if (days === null) return "—";
|
||||||
|
const abs = Math.abs(days);
|
||||||
|
// Положительное = listing раньше сделки (типичный случай: «за 45 дней до»).
|
||||||
|
// Отрицательное = listing появился позже сделки (отложенный парсинг, rare).
|
||||||
|
return days >= 0 ? `за ${abs} дн до` : `через ${abs} дн после`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Формат discount_pct со знаком и одной десятичной. */
|
||||||
|
function fmtDiscount(pct: number): string {
|
||||||
|
const sign = pct > 0 ? "+" : pct < 0 ? "−" : "";
|
||||||
|
return `${sign}${Math.abs(pct).toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
export function StreetDealsCard({ estimate }: Props) {
|
export function StreetDealsCard({ estimate }: Props) {
|
||||||
const { data, isLoading, isError } = useStreetDeals(
|
const { data, isLoading, isError } = useSalesVsListings(
|
||||||
estimate.target_address ?? null,
|
estimate.target_address ?? null,
|
||||||
estimate.area_m2 ?? null,
|
estimate.area_m2 ?? null,
|
||||||
estimate.rooms ?? null,
|
estimate.rooms ?? null,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isLoading || isError) return null;
|
if (isLoading || isError) return null;
|
||||||
if (!data || !data.street || data.count === 0) return null;
|
if (!data || !data.street || data.pairs.length === 0) return null;
|
||||||
|
|
||||||
const rangeLowM = (data.range_low_rub / 1_000_000).toFixed(1);
|
// Client-side aggregates по deal_price_rub / deal_price_per_m2 — endpoint
|
||||||
const rangeHighM = (data.range_high_rub / 1_000_000).toFixed(1);
|
// /sales-vs-listings не возвращает их (только linkage-связанные).
|
||||||
|
const prices = data.pairs.map((p) => p.deal_price_rub);
|
||||||
|
const ppm2Values = data.pairs.map((p) => p.deal_price_per_m2);
|
||||||
|
const medianPpm2 = Math.round(median(ppm2Values));
|
||||||
|
const rangeLow = Math.min(...prices);
|
||||||
|
const rangeHigh = Math.max(...prices);
|
||||||
|
const rangeLowM = (rangeLow / 1_000_000).toFixed(1);
|
||||||
|
const rangeHighM = (rangeHigh / 1_000_000).toFixed(1);
|
||||||
|
|
||||||
|
const hasAnyListing = data.deals_with_listings > 0;
|
||||||
|
const visiblePairs: SalesListingPair[] = data.pairs.slice(0, 10);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="card">
|
<article className="card">
|
||||||
|
|
@ -49,27 +92,49 @@ export function StreetDealsCard({ estimate }: Props) {
|
||||||
<div>
|
<div>
|
||||||
<div className="section-kicker">Секция · По вашей улице</div>
|
<div className="section-kicker">Секция · По вашей улице</div>
|
||||||
<h2>ДКП-сделки на улице {data.street}</h2>
|
<h2>ДКП-сделки на улице {data.street}</h2>
|
||||||
|
{hasAnyListing && (
|
||||||
|
<div className="linkage-hint">
|
||||||
|
<b className="mono">{data.linkage_rate_pct.toFixed(0)}%</b> сделок имеют
|
||||||
|
исторический ASK
|
||||||
|
{data.median_discount_pct !== null && (
|
||||||
|
<>
|
||||||
|
{" · медианный торг "}
|
||||||
|
<b
|
||||||
|
className={
|
||||||
|
data.median_discount_pct < 0
|
||||||
|
? "discount-neg mono"
|
||||||
|
: data.median_discount_pct > 0
|
||||||
|
? "discount-pos mono"
|
||||||
|
: "mono"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{fmtDiscount(data.median_discount_pct)}
|
||||||
|
</b>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="card-meta">
|
<div className="card-meta">
|
||||||
<div>
|
<div>
|
||||||
Период: <b className="mono">12 мес</b>
|
Период: <b className="mono">{data.period_months} мес</b>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: 4 }}>источник: Росреестр (открытые данные)</div>
|
<div style={{ marginTop: 4 }}>источник: Росреестр + объявления</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="count-strip">
|
<div className="count-strip">
|
||||||
<div className="count-cell">
|
<div className="count-cell">
|
||||||
<div className="label">Сделок за 12 мес</div>
|
<div className="label">Сделок за {data.period_months} мес</div>
|
||||||
<div className="value">
|
<div className="value">
|
||||||
<span data-tnum>{data.count}</span>
|
<span data-tnum>{data.total_deals}</span>
|
||||||
<span className="unit">шт</span>
|
<span className="unit">шт</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="count-cell">
|
<div className="count-cell">
|
||||||
<div className="label">Медиана</div>
|
<div className="label">Медиана</div>
|
||||||
<div className="value">
|
<div className="value">
|
||||||
<span data-tnum>{fmtRub(data.median_price_per_m2)}</span>
|
<span data-tnum>{fmtRub(medianPpm2)}</span>
|
||||||
<span className="unit">₽/м²</span>
|
<span className="unit">₽/м²</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -84,51 +149,135 @@ export function StreetDealsCard({ estimate }: Props) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table className="dt" aria-label="Сделки по улице">
|
<table className="dt dt-with-asks" aria-label="Сделки по улице с историческим ASK">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Адрес</th>
|
<th>Адрес</th>
|
||||||
<th className="num">Площадь</th>
|
<th className="num">Площадь</th>
|
||||||
<th className="num">Цена</th>
|
<th className="num">Цена сделки</th>
|
||||||
<th className="num">₽/м²</th>
|
<th className="num">₽/м²</th>
|
||||||
<th className="num">Дата</th>
|
<th className="num">Дата</th>
|
||||||
|
{hasAnyListing && <th>Объявление до сделки</th>}
|
||||||
<th />
|
<th />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{data.deals.map((d: AnalogLot, i: number) => (
|
{visiblePairs.map((p) => {
|
||||||
<tr key={i}>
|
const safeListingUrl = safeUrl(p.listing_source_url);
|
||||||
<td>
|
const hasListing = p.listing_id !== null && p.listing_price_rub !== null;
|
||||||
<span className="addr">
|
return (
|
||||||
<span className="a-main">{d.address}</span>
|
<tr key={p.deal_id}>
|
||||||
</span>
|
<td>
|
||||||
</td>
|
<span className="addr">
|
||||||
<td className="num">{d.area_m2.toFixed(1)} м²</td>
|
<span className="a-main">{p.deal_address}</span>
|
||||||
<td className="num">{fmtRub(d.price_rub)} ₽</td>
|
</span>
|
||||||
<td className="num">{fmtRub(d.price_per_m2)}</td>
|
</td>
|
||||||
<td className="num">{fmtDate(d.listing_date)}</td>
|
<td className="num">{p.deal_area_m2.toFixed(1)} м²</td>
|
||||||
<td>
|
<td className="num">{fmtRub(p.deal_price_rub)} ₽</td>
|
||||||
<button
|
<td className="num">{fmtRub(p.deal_price_per_m2)}</td>
|
||||||
type="button"
|
<td className="num">{fmtDate(p.deal_date)}</td>
|
||||||
className="rosreestr-btn"
|
{hasAnyListing && (
|
||||||
onClick={() => void openRosreestrWithAddress(d.address)}
|
<td className="listing-cell">
|
||||||
title="Скопировать адрес и открыть форму запроса выписки ЕГРН"
|
{hasListing ? (
|
||||||
>
|
<ListingBadge
|
||||||
Росреестр ↗
|
source={p.listing_source}
|
||||||
</button>
|
url={safeListingUrl}
|
||||||
</td>
|
priceRub={p.listing_price_rub}
|
||||||
</tr>
|
daysToDeal={p.days_listing_to_deal}
|
||||||
))}
|
discountPct={p.discount_pct}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="listing-empty">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rosreestr-btn"
|
||||||
|
onClick={() => void openRosreestrWithAddress(p.deal_address)}
|
||||||
|
title="Скопировать адрес и открыть форму запроса выписки ЕГРН"
|
||||||
|
>
|
||||||
|
Росреестр ↗
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<div className="table-foot">
|
<div className="table-foot">
|
||||||
<span>
|
<span>
|
||||||
Показано{" "}
|
Показано{" "}
|
||||||
<b style={{ color: "var(--fg)" }}>{Math.min(data.deals.length, 10)}</b>{" "}
|
<b style={{ color: "var(--fg)" }}>{visiblePairs.length}</b> из{" "}
|
||||||
из <b style={{ color: "var(--fg)" }}>{data.count}</b> сделок
|
<b style={{ color: "var(--fg)" }}>{data.total_deals}</b> сделок
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Listing badge cell ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ListingBadgeProps {
|
||||||
|
source: string | null;
|
||||||
|
url: string | null;
|
||||||
|
priceRub: number | null;
|
||||||
|
daysToDeal: number | null;
|
||||||
|
discountPct: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ListingBadge({
|
||||||
|
source,
|
||||||
|
url,
|
||||||
|
priceRub,
|
||||||
|
daysToDeal,
|
||||||
|
discountPct,
|
||||||
|
}: ListingBadgeProps) {
|
||||||
|
const sourceLabel = source ? source.toUpperCase() : "—";
|
||||||
|
// discount < 0 → продано дешевле выставленного (типичный торг, success).
|
||||||
|
// discount > 0 → продано дороже выставленного (редко, обычно ошибка data, danger).
|
||||||
|
const discountClass =
|
||||||
|
discountPct === null
|
||||||
|
? ""
|
||||||
|
: discountPct < 0
|
||||||
|
? "discount-neg"
|
||||||
|
: discountPct > 0
|
||||||
|
? "discount-pos"
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const inner = (
|
||||||
|
<>
|
||||||
|
<span className="listing-source-badge">{sourceLabel}</span>
|
||||||
|
<span className="listing-price mono">
|
||||||
|
{priceRub !== null ? `${fmtRub(priceRub)} ₽` : "—"}
|
||||||
|
</span>
|
||||||
|
<span className="listing-meta">
|
||||||
|
{fmtDaysToDeal(daysToDeal)}
|
||||||
|
{discountPct !== null && (
|
||||||
|
<>
|
||||||
|
{" · "}
|
||||||
|
<b className={`${discountClass} mono`}>{fmtDiscount(discountPct)}</b>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
className="listing-link"
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title={`Открыть объявление на ${sourceLabel.toLowerCase()}`}
|
||||||
|
>
|
||||||
|
{inner}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span className="listing-link is-static">{inner}</span>;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1612,3 +1612,82 @@
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
border-color: var(--fg);
|
border-color: var(--fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── PR L — StreetDealsCard with paired listings (sales-vs-listings) ── */
|
||||||
|
|
||||||
|
/* Подсказка о покрытии (linkage_rate_pct) в card-head */
|
||||||
|
.linkage-hint {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.linkage-hint b {
|
||||||
|
color: var(--fg-2);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Знак discount_pct — отрицательный = торг (success), положительный = редко (danger). */
|
||||||
|
.discount-neg { color: var(--success); }
|
||||||
|
.discount-pos { color: var(--danger); }
|
||||||
|
|
||||||
|
/* Ячейка listing-колонки */
|
||||||
|
.listing-cell {
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
.listing-empty {
|
||||||
|
color: var(--muted-2);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Container link / static — inline-flex с baseline alignment */
|
||||||
|
.listing-link {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
max-width: 100%;
|
||||||
|
color: var(--fg);
|
||||||
|
text-decoration: none;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.listing-link:hover:not(.is-static) .listing-price { color: var(--accent); }
|
||||||
|
.listing-link.is-static { cursor: default; }
|
||||||
|
|
||||||
|
/* Source badge (AVITO / CIAN / YANDEX / N1 / DOMKLIK) — per ui-tokens accent-soft + accent text uppercase 11px */
|
||||||
|
.listing-source-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 6px;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent-ink);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listing-price {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--fg);
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listing-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile (≤720px) — listing column stacks; tables уже становятся block per existing rules */
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.listing-cell { min-width: 0; max-width: none; }
|
||||||
|
.listing-link { flex-direction: column; align-items: flex-start; gap: 2px; }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import type {
|
||||||
HouseInfoForEstimate,
|
HouseInfoForEstimate,
|
||||||
IMVBenchmarkResponse,
|
IMVBenchmarkResponse,
|
||||||
PlacementHistoryItem,
|
PlacementHistoryItem,
|
||||||
|
SalesVsListingsResponse,
|
||||||
SellTimeSensitivityResponse,
|
SellTimeSensitivityResponse,
|
||||||
StreetDealsResponse,
|
StreetDealsResponse,
|
||||||
TradeInEstimateInput,
|
TradeInEstimateInput,
|
||||||
|
|
@ -144,6 +145,34 @@ export function useStreetDeals(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/trade-in/sales-vs-listings
|
||||||
|
* ДКП-сделки + paired listings по улице (PR L / #564 Phase 2). Возвращает все
|
||||||
|
* пары LEFT JOIN — сделки без listing match сохраняются (listing_* = null),
|
||||||
|
* чтобы вычислить linkage_rate. Используется в StreetDealsCard для показа
|
||||||
|
* исторических ASK рядом с фактической ценой сделки.
|
||||||
|
*
|
||||||
|
* staleTime: 10 минут — данные обновляются раз в сутки (rosreestr import +
|
||||||
|
* scrapers), нет смысла часто рефетчить.
|
||||||
|
*/
|
||||||
|
export function useSalesVsListings(
|
||||||
|
address: string | null,
|
||||||
|
area_m2: number | null,
|
||||||
|
rooms: number | null,
|
||||||
|
) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (address) params.set("address", address);
|
||||||
|
if (area_m2 !== null) params.set("area_m2", String(area_m2));
|
||||||
|
if (rooms !== null) params.set("rooms", String(rooms));
|
||||||
|
return useQuery<SalesVsListingsResponse>({
|
||||||
|
queryKey: ["trade-in", "sales-vs-listings", address, area_m2, rooms],
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<SalesVsListingsResponse>(`${BASE}/sales-vs-listings?${params}`),
|
||||||
|
enabled: !!address && area_m2 !== null && rooms !== null,
|
||||||
|
staleTime: 10 * 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/v1/trade-in/estimate/{id}/sell-time-sensitivity
|
* GET /api/v1/trade-in/estimate/{id}/sell-time-sensitivity
|
||||||
* Median exposure days bucketed by price premium (-5%, 0, +5%, +10%).
|
* Median exposure days bucketed by price premium (-5%, 0, +5%, +10%).
|
||||||
|
|
|
||||||
|
|
@ -206,6 +206,48 @@ export interface StreetDealsResponse {
|
||||||
deals: AnalogLot[]; // top-10 по deal_date DESC
|
deals: AnalogLot[]; // top-10 по deal_date DESC
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Sales vs Listings (endpoint: GET /trade-in/sales-vs-listings) ──
|
||||||
|
// Phase 2 PR L (#564): для каждой ДКП-сделки возвращает paired listing (если есть).
|
||||||
|
// LEFT JOIN — все deals сохраняются; listing_* = null при отсутствии match.
|
||||||
|
|
||||||
|
export interface SalesListingPair {
|
||||||
|
deal_id: number;
|
||||||
|
deal_date: string; // ISO date
|
||||||
|
deal_price_rub: number;
|
||||||
|
deal_price_per_m2: number;
|
||||||
|
deal_area_m2: number;
|
||||||
|
deal_rooms: number;
|
||||||
|
deal_floor: number | null;
|
||||||
|
deal_address: string;
|
||||||
|
|
||||||
|
listing_id: number | null;
|
||||||
|
listing_source: string | null; // 'avito' / 'cian' / 'yandex' / 'n1' / 'domklik'
|
||||||
|
listing_source_url: string | null;
|
||||||
|
listing_date: string | null; // ISO date
|
||||||
|
listing_price_rub: number | null;
|
||||||
|
listing_price_per_m2: number | null;
|
||||||
|
listing_area_m2: number | null;
|
||||||
|
|
||||||
|
// Положительный = listing date раньше сделки (типичный кейс).
|
||||||
|
// Отрицательный = listing появился позже сделки (отложенный парсинг).
|
||||||
|
days_listing_to_deal: number | null;
|
||||||
|
// discount_pct = (deal_price - listing_price) / listing_price * 100.
|
||||||
|
// Отрицательный = продали дешевле выставленного (нормальный торг).
|
||||||
|
discount_pct: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesVsListingsResponse {
|
||||||
|
street: string | null;
|
||||||
|
period_months: number; // 24 default
|
||||||
|
window_days: number; // 180 default
|
||||||
|
area_tolerance: number; // 0.15 default
|
||||||
|
total_deals: number;
|
||||||
|
deals_with_listings: number;
|
||||||
|
linkage_rate_pct: number;
|
||||||
|
median_discount_pct: number | null;
|
||||||
|
pairs: SalesListingPair[]; // все sorted by deal_date DESC
|
||||||
|
}
|
||||||
|
|
||||||
// ── Sell-time sensitivity (endpoint: GET /estimate/{id}/sell-time-sensitivity) ──
|
// ── Sell-time sensitivity (endpoint: GET /estimate/{id}/sell-time-sensitivity) ──
|
||||||
|
|
||||||
export interface SellTimeBucket {
|
export interface SellTimeBucket {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue