diff --git a/backend/app/services/analytics_queries.py b/backend/app/services/analytics_queries.py index 2f9a939e..05fee0da 100644 --- a/backend/app/services/analytics_queries.py +++ b/backend/app/services/analytics_queries.py @@ -1095,6 +1095,186 @@ def _velocity_baseline( } +def _district_market_saturation(db: Session, *, district_name: str) -> tuple[float | None, int]: + """Median sold% активных строящихся ЖК в районе. >50% = зрелый рынок + (конкуренты много продали, новый проект имеет место). <20% = свежий + (много инвентаря на продажу, сложнее пробиться). + + Возвращает (median_pct, n_objects). None если <5 ЖК с perc. + """ + row = ( + db.execute( + text( + """ + SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY a.perc) AS sold_median, + COUNT(*) AS n + FROM domrf_kn_sales_agg a + JOIN domrf_kn_objects o + ON o.obj_id = a.obj_id + AND o.snapshot_date = a.snapshot_date + WHERE a.type = 'apartments' + AND a.perc IS NOT NULL + AND o.district_name = :dn + AND o.site_status = 'Строящиеся' + """ + ), + {"dn": district_name}, + ) + .mappings() + .first() + ) + if not row or (row["n"] or 0) < 5: + return None, int(row["n"] or 0) if row else 0 + return _f(row["sold_median"]), int(row["n"]) + + +def _district_velocity_trend(db: Session, *, district_name: str) -> tuple[float | None, int, int]: + """Ratio realised: recent_6mo / prior_6mo. >1.5 — рынок горит, <0.7 — + остывает. Считаем за окно 12 мес: H1 2025 vs H2 2025+. + + Возвращает (ratio, recent_units, prior_units). None если данных мало. + """ + row = ( + db.execute( + text( + """ + SELECT + SUM(sg.realised) FILTER (WHERE sg.report_month >= DATE '2025-07-01') + AS recent, + SUM(sg.realised) FILTER (WHERE sg.report_month BETWEEN DATE '2025-01-01' + AND DATE '2025-06-30') + AS prior + FROM domrf_kn_sale_graph sg + JOIN domrf_kn_objects o + ON o.obj_id = sg.obj_id + AND o.snapshot_date = sg.snapshot_date + WHERE sg.type = 'apartments' + AND o.district_name = :dn + """ + ), + {"dn": district_name}, + ) + .mappings() + .first() + ) + recent = int(row["recent"] or 0) if row else 0 + prior = int(row["prior"] or 0) if row else 0 + if prior > 0 and recent > 0: + return recent / prior, recent, prior + return None, recent, prior + + +_POI_WEIGHTS = { + "Транспорт": 1.5, + "Метро": 2.0, + "Образование": 1.2, + "Медицина": 1.3, + "Спорт": 1.0, + "Продукты": 0.8, + "Развлечения": 0.7, + "Новостройки": 0.0, # сами ЖК — не используем как amenity +} + + +def _district_poi_score(db: Session, *, district_name: str) -> float | None: + """Среднее по ЖК района: weighted POI count в радиусе 1000м. + Используем категории-веса (метро/медицина важнее, новостройки игнор). + + Возвращает None если в районе <3 ЖК с POI. + """ + weights_sql = " ".join( + [f"WHEN i.poi_category = '{cat}' THEN {w:.2f}" for cat, w in _POI_WEIGHTS.items()] + ) + row = ( + db.execute( + text( + f""" + WITH per_obj AS ( + SELECT i.obj_id, + SUM(CASE {weights_sql} ELSE 0.5 END) + FILTER (WHERE i.distance_m IS NULL OR i.distance_m <= 1000) + AS weighted_poi + FROM domrf_kn_infrastructure i + JOIN domrf_kn_objects o + ON o.obj_id = i.obj_id + AND o.snapshot_date = i.snapshot_date + WHERE o.district_name = :dn + GROUP BY i.obj_id + ) + SELECT AVG(weighted_poi) AS avg_score, COUNT(*) AS n + FROM per_obj + WHERE weighted_poi > 0 + """ + ), + {"dn": district_name}, + ) + .mappings() + .first() + ) + if not row or (row["n"] or 0) < 3: + return None + return _f(row["avg_score"]) + + +def _city_avg_poi_score(db: Session, *, region_code: int = 66) -> float | None: + """Средний POI score по всему ЕКБ — для нормировки district_poi_score.""" + weights_sql = " ".join( + [f"WHEN i.poi_category = '{cat}' THEN {w:.2f}" for cat, w in _POI_WEIGHTS.items()] + ) + row = ( + db.execute( + text( + f""" + WITH per_obj AS ( + SELECT i.obj_id, + SUM(CASE {weights_sql} ELSE 0.5 END) + FILTER (WHERE i.distance_m IS NULL OR i.distance_m <= 1000) + AS weighted_poi + FROM domrf_kn_infrastructure i + JOIN domrf_kn_objects o + ON o.obj_id = i.obj_id + AND o.snapshot_date = i.snapshot_date + WHERE o.region_cd = :rc + AND o.district_name IS NOT NULL + GROUP BY i.obj_id + ) + SELECT AVG(weighted_poi) AS avg_score + FROM per_obj + WHERE weighted_poi > 0 + """ + ), + {"rc": region_code}, + ) + .mappings() + .first() + ) + return _f(row["avg_score"]) if row else None + + +def _current_mortgage_rate(db: Session) -> tuple[float | None, str | None]: + """Последняя средневзвешенная ипотечная ставка из cbr_mortgage_series. + Возвращает (rate_pct, period_label).""" + row = ( + db.execute( + text( + """ + SELECT value, period + FROM cbr_mortgage_series + WHERE title ILIKE '%ипотечн%жилищн%' + AND value IS NOT NULL + ORDER BY period DESC + LIMIT 1 + """ + ) + ) + .mappings() + .first() + ) + if not row: + return None, None + return _f(row["value"]), row["period"] + + def _active_competitors_count( db: Session, *, @@ -1289,34 +1469,52 @@ def recommend_mix( else 1.0 ) - # 3) Class multiplier from yandex_realty_zk price ranges (price_from) + # 3) Class multiplier через yandex_realty_zk + Comfort как BASELINE (×1.0). + # Раньше делили class_avg/overall_avg где overall = смесь по 12 rows + # → числа абсурдные (Elite ×1.22, Comfort+ ×0.66 < Comfort). + # Теперь: ratio(class) = class_price_avg / comfort_price_avg. + # Реалистичные индустриальные значения: Comfort=1.0, Comfort+=1.02, + # Business=1.86, Elite=4.27 (на основе текущих 12 rows yandex_realty_zk). + # yandex_realty_class_prices игнорируем — midpoint бессмыслен (ширина + # диапазонов класса искажает result). + # UI шлёт 'Comfort'/'Comfort+'/'Business'/'Elite' → realty_zk: 'COMFORT'/ + # 'COMFORT_PLUS'/'BUSINESS'/'ELITE'. class_multiplier = 1.0 + class_multiplier_source: str | None = None if target_class: - cls_row = ( - db.execute( - text( - """ + zk_norm = { + "Comfort": "COMFORT", + "Comfort+": "COMFORT_PLUS", + "Business": "BUSINESS", + "Elite": "ELITE", + }.get(target_class) + if zk_norm: + r = ( + db.execute( + text( + """ SELECT - AVG(price_from) FILTER (WHERE obj_class = :cls) AS class_avg, - AVG(price_from) AS overall_avg + AVG(price_from) FILTER (WHERE obj_class = :cls) AS class_avg, + AVG(price_from) FILTER (WHERE obj_class = 'COMFORT') AS comfort_avg FROM yandex_realty_zk WHERE price_from IS NOT NULL AND price_from > 0 """ - ), - {"cls": target_class}, - ) - .mappings() - .first() - ) - cavg = _f(cls_row["class_avg"]) if cls_row else None - oavg = _f(cls_row["overall_avg"]) if cls_row else None - if cavg and oavg and oavg > 0: - class_multiplier = cavg / oavg - else: - warnings.append( - f"Нет ценовых данных yandex_realty_zk для класса '{target_class}'," - " коэффициент класса = 1.0" + ), + {"cls": zk_norm}, + ) + .mappings() + .first() ) + cavg = _f(r["class_avg"]) if r else None + comfort_avg = _f(r["comfort_avg"]) if r else None + if cavg and comfort_avg and comfort_avg > 0: + class_multiplier = cavg / comfort_avg + class_multiplier_source = "realty_zk_vs_comfort" + else: + warnings.append( + f"Нет ценовых данных yandex_realty_zk для класса '{target_class}'" + " — коэффициент класса = 1.0" + ) # 4) Bucket distribution from rosreestr_deals — city-wide, last N months. # Если в каком-либо бакете <30 сделок и окно < 24 мес, расширяем до 24 мес @@ -1484,26 +1682,57 @@ def recommend_mix( for r in bucket_rows } + # 5b-2.5) Дополнительные district-specific signals (Tier 2): + # sat_factor — насколько зрелый рынок района (median sold% активных + # ЖК). >50% = зрелый, новый проект имеет место, +bonus. + # <20% = свежий, много инвентаря, -penalty. + # trend_factor — recent_6mo / prior_6mo realised. Clamp 0.7..2.0 чтобы + # экстремум не разрушал расчёты. + # poi_factor — weighted POI density района / city avg. ±5% на цены. + sat_median, sat_n = _district_market_saturation(db, district_name=district_row["district_name"]) + sat_factor = 1 + (sat_median - 50) / 100 * 0.3 if sat_median is not None else 1.0 + + trend_ratio, trend_recent, trend_prior = _district_velocity_trend( + db, district_name=district_row["district_name"] + ) + trend_factor = max(0.7, min(2.0, trend_ratio)) if trend_ratio else 1.0 + + poi_score = _district_poi_score(db, district_name=district_row["district_name"]) + city_avg_poi = _city_avg_poi_score(db, region_code=region_code) + poi_factor = ( + 1 + (poi_score - city_avg_poi) / max(city_avg_poi, 1) * 0.05 + if (poi_score is not None and city_avg_poi is not None and city_avg_poi > 0) + else 1.0 + ) + + mortgage_rate, mortgage_period = _current_mortgage_rate(db) + # 5b-3) Per-bucket project velocity at price_factor=1.0: # bucket_market_v = темп РЫНКА для bucket'а (deals/mo по всему региону) - # normalisation = sqrt(N_competitors) — компромисс между - # «монополист» (÷1) и «равный pie-split» (÷N). - # Реально продажи распределены по power-law: top-20% - # ЖК делают 80% сделок, поэтому linear ÷N даёт - # абсурдные сроки в десятилетия. sqrt — стандартный - # proxy для "effective competitors" в подобных - # зашумлённых market-share моделях. - # project_velocity = bucket_market_v / sqrt(N_competitors) + # normalisation = sqrt(N_competitors) — power-law эффективные + # конкуренты (sqrt компромисс между ÷1 и ÷N). + # project_velocity = bucket_market_v / sqrt(N) × sat_factor × trend_factor + # sat — зрелый рынок ускоряет; trend — текущая + # динамика (горит/остывает). # adjusted = project_velocity × price_factor^elasticity # months_to_sellout = units_planned / adjusted + # Цена тоже корректируется на poi_factor (развитость района = премиум). pf_pow = price_factor**elasticity if price_factor > 0 else 1.0 competitors_norm = math.sqrt(max(competitors, 1)) + macro_velocity_mult = sat_factor * trend_factor total_units = 0 for b in buckets: bucket_market_v = bucket_market_velocities.get(b["bucket"], 0.0) - bucket_velocity = round(bucket_market_v / competitors_norm, 4) + bucket_velocity = round(bucket_market_v / competitors_norm * macro_velocity_mult, 4) b["velocity_per_month"] = bucket_velocity + # 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) if b["units_planned"] and bucket_velocity > 0: + # Revenue тоже пересчитываем после POI-correction (linear scale). + if b["revenue_planned_rub"] is not None: + b["revenue_planned_rub"] = round(b["revenue_planned_rub"] * poi_factor, 2) adjusted_velocity = bucket_velocity * pf_pow b["months_to_sellout"] = ( round(b["units_planned"] / adjusted_velocity, 1) if adjusted_velocity > 0 else None @@ -1511,6 +1740,11 @@ def recommend_mix( total_units += b["units_planned"] else: b["months_to_sellout"] = None + # Итог revenue + weighted_avg_price после POI-correction (linear scale). + if have_revenue: + total_revenue *= poi_factor + if weighted_avg_price is not None: + weighted_avg_price = round(weighted_avg_price * poi_factor, 2) # 5c) Inverse mode: target_months → required price_factor. # required_velocity = total_units / target_months @@ -1617,6 +1851,7 @@ def recommend_mix( "district_median_price_per_m2": district_median, "district_factor": round(district_factor, 4), "class_multiplier": round(class_multiplier, 4), + "class_multiplier_source": class_multiplier_source, "target_class": target_class, "months_window": months_window, "effective_window_months": effective_window, @@ -1630,6 +1865,18 @@ def recommend_mix( "velocity_objects": vel["objects_count"], "competitors_count": competitors, "competitors_scope": competitors_scope, + "saturation_median": sat_median, + "saturation_n": sat_n, + "sat_factor": round(sat_factor, 4), + "velocity_trend_ratio": (round(trend_ratio, 2) if trend_ratio is not None else None), + "trend_recent_units": trend_recent, + "trend_prior_units": trend_prior, + "trend_factor": round(trend_factor, 4), + "poi_score": round(poi_score, 1) if poi_score is not None else None, + "poi_score_city_avg": (round(city_avg_poi, 1) if city_avg_poi is not None else None), + "poi_factor": round(poi_factor, 4), + "mortgage_rate_pct": mortgage_rate, + "mortgage_rate_period": mortgage_period, "elasticity": elasticity, "elasticity_r2": elast["r2"], "elasticity_n": elast["n"], diff --git a/frontend/src/app/analytics/recommend/page.tsx b/frontend/src/app/analytics/recommend/page.tsx index 065a68bf..b2a6c316 100644 --- a/frontend/src/app/analytics/recommend/page.tsx +++ b/frontend/src/app/analytics/recommend/page.tsx @@ -153,14 +153,35 @@ export default function RecommendPage() { lineHeight: 1.4, }} > - 💼 «{data.scope.district} - {data.scope.target_class - ? ` · ${data.scope.target_class}` - : ""} - {input.area_total_m2 - ? ` · ${input.area_total_m2.toLocaleString("ru")} м²` - : ""} - »: {data.summary.headline} +
+ 💼 «{data.scope.district} + {data.scope.target_class + ? ` · ${data.scope.target_class}` + : ""} + {input.area_total_m2 + ? ` · ${input.area_total_m2.toLocaleString("ru")} м²` + : ""} + »: {data.summary.headline} +
+
+ 📊 + {data.scope.mortgage_rate_pct != null + ? ` Ставка ЦБ ${data.scope.mortgage_rate_pct.toFixed(2)}% (${data.scope.mortgage_rate_period})` + : " ставка ЦБ нет данных"} + {data.scope.poi_score != null && + data.scope.poi_score_city_avg != null + ? ` · POI ${data.scope.poi_score.toFixed(0)}/${data.scope.poi_score_city_avg.toFixed(0)} (${data.scope.poi_score > data.scope.poi_score_city_avg ? "выше" : "ниже"} среднего)` + : ""} +
) : null} @@ -240,24 +261,43 @@ export default function RecommendPage() { } /> 50 + ? `зрелый рынок (${data.scope.saturation_n} ЖК)` + : data.scope.saturation_median < 25 + ? `свежий, мало распродано (${data.scope.saturation_n} ЖК)` + : `умеренная зрелость (${data.scope.saturation_n} ЖК)` } /> 1 ? "🚀" : data.scope.velocity_trend_ratio < 0.8 ? "❄" : "→"} ×${data.scope.velocity_trend_ratio.toFixed(2)}` + : "—" } + hint={ + data.scope.velocity_trend_ratio == null + ? "нет sale_graph" + : data.scope.velocity_trend_ratio > 1.5 + ? "рынок ускоряется" + : data.scope.velocity_trend_ratio < 0.8 + ? "остывает" + : "стабильный" + } + /> + diff --git a/frontend/src/components/analytics/RecommendVelocityPanel.tsx b/frontend/src/components/analytics/RecommendVelocityPanel.tsx index 35723614..96cd0406 100644 --- a/frontend/src/components/analytics/RecommendVelocityPanel.tsx +++ b/frontend/src/components/analytics/RecommendVelocityPanel.tsx @@ -264,8 +264,18 @@ export function RecommendVelocityPanel({ ? `sale_graph: ${scope.velocity_objects} ЖК / ${scope.velocity_observations} точек` : "fallback на rosreestr-сделки"} ), нормирован на {scope.competitors_count} активных - конкурентов в районе ). При price ×{priceFactor.toFixed(2)} темп = - базовый × {priceFactor.toFixed(2)}^{elasticity} = ×{pfPow.toFixed(3)}. + конкурентов в районе. Применены macro-факторы:{" "} + sat ×{scope.sat_factor.toFixed(2)} + {scope.saturation_median != null + ? ` (sold% ${scope.saturation_median.toFixed(0)})` + : ""}{" "} + · trend ×{scope.trend_factor.toFixed(2)} + {scope.velocity_trend_ratio != null + ? ` (raw ×${scope.velocity_trend_ratio.toFixed(2)} clamp 0.7..2.0)` + : ""}{" "} + · POI ×{scope.poi_factor.toFixed(2)} (на цену) ). При + price ×{priceFactor.toFixed(2)} темп = базовый ×{" "} + {priceFactor.toFixed(2)}^{elasticity} = ×{pfPow.toFixed(3)}. ); diff --git a/frontend/src/types/analytics.ts b/frontend/src/types/analytics.ts index 711f26e9..17dcf7f5 100644 --- a/frontend/src/types/analytics.ts +++ b/frontend/src/types/analytics.ts @@ -239,6 +239,7 @@ export interface RecommendMixOutput { district_factor: number; class_multiplier: number; target_class: string | null; + class_multiplier_source: "realty_zk_vs_comfort" | null; months_window: number; effective_window_months: number; region_code: number; @@ -253,6 +254,18 @@ export interface RecommendMixOutput { | "district" | "region" | "fallback_singleton"; + saturation_median: number | null; + saturation_n: number; + sat_factor: number; + velocity_trend_ratio: number | null; + trend_recent_units: number; + trend_prior_units: number; + trend_factor: number; + poi_score: number | null; + poi_score_city_avg: number | null; + poi_factor: number; + mortgage_rate_pct: number | null; + mortgage_rate_period: string | null; elasticity: number; elasticity_r2: number; elasticity_n: number;