upd recommend sys
This commit is contained in:
parent
132d57a3f3
commit
b39bac57bf
4 changed files with 363 additions and 53 deletions
|
|
@ -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(
|
def _active_competitors_count(
|
||||||
db: Session,
|
db: Session,
|
||||||
*,
|
*,
|
||||||
|
|
@ -1289,34 +1469,52 @@ def recommend_mix(
|
||||||
else 1.0
|
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 = 1.0
|
||||||
|
class_multiplier_source: str | None = None
|
||||||
if target_class:
|
if target_class:
|
||||||
cls_row = (
|
zk_norm = {
|
||||||
db.execute(
|
"Comfort": "COMFORT",
|
||||||
text(
|
"Comfort+": "COMFORT_PLUS",
|
||||||
"""
|
"Business": "BUSINESS",
|
||||||
|
"Elite": "ELITE",
|
||||||
|
}.get(target_class)
|
||||||
|
if zk_norm:
|
||||||
|
r = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
SELECT
|
SELECT
|
||||||
AVG(price_from) FILTER (WHERE obj_class = :cls) AS class_avg,
|
AVG(price_from) FILTER (WHERE obj_class = :cls) AS class_avg,
|
||||||
AVG(price_from) AS overall_avg
|
AVG(price_from) FILTER (WHERE obj_class = 'COMFORT') AS comfort_avg
|
||||||
FROM yandex_realty_zk
|
FROM yandex_realty_zk
|
||||||
WHERE price_from IS NOT NULL AND price_from > 0
|
WHERE price_from IS NOT NULL AND price_from > 0
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{"cls": target_class},
|
{"cls": zk_norm},
|
||||||
)
|
)
|
||||||
.mappings()
|
.mappings()
|
||||||
.first()
|
.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"
|
|
||||||
)
|
)
|
||||||
|
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.
|
# 4) Bucket distribution from rosreestr_deals — city-wide, last N months.
|
||||||
# Если в каком-либо бакете <30 сделок и окно < 24 мес, расширяем до 24 мес
|
# Если в каком-либо бакете <30 сделок и окно < 24 мес, расширяем до 24 мес
|
||||||
|
|
@ -1484,26 +1682,57 @@ def recommend_mix(
|
||||||
for r in bucket_rows
|
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:
|
# 5b-3) Per-bucket project velocity at price_factor=1.0:
|
||||||
# bucket_market_v = темп РЫНКА для bucket'а (deals/mo по всему региону)
|
# bucket_market_v = темп РЫНКА для bucket'а (deals/mo по всему региону)
|
||||||
# normalisation = sqrt(N_competitors) — компромисс между
|
# normalisation = sqrt(N_competitors) — power-law эффективные
|
||||||
# «монополист» (÷1) и «равный pie-split» (÷N).
|
# конкуренты (sqrt компромисс между ÷1 и ÷N).
|
||||||
# Реально продажи распределены по power-law: top-20%
|
# project_velocity = bucket_market_v / sqrt(N) × sat_factor × trend_factor
|
||||||
# ЖК делают 80% сделок, поэтому linear ÷N даёт
|
# sat — зрелый рынок ускоряет; trend — текущая
|
||||||
# абсурдные сроки в десятилетия. sqrt — стандартный
|
# динамика (горит/остывает).
|
||||||
# proxy для "effective competitors" в подобных
|
|
||||||
# зашумлённых market-share моделях.
|
|
||||||
# project_velocity = bucket_market_v / sqrt(N_competitors)
|
|
||||||
# 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 (развитость района = премиум).
|
||||||
pf_pow = price_factor**elasticity if price_factor > 0 else 1.0
|
pf_pow = price_factor**elasticity if price_factor > 0 else 1.0
|
||||||
competitors_norm = math.sqrt(max(competitors, 1))
|
competitors_norm = math.sqrt(max(competitors, 1))
|
||||||
|
macro_velocity_mult = sat_factor * trend_factor
|
||||||
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)
|
||||||
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
|
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:
|
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
|
adjusted_velocity = bucket_velocity * 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
|
||||||
|
|
@ -1511,6 +1740,11 @@ 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).
|
||||||
|
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.
|
# 5c) Inverse mode: target_months → required price_factor.
|
||||||
# required_velocity = total_units / target_months
|
# required_velocity = total_units / target_months
|
||||||
|
|
@ -1617,6 +1851,7 @@ def recommend_mix(
|
||||||
"district_median_price_per_m2": district_median,
|
"district_median_price_per_m2": district_median,
|
||||||
"district_factor": round(district_factor, 4),
|
"district_factor": round(district_factor, 4),
|
||||||
"class_multiplier": round(class_multiplier, 4),
|
"class_multiplier": round(class_multiplier, 4),
|
||||||
|
"class_multiplier_source": class_multiplier_source,
|
||||||
"target_class": target_class,
|
"target_class": target_class,
|
||||||
"months_window": months_window,
|
"months_window": months_window,
|
||||||
"effective_window_months": effective_window,
|
"effective_window_months": effective_window,
|
||||||
|
|
@ -1630,6 +1865,18 @@ 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,
|
||||||
|
"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": elasticity,
|
||||||
"elasticity_r2": elast["r2"],
|
"elasticity_r2": elast["r2"],
|
||||||
"elasticity_n": elast["n"],
|
"elasticity_n": elast["n"],
|
||||||
|
|
|
||||||
|
|
@ -153,14 +153,35 @@ export default function RecommendPage() {
|
||||||
lineHeight: 1.4,
|
lineHeight: 1.4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
💼 <strong>«{data.scope.district}</strong>
|
<div>
|
||||||
{data.scope.target_class
|
💼 <strong>«{data.scope.district}</strong>
|
||||||
? ` · ${data.scope.target_class}`
|
{data.scope.target_class
|
||||||
: ""}
|
? ` · ${data.scope.target_class}`
|
||||||
{input.area_total_m2
|
: ""}
|
||||||
? ` · ${input.area_total_m2.toLocaleString("ru")} м²`
|
{input.area_total_m2
|
||||||
: ""}
|
? ` · ${input.area_total_m2.toLocaleString("ru")} м²`
|
||||||
<strong>»:</strong> {data.summary.headline}
|
: ""}
|
||||||
|
<strong>»:</strong> {data.summary.headline}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 8,
|
||||||
|
paddingTop: 8,
|
||||||
|
borderTop: "1px solid rgba(255,255,255,0.1)",
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#94a3b8",
|
||||||
|
fontWeight: 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
📊
|
||||||
|
{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 ? "выше" : "ниже"} среднего)`
|
||||||
|
: ""}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
@ -240,24 +261,43 @@ export default function RecommendPage() {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label="District factor"
|
label="Sold% медиана"
|
||||||
value={`×${data.scope.district_factor.toFixed(2)}`}
|
value={
|
||||||
|
data.scope.saturation_median != null
|
||||||
|
? `${data.scope.saturation_median.toFixed(0)}%`
|
||||||
|
: "—"
|
||||||
|
}
|
||||||
hint={
|
hint={
|
||||||
data.scope.district_factor === 1
|
data.scope.saturation_median == null
|
||||||
? "median ekb_districts NULL"
|
? "<5 ЖК с данными"
|
||||||
: "корректировка к city median"
|
: data.scope.saturation_median > 50
|
||||||
|
? `зрелый рынок (${data.scope.saturation_n} ЖК)`
|
||||||
|
: data.scope.saturation_median < 25
|
||||||
|
? `свежий, мало распродано (${data.scope.saturation_n} ЖК)`
|
||||||
|
: `умеренная зрелость (${data.scope.saturation_n} ЖК)`
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label="Class multiplier"
|
label="Тренд 6 мес"
|
||||||
value={`×${data.scope.class_multiplier.toFixed(2)}`}
|
value={
|
||||||
hint={
|
data.scope.velocity_trend_ratio != null
|
||||||
data.scope.class_multiplier === 1
|
? `${data.scope.velocity_trend_ratio > 1 ? "🚀" : data.scope.velocity_trend_ratio < 0.8 ? "❄" : "→"} ×${data.scope.velocity_trend_ratio.toFixed(2)}`
|
||||||
? data.scope.target_class
|
: "—"
|
||||||
? `нет данных по ${data.scope.target_class}`
|
|
||||||
: "класс не задан"
|
|
||||||
: `корректировка цен ${data.scope.target_class}`
|
|
||||||
}
|
}
|
||||||
|
hint={
|
||||||
|
data.scope.velocity_trend_ratio == null
|
||||||
|
? "нет sale_graph"
|
||||||
|
: data.scope.velocity_trend_ratio > 1.5
|
||||||
|
? "рынок ускоряется"
|
||||||
|
: data.scope.velocity_trend_ratio < 0.8
|
||||||
|
? "остывает"
|
||||||
|
: "стабильный"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Class × District"
|
||||||
|
value={`×${(data.scope.class_multiplier * data.scope.district_factor * data.scope.poi_factor).toFixed(2)}`}
|
||||||
|
hint={`Class ×${data.scope.class_multiplier.toFixed(2)} · District ×${data.scope.district_factor.toFixed(2)} · POI ×${data.scope.poi_factor.toFixed(2)}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -264,8 +264,18 @@ export function RecommendVelocityPanel({
|
||||||
? `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> активных
|
), нормирован на <strong>{scope.competitors_count}</strong> активных
|
||||||
конкурентов в районе ). При price ×{priceFactor.toFixed(2)} темп =
|
конкурентов в районе. Применены macro-факторы:{" "}
|
||||||
базовый × {priceFactor.toFixed(2)}^{elasticity} = ×{pfPow.toFixed(3)}.
|
<strong>sat ×{scope.sat_factor.toFixed(2)}</strong>
|
||||||
|
{scope.saturation_median != null
|
||||||
|
? ` (sold% ${scope.saturation_median.toFixed(0)})`
|
||||||
|
: ""}{" "}
|
||||||
|
· <strong>trend ×{scope.trend_factor.toFixed(2)}</strong>
|
||||||
|
{scope.velocity_trend_ratio != null
|
||||||
|
? ` (raw ×${scope.velocity_trend_ratio.toFixed(2)} clamp 0.7..2.0)`
|
||||||
|
: ""}{" "}
|
||||||
|
· <strong>POI ×{scope.poi_factor.toFixed(2)}</strong> (на цену) ). При
|
||||||
|
price ×{priceFactor.toFixed(2)} темп = базовый ×{" "}
|
||||||
|
{priceFactor.toFixed(2)}^{elasticity} = ×{pfPow.toFixed(3)}.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -239,6 +239,7 @@ export interface RecommendMixOutput {
|
||||||
district_factor: number;
|
district_factor: number;
|
||||||
class_multiplier: number;
|
class_multiplier: number;
|
||||||
target_class: string | null;
|
target_class: string | null;
|
||||||
|
class_multiplier_source: "realty_zk_vs_comfort" | null;
|
||||||
months_window: number;
|
months_window: number;
|
||||||
effective_window_months: number;
|
effective_window_months: number;
|
||||||
region_code: number;
|
region_code: number;
|
||||||
|
|
@ -253,6 +254,18 @@ export interface RecommendMixOutput {
|
||||||
| "district"
|
| "district"
|
||||||
| "region"
|
| "region"
|
||||||
| "fallback_singleton";
|
| "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: number;
|
||||||
elasticity_r2: number;
|
elasticity_r2: number;
|
||||||
elasticity_n: number;
|
elasticity_n: number;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue