From c31da62e8d7866ed54c90715a7df8da4d7b3a65b Mon Sep 17 00:00:00 2001 From: lekss361 Date: Mon, 11 May 2026 22:19:41 +0300 Subject: [PATCH] feat(analytics): recommend_mix v3.1-v3.4 - noise + 2D competitors + 24m cap + success-driven --- backend/app/schemas/recommend.py | 4 +- backend/app/services/analytics_queries.py | 343 ++++++++++++++++-- .../analytics/RecommendBucketsTable.tsx | 11 + .../components/analytics/RecommendForm.tsx | 25 +- .../analytics/RecommendVelocityPanel.tsx | 83 ++++- frontend/src/types/analytics.ts | 26 ++ 6 files changed, 452 insertions(+), 40 deletions(-) diff --git a/backend/app/schemas/recommend.py b/backend/app/schemas/recommend.py index e66af731..d08ca21c 100644 --- a/backend/app/schemas/recommend.py +++ b/backend/app/schemas/recommend.py @@ -14,7 +14,7 @@ class RecommendMixInput(BaseModel): district_name: str = Field(..., min_length=2, max_length=80) area_total_m2: float | None = Field(default=None, ge=100, le=500_000) target_class: ClassLiteral | None = None - months_window: int = Field(default=12, ge=3, le=36) + months_window: int = Field(default=12, ge=3, le=24) # Velocity / pricing scenario knobs (live-tuned client-side; backend just # ships base coefficients so frontend can recompute without round-trips). # 0.01..3.0 = -99%..+200% к рынку. min=0.01 (а не 0) чтобы избежать @@ -39,6 +39,8 @@ class RecommendBucket(BaseModel): # what-if recompute. velocity_per_month: float | None = None months_to_sellout: float | None = None + # Success-driven mix flag (issue #25): bucket has top success_score in district + is_top_success: bool = False class RecommendComparable(BaseModel): diff --git a/backend/app/services/analytics_queries.py b/backend/app/services/analytics_queries.py index 89dcaf1b..b8fa497d 100644 --- a/backend/app/services/analytics_queries.py +++ b/backend/app/services/analytics_queries.py @@ -6,12 +6,15 @@ Region 66 = Sverdlovskaya oblast. Developer 6208_0 = PRINZIP. from __future__ import annotations +import logging from decimal import Decimal from typing import Any from sqlalchemy import text from sqlalchemy.orm import Session +logger = logging.getLogger(__name__) + def _f(value: Any) -> float | None: if value is None: @@ -1445,12 +1448,17 @@ def _elasticity_coef( region_code: int, district_name: str, target_class: str | None, + elasticity_window_months: int = 24, ) -> dict[str, Any]: """Fit log-log regression LN(realised) ~ LN(price_avg) on sale_graph observations for the same район+class. Returns elasticity (slope), R², n. Falls back to FALLBACK_ELASTICITY if data thin or regression weak.""" where_class = "AND o.obj_class = :cls" if target_class else "" - params: dict[str, Any] = {"rc": region_code, "dn": district_name} + params: dict[str, Any] = { + "rc": region_code, + "dn": district_name, + "ew": elasticity_window_months, + } if target_class: params["cls"] = target_class row = ( @@ -1472,7 +1480,7 @@ def _elasticity_coef( WHERE sg.type = 'apartments' AND sg.realised IS NOT NULL AND sg.realised > 0 AND sg.price_avg IS NOT NULL AND sg.price_avg > 0 - AND sg.report_month >= NOW() - INTERVAL '36 months' + AND sg.report_month >= NOW() - (:ew || ' months')::interval ) SELECT regr_slope(y, x) AS slope, @@ -1511,6 +1519,7 @@ def _elasticity_per_bucket_coef( district_name: str, target_class: str | None, fallback: dict[str, Any], + elasticity_window_months: int = 24, ) -> dict[str, dict[str, Any]]: """Per-bucket эластичность (Tier 3): группируем sale_graph-наблюдения по «доминирующему bucket» каждого ЖК (mode total_area из domrf_kn_flats), @@ -1522,7 +1531,11 @@ def _elasticity_per_bucket_coef( общую эластичность из `fallback` со source='fallback_global'. """ where_class = "AND o.obj_class = :cls" if target_class else "" - params: dict[str, Any] = {"rc": region_code, "dn": district_name} + params: dict[str, Any] = { + "rc": region_code, + "dn": district_name, + "ew": elasticity_window_months, + } if target_class: params["cls"] = target_class rows = ( @@ -1578,7 +1591,7 @@ def _elasticity_per_bucket_coef( WHERE sg.type = 'apartments' AND sg.realised IS NOT NULL AND sg.realised > 0 AND sg.price_avg IS NOT NULL AND sg.price_avg > 0 - AND sg.report_month >= NOW() - INTERVAL '36 months' + AND sg.report_month >= NOW() - (:ew || ' months')::interval ) SELECT bucket, regr_slope(y, x) AS slope, @@ -1619,6 +1632,198 @@ def _elasticity_per_bucket_coef( return out +def _noise_penalty_factor(db: Session, district_name: str | None) -> tuple[float, dict]: + """Penalty к ценам исходя из плотности шумных объектов в районе. + + Returns: (factor in [0.90, 1.0], breakdown dict). + Чем больше магистралей/жд/промзон — тем ниже factor (max -10%). + """ + if not district_name: + return 1.0, {} + row = ( + db.execute( + text( + """ + WITH district_noise AS ( + SELECT n.source_type, n.road_class, COUNT(*) AS n + FROM osm_noise_sources_ekb n + JOIN ekb_districts d ON ST_Intersects(n.geom, d.geom) + WHERE d.district_name = :dn + GROUP BY 1, 2 + ) + SELECT COALESCE(SUM(n), 0) AS total_sources, + COALESCE(SUM(CASE WHEN source_type = 'railway' THEN n END), 0) AS railway_n, + COALESCE(SUM(CASE WHEN source_type = 'industrial' THEN n END), 0) + AS industrial_n, + COALESCE( + SUM(CASE WHEN road_class IN ('motorway', 'trunk') THEN n END), 0 + ) AS magistral_n + FROM district_noise + """ + ), + {"dn": district_name}, + ) + .mappings() + .first() + ) + if not row or not row["total_sources"]: + return 1.0, {"district": district_name, "noise_sources": 0} + score = ( + float(row["magistral_n"]) * 0.05 + + float(row["railway_n"]) * 0.02 + + float(row["industrial_n"]) * 0.03 + ) + penalty = min(0.10, max(0.0, score / 100)) + factor = 1.0 - penalty + return round(factor, 4), { + "district": district_name, + "magistral_n": int(row["magistral_n"]), + "railway_n": int(row["railway_n"]), + "industrial_n": int(row["industrial_n"]), + "total_sources": int(row["total_sources"]), + "penalty_pct": round(penalty * 100, 1), + } + + +def _competitors_two_dim( + db: Session, + *, + region_code: int, + district_name: str, + target_class: str | None, +) -> tuple[int, int, float, str]: + """Двумерный подсчёт активных конкурентов: + - radius_n: ЖК в радиусе 3км от центроида района + - district_only_n: ЖК в районе, но вне 3км радиуса + - total_weighted = radius_n * 1.0 + district_only_n * 0.6 + + Returns (radius_n, district_only_n, total_weighted, scope). + Если district_name не найден в ekb_districts — падает в старый + _active_competitors_count с total_weighted = float(competitors). + """ + # Получаем центроид района для radius-фильтра + centroid_row = ( + db.execute( + text( + """ + SELECT ST_AsText(ST_Centroid(geom)) AS centroid_wkt + FROM ekb_districts + WHERE district_name = :dn + LIMIT 1 + """ + ), + {"dn": district_name}, + ) + .mappings() + .first() + ) + if not centroid_row or not centroid_row["centroid_wkt"]: + # Fallback: используем старый одномерный счётчик + n, scope = _active_competitors_count( + db, region_code=region_code, district_name=district_name, target_class=target_class + ) + return 0, n, float(n), scope + + class_filter = "AND obj_class = :cls" if target_class else "" + params: dict[str, Any] = { + "rc": region_code, + "dn": district_name, + "centroid": centroid_row["centroid_wkt"], + } + if target_class: + params["cls"] = target_class + + row = ( + db.execute( + text( + f""" + WITH active AS ( + SELECT DISTINCT ON (obj_id) obj_id, latitude, longitude, district_name + FROM domrf_kn_objects + WHERE region_cd = :rc + AND site_status = 'Строящиеся' + AND district_name = :dn + {class_filter} + ORDER BY obj_id, snapshot_date DESC NULLS LAST + ), + centroid AS ( + SELECT ST_SetSRID(ST_GeomFromText(:centroid), 4326)::geography AS pt + ) + SELECT + COUNT(*) FILTER ( + WHERE ST_DWithin( + ST_SetSRID(ST_MakePoint(a.longitude, a.latitude), 4326)::geography, + c.pt, + 3000 + ) + ) AS radius_n, + COUNT(*) FILTER ( + WHERE NOT ST_DWithin( + ST_SetSRID(ST_MakePoint(a.longitude, a.latitude), 4326)::geography, + c.pt, + 3000 + ) + ) AS district_only_n + FROM active a, centroid c + """ + ), + params, + ) + .mappings() + .first() + ) + radius_n = int(row["radius_n"] or 0) if row else 0 + district_only_n = int(row["district_only_n"] or 0) if row else 0 + total_weighted = radius_n * 1.0 + district_only_n * 0.6 + if total_weighted < 1.0: + # Нет конкурентов в районе — fallback к старому счётчику (регион) + n, scope = _active_competitors_count( + db, region_code=region_code, district_name=district_name, target_class=target_class + ) + return 0, n, float(max(n, 1)), scope + return radius_n, district_only_n, max(total_weighted, 1.0), "district_2d" + + +def _bucket_success_ranking( + db: Session, district_name: str | None, target_class: str | None +) -> list[dict]: + """Рейтинг bucket'ов по success_score из v_bucket_success_score. + + Возвращает список dict {bucket, success_score, n_deals, velocity_z, + price_z, area_z}, sorted DESC by success_score. Пустой список если + данных нет или district_name не передан. + """ + if not district_name: + return [] + rows = ( + db.execute( + text( + """ + SELECT bucket, success_score, n_deals, velocity_z, price_z, area_z + FROM v_bucket_success_score + WHERE district_name = :dn + AND obj_class = COALESCE(:cls, 'Comfort') + ORDER BY success_score DESC + """ + ), + {"dn": district_name, "cls": target_class}, + ) + .mappings() + .all() + ) + return [ + { + "bucket": r["bucket"], + "success_score": float(r["success_score"]) if r["success_score"] is not None else 0.0, + "n_deals": int(r["n_deals"] or 0), + "velocity_z": float(r["velocity_z"]) if r["velocity_z"] is not None else 0.0, + "price_z": float(r["price_z"]) if r["price_z"] is not None else 0.0, + "area_z": float(r["area_z"]) if r["area_z"] is not None else 0.0, + } + for r in rows + ] + + def recommend_mix( db: Session, *, @@ -1630,16 +1835,25 @@ def recommend_mix( price_factor: float = 1.0, target_months: int | None = None, ) -> dict[str, Any]: - """Rule-based квартирография recommender. + """Rule-based квартирография recommender v3.1-v3.4. City-wide bucket distribution from rosreestr_deals (последние N месяцев), скорректированная на район (через ekb_districts.median_price_per_m2) и класс (через yandex_realty_zk price-агрегаты per-class). - See plan: C:/Users/user/.claude/plans/crispy-swinging-gadget.md + v3.1: noise penalty (-10% max) по osm_noise_sources_ekb + v3.2: hard-cap comparables по boundaries района + v3.3: hard-cap 24 мес + elasticity_window_months = 24 + v3.4: success-driven mix из v_bucket_success_score """ warnings: list[str] = [] + # #24 Hard-cap: данные старше 24 мес нерелевантны (ставки ЦБ, ипотека менялись) + if months_window > 24: + logger.warning("recommend_mix: months_window=%d > 24, capped to 24", months_window) + months_window = 24 + elasticity_window_months = 24 # синхронизировано с share_window (issue #24) + # 1) District lookup district_row = ( db.execute( @@ -1858,6 +2072,7 @@ def recommend_mix( region_code=region_code, district_name=district_row["district_name"], target_class=target_class_for_geo, + elasticity_window_months=elasticity_window_months, ) elasticity = elast["elasticity"] if elast["source"] == "fallback": @@ -1877,23 +2092,27 @@ def recommend_mix( district_name=district_row["district_name"], target_class=target_class_for_geo, fallback=elast, + elasticity_window_months=elasticity_window_months, ) - # 5b-1) N активных конкурентов с каскадным fallback (район+класс → - # район → регион). Используется как divisor в rosreestr-fallback ветке. - competitors, competitors_scope = _active_competitors_count( - db, - region_code=region_code, - district_name=district_row["district_name"], - target_class=target_class_for_geo, + # 5b-1) Двумерные конкуренты (#23): radius_n (3км) + district_only_n. + # total_weighted используется как divisor в rosreestr-fallback. + competitors_radius_n, competitors_district_only_n, competitors_weighted, competitors_scope = ( + _competitors_two_dim( + db, + region_code=region_code, + district_name=district_row["district_name"], + target_class=target_class_for_geo, + ) ) + # Обратная совместимость: одномерный счётчик для warnings + competitors = round(competitors_weighted) if competitors_scope == "fallback_singleton": warnings.append( f"Не нашлось активно строящихся ЖК ни в районе {district_row['district_name']}" f" ни в регионе {region_code} — нормировка отключена (как для монополиста)." ) - elif competitors_scope != "district+class": - # Информативное сообщение о расширении scope при недостатке локальных данных. + elif competitors_scope not in ("district+class", "district_2d"): scope_label = { "district": f"районе {district_row['district_name']} (без класса)", "region": f"регионе {region_code} (вне района)", @@ -1917,8 +2136,8 @@ def recommend_mix( " темп считается по rosreestr-сделкам ÷ конкуренты (грубее)." ) market_vel_pm = ( - (total_deals / max(effective_window, 1) / max(competitors, 1)) - if total_deals and competitors + (total_deals / max(effective_window, 1) / max(competitors_weighted, 1.0)) + if total_deals and competitors_weighted else 0.0 ) @@ -1960,6 +2179,12 @@ def recommend_mix( mortgage_rate, mortgage_period = _current_mortgage_rate(db) + # #22 Noise penalty: плотность шумных объектов района → штраф до -10% цены + noise_penalty, noise_breakdown = _noise_penalty_factor(db, district_row["district_name"]) + + # #25 Success-driven ranking из v_bucket_success_score + success_ranking = _bucket_success_ranking(db, district_row["district_name"], target_class) + # 5b-3) Per-bucket project velocity at price_factor=1.0: # bucket_market_v = market_vel_pm × bucket.share/100 — доля per-ЖК # темпа, аллоцированная на размерный сегмент. @@ -1970,9 +2195,12 @@ def recommend_mix( # динамика (горит/остывает). # adjusted = project_velocity × price_factor^elasticity # months_to_sellout = units_planned / adjusted - # Цена тоже корректируется на poi_factor (развитость района = премиум). + # Цены корректируются на poi_factor (развитость района = премиум) + # и noise_penalty (шумное окружение = дисконт). pf_pow = price_factor**elasticity if price_factor > 0 else 1.0 macro_velocity_mult = sat_factor * trend_factor + # Комбинированный ценовой коэффициент: POI-премиум × noise-дисконт + combined_price_factor = poi_factor * noise_penalty total_units = 0 for b in buckets: bucket_market_v = bucket_market_velocities.get(b["bucket"], 0.0) @@ -1986,14 +2214,17 @@ def recommend_mix( b["elasticity_r2"] = be.get("r2", 0.0) b["elasticity_n"] = be.get("n", 0) b["elasticity_source"] = be.get("source", "fallback_global") - # POI-корректировка на цену (на ВСЕ p25/median/p75) - b["price_median_per_m2"] = round(b["price_median_per_m2"] * poi_factor, 2) - b["price_p25_per_m2"] = round(b["price_p25_per_m2"] * poi_factor, 2) - b["price_p75_per_m2"] = round(b["price_p75_per_m2"] * poi_factor, 2) + # POI-корректировка + noise penalty на цены (ВСЕ p25/median/p75) + b["price_median_per_m2"] = round(b["price_median_per_m2"] * combined_price_factor, 2) + b["price_p25_per_m2"] = round(b["price_p25_per_m2"] * combined_price_factor, 2) + b["price_p75_per_m2"] = round(b["price_p75_per_m2"] * combined_price_factor, 2) + b["is_top_success"] = False if b["units_planned"] and bucket_velocity > 0: - # Revenue тоже пересчитываем после POI-correction (linear scale). + # Revenue тоже пересчитываем после combined-correction (linear scale). if b["revenue_planned_rub"] is not None: - b["revenue_planned_rub"] = round(b["revenue_planned_rub"] * poi_factor, 2) + b["revenue_planned_rub"] = round( + b["revenue_planned_rub"] * combined_price_factor, 2 + ) adjusted_velocity = bucket_velocity * bucket_pf_pow b["months_to_sellout"] = ( round(b["units_planned"] / adjusted_velocity, 1) if adjusted_velocity > 0 else None @@ -2001,11 +2232,34 @@ def recommend_mix( total_units += b["units_planned"] else: b["months_to_sellout"] = None - # Итог revenue + weighted_avg_price после POI-correction (linear scale). + # Итог revenue + weighted_avg_price после POI-correction + noise penalty. if have_revenue: - total_revenue *= poi_factor + total_revenue *= combined_price_factor if weighted_avg_price is not None: - weighted_avg_price = round(weighted_avg_price * poi_factor, 2) + weighted_avg_price = round(weighted_avg_price * combined_price_factor, 2) + + # #25 Success-driven mix: поднимаем долю top-success bucket'а на 10%, + # пропорционально уменьшаем остальные. Условие: success_score > 0 AND n_deals >= 30. + if success_ranking: + top = next( + (r for r in success_ranking if r["success_score"] > 0 and r["n_deals"] >= 30), + None, + ) + if top: + top_bucket_name = top["bucket"] + # Найти bucket в списке по имени + top_b = next((b for b in buckets if b["bucket"] == top_bucket_name), None) + if top_b is not None: + boost = top_b["share_pct"] * 0.10 # +10% + top_b["share_pct"] = round(top_b["share_pct"] + boost, 1) + top_b["is_top_success"] = True + # Пропорционально уменьшаем остальные чтобы sum = 100 + other_sum = sum(b["share_pct"] for b in buckets if b["bucket"] != top_bucket_name) + if other_sum > 0: + scale = (100.0 - top_b["share_pct"]) / other_sum + for b in buckets: + if b["bucket"] != top_bucket_name: + b["share_pct"] = round(b["share_pct"] * scale, 1) # 5c) Inverse mode: target_months → required price_factor. # Tier 3: используем weighted-by-units эластичность (per-bucket эластичности @@ -2067,7 +2321,11 @@ def recommend_mix( round(total_revenue / total_units, 2) if (have_revenue and total_units > 0) else None ) - # 6) Comparable ЖК — same district (parsed from addr) and class + # 6) Comparable ЖК — same district (parsed from addr) and class. + # #22 Hard-cap по границам: фильтруем по ST_Within чтобы исключить ЖК + # у границы района, формально в domrf по district_name, но реально за + # пределами полигона (координаты из v_complex_full). ЖК без координат + # (latitude/longitude NULL) — пропускаем через LEFT JOIN + фильтр. cmp_rows = ( db.execute( text( @@ -2088,7 +2346,10 @@ def recommend_mix( cad_buildings_n FROM v_complex_full ORDER BY lower(canonical_name), cad_buildings_n DESC NULLS LAST - ) + ), + district_geom AS ( + SELECT geom FROM ekb_districts WHERE district_name = :dn LIMIT 1 + ), latest_obj AS ( -- domrf_kn_objects содержит ~3 snapshot'а на obj_id; -- берём только самый свежий, иначе comparables дублируются @@ -2112,6 +2373,16 @@ def recommend_mix( AND a.snapshot_date = la.snap AND a.type = 'apartments' LEFT JOIN vcf_dedup c ON c.name_key = lower(o.comm_name) + WHERE ( + -- hard-cap по границам района: только если координаты известны И + -- точка внутри полигона. Без координат — включаем (нет данных для отсева) + c.latitude IS NULL + OR c.longitude IS NULL + OR ST_Within( + ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326), + (SELECT geom FROM district_geom) + ) + ) ORDER BY o.flat_count DESC NULLS LAST LIMIT 5 """ @@ -2166,6 +2437,8 @@ def recommend_mix( "velocity_objects": vel["objects_count"], "competitors_count": competitors, "competitors_scope": competitors_scope, + "competitors_radius_n": competitors_radius_n, + "competitors_district_only_n": competitors_district_only_n, "saturation_median": sat_median, "saturation_n": sat_n, "sat_factor": round(sat_factor, 4), @@ -2184,13 +2457,17 @@ def recommend_mix( "elasticity_source": elast["source"], "elasticity_weighted": (round(weighted_elasticity, 4) if total_units > 0 else None), "elasticity_per_bucket": elast_per_bucket, - # Окна источников данных — для прозрачности и UI-tooltip: + # Окна источников данных — для прозрачности и UI-tooltip. # share_window_months — окно по rosreestr_deals для bucket-shares # и market velocity (input months_window, может расшириться до 27). - # elasticity_window_months — окно по domrf_kn_sale_graph (фиксировано - # 36 мес — sale_graph есть с 2023г, шире окно даёт устойчивее регрессию). + # elasticity_window_months — синхронизировано с share_window (issue #24). "share_window_months": effective_window, - "elasticity_window_months": 36, + "elasticity_window_months": elasticity_window_months, + # Noise penalty (issue #22) + "noise_penalty": noise_penalty, + "noise_breakdown": noise_breakdown, + # Success ranking (issue #25) + "success_ranking": success_ranking, "cadastre_median_per_m2": ( round(cadastre["median_per_m2"], 0) if cadastre["median_per_m2"] is not None diff --git a/frontend/src/components/analytics/RecommendBucketsTable.tsx b/frontend/src/components/analytics/RecommendBucketsTable.tsx index f05ed7e4..e866d4ec 100644 --- a/frontend/src/components/analytics/RecommendBucketsTable.tsx +++ b/frontend/src/components/analytics/RecommendBucketsTable.tsx @@ -40,6 +40,7 @@ export function RecommendBucketsTable({ {[ "Бакет", + "Успех", "Доля", "Сделок", "Площадь ср., м²", @@ -88,6 +89,16 @@ export function RecommendBucketsTable({ {r.bucket} + + {r.is_top_success === true ? ( + + ⭐ + + ) : null} + {r.effective_share_pct.toFixed(1)}% {fmtInt(r.deal_count)} {r.area_avg_m2.toFixed(1)} diff --git a/frontend/src/components/analytics/RecommendForm.tsx b/frontend/src/components/analytics/RecommendForm.tsx index 515a65a3..b24be656 100644 --- a/frontend/src/components/analytics/RecommendForm.tsx +++ b/frontend/src/components/analytics/RecommendForm.tsx @@ -4,7 +4,7 @@ import { useDistricts } from "@/lib/analytics-api"; import type { RecommendClass, RecommendMixInput } from "@/types/analytics"; const CLASSES: RecommendClass[] = ["Comfort", "Comfort+", "Business", "Elite"]; -const MONTHS_OPTIONS = [12, 18, 24, 27]; +const MONTHS_OPTIONS = [12, 18, 24]; interface Props { value: RecommendMixInput; @@ -130,7 +130,25 @@ export function RecommendForm({ diff --git a/frontend/src/components/analytics/RecommendVelocityPanel.tsx b/frontend/src/components/analytics/RecommendVelocityPanel.tsx index b4eb601b..282b93af 100644 --- a/frontend/src/components/analytics/RecommendVelocityPanel.tsx +++ b/frontend/src/components/analytics/RecommendVelocityPanel.tsx @@ -148,6 +148,46 @@ export function RecommendVelocityPanel({ /> + {/* #22 Noise penalty badge */} + {scope.noise_penalty != null && scope.noise_penalty < 0.98 ? ( +
+ { + const nb = scope.noise_breakdown; + if (!nb || !("magistral_n" in nb)) return "нет данных"; + return ( + `Магистрали: ${nb.magistral_n} · ` + + `ЖД: ${nb.railway_n} · ` + + `Промзоны: ${nb.industrial_n} · ` + + `Итого источников: ${nb.total_sources}` + ); + })()} + > + {"🔊"} − + {scope.noise_breakdown && "penalty_pct" in scope.noise_breakdown + ? scope.noise_breakdown.penalty_pct + : Math.round((1 - scope.noise_penalty) * 100)} + % (магистрали/жд/промзоны в районе) + +
+ ) : null} + {/* Warning badge — fallback на rosreestr (нет sale_graph для контекста) */} {scope.velocity_source === "rosreestr_fallback" ? (
) : null} + {/* #25 Success ranking banner */} + {scope.success_ranking != null && + scope.success_ranking.length > 0 && + scope.success_ranking[0].success_score > 0 ? ( +
+ 💎 Рекомендация смещена в пользу{" "} + {scope.success_ranking[0].bucket} — лучшая динамика в + районе ({scope.success_ranking[0].n_deals} сделок, успех{" "} + {scope.success_ranking[0].success_score.toFixed(2)}) +
+ ) : null} + {/* Methodology note */}
{scope.competitors_count} активных - конкурентов в районе. Применены macro-факторы:{" "} + ),{" "} + {scope.competitors_radius_n != null && + scope.competitors_district_only_n != null ? ( + + нормирован: в радиусе 3км{" "} + {scope.competitors_radius_n} ЖК · дальше по району{" "} + {scope.competitors_district_only_n} ЖК (вес 0.6) + + ) : ( + <> + нормирован на {scope.competitors_count} активных + конкурентов в районе + + )} + . Применены macro-факторы:{" "} sat ×{scope.sat_factor.toFixed(2)} {scope.saturation_median != null ? ` (sold% ${scope.saturation_median.toFixed(0)})` diff --git a/frontend/src/types/analytics.ts b/frontend/src/types/analytics.ts index 4abf0368..a1eeba92 100644 --- a/frontend/src/types/analytics.ts +++ b/frontend/src/types/analytics.ts @@ -236,6 +236,8 @@ export interface RecommendBucket { elasticity_r2?: number; elasticity_n?: number; elasticity_source?: "regression" | "fallback_global"; + /** #25 — top success bucket flag */ + is_top_success?: boolean; } export interface ElasticityPerBucket { @@ -303,6 +305,30 @@ export interface RecommendMixOutput { elasticity_per_bucket: ElasticityPerBucket; share_window_months: number; elasticity_window_months: number; + /** #22 — noise penalty (0.9..1.0) */ + noise_penalty?: number; + noise_breakdown?: + | { + district: string; + magistral_n: number; + railway_n: number; + industrial_n: number; + total_sources: number; + penalty_pct: number; + } + | Record; + /** #23 — 2D competitors */ + competitors_radius_n?: number; + competitors_district_only_n?: number; + /** #25 — success ranking */ + success_ranking?: Array<{ + bucket: string; + success_score: number; + n_deals: number; + velocity_z: number; + price_z: number; + area_z: number; + }>; cadastre_median_per_m2: number | null; cadastre_buildings_n: number; cadastre_vs_market_pct: number | null;