feat: Tier α velocity overhaul + scraper FK fixes
Velocity model:
- _active_competitors_count() с каскадным fallback (район+класс → район → регион)
- Per-bucket velocity = bucket_market_v / sqrt(N_competitors) — sqrt-нормировка
как proxy для эффективных конкурентов (power-law)
- Per-bucket market velocities из rosreestr (свой темп каждому сегменту,
поэтому mix-слайдеры теперь двигают total)
- Headline format: f"{months:.1f}" вместо int() (закрывает «за 0 мес»)
- competitors_count + competitors_scope в scope, warning-badge в UI
при rosreestr_fallback, methodology note показывает competitors
Scraper FK violations:
- _ensure_snapshot() ON CONFLICT DO NOTHING — закрыть FK на domrf_snapshots
для domrf_raw_endpoints (Phase A insert до создания snapshot row)
- _ensure_developers() pre-upsert уникальных (dev_id, dev_name) перед
upsert_objects, ON CONFLICT (developer_id) DO NOTHING — закрыть FK
fk_kn_obj_developer
- SAVEPOINT (db.begin_nested) во всех 5 upsert helpers + _log_failure
заменяет db.rollback() — теперь сбой одного row не сносит транзакцию
Bug Bug_Velocity_Mix_Static_OPEN, Bug_Velocity_Unrealistic_OPEN — закрыты
(numbers conservative до полного 442-sweep + sale_graph data fill).
This commit is contained in:
parent
b74c7db438
commit
195d942ca7
3 changed files with 143 additions and 9 deletions
|
|
@ -6,6 +6,7 @@ Region 66 = Sverdlovskaya oblast. Developer 6208_0 = PRINZIP.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
@ -1094,6 +1095,58 @@ def _velocity_baseline(
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _active_competitors_count(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
region_code: int,
|
||||||
|
district_name: str,
|
||||||
|
target_class: str | None,
|
||||||
|
) -> tuple[int, str]:
|
||||||
|
"""N активно строящихся ЖК для нормировки velocity. Каскадный fallback:
|
||||||
|
1) (район + класс) — самый узкий
|
||||||
|
2) (район) без класса — если первый дал <2
|
||||||
|
3) весь регион — если второй дал <2
|
||||||
|
Возвращает (count, scope_used). Min 1 чтобы не делить на 0."""
|
||||||
|
|
||||||
|
def _q(where_extras: str, params: dict[str, Any]) -> int:
|
||||||
|
n = db.execute(
|
||||||
|
text(
|
||||||
|
f"""
|
||||||
|
SELECT COUNT(*) FROM domrf_kn_objects
|
||||||
|
WHERE region_cd = :rc
|
||||||
|
AND site_status = 'Строящиеся'
|
||||||
|
{where_extras}
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
params,
|
||||||
|
).scalar()
|
||||||
|
return int(n or 0)
|
||||||
|
|
||||||
|
# Tier 1: район + класс
|
||||||
|
if target_class:
|
||||||
|
n = _q(
|
||||||
|
"AND addr ILIKE '%' || :dn || '%' AND obj_class = :cls",
|
||||||
|
{"rc": region_code, "dn": district_name, "cls": target_class},
|
||||||
|
)
|
||||||
|
if n >= 2:
|
||||||
|
return n, "district+class"
|
||||||
|
|
||||||
|
# Tier 2: район (без класса — могут быть ЖК где obj_class NULL)
|
||||||
|
n = _q(
|
||||||
|
"AND addr ILIKE '%' || :dn || '%'",
|
||||||
|
{"rc": region_code, "dn": district_name},
|
||||||
|
)
|
||||||
|
if n >= 2:
|
||||||
|
return n, "district"
|
||||||
|
|
||||||
|
# Tier 3: весь регион (когда район по сути не покрыт скрапером)
|
||||||
|
n = _q("", {"rc": region_code})
|
||||||
|
if n >= 1:
|
||||||
|
return n, "region"
|
||||||
|
|
||||||
|
return 1, "fallback_singleton"
|
||||||
|
|
||||||
|
|
||||||
def _elasticity_coef(
|
def _elasticity_coef(
|
||||||
db: Session,
|
db: Session,
|
||||||
*,
|
*,
|
||||||
|
|
@ -1372,14 +1425,58 @@ def recommend_mix(
|
||||||
" (недостаточно для регрессии)."
|
" (недостаточно для регрессии)."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Per-bucket velocity at price_factor=1.0:
|
# 5b-1) N активных конкурентов с каскадным fallback (район+класс →
|
||||||
# share — рыночная доля бакета по сделкам Rosreestr, market_vel_pm —
|
# район → регион). Используется для нормировки рыночной velocity.
|
||||||
# темп сопоставимого ЖК. velocity_per_bucket = market_vel_pm × share/100.
|
competitors, competitors_scope = _active_competitors_count(
|
||||||
# При allocation: months_to_sellout = units_planned / (velocity × price_factor^elasticity).
|
db,
|
||||||
|
region_code=region_code,
|
||||||
|
district_name=district_row["district_name"],
|
||||||
|
target_class=target_class,
|
||||||
|
)
|
||||||
|
if competitors_scope == "fallback_singleton":
|
||||||
|
warnings.append(
|
||||||
|
f"Не нашлось активно строящихся ЖК ни в районе {district_row['district_name']}"
|
||||||
|
f" ни в регионе {region_code} — нормировка отключена (как для монополиста)."
|
||||||
|
)
|
||||||
|
elif competitors_scope != "district+class":
|
||||||
|
# Информативное сообщение о расширении scope при недостатке локальных данных.
|
||||||
|
scope_label = {
|
||||||
|
"district": f"районе {district_row['district_name']} (без класса)",
|
||||||
|
"region": f"регионе {region_code} (вне района)",
|
||||||
|
}.get(competitors_scope, competitors_scope)
|
||||||
|
warnings.append(
|
||||||
|
f"Конкурентов класса '{target_class or '*'}' в районе мало —"
|
||||||
|
f" нормировка по {competitors} ЖК в {scope_label}."
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5b-2) Per-bucket market velocity (сделок/мес для каждого размерного
|
||||||
|
# сегмента из rosreestr — НЕ city-wide, а РЕАЛЬНАЯ интенсивность сегмента).
|
||||||
|
# Студии/1к — обычно выше, 80+ — ниже.
|
||||||
|
bucket_market_velocities = {
|
||||||
|
_BUCKET_PRETTY.get(r["bucket"], r["bucket"]): (
|
||||||
|
int(r["deals"] or 0) / max(effective_window, 1)
|
||||||
|
)
|
||||||
|
for r in bucket_rows
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
# adjusted = project_velocity × price_factor^elasticity
|
||||||
|
# months_to_sellout = units_planned / adjusted
|
||||||
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))
|
||||||
total_units = 0
|
total_units = 0
|
||||||
for b in buckets:
|
for b in buckets:
|
||||||
bucket_velocity = round(market_vel_pm * (b["share_pct"] / 100), 3)
|
bucket_market_v = bucket_market_velocities.get(b["bucket"], 0.0)
|
||||||
|
bucket_velocity = round(bucket_market_v / competitors_norm, 4)
|
||||||
b["velocity_per_month"] = bucket_velocity
|
b["velocity_per_month"] = bucket_velocity
|
||||||
if b["units_planned"] and bucket_velocity > 0:
|
if b["units_planned"] and bucket_velocity > 0:
|
||||||
adjusted_velocity = bucket_velocity * pf_pow
|
adjusted_velocity = bucket_velocity * pf_pow
|
||||||
|
|
@ -1471,11 +1568,15 @@ def recommend_mix(
|
||||||
if have_revenue:
|
if have_revenue:
|
||||||
headline_parts.append(f"{round(total_revenue / 1_000_000, 1)} млн ₽")
|
headline_parts.append(f"{round(total_revenue / 1_000_000, 1)} млн ₽")
|
||||||
if months_to_sellout_total:
|
if months_to_sellout_total:
|
||||||
headline_parts.append(f"за ~{int(months_to_sellout_total)} мес")
|
headline_parts.append(f"за ~{months_to_sellout_total:.1f} мес")
|
||||||
if avg_ticket:
|
if avg_ticket:
|
||||||
headline_parts.append(f"ср. чек {round(avg_ticket / 1_000_000, 1)} М ₽")
|
headline_parts.append(f"ср. чек {round(avg_ticket / 1_000_000, 1)} М ₽")
|
||||||
if base_total_v > 0:
|
if base_total_v > 0:
|
||||||
headline_parts.append(f"темп {round(base_total_v * pf_pow, 1)} кв/мес")
|
# Малая velocity — формат с 2 десятыми (0.07 кв/мес для ЖК-доли).
|
||||||
|
tempo = base_total_v * pf_pow
|
||||||
|
headline_parts.append(
|
||||||
|
f"темп {tempo:.2f} кв/мес" if tempo < 1 else f"темп {tempo:.1f} кв/мес"
|
||||||
|
)
|
||||||
if liquidity_24mo is not None:
|
if liquidity_24mo is not None:
|
||||||
headline_parts.append(f"ликвидность {liquidity_24mo:.0f}/100")
|
headline_parts.append(f"ликвидность {liquidity_24mo:.0f}/100")
|
||||||
headline = " · ".join(headline_parts) if headline_parts else None
|
headline = " · ".join(headline_parts) if headline_parts else None
|
||||||
|
|
@ -1498,6 +1599,8 @@ def recommend_mix(
|
||||||
"velocity_source": velocity_source,
|
"velocity_source": velocity_source,
|
||||||
"velocity_observations": vel["observations"],
|
"velocity_observations": vel["observations"],
|
||||||
"velocity_objects": vel["objects_count"],
|
"velocity_objects": vel["objects_count"],
|
||||||
|
"competitors_count": competitors,
|
||||||
|
"competitors_scope": competitors_scope,
|
||||||
"elasticity": elasticity,
|
"elasticity": elasticity,
|
||||||
"elasticity_r2": elast["r2"],
|
"elasticity_r2": elast["r2"],
|
||||||
"elasticity_n": elast["n"],
|
"elasticity_n": elast["n"],
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,30 @@ export function RecommendVelocityPanel({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Warning badge — fallback на rosreestr (нет sale_graph для контекста) */}
|
||||||
|
{scope.velocity_source === "rosreestr_fallback" ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: 10,
|
||||||
|
marginTop: 4,
|
||||||
|
marginBottom: 12,
|
||||||
|
background: "#fef3c7",
|
||||||
|
border: "1px solid #fcd34d",
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 13,
|
||||||
|
color: "#92400e",
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⚠ <strong>Темп грубый</strong> — нет sale_graph для ({scope.district}
|
||||||
|
{scope.target_class ? ` · ${scope.target_class}` : ""}). Показано:
|
||||||
|
city-wide rosreestr ({scope.total_deals} сделок /{" "}
|
||||||
|
{scope.effective_window_months} мес) ÷ {scope.competitors_count}{" "}
|
||||||
|
активных строящихся ЖК. Запусти 442-sweep по {scope.district} для
|
||||||
|
точных данных.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Price factor slider */}
|
{/* Price factor slider */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -237,8 +261,9 @@ 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-сделки"}
|
||||||
). При price ×{priceFactor.toFixed(2)} темп = базовый ×{" "}
|
), нормирован на <strong>{scope.competitors_count}</strong> активных
|
||||||
{priceFactor.toFixed(2)}^{elasticity} = ×{pfPow.toFixed(3)}.
|
конкурентов в районе ). При price ×{priceFactor.toFixed(2)} темп =
|
||||||
|
базовый × {priceFactor.toFixed(2)}^{elasticity} = ×{pfPow.toFixed(3)}.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -247,6 +247,12 @@ export interface RecommendMixOutput {
|
||||||
velocity_source: "sale_graph" | "rosreestr_fallback";
|
velocity_source: "sale_graph" | "rosreestr_fallback";
|
||||||
velocity_observations: number;
|
velocity_observations: number;
|
||||||
velocity_objects: number;
|
velocity_objects: number;
|
||||||
|
competitors_count: number;
|
||||||
|
competitors_scope:
|
||||||
|
| "district+class"
|
||||||
|
| "district"
|
||||||
|
| "region"
|
||||||
|
| "fallback_singleton";
|
||||||
elasticity: number;
|
elasticity: number;
|
||||||
elasticity_r2: number;
|
elasticity_r2: number;
|
||||||
elasticity_n: number;
|
elasticity_n: number;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue