diff --git a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx
index ee72f119..969aa3a9 100644
--- a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx
+++ b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx
@@ -73,6 +73,23 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
const lo = estimate.range_low_rub;
const hi = estimate.range_high_rub;
+ // ── Ожидаемая цена продажи (S3). Рисуем второй блок только при валидном числе:
+ // null/undefined/0 (старые оценки или пустая ratio-таблица) → одиночный layout. ──
+ const sold = estimate.expected_sold_price_rub;
+ const hasSold = typeof sold === "number" && sold > 0;
+ const soldLo = estimate.expected_sold_range_low_rub;
+ const soldHi = estimate.expected_sold_range_high_rub;
+ const soldPerM2 = estimate.expected_sold_per_m2;
+ // Скидка запрос→продажа: из ratio при наличии, иначе из самих чисел. Только если ≥1%.
+ const discountPct = hasSold
+ ? Math.round(
+ (typeof estimate.asking_to_sold_ratio === "number" && estimate.asking_to_sold_ratio > 0
+ ? 1 - estimate.asking_to_sold_ratio
+ : 1 - (sold as number) / m) * 100,
+ )
+ : 0;
+ const showDiscount = hasSold && discountPct >= 1;
+
const showMockDisclaimer = isMockAddress(estimate);
// Progressive enrichment state
@@ -237,6 +254,51 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
+
+
+
Ориентир запроса
+
{formatMln(m)} ₽
+
+ {formatMln(lo)} – {formatMln(hi)} ₽
+
+
+ {estimate.median_price_per_m2.toLocaleString("ru-RU")} ₽/м²
+
+
+
+ {hasSold && (
+
+
+ Ожидаемая цена продажи
+ {showDiscount && (
+
+ −{discountPct}%
+
+ )}
+
+
{formatMln(sold as number)} ₽
+ {typeof soldLo === "number" && typeof soldHi === "number" && (
+
+ {formatMln(soldLo)} – {formatMln(soldHi)} ₽
+
+ )}
+ {typeof soldPerM2 === "number" && (
+
+ {soldPerM2.toLocaleString("ru-RU")} ₽/м²
+
+ )}
+
+ )}
+
+
+ {hasSold && (
+
+ Запрос — по чему выставлены сопоставимые квартиры в объявлениях.{" "}
+ Ожидаемая цена продажи — реалистичная цена сделки по данным ДКП Росреестра, обычно
+ ниже запроса.
+
+ )}
+
diff --git a/tradein-mvp/frontend/src/components/trade-in/trade-in.css b/tradein-mvp/frontend/src/components/trade-in/trade-in.css
index 28bb70e0..6bbe0280 100644
--- a/tradein-mvp/frontend/src/components/trade-in/trade-in.css
+++ b/tradein-mvp/frontend/src/components/trade-in/trade-in.css
@@ -624,6 +624,75 @@
.meta-grid .meta-row .v { color: var(--fg); font-weight: 500; }
.meta-grid .meta-row .v.mono { font-family: var(--font-mono); }
+ /* Два равнозначных ценовых ориентира: запрос vs ожидаемая продажа (#648 S5a).
+ Одинаковый размер value у обоих — ни один не доминирует. */
+ .hero-prices {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 16px;
+ padding: 20px 22px;
+ border-bottom: 1px solid var(--border);
+ }
+ .hero-prices.single { grid-template-columns: 1fr; }
+ .price-figure {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding: 16px 18px;
+ background: var(--surface-2);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ }
+ .price-figure[data-kind="sold"] {
+ background: var(--accent-soft);
+ border-color: color-mix(in oklch, var(--accent) 22%, var(--border));
+ }
+ .price-figure-label {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 12px;
+ font-weight: 500;
+ letter-spacing: 0.02em;
+ color: var(--muted);
+ }
+ .price-figure[data-kind="sold"] .price-figure-label { color: var(--accent-ink); }
+ .price-figure-delta {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+ padding: 1px 6px;
+ border-radius: var(--radius-sm);
+ background: color-mix(in oklch, var(--accent) 14%, transparent);
+ color: var(--accent-ink);
+ }
+ .price-figure-value {
+ font-size: 28px;
+ font-weight: 600;
+ line-height: 1.1;
+ letter-spacing: -0.02em;
+ color: var(--fg);
+ }
+ .price-figure-range {
+ font-size: 12px;
+ color: var(--muted);
+ letter-spacing: 0.01em;
+ }
+ .price-figure-perm2 {
+ font-size: 11px;
+ color: var(--muted-2);
+ letter-spacing: 0.04em;
+ }
+ .hero-prices-note {
+ margin: 0;
+ padding: 12px 22px 0;
+ font-size: 12px;
+ line-height: 1.5;
+ color: var(--muted);
+ }
+ .hero-prices-note b { color: var(--fg-2); font-weight: 600; }
+
.hero-bars {
padding: 18px 22px 20px;
border-bottom: 1px solid var(--border);
@@ -1070,6 +1139,7 @@
.layout { grid-template-columns: 1fr; }
.form-card { position: static; max-height: none; }
.hero-top { grid-template-columns: 1fr; }
+ .hero-prices { grid-template-columns: 1fr; }
.hero-photo { aspect-ratio: 16/9; }
.progress-list { grid-template-columns: 1fr; }
.advantages { grid-template-columns: repeat(2, minmax(0, 1fr)); }
diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts
index 4ade4328..f6aa9ad6 100644
--- a/tradein-mvp/frontend/src/types/trade-in.ts
+++ b/tradein-mvp/frontend/src/types/trade-in.ts
@@ -69,12 +69,24 @@ export interface CianValuationSummary {
chart_change_direction: 'increase' | 'decrease' | 'neutral' | null;
}
+// Базис коэффициента asking→sold (S3): по группе комнат либо городской fallback.
+export type RatioBasis = "per_rooms" | "global_fallback";
+
export interface AggregatedEstimate {
estimate_id: string; // UUID
- median_price_rub: number;
+ median_price_rub: number; // ASKING-ориентир: по чему выставлены аналоги
range_low_rub: number;
range_high_rub: number;
median_price_per_m2: number;
+ // ── Ожидаемая цена продажи (S3) — реалистичная цена сделки по ДКП Росреестра,
+ // обычно ~на 15–20% ниже asking. Все поля optional + nullable: старые оценки
+ // их не содержат, а graceful-degradation отдаёт null при пустой ratio-таблице.
+ expected_sold_price_rub?: number | null;
+ expected_sold_range_low_rub?: number | null;
+ 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'
confidence: ConfidenceLevel;
confidence_explanation: string | null;
n_analogs: number;