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) district_name: str = Field(..., min_length=2, max_length=80)
area_total_m2: float | None = Field(default=None, ge=100, le=500_000) area_total_m2: float | None = Field(default=None, ge=100, le=500_000)
target_class: ClassLiteral | None = None 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 # Velocity / pricing scenario knobs (live-tuned client-side; backend just
# ships base coefficients so frontend can recompute without round-trips). # ships base coefficients so frontend can recompute without round-trips).
# 0.01..3.0 = -99%..+200% к рынку. min=0.01 (а не 0) чтобы избежать # 0.01..3.0 = -99%..+200% к рынку. min=0.01 (а не 0) чтобы избежать
@ -39,6 +39,8 @@ class RecommendBucket(BaseModel):
# what-if recompute. # what-if recompute.
velocity_per_month: float | None = None velocity_per_month: float | None = None
months_to_sellout: 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): class RecommendComparable(BaseModel):

View file

@ -6,12 +6,15 @@ Region 66 = Sverdlovskaya oblast. Developer 6208_0 = PRINZIP.
from __future__ import annotations from __future__ import annotations
import logging
from decimal import Decimal from decimal import Decimal
from typing import Any from typing import Any
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
def _f(value: Any) -> float | None: def _f(value: Any) -> float | None:
if value is None: if value is None:
@ -1445,12 +1448,17 @@ def _elasticity_coef(
region_code: int, region_code: int,
district_name: str, district_name: str,
target_class: str | None, target_class: str | None,
elasticity_window_months: int = 24,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Fit log-log regression LN(realised) ~ LN(price_avg) on sale_graph """Fit log-log regression LN(realised) ~ LN(price_avg) on sale_graph
observations for the same район+class. Returns elasticity (slope), , observations for the same район+class. Returns elasticity (slope), ,
n. Falls back to FALLBACK_ELASTICITY if data thin or regression weak.""" n. Falls back to FALLBACK_ELASTICITY if data thin or regression weak."""
where_class = "AND o.obj_class = :cls" if target_class else "" 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: if target_class:
params["cls"] = target_class params["cls"] = target_class
row = ( row = (
@ -1472,7 +1480,7 @@ def _elasticity_coef(
WHERE sg.type = 'apartments' WHERE sg.type = 'apartments'
AND sg.realised IS NOT NULL AND sg.realised > 0 AND sg.realised IS NOT NULL AND sg.realised > 0
AND sg.price_avg IS NOT NULL AND sg.price_avg > 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 SELECT
regr_slope(y, x) AS slope, regr_slope(y, x) AS slope,
@ -1511,6 +1519,7 @@ def _elasticity_per_bucket_coef(
district_name: str, district_name: str,
target_class: str | None, target_class: str | None,
fallback: dict[str, Any], fallback: dict[str, Any],
elasticity_window_months: int = 24,
) -> dict[str, dict[str, Any]]: ) -> dict[str, dict[str, Any]]:
"""Per-bucket эластичность (Tier 3): группируем sale_graph-наблюдения по """Per-bucket эластичность (Tier 3): группируем sale_graph-наблюдения по
«доминирующему bucket» каждого ЖК (mode total_area из domrf_kn_flats), «доминирующему bucket» каждого ЖК (mode total_area из domrf_kn_flats),
@ -1522,7 +1531,11 @@ def _elasticity_per_bucket_coef(
общую эластичность из `fallback` со source='fallback_global'. общую эластичность из `fallback` со source='fallback_global'.
""" """
where_class = "AND o.obj_class = :cls" if target_class else "" 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: if target_class:
params["cls"] = target_class params["cls"] = target_class
rows = ( rows = (
@ -1578,7 +1591,7 @@ def _elasticity_per_bucket_coef(
WHERE sg.type = 'apartments' WHERE sg.type = 'apartments'
AND sg.realised IS NOT NULL AND sg.realised > 0 AND sg.realised IS NOT NULL AND sg.realised > 0
AND sg.price_avg IS NOT NULL AND sg.price_avg > 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, SELECT bucket,
regr_slope(y, x) AS slope, regr_slope(y, x) AS slope,
@ -1619,6 +1632,198 @@ def _elasticity_per_bucket_coef(
return out 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( def recommend_mix(
db: Session, db: Session,
*, *,
@ -1630,16 +1835,25 @@ def recommend_mix(
price_factor: float = 1.0, price_factor: float = 1.0,
target_months: int | None = None, target_months: int | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Rule-based квартирография recommender. """Rule-based квартирография recommender v3.1-v3.4.
City-wide bucket distribution from rosreestr_deals (последние N месяцев), City-wide bucket distribution from rosreestr_deals (последние N месяцев),
скорректированная на район (через ekb_districts.median_price_per_m2) и скорректированная на район (через ekb_districts.median_price_per_m2) и
класс (через yandex_realty_zk price-агрегаты per-class). класс (через 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] = [] 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 # 1) District lookup
district_row = ( district_row = (
db.execute( db.execute(
@ -1858,6 +2072,7 @@ def recommend_mix(
region_code=region_code, region_code=region_code,
district_name=district_row["district_name"], district_name=district_row["district_name"],
target_class=target_class_for_geo, target_class=target_class_for_geo,
elasticity_window_months=elasticity_window_months,
) )
elasticity = elast["elasticity"] elasticity = elast["elasticity"]
if elast["source"] == "fallback": if elast["source"] == "fallback":
@ -1877,23 +2092,27 @@ def recommend_mix(
district_name=district_row["district_name"], district_name=district_row["district_name"],
target_class=target_class_for_geo, target_class=target_class_for_geo,
fallback=elast, fallback=elast,
elasticity_window_months=elasticity_window_months,
) )
# 5b-1) N активных конкурентов с каскадным fallback (район+класс → # 5b-1) Двумерные конкуренты (#23): radius_n (3км) + district_only_n.
# район → регион). Используется как divisor в rosreestr-fallback ветке. # total_weighted используется как divisor в rosreestr-fallback.
competitors, competitors_scope = _active_competitors_count( competitors_radius_n, competitors_district_only_n, competitors_weighted, competitors_scope = (
db, _competitors_two_dim(
region_code=region_code, db,
district_name=district_row["district_name"], region_code=region_code,
target_class=target_class_for_geo, district_name=district_row["district_name"],
target_class=target_class_for_geo,
)
) )
# Обратная совместимость: одномерный счётчик для warnings
competitors = round(competitors_weighted)
if competitors_scope == "fallback_singleton": if competitors_scope == "fallback_singleton":
warnings.append( warnings.append(
f"Не нашлось активно строящихся ЖК ни в районе {district_row['district_name']}" f"Не нашлось активно строящихся ЖК ни в районе {district_row['district_name']}"
f" ни в регионе {region_code} — нормировка отключена (как для монополиста)." f" ни в регионе {region_code} — нормировка отключена (как для монополиста)."
) )
elif competitors_scope != "district+class": elif competitors_scope not in ("district+class", "district_2d"):
# Информативное сообщение о расширении scope при недостатке локальных данных.
scope_label = { scope_label = {
"district": f"районе {district_row['district_name']} (без класса)", "district": f"районе {district_row['district_name']} (без класса)",
"region": f"регионе {region_code} (вне района)", "region": f"регионе {region_code} (вне района)",
@ -1917,8 +2136,8 @@ def recommend_mix(
" темп считается по rosreestr-сделкам ÷ конкуренты (грубее)." " темп считается по rosreestr-сделкам ÷ конкуренты (грубее)."
) )
market_vel_pm = ( market_vel_pm = (
(total_deals / max(effective_window, 1) / max(competitors, 1)) (total_deals / max(effective_window, 1) / max(competitors_weighted, 1.0))
if total_deals and competitors if total_deals and competitors_weighted
else 0.0 else 0.0
) )
@ -1960,6 +2179,12 @@ def recommend_mix(
mortgage_rate, mortgage_period = _current_mortgage_rate(db) 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: # 5b-3) Per-bucket project velocity at price_factor=1.0:
# bucket_market_v = market_vel_pm × bucket.share/100 — доля per-ЖК # bucket_market_v = market_vel_pm × bucket.share/100 — доля per-ЖК
# темпа, аллоцированная на размерный сегмент. # темпа, аллоцированная на размерный сегмент.
@ -1970,9 +2195,12 @@ def recommend_mix(
# динамика (горит/остывает). # динамика (горит/остывает).
# adjusted = project_velocity × price_factor^elasticity # adjusted = project_velocity × price_factor^elasticity
# months_to_sellout = units_planned / adjusted # months_to_sellout = units_planned / adjusted
# Цена тоже корректируется на poi_factor (развитость района = премиум). # Цены корректируются на poi_factor (развитость района = премиум)
# и noise_penalty (шумное окружение = дисконт).
pf_pow = price_factor**elasticity if price_factor > 0 else 1.0 pf_pow = price_factor**elasticity if price_factor > 0 else 1.0
macro_velocity_mult = sat_factor * trend_factor macro_velocity_mult = sat_factor * trend_factor
# Комбинированный ценовой коэффициент: POI-премиум × noise-дисконт
combined_price_factor = poi_factor * noise_penalty
total_units = 0 total_units = 0
for b in buckets: for b in buckets:
bucket_market_v = bucket_market_velocities.get(b["bucket"], 0.0) 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_r2"] = be.get("r2", 0.0)
b["elasticity_n"] = be.get("n", 0) b["elasticity_n"] = be.get("n", 0)
b["elasticity_source"] = be.get("source", "fallback_global") b["elasticity_source"] = be.get("source", "fallback_global")
# POI-корректировка на цену (на ВСЕ p25/median/p75) # POI-корректировка + noise penalty на цены (ВСЕ p25/median/p75)
b["price_median_per_m2"] = round(b["price_median_per_m2"] * poi_factor, 2) 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"] * poi_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"] * poi_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: 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: 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 adjusted_velocity = bucket_velocity * bucket_pf_pow
b["months_to_sellout"] = ( b["months_to_sellout"] = (
round(b["units_planned"] / adjusted_velocity, 1) if adjusted_velocity > 0 else None 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"] total_units += b["units_planned"]
else: else:
b["months_to_sellout"] = None b["months_to_sellout"] = None
# Итог revenue + weighted_avg_price после POI-correction (linear scale). # Итог revenue + weighted_avg_price после POI-correction + noise penalty.
if have_revenue: if have_revenue:
total_revenue *= poi_factor total_revenue *= combined_price_factor
if weighted_avg_price is not None: 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. # 5c) Inverse mode: target_months → required price_factor.
# Tier 3: используем weighted-by-units эластичность (per-bucket эластичности # 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 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 = ( cmp_rows = (
db.execute( db.execute(
text( text(
@ -2088,7 +2346,10 @@ def recommend_mix(
cad_buildings_n cad_buildings_n
FROM v_complex_full FROM v_complex_full
ORDER BY lower(canonical_name), cad_buildings_n DESC NULLS LAST 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 ( latest_obj AS (
-- domrf_kn_objects содержит ~3 snapshot'а на obj_id; -- domrf_kn_objects содержит ~3 snapshot'а на obj_id;
-- берём только самый свежий, иначе comparables дублируются -- берём только самый свежий, иначе comparables дублируются
@ -2112,6 +2373,16 @@ def recommend_mix(
AND a.snapshot_date = la.snap AND a.snapshot_date = la.snap
AND a.type = 'apartments' AND a.type = 'apartments'
LEFT JOIN vcf_dedup c ON c.name_key = lower(o.comm_name) 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 ORDER BY o.flat_count DESC NULLS LAST
LIMIT 5 LIMIT 5
""" """
@ -2166,6 +2437,8 @@ def recommend_mix(
"velocity_objects": vel["objects_count"], "velocity_objects": vel["objects_count"],
"competitors_count": competitors, "competitors_count": competitors,
"competitors_scope": competitors_scope, "competitors_scope": competitors_scope,
"competitors_radius_n": competitors_radius_n,
"competitors_district_only_n": competitors_district_only_n,
"saturation_median": sat_median, "saturation_median": sat_median,
"saturation_n": sat_n, "saturation_n": sat_n,
"sat_factor": round(sat_factor, 4), "sat_factor": round(sat_factor, 4),
@ -2184,13 +2457,17 @@ def recommend_mix(
"elasticity_source": elast["source"], "elasticity_source": elast["source"],
"elasticity_weighted": (round(weighted_elasticity, 4) if total_units > 0 else None), "elasticity_weighted": (round(weighted_elasticity, 4) if total_units > 0 else None),
"elasticity_per_bucket": elast_per_bucket, "elasticity_per_bucket": elast_per_bucket,
# Окна источников данных — для прозрачности и UI-tooltip: # Окна источников данных — для прозрачности и UI-tooltip.
# share_window_months — окно по rosreestr_deals для bucket-shares # share_window_months — окно по rosreestr_deals для bucket-shares
# и market velocity (input months_window, может расшириться до 27). # и market velocity (input months_window, может расшириться до 27).
# elasticity_window_months — окно по domrf_kn_sale_graph (фиксировано # elasticity_window_months — синхронизировано с share_window (issue #24).
# 36 мес — sale_graph есть с 2023г, шире окно даёт устойчивее регрессию).
"share_window_months": effective_window, "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": ( "cadastre_median_per_m2": (
round(cadastre["median_per_m2"], 0) round(cadastre["median_per_m2"], 0)
if cadastre["median_per_m2"] is not None if cadastre["median_per_m2"] is not None

View file

@ -40,6 +40,7 @@ export function RecommendBucketsTable({
<tr style={{ background: "#f6f7f9" }}> <tr style={{ background: "#f6f7f9" }}>
{[ {[
"Бакет", "Бакет",
"Успех",
"Доля", "Доля",
"Сделок", "Сделок",
"Площадь ср., м²", "Площадь ср., м²",
@ -88,6 +89,16 @@ export function RecommendBucketsTable({
<td style={td}> <td style={td}>
<strong>{r.bucket}</strong> <strong>{r.bucket}</strong>
</td> </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}>{r.effective_share_pct.toFixed(1)}%</td>
<td style={td}>{fmtInt(r.deal_count)}</td> <td style={td}>{fmtInt(r.deal_count)}</td>
<td style={td}>{r.area_avg_m2.toFixed(1)}</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"; import type { RecommendClass, RecommendMixInput } from "@/types/analytics";
const CLASSES: RecommendClass[] = ["Comfort", "Comfort+", "Business", "Elite"]; const CLASSES: RecommendClass[] = ["Comfort", "Comfort+", "Business", "Elite"];
const MONTHS_OPTIONS = [12, 18, 24, 27]; const MONTHS_OPTIONS = [12, 18, 24];
interface Props { interface Props {
value: RecommendMixInput; value: RecommendMixInput;
@ -130,7 +130,25 @@ export function RecommendForm({
</label> </label>
<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 <select
value={value.months_window} value={value.months_window}
onChange={(e) => onChange={(e) =>
@ -145,8 +163,7 @@ export function RecommendForm({
))} ))}
</select> </select>
<span style={hintStyle}> <span style={hintStyle}>
При &lt;30 сделок в любом бакете окно автоматически расширяется до 27 При &lt;30 сделок в любом бакете окно расширяется до max 24 мес.
мес (max доступный rosreestr-архив).
</span> </span>
</label> </label>

View file

@ -148,6 +148,46 @@ export function RecommendVelocityPanel({
/> />
</div> </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 для контекста) */} {/* Warning badge — fallback на rosreestr (нет sale_graph для контекста) */}
{scope.velocity_source === "rosreestr_fallback" ? ( {scope.velocity_source === "rosreestr_fallback" ? (
<div <div
@ -376,6 +416,29 @@ export function RecommendVelocityPanel({
</div> </div>
) : null} ) : 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 */} {/* Methodology note */}
<div <div
style={{ style={{
@ -401,8 +464,24 @@ export function RecommendVelocityPanel({
{scope.velocity_source === "sale_graph" {scope.velocity_source === "sale_graph"
? `sale_graph: ${scope.velocity_objects} ЖК / ${scope.velocity_observations} точек` ? `sale_graph: ${scope.velocity_objects} ЖК / ${scope.velocity_observations} точек`
: "fallback на rosreestr-сделки"} : "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> <strong>sat ×{scope.sat_factor.toFixed(2)}</strong>
{scope.saturation_median != null {scope.saturation_median != null
? ` (sold% ${scope.saturation_median.toFixed(0)})` ? ` (sold% ${scope.saturation_median.toFixed(0)})`

View file

@ -236,6 +236,8 @@ export interface RecommendBucket {
elasticity_r2?: number; elasticity_r2?: number;
elasticity_n?: number; elasticity_n?: number;
elasticity_source?: "regression" | "fallback_global"; elasticity_source?: "regression" | "fallback_global";
/** #25 — top success bucket flag */
is_top_success?: boolean;
} }
export interface ElasticityPerBucket { export interface ElasticityPerBucket {
@ -303,6 +305,30 @@ export interface RecommendMixOutput {
elasticity_per_bucket: ElasticityPerBucket; elasticity_per_bucket: ElasticityPerBucket;
share_window_months: number; share_window_months: number;
elasticity_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_median_per_m2: number | null;
cadastre_buildings_n: number; cadastre_buildings_n: number;
cadastre_vs_market_pct: number | null; cadastre_vs_market_pct: number | null;