diff --git a/backend/app/services/analytics_queries.py b/backend/app/services/analytics_queries.py index 71562c1d..51576d49 100644 --- a/backend/app/services/analytics_queries.py +++ b/backend/app/services/analytics_queries.py @@ -6,6 +6,7 @@ Region 66 = Sverdlovskaya oblast. Developer 6208_0 = PRINZIP. from __future__ import annotations +import math from decimal import Decimal from typing import Any @@ -1094,6 +1095,58 @@ def _velocity_baseline( } +def _active_competitors_count( + db: Session, + *, + region_code: int, + district_name: str, + target_class: str | None, +) -> tuple[int, str]: + """N активно строящихся ЖК для нормировки velocity. Каскадный fallback: + 1) (район + класс) — самый узкий + 2) (район) без класса — если первый дал <2 + 3) весь регион — если второй дал <2 + Возвращает (count, scope_used). Min 1 чтобы не делить на 0.""" + + def _q(where_extras: str, params: dict[str, Any]) -> int: + n = db.execute( + text( + f""" + SELECT COUNT(*) FROM domrf_kn_objects + WHERE region_cd = :rc + AND site_status = 'Строящиеся' + {where_extras} + """ + ), + params, + ).scalar() + return int(n or 0) + + # Tier 1: район + класс + if target_class: + n = _q( + "AND addr ILIKE '%' || :dn || '%' AND obj_class = :cls", + {"rc": region_code, "dn": district_name, "cls": target_class}, + ) + if n >= 2: + return n, "district+class" + + # Tier 2: район (без класса — могут быть ЖК где obj_class NULL) + n = _q( + "AND addr ILIKE '%' || :dn || '%'", + {"rc": region_code, "dn": district_name}, + ) + if n >= 2: + return n, "district" + + # Tier 3: весь регион (когда район по сути не покрыт скрапером) + n = _q("", {"rc": region_code}) + if n >= 1: + return n, "region" + + return 1, "fallback_singleton" + + def _elasticity_coef( db: Session, *, @@ -1372,14 +1425,58 @@ def recommend_mix( " (недостаточно для регрессии)." ) - # Per-bucket velocity at price_factor=1.0: - # share — рыночная доля бакета по сделкам Rosreestr, market_vel_pm — - # темп сопоставимого ЖК. velocity_per_bucket = market_vel_pm × share/100. - # При allocation: months_to_sellout = units_planned / (velocity × price_factor^elasticity). + # 5b-1) N активных конкурентов с каскадным fallback (район+класс → + # район → регион). Используется для нормировки рыночной velocity. + competitors, competitors_scope = _active_competitors_count( + db, + region_code=region_code, + district_name=district_row["district_name"], + target_class=target_class, + ) + if competitors_scope == "fallback_singleton": + warnings.append( + f"Не нашлось активно строящихся ЖК ни в районе {district_row['district_name']}" + f" ни в регионе {region_code} — нормировка отключена (как для монополиста)." + ) + elif competitors_scope != "district+class": + # Информативное сообщение о расширении scope при недостатке локальных данных. + scope_label = { + "district": f"районе {district_row['district_name']} (без класса)", + "region": f"регионе {region_code} (вне района)", + }.get(competitors_scope, competitors_scope) + warnings.append( + f"Конкурентов класса '{target_class or '*'}' в районе мало —" + f" нормировка по {competitors} ЖК в {scope_label}." + ) + + # 5b-2) Per-bucket market velocity (сделок/мес для каждого размерного + # сегмента из rosreestr — НЕ city-wide, а РЕАЛЬНАЯ интенсивность сегмента). + # Студии/1к — обычно выше, 80+ — ниже. + bucket_market_velocities = { + _BUCKET_PRETTY.get(r["bucket"], r["bucket"]): ( + int(r["deals"] or 0) / max(effective_window, 1) + ) + for r in bucket_rows + } + + # 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) + # adjusted = project_velocity × price_factor^elasticity + # months_to_sellout = units_planned / adjusted pf_pow = price_factor**elasticity if price_factor > 0 else 1.0 + competitors_norm = math.sqrt(max(competitors, 1)) total_units = 0 for b in buckets: - bucket_velocity = round(market_vel_pm * (b["share_pct"] / 100), 3) + bucket_market_v = bucket_market_velocities.get(b["bucket"], 0.0) + bucket_velocity = round(bucket_market_v / competitors_norm, 4) b["velocity_per_month"] = bucket_velocity if b["units_planned"] and bucket_velocity > 0: adjusted_velocity = bucket_velocity * pf_pow @@ -1471,11 +1568,15 @@ def recommend_mix( if have_revenue: headline_parts.append(f"{round(total_revenue / 1_000_000, 1)} млн ₽") if months_to_sellout_total: - headline_parts.append(f"за ~{int(months_to_sellout_total)} мес") + headline_parts.append(f"за ~{months_to_sellout_total:.1f} мес") if avg_ticket: headline_parts.append(f"ср. чек {round(avg_ticket / 1_000_000, 1)} М ₽") if base_total_v > 0: - headline_parts.append(f"темп {round(base_total_v * pf_pow, 1)} кв/мес") + # Малая velocity — формат с 2 десятыми (0.07 кв/мес для ЖК-доли). + tempo = base_total_v * pf_pow + headline_parts.append( + f"темп {tempo:.2f} кв/мес" if tempo < 1 else f"темп {tempo:.1f} кв/мес" + ) if liquidity_24mo is not None: headline_parts.append(f"ликвидность {liquidity_24mo:.0f}/100") headline = " · ".join(headline_parts) if headline_parts else None @@ -1498,6 +1599,8 @@ def recommend_mix( "velocity_source": velocity_source, "velocity_observations": vel["observations"], "velocity_objects": vel["objects_count"], + "competitors_count": competitors, + "competitors_scope": competitors_scope, "elasticity": elasticity, "elasticity_r2": elast["r2"], "elasticity_n": elast["n"], diff --git a/frontend/src/components/analytics/RecommendVelocityPanel.tsx b/frontend/src/components/analytics/RecommendVelocityPanel.tsx index 2e900be0..7cf14872 100644 --- a/frontend/src/components/analytics/RecommendVelocityPanel.tsx +++ b/frontend/src/components/analytics/RecommendVelocityPanel.tsx @@ -141,6 +141,30 @@ export function RecommendVelocityPanel({ /> + {/* Warning badge — fallback на rosreestr (нет sale_graph для контекста) */} + {scope.velocity_source === "rosreestr_fallback" ? ( +
+ ⚠ Темп грубый — нет sale_graph для ({scope.district} + {scope.target_class ? ` · ${scope.target_class}` : ""}). Показано: + city-wide rosreestr ({scope.total_deals} сделок /{" "} + {scope.effective_window_months} мес) ÷ {scope.competitors_count}{" "} + активных строящихся ЖК. Запусти 442-sweep по {scope.district} для + точных данных. +
+ ) : null} + {/* Price factor slider */}
{scope.competitors_count} активных + конкурентов в районе ). При 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 d43b83be..711f26e9 100644 --- a/frontend/src/types/analytics.ts +++ b/frontend/src/types/analytics.ts @@ -247,6 +247,12 @@ export interface RecommendMixOutput { velocity_source: "sale_graph" | "rosreestr_fallback"; velocity_observations: number; velocity_objects: number; + competitors_count: number; + competitors_scope: + | "district+class" + | "district" + | "region" + | "fallback_singleton"; elasticity: number; elasticity_r2: number; elasticity_n: number;