upd recommend sys

This commit is contained in:
lekss361 2026-04-29 08:39:09 +03:00
parent 132d57a3f3
commit b39bac57bf
4 changed files with 363 additions and 53 deletions

View file

@ -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"],

View file

@ -153,14 +153,35 @@ export default function RecommendPage() {
lineHeight: 1.4,
}}
>
💼 <strong>«{data.scope.district}</strong>
{data.scope.target_class
? ` · ${data.scope.target_class}`
: ""}
{input.area_total_m2
? ` · ${input.area_total_m2.toLocaleString("ru")} м²`
: ""}
<strong>»:</strong> {data.summary.headline}
<div>
💼 <strong>«{data.scope.district}</strong>
{data.scope.target_class
? ` · ${data.scope.target_class}`
: ""}
{input.area_total_m2
? ` · ${input.area_total_m2.toLocaleString("ru")} м²`
: ""}
<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>
) : null}
@ -240,24 +261,43 @@ export default function RecommendPage() {
}
/>
<KpiCard
label="District factor"
value={`×${data.scope.district_factor.toFixed(2)}`}
label="Sold% медиана"
value={
data.scope.saturation_median != null
? `${data.scope.saturation_median.toFixed(0)}%`
: "—"
}
hint={
data.scope.district_factor === 1
? "median ekb_districts NULL"
: "корректировка к city median"
data.scope.saturation_median == null
? "<5 ЖК с данными"
: data.scope.saturation_median > 50
? `зрелый рынок (${data.scope.saturation_n} ЖК)`
: data.scope.saturation_median < 25
? `свежий, мало распродано (${data.scope.saturation_n} ЖК)`
: `умеренная зрелость (${data.scope.saturation_n} ЖК)`
}
/>
<KpiCard
label="Class multiplier"
value={`×${data.scope.class_multiplier.toFixed(2)}`}
hint={
data.scope.class_multiplier === 1
? data.scope.target_class
? `нет данных по ${data.scope.target_class}`
: "класс не задан"
: `корректировка цен ${data.scope.target_class}`
label="Тренд 6 мес"
value={
data.scope.velocity_trend_ratio != null
? `${data.scope.velocity_trend_ratio > 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
? "остывает"
: "стабильный"
}
/>
<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>

View file

@ -264,8 +264,18 @@ export function RecommendVelocityPanel({
? `sale_graph: ${scope.velocity_objects} ЖК / ${scope.velocity_observations} точек`
: "fallback на rosreestr-сделки"}
), нормирован на <strong>{scope.competitors_count}</strong> активных
конкурентов в районе ). При price ×{priceFactor.toFixed(2)} темп =
базовый × {priceFactor.toFixed(2)}^{elasticity} = ×{pfPow.toFixed(3)}.
конкурентов в районе. Применены macro-факторы:{" "}
<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>
);

View file

@ -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;