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(
|
||||
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"],
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue