feat(analytics): recommend_mix v3.1-v3.4 - noise + 2D competitors + 24m cap + success-driven

This commit is contained in:
lekss361 2026-05-11 22:19:41 +03:00
parent 369b5a4706
commit c31da62e8d
6 changed files with 452 additions and 40 deletions

View file

@ -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):

View file

@ -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), ,
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

View file

@ -40,6 +40,7 @@ export function RecommendBucketsTable({
<tr style={{ background: "#f6f7f9" }}>
{[
"Бакет",
"Успех",
"Доля",
"Сделок",
"Площадь ср., м²",
@ -88,6 +89,16 @@ export function RecommendBucketsTable({
<td style={td}>
<strong>{r.bucket}</strong>
</td>
<td style={{ ...td, textAlign: "center" }}>
{r.is_top_success === true ? (
<span
title="Топ-bucket в районе за 24м (быстрые продажи + высокая цена + компактная площадь)"
style={{ cursor: "help", fontSize: 16 }}
>
</span>
) : null}
</td>
<td style={td}>{r.effective_share_pct.toFixed(1)}%</td>
<td style={td}>{fmtInt(r.deal_count)}</td>
<td style={td}>{r.area_avg_m2.toFixed(1)}</td>

View file

@ -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({
</label>
<label>
<span style={labelStyle}>Окно сделок</span>
<span style={labelStyle}>
Окно сделок{" "}
<span
style={{
background: "#eff6ff",
color: "#1d4ed8",
padding: "1px 6px",
borderRadius: 4,
fontSize: 10,
fontWeight: 600,
textTransform: "none",
letterSpacing: 0,
marginLeft: 4,
}}
title="ЦБ менял ключевую ставку, льготная ипотека, программы — данные >2 лет нерелевантны для прогноза"
>
📊 Данные: последние 24 месяца
</span>
</span>
<select
value={value.months_window}
onChange={(e) =>
@ -145,8 +163,7 @@ export function RecommendForm({
))}
</select>
<span style={hintStyle}>
При &lt;30 сделок в любом бакете окно автоматически расширяется до 27
мес (max доступный rosreestr-архив).
При &lt;30 сделок в любом бакете окно расширяется до max 24 мес.
</span>
</label>

View file

@ -148,6 +148,46 @@ export function RecommendVelocityPanel({
/>
</div>
{/* #22 Noise penalty badge */}
{scope.noise_penalty != null && scope.noise_penalty < 0.98 ? (
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
marginBottom: 12,
}}
>
<span
style={{
background: "#fef2f2",
color: "#b91c1c",
padding: "2px 8px",
borderRadius: 6,
fontSize: 11,
fontWeight: 600,
cursor: "default",
}}
title={(() => {
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}`
);
})()}
>
{"🔊"} &minus;
{scope.noise_breakdown && "penalty_pct" in scope.noise_breakdown
? scope.noise_breakdown.penalty_pct
: Math.round((1 - scope.noise_penalty) * 100)}
% (магистрали/жд/промзоны в районе)
</span>
</div>
) : null}
{/* Warning badge — fallback на rosreestr (нет sale_graph для контекста) */}
{scope.velocity_source === "rosreestr_fallback" ? (
<div
@ -376,6 +416,29 @@ export function RecommendVelocityPanel({
</div>
) : null}
{/* #25 Success ranking banner */}
{scope.success_ranking != null &&
scope.success_ranking.length > 0 &&
scope.success_ranking[0].success_score > 0 ? (
<div
style={{
marginTop: 12,
padding: "8px 12px",
background: "#fefce8",
border: "1px solid #fde68a",
borderRadius: 8,
fontSize: 13,
color: "#78350f",
lineHeight: 1.5,
}}
>
💎 Рекомендация смещена в пользу{" "}
<strong>{scope.success_ranking[0].bucket}</strong> лучшая динамика в
районе ({scope.success_ranking[0].n_deals} сделок, успех{" "}
{scope.success_ranking[0].success_score.toFixed(2)})
</div>
) : null}
{/* Methodology note */}
<div
style={{
@ -401,8 +464,24 @@ export function RecommendVelocityPanel({
{scope.velocity_source === "sale_graph"
? `sale_graph: ${scope.velocity_objects} ЖК / ${scope.velocity_observations} точек`
: "fallback на rosreestr-сделки"}
), нормирован на <strong>{scope.competitors_count}</strong> активных
конкурентов в районе. Применены macro-факторы:{" "}
),{" "}
{scope.competitors_radius_n != null &&
scope.competitors_district_only_n != null ? (
<span
title="Дальние конкуренты в районе тоже создают спрос, но меньше близких. Учитываются с весом 0.6 при нормировке velocity."
style={{ cursor: "help" }}
>
нормирован: в радиусе 3км{" "}
<strong>{scope.competitors_radius_n} ЖК</strong> · дальше по району{" "}
<strong>{scope.competitors_district_only_n} ЖК</strong> (вес 0.6)
</span>
) : (
<>
нормирован на <strong>{scope.competitors_count}</strong> активных
конкурентов в районе
</>
)}
. Применены macro-факторы:{" "}
<strong>sat ×{scope.sat_factor.toFixed(2)}</strong>
{scope.saturation_median != null
? ` (sold% ${scope.saturation_median.toFixed(0)})`

View file

@ -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<string, never>;
/** #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;