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
|
||||
|
||||
import math
|
||||
from decimal import Decimal
|
||||
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(
|
||||
db: Session,
|
||||
*,
|
||||
|
|
@ -1372,14 +1425,58 @@ def recommend_mix(
|
|||
" (недостаточно для регрессии)."
|
||||
)
|
||||
|
||||
# Per-bucket velocity at price_factor=1.0:
|
||||
# share — рыночная доля бакета по сделкам Rosreestr, market_vel_pm —
|
||||
# темп сопоставимого ЖК. velocity_per_bucket = market_vel_pm × share/100.
|
||||
# При allocation: months_to_sellout = units_planned / (velocity × price_factor^elasticity).
|
||||
# 5b-1) N активных конкурентов с каскадным fallback (район+класс →
|
||||
# район → регион). Используется для нормировки рыночной velocity.
|
||||
competitors, competitors_scope = _active_competitors_count(
|
||||
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
|
||||
competitors_norm = math.sqrt(max(competitors, 1))
|
||||
total_units = 0
|
||||
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
|
||||
if b["units_planned"] and bucket_velocity > 0:
|
||||
adjusted_velocity = bucket_velocity * pf_pow
|
||||
|
|
@ -1471,11 +1568,15 @@ def recommend_mix(
|
|||
if have_revenue:
|
||||
headline_parts.append(f"{round(total_revenue / 1_000_000, 1)} млн ₽")
|
||||
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:
|
||||
headline_parts.append(f"ср. чек {round(avg_ticket / 1_000_000, 1)} М ₽")
|
||||
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:
|
||||
headline_parts.append(f"ликвидность {liquidity_24mo:.0f}/100")
|
||||
headline = " · ".join(headline_parts) if headline_parts else None
|
||||
|
|
@ -1498,6 +1599,8 @@ def recommend_mix(
|
|||
"velocity_source": velocity_source,
|
||||
"velocity_observations": vel["observations"],
|
||||
"velocity_objects": vel["objects_count"],
|
||||
"competitors_count": competitors,
|
||||
"competitors_scope": competitors_scope,
|
||||
"elasticity": elasticity,
|
||||
"elasticity_r2": elast["r2"],
|
||||
"elasticity_n": elast["n"],
|
||||
|
|
|
|||
|
|
@ -141,6 +141,30 @@ export function RecommendVelocityPanel({
|
|||
/>
|
||||
</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 */}
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -237,8 +261,9 @@ export function RecommendVelocityPanel({
|
|||
{scope.velocity_source === "sale_graph"
|
||||
? `sale_graph: ${scope.velocity_objects} ЖК / ${scope.velocity_observations} точек`
|
||||
: "fallback на rosreestr-сделки"}
|
||||
). При price ×{priceFactor.toFixed(2)} темп = базовый ×{" "}
|
||||
{priceFactor.toFixed(2)}^{elasticity} = ×{pfPow.toFixed(3)}.
|
||||
), нормирован на <strong>{scope.competitors_count}</strong> активных
|
||||
конкурентов в районе ). При price ×{priceFactor.toFixed(2)} темп =
|
||||
базовый × {priceFactor.toFixed(2)}^{elasticity} = ×{pfPow.toFixed(3)}.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -247,6 +247,12 @@ export interface RecommendMixOutput {
|
|||
velocity_source: "sale_graph" | "rosreestr_fallback";
|
||||
velocity_observations: number;
|
||||
velocity_objects: number;
|
||||
competitors_count: number;
|
||||
competitors_scope:
|
||||
| "district+class"
|
||||
| "district"
|
||||
| "region"
|
||||
| "fallback_singleton";
|
||||
elasticity: number;
|
||||
elasticity_r2: number;
|
||||
elasticity_n: number;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue