From 07655a26429cee608ee5fb592b1f17cb65829743 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 21 Aug 2026 16:59:05 +0500 Subject: [PATCH 1/3] =?UTF-8?q?feat(tradein/v2):=20=D0=BF=D0=BE=D0=B7?= =?UTF-8?q?=D0=B8=D1=86=D0=B8=D1=8F=20=D0=BA=D0=B2=D0=B0=D1=80=D1=82=D0=B8?= =?UTF-8?q?=D1=80=D1=8B=20=D0=BD=D0=B0=20=D1=80=D1=8B=D0=BD=D0=BA=D0=B5=20?= =?UTF-8?q?=E2=80=94=20=D0=BF=D0=BB=D0=B0=D1=88=D0=BA=D0=B0=20=D0=BD=D0=B0?= =?UTF-8?q?=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B5=20=D1=80?= =?UTF-8?q?=D0=B5=D0=BA=D0=BE=D0=BC=D0=B5=D0=BD=D0=B4=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=BD=D0=BE=D0=B9=20=D1=86=D0=B5=D0=BD=D1=8B=20(#2899)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Почему: бэкенд с #2926 отдаёт market_percentile (1..99, null при когорте < 15), на проде он уже считается (21.08: 69 при 16 аналогах, 57 при 23, null при 11), но фронт поле не знал — «бейдж из макета» так и не появился. Что: AggregatedEstimate.market_percentile в wire-типе; маппер даёт карточке «РЕКОМЕНДОВАННАЯ ЦЕНА» ту же спокойную плашку, что у карточки 2: лейбл по терцилям («Низ рынка» ≤33 / «В рынке» / «Верх рынка» ≥67) и подпись «69-й перцентиль среди 16 аналогов» — число рядом, чтобы лейбл не читался точнее, чем он есть; при null плашки нет (не «В рынке» по умолчанию). ResultPanel: JSX плашки вынесен в DeltaPill и рисуется и в ветке с гистограммой — раньше delta у карточки с барами терялся бы молча. Тест marketPercentile.test.ts (vitest): лейблы и границы терцилей, null → нет плашки, delta карточки 2 не тронут, склонение по n_analogs. На main красный по значению (delta карточки 1 === undefined). Refs #2899 --- .../v2/__tests__/marketPercentile.test.ts | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 tradein-mvp/frontend/src/components/trade-in/v2/__tests__/marketPercentile.test.ts diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/marketPercentile.test.ts b/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/marketPercentile.test.ts new file mode 100644 index 00000000..f1bd46e2 --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/marketPercentile.test.ts @@ -0,0 +1,58 @@ +// #2899 — позиция квартиры внутри когорты аналогов на карточке «РЕКОМЕНДОВАННАЯ +// ЦЕНА». Бэкенд считает market_percentile (1..99, null при когорте < 15); маппер +// обязан превратить его в лейбл по терцилям + подпись с числом и размером когорты, +// а при null — не рисовать ничего (не «В рынке» по умолчанию). На main карточка 1 +// плашки не имеет вовсе — первый тест красный по значению (delta === undefined). + +import { describe, expect, it } from "vitest"; + +import { mapResultPanel } from "../mappers"; +import { FIXTURE_ESTIMATE } from "@/app/ui-preview/estimate/fixture"; +import type { AggregatedEstimate } from "@/types/trade-in"; + +function est(over: Partial): AggregatedEstimate { + return { ...FIXTURE_ESTIMATE, n_analogs: 16, ...over }; +} + +function card1(e: AggregatedEstimate) { + return mapResultPanel(e).cards[0]; +} + +describe("#2899 market_percentile → плашка карточки 1", () => { + it("69-й перцентиль среди 16 аналогов → «Верх рынка» + честная подпись", () => { + const c = card1(est({ market_percentile: 69 })); + expect(c.delta).toBe("Верх рынка"); + expect(c.deltaLabel).toBe("69-й перцентиль среди 16 аналогов"); + }); + + it("терцили: ≤33 — низ, 34..66 — в рынке, ≥67 — верх", () => { + expect(card1(est({ market_percentile: 20 })).delta).toBe("Низ рынка"); + expect(card1(est({ market_percentile: 33 })).delta).toBe("Низ рынка"); + expect(card1(est({ market_percentile: 34 })).delta).toBe("В рынке"); + expect(card1(est({ market_percentile: 50 })).delta).toBe("В рынке"); + expect(card1(est({ market_percentile: 66 })).delta).toBe("В рынке"); + expect(card1(est({ market_percentile: 67 })).delta).toBe("Верх рынка"); + }); + + it("null / отсутствие поля → плашки нет; delta карточки 2 не тронут", () => { + const withNull = mapResultPanel(est({ market_percentile: null })).cards; + const legacy = mapResultPanel(est({})).cards; + expect(withNull[0].delta).toBeUndefined(); + expect(withNull[0].deltaLabel).toBeUndefined(); + expect(legacy[0].delta).toBeUndefined(); + // контроль: плашка «к цене объявления» карточки 2 живёт своей жизнью + expect(withNull[1].delta).toBe( + mapResultPanel(FIXTURE_ESTIMATE).cards[1].delta, + ); + expect(withNull[1].deltaLabel).toBe("к цене объявления"); + }); + + it("размер когорты в подписи берётся из n_analogs, склонение по числу", () => { + expect( + card1(est({ market_percentile: 50, n_analogs: 21 })).deltaLabel, + ).toBe("50-й перцентиль среди 21 аналога"); + expect( + card1(est({ market_percentile: 50, n_analogs: 23 })).deltaLabel, + ).toBe("50-й перцентиль среди 23 аналогов"); + }); +}); -- 2.45.3 From 03af7386e9cc27257f4d9713332e8bd59b50213a Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 21 Aug 2026 17:00:05 +0500 Subject: [PATCH 2/3] =?UTF-8?q?feat(tradein/v2):=20#2899=20=E2=80=94=20?= =?UTF-8?q?=D1=81=D0=B0=D0=BC=D0=B8=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8?= =?UTF-8?q?=20=D1=82=D0=B8=D0=BF=D0=B0/=D0=BC=D0=B0=D0=BF=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B0/ResultPanel=20(=D0=B2=20=D0=BF=D0=B5=D1=80=D0=B2?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B8=D1=82=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BF=D0=B0=D0=BB=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE?= =?UTF-8?q?=20=D1=82=D0=B5=D1=81=D1=82:=20git=20checkout=20HEAD=20=D0=BE?= =?UTF-8?q?=D1=82=D0=BA=D0=B0=D1=82=D0=B8=D0=BB=20=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=87=D0=B5=D0=B5=20=D0=B4=D0=B5=D1=80=D0=B5=D0=B2=D0=BE=20?= =?UTF-8?q?=D0=B4=D0=BE=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B8=D1=82=D0=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/trade-in/v2/ResultPanel.tsx | 617 +++++++++--------- .../src/components/trade-in/v2/mappers.ts | 57 +- tradein-mvp/frontend/src/types/trade-in.ts | 90 +-- 3 files changed, 418 insertions(+), 346 deletions(-) diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx index 80895489..ab07840c 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx @@ -37,6 +37,41 @@ const { font, } = tokens; +// #2899 — одна и та же спокойная плашка для «−18% · к цене объявления» (карточка +// 2) и «Верх рынка · 69-й перцентиль среди 16 аналогов» (карточка 1). Раньше JSX +// плашки жил только в ветке без гистограммы, и delta карточки с барами терялся бы +// молча. +function DeltaPill({ + delta, + deltaLabel, +}: { + delta: string; + deltaLabel?: string; +}) { + return ( + + + {delta} + + {deltaLabel} + + ); +} + interface ResultPanelProps { // Required on the app path (v2/page.tsx always supplies mapResultPanel // output) — an omitted prop must be a TS error, not a silent fallback to @@ -205,319 +240,198 @@ export default function ResultPanel({ const isHeadline = !isEmptyCard && ci === headlineIndex; const isAsking = ci === 0; return ( -
-
+
- {lines(card.title)} -
- {/* #1991 — explicit "this is the figure that goes into the offer" +
+ {lines(card.title)} +
+ {/* #1991 — explicit "this is the figure that goes into the offer" mark on the headline card, so the two dominant prices are not just visually bigger but literally labelled which one to quote. */} - {isHeadline && !isEmptyCard && ( - - В ОФФЕР - - )} -
- {isRegistry && ( -
- {/* §M8 — расшифровка «ДКП» один раз на секцию. */} - СПРАВОЧНО · ДОГОВОРЫ КУПЛИ-ПРОДАЖИ -
- )} - {/* H1 empty-state: no bare «—» / gauge / «· с учётом торга» — just an - honest note pointing at the recommended asking price. */} - {isEmptyCard ? ( -
- {card.emptyNote} -
- ) : ( - <> -
+ {isHeadline && !isEmptyCard && ( - {card.value} - - - {card.unit} + В ОФФЕР -
- {card.range && ( -
- {card.range} -
)} - {card.ppm && ( -
- {card.ppm} -
- )} - {card.note && ( -
- {card.note} -
- )} - - )} - - {isEmptyCard ? ( - - ) : card.bars ? ( -
+
+ {isRegistry && (
- {card.bars.map((h, bi) => ( - - ))} + {/* §M8 — расшифровка «ДКП» один раз на секцию. */} + СПРАВОЧНО · ДОГОВОРЫ КУПЛИ-ПРОДАЖИ
- -
- ) : ( - // M2 — calm delta pill (was a 51px circular gauge that read like a - // tech "занижение" indicator). Full text «−18% к цене объявления», - // neutral accentDeep on a soft-blue chip, tabular-nums. - <> -
- {card.delta ? ( - + ) : ( + <> +
- {card.delta} + {card.value} - - {card.deltaLabel} + + {card.unit} - - ) : ( - - )} +
+ {card.range && ( +
+ {card.range} +
+ )} + {card.ppm && ( +
+ {card.ppm} +
+ )} + {card.note && ( +
+ {card.note} +
+ )} + + )} + + {isEmptyCard ? ( -
- {card.exposureNote && ( -
- {card.exposureNote} -
+ ) : card.bars ? ( + <> +
+
+ {card.bars.map((h, bi) => ( + + ))} +
+ +
+ {/* #2899: позиция на рынке под гистограммой («Верх рынка · 69-й + перцентиль среди 16 аналогов»); при когорте < 15 delta нет. */} + {card.delta ? ( +
+ +
+ ) : null} + + ) : ( + // M2 — calm delta pill (was a 51px circular gauge that read like a + // tech "занижение" indicator). Full text «−18% к цене объявления», + // neutral accentDeep on a soft-blue chip, tabular-nums. + <> +
+ {card.delta ? ( + + ) : ( + + )} + +
+ {card.exposureNote && ( +
+ {card.exposureNote} +
+ )} + )} - - )} -
+
); })} @@ -583,8 +610,8 @@ export default function ResultPanel({ marginTop: -6, }} > - {SHORT_ESTIMATE_DISCLAIMER} Диапазоны показывают разброс цен на рынке, - а не погрешность оценки. + {SHORT_ESTIMATE_DISCLAIMER} Диапазоны показывают разброс цен на рынке, а + не погрешность оценки. {/* ranges + radar */} diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts index 777918d1..fb543494 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts @@ -605,10 +605,16 @@ interface DealTier { } /** Свежайшая дата среди лотов + её точность (лоты не всегда отсортированы). */ -function newestLot(lots: AnalogLot[]): [string | null, "day" | "quarter" | null] { +function newestLot( + lots: AnalogLot[], +): [string | null, "day" | "quarter" | null] { let best: AnalogLot | null = null; for (const l of lots) { - if (l.listing_date && (!best?.listing_date || l.listing_date > best.listing_date)) best = l; + if ( + l.listing_date && + (!best?.listing_date || l.listing_date > best.listing_date) + ) + best = l; } return [best?.listing_date ?? null, best?.date_precision ?? null]; } @@ -636,7 +642,9 @@ function resolveDealTier( // Fix #1/#8: guard the deal ₽/м² histogram against a high outlier the // same way the ads card does — otherwise a single mis-scraped lot bins // over [min,max] and crushes the real deals into the left buckets. - bars: bins8(guardPriceOutliers(sd.deals.map((d) => d.price_per_m2)).clean), + bars: bins8( + guardPriceOutliers(sd.deals.map((d) => d.price_per_m2)).clean, + ), // sd.deals — top-10 из ORDER BY deal_date DESC по всем sd.count сделкам, // так что максимум по ним = максимум по всей выборке, не по показанным. asOf: dealsAsOfLabel(...newestLot(sd.deals)), @@ -825,11 +833,13 @@ export function mapReport(e: AggregatedEstimate): Report { * - "out_of_coverage" → "вне ЕКБ" (the index only covers Yekaterinburg) * - "insufficient_data" → "мало данных" (too few comparable listings) */ -function locationIndexBadge( - li: LocationIndexResponse | null | undefined, -): { label: string; ok: boolean } { +function locationIndexBadge(li: LocationIndexResponse | null | undefined): { + label: string; + ok: boolean; +} { if (li == null) return { label: "нет данных", ok: false }; - if (li.status === "ok") return { label: fmtPct(li.location_index_pct), ok: true }; + if (li.status === "ok") + return { label: fmtPct(li.location_index_pct), ok: true }; if (li.status === "out_of_coverage") return { label: "вне ЕКБ", ok: false }; return { label: "мало данных", ok: false }; // status === "insufficient_data" } @@ -1063,6 +1073,23 @@ export function mapMarkers(e: AggregatedEstimate | null): MapMarker[] { } /** Full 02 РЕЗУЛЬТАТ block: 3 cards + meta + ranges + scatter + sources. */ +// #2899 — позиция квартиры внутри когорты аналогов: плашка на карточке +// «РЕКОМЕНДОВАННАЯ ЦЕНА». Перцентиль считает бэкенд (1..99, «какая доля аналогов +// дешевле»; null при когорте < 15 — тогда плашки нет, а не «В рынке» по умолчанию). +// Лейбл — по терцилям: ≤33 «Низ рынка», ≥67 «Верх рынка», между — «В рынке»; +// число рядом, чтобы лейбл не читался точнее, чем он есть. Это НЕ +// location_index_pct (район против города) — тот живёт в HeroBar. +export function marketPositionPill( + e: Pick, +): { delta: string; deltaLabel: string } | undefined { + const pct = e.market_percentile; + if (pct == null || !Number.isFinite(pct)) return undefined; + const delta = pct <= 33 ? "Низ рынка" : pct >= 67 ? "Верх рынка" : "В рынке"; + const n = e.n_analogs; + const deltaLabel = `${pct}-й перцентиль среди ${n} ${pluralRu(n, ["аналога", "аналогов", "аналогов"])}`; + return { delta, deltaLabel }; +} + export function mapResultPanel( e: AggregatedEstimate, streetDeals?: StreetDealsResponse | null, @@ -1108,7 +1135,12 @@ export function mapResultPanel( // 872k выброс this card already reports as "исключён", squashing the real // analogs into the left bins while the number says the outlier is dropped. // dealsOnlyPrice → e.analogs is empty → bins8([]) → [] (no bars drawn). - bars: bins8(guardPriceOutliers(e.analogs.map((a) => a.price_per_m2)).clean), + bars: bins8( + guardPriceOutliers(e.analogs.map((a) => a.price_per_m2)).clean, + ), + // #2899: «Верх рынка · 69-й перцентиль среди 16 аналогов» — та же спокойная + // плашка, что у карточки 2; undefined при когорте < 15 (бэкенд отдаёт null). + ...(marketPositionPill(e) ?? {}), nav: 2, }, { @@ -1121,7 +1153,9 @@ export function mapResultPanel( e.expected_sold_range_high_rub, ) : "", - ppm: hasExpected ? `${fmtPpm(e.expected_sold_per_m2)} · с учётом торга` : "", + ppm: hasExpected + ? `${fmtPpm(e.expected_sold_per_m2)} · с учётом торга` + : "", delta: hasExpected && e.asking_to_sold_ratio != null ? fmtPct((e.asking_to_sold_ratio - 1) * 100) @@ -2148,7 +2182,10 @@ export function mapSources( // sample still shown in the table right below this KPI tile) — a bare // "0" here would directly contradict visible rows. Fall back to the // actual displayed population (same fix as ListingsCard's count-strip). - count: e != null ? String(e.n_analogs > 0 ? e.n_analogs : e.analogs.length) : "—", + count: + e != null + ? String(e.n_analogs > 0 ? e.n_analogs : e.analogs.length) + : "—", median: e != null ? fmtMln(e.median_price_rub) : "—", ppm: e != null && Number.isFinite(e.median_price_per_m2) diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts index c136e129..f7c3c002 100644 --- a/tradein-mvp/frontend/src/types/trade-in.ts +++ b/tradein-mvp/frontend/src/types/trade-in.ts @@ -10,11 +10,7 @@ export interface QuotaStatus { } export type HouseType = - | "panel" - | "brick" - | "monolith" - | "monolith_brick" - | "other"; + "panel" | "brick" | "monolith" | "monolith_brick" | "other"; export type RepairState = "needs_repair" | "standard" | "good" | "excellent"; @@ -33,11 +29,19 @@ export const REPAIR_STATES: readonly RepairState[] = [ "good", "excellent", ]; -export function asHouseType(v: string | null | undefined): HouseType | undefined { - return v && (HOUSE_TYPES as readonly string[]).includes(v) ? (v as HouseType) : undefined; +export function asHouseType( + v: string | null | undefined, +): HouseType | undefined { + return v && (HOUSE_TYPES as readonly string[]).includes(v) + ? (v as HouseType) + : undefined; } -export function asRepairState(v: string | null | undefined): RepairState | undefined { - return v && (REPAIR_STATES as readonly string[]).includes(v) ? (v as RepairState) : undefined; +export function asRepairState( + v: string | null | undefined, +): RepairState | undefined { + return v && (REPAIR_STATES as readonly string[]).includes(v) + ? (v as RepairState) + : undefined; } export type ConfidenceLevel = "low" | "medium" | "high"; @@ -103,9 +107,9 @@ export interface AnalogLot { days_on_market: number | null; photo_url: string | null; // ── Слой 5.2 — clickable links + distance ── - source: string | null; // 'avito' / 'cian' / 'domklik' / 'rosreestr' - source_url: string | null; // ссылка на оригинал - distance_m: number | null; // расстояние до целевой квартиры в метрах + source: string | null; // 'avito' / 'cian' / 'domklik' / 'rosreestr' + source_url: string | null; // ссылка на оригинал + distance_m: number | null; // расстояние до целевой квартиры в метрах // ── PR M (#564 Phase 3) — confidence tier для rosreestr deals ── // 'T0_per_house' — kadastr_num exact match (currently not available in open dataset) // 'T1_per_street' — street-level only (default for all rosreestr deals) @@ -125,7 +129,7 @@ export interface AnalogLot { // month — "YYYY-MM"; ppm2 — медиана ₽/м² за месяц по зданию/району. export interface PriceTrendPoint { month: string; // "YYYY-MM" - ppm2: number; // ₽/м² + ppm2: number; // ₽/м² } export interface CianValuationSummary { @@ -133,7 +137,7 @@ export interface CianValuationSummary { rent_price_rub: number | null; chart: Array<{ date: string; price: number }>; chart_change_pct: number | null; - chart_change_direction: 'increase' | 'decrease' | 'neutral' | null; + chart_change_direction: "increase" | "decrease" | "neutral" | null; } // Базис коэффициента asking→sold (S3): по группе комнат либо городской fallback. @@ -144,20 +148,20 @@ export type RatioBasis = "per_rooms" | "global_fallback"; // blend'а медианы. Сурфейсится как референсный маркер. null при отсутствии записи. export interface AvitoImvSummary { recommended_price: number | null; // рекомендованная цена Avito, ₽ - lower_price: number | null; // нижняя граница IMV-коридора, ₽ - higher_price: number | null; // верхняя граница IMV-коридора, ₽ - market_count: number | null; // объём рынка, на котором построена оценка + lower_price: number | null; // нижняя граница IMV-коридора, ₽ + higher_price: number | null; // верхняя граница IMV-коридора, ₽ + market_count: number | null; // объём рынка, на котором построена оценка } // ── #652: коридор реальных ДКП-сделок Росреестра (advisory, не клампит) ── // Агрегаты ₽/м² по сопоставимым сделкам за период. null / count=0 если по улице // нет сделок. count..period_months — required (бэкенд не отдаёт частичный объект). export interface DkpCorridor { - count: number; // число ДКП-сделок в выборке - low_ppm2: number; // P10 ₽/м² по сделкам (робастный коридор) - median_ppm2: number; // медиана ₽/м² - high_ppm2: number; // P90 ₽/м² по сделкам (робастный коридор) - period_months: number; // окно ПОИСКА сделок — НЕ возраст данных + count: number; // число ДКП-сделок в выборке + low_ppm2: number; // P10 ₽/м² по сделкам (робастный коридор) + median_ppm2: number; // медиана ₽/м² + high_ppm2: number; // P90 ₽/м² по сделкам (робастный коридор) + period_months: number; // окно ПОИСКА сделок — НЕ возраст данных // #2846: ISO-дата свежайшей из ОТОБРАННЫХ сделок (не из всей таблицы). Точность // — квартал: Росреестр публикует deal_date = первым днём квартала, поэтому // «2026-01-01» читается как «I кв. 2026», а не как 1 января. optional: оценки, @@ -179,10 +183,14 @@ export interface AggregatedEstimate { expected_sold_range_high_rub?: number | null; expected_sold_per_m2?: number | null; asking_to_sold_ratio?: number | null; // sold / asking (≈0.82) - ratio_basis?: RatioBasis | null; // 'per_rooms' | 'global_fallback' + ratio_basis?: RatioBasis | null; // 'per_rooms' | 'global_fallback' confidence: ConfidenceLevel; confidence_explanation: string | null; n_analogs: number; + // #2899: позиция ЭТОЙ квартиры внутри когорты аналогов, 1..99 — «какая доля + // аналогов дешевле». null/отсутствует = когорта < 15 либо старая оценка; + // показывать только вместе с n_analogs. НЕ location_index_pct (тот про район). + market_percentile?: number | null; insufficient_data: boolean; // backend #697: true когда median_price_rub <= 0 (нет данных) // fix (never-block estimate) — оценка теперь показывается всегда, пока цена // посчитана (insufficient_data=false), даже при n_analogs=0 (фолбэк по @@ -210,16 +218,16 @@ export interface AggregatedEstimate { // Ephemeral (не персистится в БД, только для текущего ответа) — optional, // т.к. оценки, посчитанные до деплоя бэкенда, поле не содержат. target_city_ambiguous?: boolean; - sources_used: string[]; // ['avito', 'cian', 'rosreestr'] + sources_used: string[]; // ['avito', 'cian', 'rosreestr'] // #2043 (BE-1): достоверность выборки — реальный коэффициент вариации ₽/м² (std/mean), // счётчики аналогов по источнику, момент создания оценки. Все optional: старые // кешированные оценки их не содержат → UI graceful fallback. - cv?: number | null; // коэффициент вариации ₽/м² (0..1), null если <2 цен - source_counts?: Record; // {'avito': 12, 'cian': 5} - created_at?: string | null; // ISO datetime — «отчёт от DD.MM» + cv?: number | null; // коэффициент вариации ₽/м² (0..1), null если <2 цен + source_counts?: Record; // {'avito': 12, 'cian': 5} + created_at?: string | null; // ISO datetime — «отчёт от DD.MM» data_freshness_minutes: number | null; // «обновлено N минут назад» - last_scraped_at?: string | null; // ISO datetime последнего скрейпа источников (optional) - est_days_on_market: number | null; // прогноз срока продажи + last_scraped_at?: string | null; // ISO datetime последнего скрейпа источников (optional) + est_days_on_market: number | null; // прогноз срока продажи // address_precision — точность гео-привязки адреса (из DaData qc_geo): // «house» (qc_geo=0, дом точно), «street» (qc_geo=1, до улицы), // «approximate» (qc_geo≥2: населённый пункт/город/регион/не распознан). @@ -383,15 +391,15 @@ export interface HouseAnalyticsResponse { // ── Street deals (endpoint: GET /trade-in/street-deals) ── export interface StreetDealsResponse { - street: string | null; // "Космонавтов" / null если не извлёкся - period_from: string; // ISO date "2025-05-29" + street: string | null; // "Космонавтов" / null если не извлёкся + period_from: string; // ISO date "2025-05-29" period_to: string; - count: number; // all matching, не топ-10 + count: number; // all matching, не топ-10 median_price_rub: number; median_price_per_m2: number; range_low_rub: number; range_high_rub: number; - deals: AnalogLot[]; // top-10 по deal_date DESC + deals: AnalogLot[]; // top-10 по deal_date DESC } // ── Sales vs Listings (endpoint: GET /trade-in/sales-vs-listings) ── @@ -400,7 +408,7 @@ export interface StreetDealsResponse { export interface SalesListingPair { deal_id: number; - deal_date: string; // ISO date + deal_date: string; // ISO date deal_price_rub: number; deal_price_per_m2: number; deal_area_m2: number; @@ -409,9 +417,9 @@ export interface SalesListingPair { deal_address: string; listing_id: number | null; - listing_source: string | null; // 'avito' / 'cian' / 'yandex' / 'domklik' (истор. могут встречаться выключенные источники) + listing_source: string | null; // 'avito' / 'cian' / 'yandex' / 'domklik' (истор. могут встречаться выключенные источники) listing_source_url: string | null; - listing_date: string | null; // ISO date + listing_date: string | null; // ISO date listing_price_rub: number | null; listing_price_per_m2: number | null; listing_area_m2: number | null; @@ -426,9 +434,9 @@ export interface SalesListingPair { export interface SalesVsListingsResponse { street: string | null; - period_months: number; // 24 default - window_days: number; // 180 default - area_tolerance: number; // 0.15 default + 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; @@ -440,14 +448,14 @@ export interface SalesVsListingsResponse { // Качество данных: house_linked = есть пары ДКП↔listing; street_only = есть // сделки, но привязка к конкретному дому/объявлению невозможна; no_data = нет сделок. data_quality: "house_linked" | "street_only" | "no_data"; - pairs: SalesListingPair[]; // все sorted by deal_date DESC + pairs: SalesListingPair[]; // все sorted by deal_date DESC } // ── 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 + price_premium_pct: number; // -5, 0, 5, 10 median_exposure_days: number | null; p25_days: number | null; p75_days: number | null; -- 2.45.3 From b6ab582211ec20652386d30073d159a8cac3f094 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 21 Aug 2026 17:01:25 +0500 Subject: [PATCH 3/3] =?UTF-8?q?chore(tradein/v2):=20#2899=20=E2=80=94=20?= =?UTF-8?q?=D1=83=D0=B1=D1=80=D0=B0=D1=82=D1=8C=20=D1=88=D1=83=D0=BC=20pre?= =?UTF-8?q?ttier=20=D0=B8=D0=B7=20=D0=BF=D1=80=D0=B5=D0=B4=D1=8B=D0=B4?= =?UTF-8?q?=D1=83=D1=89=D0=B5=D0=B3=D0=BE=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B8?= =?UTF-8?q?=D1=82=D0=B0=20(=D1=83=20tradein-frontend=20=D0=BD=D0=B5=D1=82?= =?UTF-8?q?=20prettier-=D0=BA=D0=BE=D0=BD=D0=B2=D0=B5=D0=BD=D1=86=D0=B8?= =?UTF-8?q?=D0=B8),=20=D0=BE=D1=81=D1=82=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20?= =?UTF-8?q?=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/trade-in/v2/ResultPanel.tsx | 604 +++++++++--------- .../src/components/trade-in/v2/mappers.ts | 40 +- tradein-mvp/frontend/src/types/trade-in.ts | 86 ++- 3 files changed, 353 insertions(+), 377 deletions(-) diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx index ab07840c..889cf35b 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx @@ -64,7 +64,13 @@ function DeltaPill({ whiteSpace: "nowrap", }} > - + {delta} {deltaLabel} @@ -240,198 +246,250 @@ export default function ResultPanel({ const isHeadline = !isEmptyCard && ci === headlineIndex; const isAsking = ci === 0; return ( +
+
+ > + {lines(card.title)} +
+ {/* #1991 — explicit "this is the figure that goes into the offer" + mark on the headline card, so the two dominant prices are not + just visually bigger but literally labelled which one to quote. */} + {isHeadline && !isEmptyCard && ( + + В ОФФЕР + + )} +
+ {isRegistry && ( +
+ {/* §M8 — расшифровка «ДКП» один раз на секцию. */} + СПРАВОЧНО · ДОГОВОРЫ КУПЛИ-ПРОДАЖИ +
+ )} + {/* H1 empty-state: no bare «—» / gauge / «· с учётом торга» — just an + honest note pointing at the recommended asking price. */} + {isEmptyCard ? ( +
+ {card.emptyNote} +
+ ) : ( + <> +
+ + {card.value} + + + {card.unit} + +
+ {card.range && ( +
+ {card.range} +
+ )} + {card.ppm && ( +
+ {card.ppm} +
+ )} + {card.note && ( +
+ {card.note} +
+ )} + + )} + + {isEmptyCard ? ( + + ) : card.bars ? ( + <>
- {lines(card.title)} -
- {/* #1991 — explicit "this is the figure that goes into the offer" - mark on the headline card, so the two dominant prices are not - just visually bigger but literally labelled which one to quote. */} - {isHeadline && !isEmptyCard && ( - - В ОФФЕР - - )} -
- {isRegistry && ( -
- {/* §M8 — расшифровка «ДКП» один раз на секцию. */} - СПРАВОЧНО · ДОГОВОРЫ КУПЛИ-ПРОДАЖИ -
- )} - {/* H1 empty-state: no bare «—» / gauge / «· с учётом торга» — just an - honest note pointing at the recommended asking price. */} - {isEmptyCard ? ( -
- {card.emptyNote} -
- ) : ( - <> -
+ {card.bars.map((h, bi) => ( - {card.value} - - - {card.unit} - -
- {card.range && ( -
- {card.range} -
- )} - {card.ppm && ( -
- {card.ppm} -
- )} - {card.note && ( -
- {card.note} -
- )} - - )} - - {isEmptyCard ? ( + /> + ))} +
- ) : card.bars ? ( - <> -
-
- {card.bars.map((h, bi) => ( - - ))} -
- -
- {/* #2899: позиция на рынке под гистограммой («Верх рынка · 69-й +
+ {/* #2899: позиция на рынке под гистограммой («Верх рынка · 69-й перцентиль среди 16 аналогов»); при когорте < 15 delta нет. */} - {card.delta ? ( -
- -
- ) : null} - - ) : ( - // M2 — calm delta pill (was a 51px circular gauge that read like a - // tech "занижение" indicator). Full text «−18% к цене объявления», - // neutral accentDeep on a soft-blue chip, tabular-nums. - <> -
- {card.delta ? ( - - ) : ( - - )} - -
- {card.exposureNote && ( -
- {card.exposureNote} -
- )} - + {card.delta ? ( +
+ +
+ ) : null} + + ) : ( + // M2 — calm delta pill (was a 51px circular gauge that read like a + // tech "занижение" indicator). Full text «−18% к цене объявления», + // neutral accentDeep on a soft-blue chip, tabular-nums. + <> +
+ {card.delta ? ( + + ) : ( + + )} + +
+ {card.exposureNote && ( +
+ {card.exposureNote} +
)} - + + )} + ); })} @@ -610,8 +606,8 @@ export default function ResultPanel({ marginTop: -6, }} > - {SHORT_ESTIMATE_DISCLAIMER} Диапазоны показывают разброс цен на рынке, а - не погрешность оценки. + {SHORT_ESTIMATE_DISCLAIMER} Диапазоны показывают разброс цен на рынке, + а не погрешность оценки. {/* ranges + radar */} diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts index fb543494..72e45266 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts @@ -605,16 +605,10 @@ interface DealTier { } /** Свежайшая дата среди лотов + её точность (лоты не всегда отсортированы). */ -function newestLot( - lots: AnalogLot[], -): [string | null, "day" | "quarter" | null] { +function newestLot(lots: AnalogLot[]): [string | null, "day" | "quarter" | null] { let best: AnalogLot | null = null; for (const l of lots) { - if ( - l.listing_date && - (!best?.listing_date || l.listing_date > best.listing_date) - ) - best = l; + if (l.listing_date && (!best?.listing_date || l.listing_date > best.listing_date)) best = l; } return [best?.listing_date ?? null, best?.date_precision ?? null]; } @@ -642,9 +636,7 @@ function resolveDealTier( // Fix #1/#8: guard the deal ₽/м² histogram against a high outlier the // same way the ads card does — otherwise a single mis-scraped lot bins // over [min,max] and crushes the real deals into the left buckets. - bars: bins8( - guardPriceOutliers(sd.deals.map((d) => d.price_per_m2)).clean, - ), + bars: bins8(guardPriceOutliers(sd.deals.map((d) => d.price_per_m2)).clean), // sd.deals — top-10 из ORDER BY deal_date DESC по всем sd.count сделкам, // так что максимум по ним = максимум по всей выборке, не по показанным. asOf: dealsAsOfLabel(...newestLot(sd.deals)), @@ -833,13 +825,11 @@ export function mapReport(e: AggregatedEstimate): Report { * - "out_of_coverage" → "вне ЕКБ" (the index only covers Yekaterinburg) * - "insufficient_data" → "мало данных" (too few comparable listings) */ -function locationIndexBadge(li: LocationIndexResponse | null | undefined): { - label: string; - ok: boolean; -} { +function locationIndexBadge( + li: LocationIndexResponse | null | undefined, +): { label: string; ok: boolean } { if (li == null) return { label: "нет данных", ok: false }; - if (li.status === "ok") - return { label: fmtPct(li.location_index_pct), ok: true }; + if (li.status === "ok") return { label: fmtPct(li.location_index_pct), ok: true }; if (li.status === "out_of_coverage") return { label: "вне ЕКБ", ok: false }; return { label: "мало данных", ok: false }; // status === "insufficient_data" } @@ -1086,7 +1076,8 @@ export function marketPositionPill( if (pct == null || !Number.isFinite(pct)) return undefined; const delta = pct <= 33 ? "Низ рынка" : pct >= 67 ? "Верх рынка" : "В рынке"; const n = e.n_analogs; - const deltaLabel = `${pct}-й перцентиль среди ${n} ${pluralRu(n, ["аналога", "аналогов", "аналогов"])}`; + const forms: [string, string, string] = ["аналога", "аналогов", "аналогов"]; + const deltaLabel = `${pct}-й перцентиль среди ${n} ${pluralRu(n, forms)}`; return { delta, deltaLabel }; } @@ -1135,9 +1126,7 @@ export function mapResultPanel( // 872k выброс this card already reports as "исключён", squashing the real // analogs into the left bins while the number says the outlier is dropped. // dealsOnlyPrice → e.analogs is empty → bins8([]) → [] (no bars drawn). - bars: bins8( - guardPriceOutliers(e.analogs.map((a) => a.price_per_m2)).clean, - ), + bars: bins8(guardPriceOutliers(e.analogs.map((a) => a.price_per_m2)).clean), // #2899: «Верх рынка · 69-й перцентиль среди 16 аналогов» — та же спокойная // плашка, что у карточки 2; undefined при когорте < 15 (бэкенд отдаёт null). ...(marketPositionPill(e) ?? {}), @@ -1153,9 +1142,7 @@ export function mapResultPanel( e.expected_sold_range_high_rub, ) : "", - ppm: hasExpected - ? `${fmtPpm(e.expected_sold_per_m2)} · с учётом торга` - : "", + ppm: hasExpected ? `${fmtPpm(e.expected_sold_per_m2)} · с учётом торга` : "", delta: hasExpected && e.asking_to_sold_ratio != null ? fmtPct((e.asking_to_sold_ratio - 1) * 100) @@ -2182,10 +2169,7 @@ export function mapSources( // sample still shown in the table right below this KPI tile) — a bare // "0" here would directly contradict visible rows. Fall back to the // actual displayed population (same fix as ListingsCard's count-strip). - count: - e != null - ? String(e.n_analogs > 0 ? e.n_analogs : e.analogs.length) - : "—", + count: e != null ? String(e.n_analogs > 0 ? e.n_analogs : e.analogs.length) : "—", median: e != null ? fmtMln(e.median_price_rub) : "—", ppm: e != null && Number.isFinite(e.median_price_per_m2) diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts index f7c3c002..9de256e9 100644 --- a/tradein-mvp/frontend/src/types/trade-in.ts +++ b/tradein-mvp/frontend/src/types/trade-in.ts @@ -10,7 +10,11 @@ export interface QuotaStatus { } export type HouseType = - "panel" | "brick" | "monolith" | "monolith_brick" | "other"; + | "panel" + | "brick" + | "monolith" + | "monolith_brick" + | "other"; export type RepairState = "needs_repair" | "standard" | "good" | "excellent"; @@ -29,19 +33,11 @@ export const REPAIR_STATES: readonly RepairState[] = [ "good", "excellent", ]; -export function asHouseType( - v: string | null | undefined, -): HouseType | undefined { - return v && (HOUSE_TYPES as readonly string[]).includes(v) - ? (v as HouseType) - : undefined; +export function asHouseType(v: string | null | undefined): HouseType | undefined { + return v && (HOUSE_TYPES as readonly string[]).includes(v) ? (v as HouseType) : undefined; } -export function asRepairState( - v: string | null | undefined, -): RepairState | undefined { - return v && (REPAIR_STATES as readonly string[]).includes(v) - ? (v as RepairState) - : undefined; +export function asRepairState(v: string | null | undefined): RepairState | undefined { + return v && (REPAIR_STATES as readonly string[]).includes(v) ? (v as RepairState) : undefined; } export type ConfidenceLevel = "low" | "medium" | "high"; @@ -107,9 +103,9 @@ export interface AnalogLot { days_on_market: number | null; photo_url: string | null; // ── Слой 5.2 — clickable links + distance ── - source: string | null; // 'avito' / 'cian' / 'domklik' / 'rosreestr' - source_url: string | null; // ссылка на оригинал - distance_m: number | null; // расстояние до целевой квартиры в метрах + source: string | null; // 'avito' / 'cian' / 'domklik' / 'rosreestr' + source_url: string | null; // ссылка на оригинал + distance_m: number | null; // расстояние до целевой квартиры в метрах // ── PR M (#564 Phase 3) — confidence tier для rosreestr deals ── // 'T0_per_house' — kadastr_num exact match (currently not available in open dataset) // 'T1_per_street' — street-level only (default for all rosreestr deals) @@ -129,7 +125,7 @@ export interface AnalogLot { // month — "YYYY-MM"; ppm2 — медиана ₽/м² за месяц по зданию/району. export interface PriceTrendPoint { month: string; // "YYYY-MM" - ppm2: number; // ₽/м² + ppm2: number; // ₽/м² } export interface CianValuationSummary { @@ -137,7 +133,7 @@ export interface CianValuationSummary { rent_price_rub: number | null; chart: Array<{ date: string; price: number }>; chart_change_pct: number | null; - chart_change_direction: "increase" | "decrease" | "neutral" | null; + chart_change_direction: 'increase' | 'decrease' | 'neutral' | null; } // Базис коэффициента asking→sold (S3): по группе комнат либо городской fallback. @@ -148,20 +144,20 @@ export type RatioBasis = "per_rooms" | "global_fallback"; // blend'а медианы. Сурфейсится как референсный маркер. null при отсутствии записи. export interface AvitoImvSummary { recommended_price: number | null; // рекомендованная цена Avito, ₽ - lower_price: number | null; // нижняя граница IMV-коридора, ₽ - higher_price: number | null; // верхняя граница IMV-коридора, ₽ - market_count: number | null; // объём рынка, на котором построена оценка + lower_price: number | null; // нижняя граница IMV-коридора, ₽ + higher_price: number | null; // верхняя граница IMV-коридора, ₽ + market_count: number | null; // объём рынка, на котором построена оценка } // ── #652: коридор реальных ДКП-сделок Росреестра (advisory, не клампит) ── // Агрегаты ₽/м² по сопоставимым сделкам за период. null / count=0 если по улице // нет сделок. count..period_months — required (бэкенд не отдаёт частичный объект). export interface DkpCorridor { - count: number; // число ДКП-сделок в выборке - low_ppm2: number; // P10 ₽/м² по сделкам (робастный коридор) - median_ppm2: number; // медиана ₽/м² - high_ppm2: number; // P90 ₽/м² по сделкам (робастный коридор) - period_months: number; // окно ПОИСКА сделок — НЕ возраст данных + count: number; // число ДКП-сделок в выборке + low_ppm2: number; // P10 ₽/м² по сделкам (робастный коридор) + median_ppm2: number; // медиана ₽/м² + high_ppm2: number; // P90 ₽/м² по сделкам (робастный коридор) + period_months: number; // окно ПОИСКА сделок — НЕ возраст данных // #2846: ISO-дата свежайшей из ОТОБРАННЫХ сделок (не из всей таблицы). Точность // — квартал: Росреестр публикует deal_date = первым днём квартала, поэтому // «2026-01-01» читается как «I кв. 2026», а не как 1 января. optional: оценки, @@ -183,7 +179,7 @@ export interface AggregatedEstimate { expected_sold_range_high_rub?: number | null; expected_sold_per_m2?: number | null; asking_to_sold_ratio?: number | null; // sold / asking (≈0.82) - ratio_basis?: RatioBasis | null; // 'per_rooms' | 'global_fallback' + ratio_basis?: RatioBasis | null; // 'per_rooms' | 'global_fallback' confidence: ConfidenceLevel; confidence_explanation: string | null; n_analogs: number; @@ -218,16 +214,16 @@ export interface AggregatedEstimate { // Ephemeral (не персистится в БД, только для текущего ответа) — optional, // т.к. оценки, посчитанные до деплоя бэкенда, поле не содержат. target_city_ambiguous?: boolean; - sources_used: string[]; // ['avito', 'cian', 'rosreestr'] + sources_used: string[]; // ['avito', 'cian', 'rosreestr'] // #2043 (BE-1): достоверность выборки — реальный коэффициент вариации ₽/м² (std/mean), // счётчики аналогов по источнику, момент создания оценки. Все optional: старые // кешированные оценки их не содержат → UI graceful fallback. - cv?: number | null; // коэффициент вариации ₽/м² (0..1), null если <2 цен - source_counts?: Record; // {'avito': 12, 'cian': 5} - created_at?: string | null; // ISO datetime — «отчёт от DD.MM» + cv?: number | null; // коэффициент вариации ₽/м² (0..1), null если <2 цен + source_counts?: Record; // {'avito': 12, 'cian': 5} + created_at?: string | null; // ISO datetime — «отчёт от DD.MM» data_freshness_minutes: number | null; // «обновлено N минут назад» - last_scraped_at?: string | null; // ISO datetime последнего скрейпа источников (optional) - est_days_on_market: number | null; // прогноз срока продажи + last_scraped_at?: string | null; // ISO datetime последнего скрейпа источников (optional) + est_days_on_market: number | null; // прогноз срока продажи // address_precision — точность гео-привязки адреса (из DaData qc_geo): // «house» (qc_geo=0, дом точно), «street» (qc_geo=1, до улицы), // «approximate» (qc_geo≥2: населённый пункт/город/регион/не распознан). @@ -391,15 +387,15 @@ export interface HouseAnalyticsResponse { // ── Street deals (endpoint: GET /trade-in/street-deals) ── export interface StreetDealsResponse { - street: string | null; // "Космонавтов" / null если не извлёкся - period_from: string; // ISO date "2025-05-29" + street: string | null; // "Космонавтов" / null если не извлёкся + period_from: string; // ISO date "2025-05-29" period_to: string; - count: number; // all matching, не топ-10 + count: number; // all matching, не топ-10 median_price_rub: number; median_price_per_m2: number; range_low_rub: number; range_high_rub: number; - deals: AnalogLot[]; // top-10 по deal_date DESC + deals: AnalogLot[]; // top-10 по deal_date DESC } // ── Sales vs Listings (endpoint: GET /trade-in/sales-vs-listings) ── @@ -408,7 +404,7 @@ export interface StreetDealsResponse { export interface SalesListingPair { deal_id: number; - deal_date: string; // ISO date + deal_date: string; // ISO date deal_price_rub: number; deal_price_per_m2: number; deal_area_m2: number; @@ -417,9 +413,9 @@ export interface SalesListingPair { deal_address: string; listing_id: number | null; - listing_source: string | null; // 'avito' / 'cian' / 'yandex' / 'domklik' (истор. могут встречаться выключенные источники) + listing_source: string | null; // 'avito' / 'cian' / 'yandex' / 'domklik' (истор. могут встречаться выключенные источники) listing_source_url: string | null; - listing_date: string | null; // ISO date + listing_date: string | null; // ISO date listing_price_rub: number | null; listing_price_per_m2: number | null; listing_area_m2: number | null; @@ -434,9 +430,9 @@ export interface SalesListingPair { export interface SalesVsListingsResponse { street: string | null; - period_months: number; // 24 default - window_days: number; // 180 default - area_tolerance: number; // 0.15 default + 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; @@ -448,14 +444,14 @@ export interface SalesVsListingsResponse { // Качество данных: house_linked = есть пары ДКП↔listing; street_only = есть // сделки, но привязка к конкретному дому/объявлению невозможна; no_data = нет сделок. data_quality: "house_linked" | "street_only" | "no_data"; - pairs: SalesListingPair[]; // все sorted by deal_date DESC + pairs: SalesListingPair[]; // все sorted by deal_date DESC } // ── 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 + price_premium_pct: number; // -5, 0, 5, 10 median_exposure_days: number | null; p25_days: number | null; p75_days: number | null; -- 2.45.3