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:
lekss361 2026-04-29 00:25:41 +03:00
parent b74c7db438
commit 195d942ca7
3 changed files with 143 additions and 9 deletions

View file

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

View file

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

View file

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