fix: heartbeat-based zombie autoclean (10min) + Phase A heartbeats; ship recommend velocity calculator

Scraper resume:
- /admin/scrape/queue autoclean уменьшен с 60min started_at до 10min
  COALESCE(heartbeat_at, started_at). Не режет валидные long sweeps.
- Phase A пишет heartbeat после каждого objStatus fetch (3min Phase A
  больше не рискует быть отмеченной как zombie при новом 10min threshold).

Recommend (Уровень 1 калькулятор):
- POST /api/v1/analytics/recommend/mix с velocity baseline (sale_graph),
  price elasticity (regr_slope/r2 на sale_graph, fallback -1.5),
  inverse mode (target_months → required price_factor), liquidity score
  24mo, headline.
- /analytics/recommend: RecommendVelocityPanel (price slider 0.85..1.15
  + 4 KPI Чек/Срок/Темп/Ликвидность + методология эластичности),
  RecommendLiquidityChart (cumulative 0..36 mo с пунктиром на 24).
- BucketsTable: +колонки Темп и Срок, цены/выручка масштабируются по
  priceFactor live.
- Все слайдеры и target_months считаются клиентски — никаких round-trip.

Fix: SQLAlchemy не парсит :cls::text — заменено на CAST(:cls AS TEXT).
This commit is contained in:
lekss361 2026-04-28 22:54:03 +03:00
parent 1507575698
commit 7a23aa9edc
12 changed files with 884 additions and 40 deletions

View file

@ -91,20 +91,21 @@ def queue_status(
from app.workers.celery_app import celery_app from app.workers.celery_app import celery_app
# 0) Reap zombies: workers killed by SIGKILL (OOM, container restart, # 0) Reap zombies by heartbeat. After Resume_Checkpoint refactor the worker
# deploy mid-run) leave kn_scrape_runs rows stuck in 'running' forever # writes heartbeat_at every ~30 sec (per 10 objects). 10 min без heartbeat
# because the except/finally block never fires. Anything older than # = точно мёртв. Используем COALESCE(heartbeat_at, started_at) чтобы
# 60 minutes without finishing is almost certainly dead. # подхватить и legacy-runs (без heartbeat) старше 10 мин.
db.execute( db.execute(
text( text(
""" """
UPDATE kn_scrape_runs UPDATE kn_scrape_runs
SET status = 'zombie', SET status = 'zombie',
finished_at = NOW(), finished_at = NOW(),
error = 'auto-marked: no heartbeat 60+ min' error = 'auto-marked: no heartbeat 10+ min'
WHERE status = 'running' WHERE status = 'running'
AND finished_at IS NULL AND finished_at IS NULL
AND started_at < NOW() - INTERVAL '60 minutes' AND COALESCE(heartbeat_at, started_at)
< NOW() - INTERVAL '10 minutes'
""" """
) )
) )

View file

@ -203,4 +203,6 @@ def recommend_mix(
area_total_m2=payload.area_total_m2, area_total_m2=payload.area_total_m2,
target_class=payload.target_class, target_class=payload.target_class,
months_window=payload.months_window, months_window=payload.months_window,
price_factor=payload.price_factor,
target_months=payload.target_months,
) )

View file

@ -15,6 +15,10 @@ class RecommendMixInput(BaseModel):
area_total_m2: float | None = Field(default=None, ge=100, le=500_000) area_total_m2: float | None = Field(default=None, ge=100, le=500_000)
target_class: ClassLiteral | None = None target_class: ClassLiteral | None = None
months_window: int = Field(default=12, ge=3, le=36) months_window: int = Field(default=12, ge=3, le=36)
# Velocity / pricing scenario knobs (live-tuned client-side; backend just
# ships base coefficients so frontend can recompute without round-trips).
price_factor: float = Field(default=1.0, ge=0.5, le=2.0)
target_months: int | None = Field(default=None, ge=3, le=120)
class RecommendBucket(BaseModel): class RecommendBucket(BaseModel):
@ -28,6 +32,11 @@ class RecommendBucket(BaseModel):
price_p75_per_m2: float price_p75_per_m2: float
units_planned: int | None = None units_planned: int | None = None
revenue_planned_rub: float | None = None revenue_planned_rub: float | None = None
# Velocity baseline (units/month for THIS project allocated to this bucket
# at price_factor=1.0). Frontend scales by price_factor^elasticity for live
# what-if recompute.
velocity_per_month: float | None = None
months_to_sellout: float | None = None
class RecommendComparable(BaseModel): class RecommendComparable(BaseModel):

View file

@ -1022,6 +1022,143 @@ def _bucket_distribution(db: Session, region_code: int, months_window: int) -> l
) )
# Industry-default elasticity used when sale_graph regression is not reliable
# (n<30 or R²<0.1). Negative because higher price ⇒ slower sales.
FALLBACK_ELASTICITY = -1.5
def _velocity_baseline(
db: Session,
*,
region_code: int,
district_name: str,
target_class: str | None,
) -> dict[str, Any]:
"""Median monthly sales velocity (apartments/month per ЖК) from
domrf_kn_sale_graph for objects in the same район+class over last 24 mo.
Returns dict {realised_per_month_median, realised_per_month_avg,
objects_count, observations}. All-None means no data caller falls back.
"""
where_class = "AND o.obj_class = :cls" if target_class else ""
params: dict[str, Any] = {
"rc": region_code,
"dn": district_name,
}
if target_class:
params["cls"] = target_class
row = (
db.execute(
text(
f"""
WITH obj_pool AS (
SELECT o.obj_id
FROM domrf_kn_objects o
WHERE o.region_cd = :rc
AND o.addr ILIKE '%' || :dn || '%'
{where_class}
),
sg AS (
SELECT sg.obj_id, sg.realised
FROM domrf_kn_sale_graph sg
JOIN obj_pool p ON p.obj_id = sg.obj_id
WHERE sg.type = 'apartments'
AND sg.realised IS NOT NULL
AND sg.report_month >= NOW() - INTERVAL '24 months'
)
SELECT
AVG(realised) AS avg_pm,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY realised) AS median_pm,
COUNT(DISTINCT obj_id) AS objects,
COUNT(*) AS observations
FROM sg
"""
),
params,
)
.mappings()
.first()
)
if not row:
return {
"realised_per_month_avg": None,
"realised_per_month_median": None,
"objects_count": 0,
"observations": 0,
}
return {
"realised_per_month_avg": _f(row["avg_pm"]),
"realised_per_month_median": _f(row["median_pm"]),
"objects_count": int(row["objects"] or 0),
"observations": int(row["observations"] or 0),
}
def _elasticity_coef(
db: Session,
*,
region_code: int,
district_name: str,
target_class: str | None,
) -> dict[str, Any]:
"""Fit log-log regression LN(realised) ~ LN(price_avg) on sale_graph
observations for the same район+class. Returns elasticity (slope), ,
n. Falls back to FALLBACK_ELASTICITY if data thin or regression weak."""
where_class = "AND o.obj_class = :cls" if target_class else ""
params: dict[str, Any] = {"rc": region_code, "dn": district_name}
if target_class:
params["cls"] = target_class
row = (
db.execute(
text(
f"""
WITH obj_pool AS (
SELECT o.obj_id
FROM domrf_kn_objects o
WHERE o.region_cd = :rc
AND o.addr ILIKE '%' || :dn || '%'
{where_class}
),
pts AS (
SELECT LN(sg.realised)::float8 AS y,
LN(sg.price_avg)::float8 AS x
FROM domrf_kn_sale_graph sg
JOIN obj_pool p ON p.obj_id = sg.obj_id
WHERE sg.type = 'apartments'
AND sg.realised IS NOT NULL AND sg.realised > 0
AND sg.price_avg IS NOT NULL AND sg.price_avg > 0
AND sg.report_month >= NOW() - INTERVAL '36 months'
)
SELECT
regr_slope(y, x) AS slope,
regr_r2(y, x) AS r2,
COUNT(*) AS n
FROM pts
"""
),
params,
)
.mappings()
.first()
)
n = int(row["n"]) if row and row["n"] is not None else 0
slope = _f(row["slope"]) if row else None
r2 = _f(row["r2"]) if row else None
if n >= 30 and slope is not None and r2 is not None and r2 >= 0.1 and slope < 0:
return {
"elasticity": round(slope, 4),
"r2": round(r2, 4),
"n": n,
"source": "regression",
}
return {
"elasticity": FALLBACK_ELASTICITY,
"r2": r2 or 0.0,
"n": n,
"source": "fallback",
}
def recommend_mix( def recommend_mix(
db: Session, db: Session,
*, *,
@ -1030,6 +1167,8 @@ def recommend_mix(
target_class: str | None = None, target_class: str | None = None,
months_window: int = 12, months_window: int = 12,
region_code: int = 66, region_code: int = 66,
price_factor: float = 1.0,
target_months: int | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Rule-based квартирография recommender. """Rule-based квартирография recommender.
@ -1198,6 +1337,103 @@ def recommend_mix(
weighted_avg_price = round(weighted_num / weighted_den, 2) if weighted_den > 0 else None weighted_avg_price = round(weighted_num / weighted_den, 2) if weighted_den > 0 else None
# 5b) Velocity baseline (apartments/month per ЖК) + price elasticity.
# Both are required for the live "цена↔темп" calculator on the frontend.
vel = _velocity_baseline(
db,
region_code=region_code,
district_name=district_row["district_name"],
target_class=target_class,
)
market_vel_pm = vel["realised_per_month_median"] or vel["realised_per_month_avg"]
if market_vel_pm is None:
# Fallback: derive from city-wide rosreestr deals (distribute per bucket
# by share). Coarser, but lets the calculator work anywhere.
warnings.append(
"Нет sale_graph данных для этого района и класса —"
" темп считается по rosreestr-сделкам (грубее)."
)
market_vel_pm = (total_deals / max(effective_window, 1)) if total_deals else 0.0
velocity_source = "rosreestr_fallback"
else:
velocity_source = "sale_graph"
elast = _elasticity_coef(
db,
region_code=region_code,
district_name=district_row["district_name"],
target_class=target_class,
)
elasticity = elast["elasticity"]
if elast["source"] == "fallback":
warnings.append(
f"Эластичность цена↔темп взята по умолчанию ({elasticity})"
f" — sale_graph даёт n={elast['n']}, R²={round(elast['r2'], 2)}"
" (недостаточно для регрессии)."
)
# 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).
pf_pow = price_factor**elasticity if price_factor > 0 else 1.0
total_units = 0
for b in buckets:
bucket_velocity = round(market_vel_pm * (b["share_pct"] / 100), 3)
b["velocity_per_month"] = bucket_velocity
if b["units_planned"] and bucket_velocity > 0:
adjusted_velocity = bucket_velocity * pf_pow
b["months_to_sellout"] = (
round(b["units_planned"] / adjusted_velocity, 1) if adjusted_velocity > 0 else None
)
total_units += b["units_planned"]
else:
b["months_to_sellout"] = None
# 5c) Inverse mode: target_months → required price_factor.
# required_velocity = total_units / target_months
# base_velocity_total = sum(bucket_velocity) (at price_factor=1)
# required_pf^elasticity = required_velocity / base_velocity_total
# → required_pf = (required_velocity / base_velocity_total)^(1/elasticity)
required_price_factor: float | None = None
if target_months and total_units > 0:
base_total_velocity = sum(b["velocity_per_month"] or 0 for b in buckets)
if base_total_velocity > 0 and elasticity != 0:
required_velocity = total_units / target_months
ratio = required_velocity / base_total_velocity
try:
required_price_factor = round(ratio ** (1.0 / elasticity), 4)
except Exception:
required_price_factor = None
if required_price_factor and required_price_factor < 0.7:
warnings.append(
f"Целевой срок {target_months} мес требует скидки"
f" >{round((1 - required_price_factor) * 100)}% — рассмотри"
" сдвиг ассортимента в сторону ликвидных бакетов."
)
# 5d) Liquidity score (0-100): % units sold within 24 months.
liquidity_24mo: float | None = None
if total_units > 0:
sold_24mo = 0.0
for b in buckets:
mts = b["months_to_sellout"]
up = b["units_planned"] or 0
if up <= 0 or mts is None or mts <= 0:
continue
frac = min(1.0, 24.0 / mts)
sold_24mo += frac * up
liquidity_24mo = round(sold_24mo / total_units * 100, 1)
# 5e) Aggregate KPIs
months_to_sellout_total: float | None = None
base_total_v = sum(b["velocity_per_month"] or 0 for b in buckets)
if total_units > 0 and base_total_v > 0:
months_to_sellout_total = round(total_units / (base_total_v * pf_pow), 1)
avg_ticket = (
round(total_revenue / total_units, 2) if (have_revenue and total_units > 0) else None
)
# 6) Comparable ЖК — same district (parsed from addr) and class # 6) Comparable ЖК — same district (parsed from addr) and class
cmp_rows = ( cmp_rows = (
db.execute( db.execute(
@ -1230,6 +1466,20 @@ def recommend_mix(
.all() .all()
) )
# 7) Headline для CEO — одна строка с тремя главными цифрами
headline_parts: list[str] = []
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)} мес")
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)} кв/мес")
if liquidity_24mo is not None:
headline_parts.append(f"ликвидность {liquidity_24mo:.0f}/100")
headline = " · ".join(headline_parts) if headline_parts else None
return { return {
"scope": { "scope": {
"district": district_row["district_name"], "district": district_row["district_name"],
@ -1242,6 +1492,19 @@ def recommend_mix(
"effective_window_months": effective_window, "effective_window_months": effective_window,
"region_code": region_code, "region_code": region_code,
"total_deals": total_deals if bucket_rows else 0, "total_deals": total_deals if bucket_rows else 0,
"market_velocity_per_month": (
round(market_vel_pm, 3) if market_vel_pm is not None else None
),
"velocity_source": velocity_source,
"velocity_observations": vel["observations"],
"velocity_objects": vel["objects_count"],
"elasticity": elasticity,
"elasticity_r2": elast["r2"],
"elasticity_n": elast["n"],
"elasticity_source": elast["source"],
"price_factor_applied": round(price_factor, 4),
"required_price_factor": required_price_factor,
"target_months": target_months,
"data_caveat": ( "data_caveat": (
"MVP: bucket-распределение город-wide (регион 66). Район влияет" "MVP: bucket-распределение город-wide (регион 66). Район влияет"
" только на ценовой коэффициент. v2 добавит per-district demand" " только на ценовой коэффициент. v2 добавит per-district demand"
@ -1252,6 +1515,11 @@ def recommend_mix(
"summary": { "summary": {
"total_revenue_rub": round(total_revenue, 2) if have_revenue else None, "total_revenue_rub": round(total_revenue, 2) if have_revenue else None,
"weighted_avg_price_per_m2": weighted_avg_price, "weighted_avg_price_per_m2": weighted_avg_price,
"total_units_planned": total_units if total_units > 0 else None,
"months_to_sellout_total": months_to_sellout_total,
"avg_ticket_rub": avg_ticket,
"liquidity_score_24mo": liquidity_24mo,
"headline": headline,
"warnings": warnings, "warnings": warnings,
}, },
"comparables": [ "comparables": [

View file

@ -1106,6 +1106,11 @@ async def run_region_sweep(
f"objStatus={status}: получено {len(rows)} объектов", f"objStatus={status}: получено {len(rows)} объектов",
stage="fetch_objects", stage="fetch_objects",
) )
# Heartbeat-tick: Phase A для 1516 ЖК идёт ~3 мин, autoclean
# пропустит это окно если хотя бы каждые ~1 мин обновлять
# heartbeat_at. progress_obj_index пока 0 — snapshot ещё
# не сохранён.
_checkpoint(db, run_id, 0)
logger.info( logger.info(
"place=%s total objects across %s = %d", "place=%s total objects across %s = %d",
place, place,

View file

@ -7,8 +7,10 @@ import { RecommendBucketsChart } from "@/components/analytics/RecommendBucketsCh
import { RecommendBucketsTable } from "@/components/analytics/RecommendBucketsTable"; import { RecommendBucketsTable } from "@/components/analytics/RecommendBucketsTable";
import { RecommendComparables } from "@/components/analytics/RecommendComparables"; import { RecommendComparables } from "@/components/analytics/RecommendComparables";
import { RecommendForm } from "@/components/analytics/RecommendForm"; import { RecommendForm } from "@/components/analytics/RecommendForm";
import { RecommendLiquidityChart } from "@/components/analytics/RecommendLiquidityChart";
import { RecommendRevenueChart } from "@/components/analytics/RecommendRevenueChart"; import { RecommendRevenueChart } from "@/components/analytics/RecommendRevenueChart";
import { RecommendShareSliders } from "@/components/analytics/RecommendShareSliders"; import { RecommendShareSliders } from "@/components/analytics/RecommendShareSliders";
import { RecommendVelocityPanel } from "@/components/analytics/RecommendVelocityPanel";
import { Section } from "@/components/analytics/Section"; import { Section } from "@/components/analytics/Section";
import { useRecommendMix } from "@/lib/analytics-api"; import { useRecommendMix } from "@/lib/analytics-api";
import type { RecommendMixInput } from "@/types/analytics"; import type { RecommendMixInput } from "@/types/analytics";
@ -18,17 +20,25 @@ const DEFAULT_INPUT: RecommendMixInput = {
area_total_m2: null, area_total_m2: null,
target_class: null, target_class: null,
months_window: 12, months_window: 12,
price_factor: 1.0,
target_months: null,
}; };
export default function RecommendPage() { export default function RecommendPage() {
const [input, setInput] = useState<RecommendMixInput>(DEFAULT_INPUT); const [input, setInput] = useState<RecommendMixInput>(DEFAULT_INPUT);
const [overrideShares, setOverrideShares] = useState<number[] | null>(null); const [overrideShares, setOverrideShares] = useState<number[] | null>(null);
// priceFactor живёт отдельно от input.price_factor, чтобы слайдер двигался
// мгновенно (client-side recompute), а не дёргал API на каждое движение.
const [priceFactor, setPriceFactor] = useState(1.0);
const mutation = useRecommendMix(); const mutation = useRecommendMix();
const data = mutation.data; const data = mutation.data;
// reset slider override whenever a fresh API response arrives // reset slider override whenever a fresh API response arrives
useEffect(() => { useEffect(() => {
if (data) setOverrideShares(null); if (data) {
setOverrideShares(null);
setPriceFactor(1.0);
}
}, [data]); }, [data]);
const recommendedShares = useMemo( const recommendedShares = useMemo(
@ -141,6 +151,59 @@ export default function RecommendPage() {
</div> </div>
) : ( ) : (
<> <>
{/* Headline для CEO — одна строка с тремя главными цифрами */}
{data.summary.headline ? (
<div
style={{
background: "#0f172a",
color: "#e2e8f0",
borderRadius: 12,
padding: "14px 18px",
fontSize: 15,
fontWeight: 500,
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>
) : null}
{/* Velocity panel — главный калькулятор */}
<Section
title="Цена · Темп · Срок · Ликвидность"
subtitle="Двигай слайдер цены — KPI пересчитываются live по эластичности sale_graph. Введи целевой срок в форме слева — система предложит требуемый коэффициент."
>
<RecommendVelocityPanel
scope={data.scope}
derivedRows={derivedRows}
priceFactor={priceFactor}
onPriceFactorChange={setPriceFactor}
targetMonths={input.target_months ?? null}
hasAllocation={hasAllocation}
/>
</Section>
{/* Liquidity chart — кумулятивная кривая продаж */}
{hasAllocation ? (
<Section
title="Кумулятивные продажи (горизонт 36 мес)"
subtitle="Сколько % инвентаря продастся к месяцу X при текущей цене. Пунктир — горизонт 24 мес = liquidity score."
>
<RecommendLiquidityChart
rows={derivedRows}
priceFactor={priceFactor}
elasticity={data.scope.elasticity}
/>
</Section>
) : null}
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}> <div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
<KpiCard label="Район" value={data.scope.district} /> <KpiCard label="Район" value={data.scope.district} />
<KpiCard <KpiCard
@ -152,7 +215,9 @@ export default function RecommendPage() {
label="Weighted ₽/м²" label="Weighted ₽/м²"
value={ value={
weightedAvgPrice weightedAvgPrice
? Math.round(weightedAvgPrice).toLocaleString("ru") ? Math.round(
weightedAvgPrice * priceFactor,
).toLocaleString("ru")
: "—" : "—"
} }
unit="₽" unit="₽"
@ -161,13 +226,13 @@ export default function RecommendPage() {
label="Выручка" label="Выручка"
value={ value={
hasAllocation hasAllocation
? `${(totalRevenue / 1_000_000).toFixed(1)}` ? `${((totalRevenue * priceFactor) / 1_000_000).toFixed(1)}`
: "—" : "—"
} }
unit="млн ₽" unit="млн ₽"
hint={ hint={
hasAllocation hasAllocation
? "при текущем распределении" ? `при цене ×${priceFactor.toFixed(2)}`
: "укажи площадь, чтобы посчитать" : "укажи площадь, чтобы посчитать"
} }
/> />
@ -218,6 +283,8 @@ export default function RecommendPage() {
<RecommendBucketsTable <RecommendBucketsTable
rows={derivedRows} rows={derivedRows}
hasAllocation={hasAllocation} hasAllocation={hasAllocation}
priceFactor={priceFactor}
elasticity={data.scope.elasticity}
/> />
</Section> </Section>

View file

@ -11,6 +11,8 @@ interface DerivedBucket extends RecommendBucket {
interface Props { interface Props {
rows: DerivedBucket[]; rows: DerivedBucket[];
hasAllocation: boolean; hasAllocation: boolean;
priceFactor?: number;
elasticity?: number;
} }
const fmtInt = (n: number | null | undefined) => const fmtInt = (n: number | null | undefined) =>
@ -18,7 +20,13 @@ const fmtInt = (n: number | null | undefined) =>
const fmtMln = (rub: number | null | undefined) => const fmtMln = (rub: number | null | undefined) =>
rub == null ? "—" : `${(rub / 1_000_000).toFixed(1)} млн ₽`; rub == null ? "—" : `${(rub / 1_000_000).toFixed(1)} млн ₽`;
export function RecommendBucketsTable({ rows, hasAllocation }: Props) { export function RecommendBucketsTable({
rows,
hasAllocation,
priceFactor = 1,
elasticity = -1.5,
}: Props) {
const pfPow = priceFactor > 0 ? priceFactor ** elasticity : 1;
return ( return (
<div style={{ overflowX: "auto" }}> <div style={{ overflowX: "auto" }}>
<table <table
@ -38,7 +46,8 @@ export function RecommendBucketsTable({ rows, hasAllocation }: Props) {
"Цена p25, ₽/м²", "Цена p25, ₽/м²",
"Цена медиана, ₽/м²", "Цена медиана, ₽/м²",
"Цена p75, ₽/м²", "Цена p75, ₽/м²",
...(hasAllocation ? ["Юнитов", "Выручка"] : []), "Темп, кв/мес",
...(hasAllocation ? ["Юнитов", "Срок, мес", "Выручка"] : []),
].map((h) => ( ].map((h) => (
<th <th
key={h} key={h}
@ -55,33 +64,57 @@ export function RecommendBucketsTable({ rows, hasAllocation }: Props) {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.map((r, i) => ( {rows.map((r, i) => {
<tr const adjVelocity = (r.velocity_per_month ?? 0) * pfPow;
key={r.bucket} const months =
style={{ r.effective_units && adjVelocity > 0
borderBottom: "1px solid #eef0f3", ? r.effective_units / adjVelocity
background: i % 2 ? "#fafbfc" : "#fff", : null;
}} const adjPriceMedian = r.price_median_per_m2 * priceFactor;
> const adjP25 = r.price_p25_per_m2 * priceFactor;
<td style={td}> const adjP75 = r.price_p75_per_m2 * priceFactor;
<strong>{r.bucket}</strong> const adjRevenue =
</td> r.effective_revenue_rub != null
<td style={td}>{r.effective_share_pct.toFixed(1)}%</td> ? r.effective_revenue_rub * priceFactor
<td style={td}>{fmtInt(r.deal_count)}</td> : null;
<td style={td}>{r.area_avg_m2.toFixed(1)}</td> return (
<td style={td}>{fmtInt(r.price_p25_per_m2)}</td> <tr
<td style={{ ...td, fontWeight: 600 }}> key={r.bucket}
{fmtInt(r.price_median_per_m2)} style={{
</td> borderBottom: "1px solid #eef0f3",
<td style={td}>{fmtInt(r.price_p75_per_m2)}</td> background: i % 2 ? "#fafbfc" : "#fff",
{hasAllocation ? ( }}
<> >
<td style={td}>{fmtInt(r.effective_units)}</td> <td style={td}>
<td style={td}>{fmtMln(r.effective_revenue_rub)}</td> <strong>{r.bucket}</strong>
</> </td>
) : null} <td style={td}>{r.effective_share_pct.toFixed(1)}%</td>
</tr> <td style={td}>{fmtInt(r.deal_count)}</td>
))} <td style={td}>{r.area_avg_m2.toFixed(1)}</td>
<td style={td}>{fmtInt(adjP25)}</td>
<td style={{ ...td, fontWeight: 600 }}>
{fmtInt(adjPriceMedian)}
</td>
<td style={td}>{fmtInt(adjP75)}</td>
<td style={td}>
{adjVelocity > 0 ? adjVelocity.toFixed(1) : "—"}
</td>
{hasAllocation ? (
<>
<td style={td}>{fmtInt(r.effective_units)}</td>
<td style={td}>
{months == null
? "—"
: months > 60
? "60+"
: months.toFixed(1)}
</td>
<td style={td}>{fmtMln(adjRevenue)}</td>
</>
) : null}
</tr>
);
})}
</tbody> </tbody>
</table> </table>
</div> </div>

View file

@ -136,6 +136,30 @@ export function RecommendForm({
</span> </span>
</label> </label>
<label>
<span style={labelStyle}>Целевой срок реализации (опц.)</span>
<input
type="number"
inputMode="numeric"
min={3}
max={120}
step={1}
placeholder="например 18"
value={value.target_months ?? ""}
onChange={(e) =>
onChange({
...value,
target_months:
e.target.value === "" ? null : Number(e.target.value),
})
}
style={inputStyle}
/>
<span style={hintStyle}>
Если задан система предложит требуемый коэффициент к рынку.
</span>
</label>
<button <button
type="submit" type="submit"
disabled={!valid || isPending} disabled={!valid || isPending}

View file

@ -0,0 +1,106 @@
"use client";
import { useMemo } from "react";
import type { RecommendBucket } from "@/types/analytics";
import { ChartShell } from "./ChartShell";
interface DerivedBucket extends RecommendBucket {
effective_share_pct: number;
effective_units: number | null;
effective_revenue_rub: number | null;
}
interface Props {
rows: DerivedBucket[];
priceFactor: number;
elasticity: number;
}
const HORIZON_MONTHS = 36;
/**
* Cumulative-sold curve over 36 months. Per bucket: linear sellout up to
* months_to_sellout, then 100%. Aggregate = unit-weighted.
*
* Marker line at 24 months that's the liquidity-score reference.
*/
export function RecommendLiquidityChart({
rows,
priceFactor,
elasticity,
}: Props) {
const option = useMemo(() => {
const pfPow = priceFactor > 0 ? priceFactor ** elasticity : 1;
const totalUnits = rows.reduce((a, r) => a + (r.effective_units ?? 0), 0);
if (totalUnits === 0) {
return {
title: {
text: "Укажи площадь — посчитаем сколько и когда продастся",
left: "center",
top: "middle",
textStyle: { color: "#9ca3af", fontSize: 13, fontWeight: 400 },
},
xAxis: { show: false },
yAxis: { show: false },
};
}
const xs = Array.from({ length: HORIZON_MONTHS + 1 }, (_, m) => m);
const cumulative = xs.map((m) => {
let sold = 0;
for (const r of rows) {
const u = r.effective_units ?? 0;
if (u <= 0) continue;
const v = (r.velocity_per_month ?? 0) * pfPow;
if (v <= 0) continue;
const monthsTotal = u / v;
const f = Math.min(1, m / monthsTotal);
sold += f * u;
}
return Math.round((sold / totalUnits) * 1000) / 10; // %
});
return {
tooltip: {
trigger: "axis",
valueFormatter: (v: unknown) =>
typeof v === "number" ? `${v.toFixed(1)}%` : "—",
},
grid: { left: 56, right: 32, top: 24, bottom: 36 },
xAxis: {
type: "category",
data: xs.map((m) => `${m}`),
axisLabel: { formatter: "{value} мес" },
},
yAxis: {
type: "value",
max: 100,
axisLabel: { formatter: "{value}%" },
},
series: [
{
name: "Кумулятивные продажи",
type: "line",
smooth: true,
areaStyle: { color: "rgba(29, 78, 216, 0.15)" },
lineStyle: { color: "#1d4ed8", width: 2 },
symbol: "none",
data: cumulative,
markLine: {
symbol: "none",
label: { formatter: "24 мес — горизонт ликвидности" },
data: [
{
xAxis: "24",
lineStyle: { color: "#9a6700", type: "dashed" },
},
],
},
},
],
};
}, [rows, priceFactor, elasticity]);
return <ChartShell option={option} height={240} notMerge />;
}

View file

@ -0,0 +1,309 @@
"use client";
import { useMemo } from "react";
import type { RecommendBucket, RecommendMixOutput } from "@/types/analytics";
interface DerivedBucket extends RecommendBucket {
effective_share_pct: number;
effective_units: number | null;
effective_revenue_rub: number | null;
}
interface Props {
scope: RecommendMixOutput["scope"];
derivedRows: DerivedBucket[];
priceFactor: number;
onPriceFactorChange: (next: number) => void;
targetMonths: number | null;
/** Set when user wants the system to suggest required price_factor. */
onTargetMonthsApply?: () => void;
hasAllocation: boolean;
}
const fmtMln = (rub: number | null) =>
rub == null ? "—" : `${(rub / 1_000_000).toFixed(1)}`;
/**
* Live "цена↔темп↔срок↔ликвидность" calculator.
*
* Все расчёты клиентские по базовым коэффициентам из API:
* velocity_at_pf = velocity_base × price_factor^elasticity
* months_to_sellout = units / velocity_at_pf
* liquidity = avg(min(1, 24/months) × units) / total_units × 100
*
* Никаких round-trip к backend на каждое движение слайдера.
*/
export function RecommendVelocityPanel({
scope,
derivedRows,
priceFactor,
onPriceFactorChange,
targetMonths,
hasAllocation,
}: Props) {
const elasticity = scope.elasticity;
const pfPow = useMemo(
() => (priceFactor > 0 ? priceFactor ** elasticity : 1),
[priceFactor, elasticity],
);
// Aggregate live recompute
const totals = useMemo(() => {
let units = 0;
let revenue = 0;
let baseVelocity = 0; // sum of bucket velocity at price_factor=1
let weightedSold24 = 0; // weighted sum for liquidity
for (const r of derivedRows) {
const u = r.effective_units ?? 0;
units += u;
// Revenue scales linearly with price_factor (price_median × pf).
const baseRev = r.effective_revenue_rub ?? 0;
revenue += baseRev * priceFactor;
const v = r.velocity_per_month ?? 0;
baseVelocity += v * (r.effective_share_pct / Math.max(r.share_pct, 0.01));
const adjustedV = v * pfPow;
if (u > 0 && adjustedV > 0) {
const months = u / adjustedV;
const fracIn24 = Math.min(1, 24 / months);
weightedSold24 += fracIn24 * u;
}
}
const tempo = baseVelocity * pfPow;
const monthsToSellout = tempo > 0 && units > 0 ? units / tempo : null;
const liquidity = units > 0 ? (weightedSold24 / units) * 100 : null;
const avgTicket = units > 0 && revenue > 0 ? revenue / units : null;
return {
units,
revenue,
tempo,
monthsToSellout,
liquidity,
avgTicket,
};
}, [derivedRows, priceFactor, pfPow]);
const liquidityColor =
totals.liquidity == null
? "#9ca3af"
: totals.liquidity >= 70
? "#0a7a3a"
: totals.liquidity >= 40
? "#9a6700"
: "#b3261e";
const monthsLabel =
totals.monthsToSellout == null
? "—"
: totals.monthsToSellout > 60
? "60+"
: totals.monthsToSellout.toFixed(1);
// Preview required price_factor if target_months active
const required = scope.required_price_factor;
const requiredPct =
required != null ? Math.round((required - 1) * 100) : null;
return (
<div>
{/* Big KPI strip */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))",
gap: 12,
marginBottom: 16,
}}
>
<BigKpi
label="Средний чек"
value={fmtMln(totals.avgTicket)}
unit="М ₽"
/>
<BigKpi
label="Срок реализации"
value={monthsLabel}
unit="мес"
hint={hasAllocation ? undefined : "укажи площадь"}
/>
<BigKpi
label="Темп продаж"
value={totals.tempo > 0 ? totals.tempo.toFixed(1) : "—"}
unit="кв/мес"
/>
<BigKpi
label="Ликвидность 24 мес"
value={
totals.liquidity != null ? `${totals.liquidity.toFixed(0)}` : "—"
}
unit="/ 100"
color={liquidityColor}
/>
</div>
{/* Price factor slider */}
<div
style={{
display: "grid",
gridTemplateColumns: "180px 1fr 110px",
gap: 12,
alignItems: "center",
marginBottom: 8,
}}
>
<span style={{ fontSize: 13, fontWeight: 600 }}>Цена ± к рынку</span>
<input
type="range"
min={0.85}
max={1.15}
step={0.01}
value={priceFactor}
onChange={(e) => onPriceFactorChange(Number(e.target.value))}
/>
<div style={{ textAlign: "right", fontSize: 14 }}>
<span style={{ fontWeight: 700 }}>
{priceFactor === 1
? "0%"
: `${(priceFactor - 1) * 100 > 0 ? "+" : ""}${((priceFactor - 1) * 100).toFixed(0)}%`}
</span>
<div style={{ fontSize: 11, color: "#73767e" }}>
×{priceFactor.toFixed(2)}
</div>
</div>
</div>
{/* Inverse mode hint */}
{targetMonths && required != null ? (
<div
style={{
padding: 10,
background:
requiredPct != null && requiredPct < -15 ? "#fef2f2" : "#f0fdf4",
border: `1px solid ${requiredPct != null && requiredPct < -15 ? "#fecaca" : "#bbf7d0"}`,
borderRadius: 8,
fontSize: 13,
marginTop: 8,
marginBottom: 8,
}}
>
<strong>Целевой срок {targetMonths} мес</strong> требует price-factor{" "}
<code
style={{
background: "rgba(0,0,0,0.05)",
padding: "1px 5px",
borderRadius: 3,
}}
>
×{required.toFixed(2)}
</code>{" "}
({requiredPct !== null && requiredPct >= 0 ? "+" : ""}
{requiredPct}% к рынку).{" "}
<button
onClick={() =>
onPriceFactorChange(Math.max(0.85, Math.min(1.15, required)))
}
style={{
padding: "2px 8px",
fontSize: 12,
border: "1px solid #d1d5db",
borderRadius: 4,
background: "#fff",
cursor: "pointer",
marginLeft: 8,
}}
>
Применить
</button>
</div>
) : null}
{/* Methodology note */}
<div
style={{
fontSize: 11,
color: "#73767e",
marginTop: 12,
paddingTop: 8,
borderTop: "1px dashed #e6e8ec",
}}
>
Эластичность ценатемп <strong>{elasticity}</strong> (
{scope.elasticity_source === "regression"
? `регрессия sale_graph: R²=${scope.elasticity_r2.toFixed(2)}, n=${scope.elasticity_n}`
: `по умолчанию — sale_graph недостаточно (n=${scope.elasticity_n})`}
). Базовый темп{" "}
<strong>{scope.market_velocity_per_month?.toFixed(1) ?? "—"}</strong>{" "}
кв/мес (
{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)}.
</div>
</div>
);
}
function BigKpi({
label,
value,
unit,
hint,
color,
}: {
label: string;
value: string;
unit?: string;
hint?: string;
color?: string;
}) {
return (
<div
style={{
background: "#fff",
border: "1px solid #e6e8ec",
borderRadius: 10,
padding: "12px 14px",
}}
>
<div
style={{
fontSize: 11,
color: "#5b6066",
textTransform: "uppercase",
letterSpacing: 0.4,
marginBottom: 4,
}}
>
{label}
</div>
<div
style={{
fontSize: 24,
fontWeight: 700,
color: color ?? "#0f172a",
lineHeight: 1.1,
}}
>
{value}
{unit ? (
<span
style={{
fontSize: 13,
fontWeight: 500,
color: "#5b6066",
marginLeft: 4,
}}
>
{unit}
</span>
) : null}
</div>
{hint ? (
<div style={{ fontSize: 11, color: "#9ca3af", marginTop: 2 }}>
{hint}
</div>
) : null}
</div>
);
}

View file

@ -203,6 +203,8 @@ export interface RecommendMixInput {
area_total_m2: number | null; area_total_m2: number | null;
target_class: RecommendClass | null; target_class: RecommendClass | null;
months_window: number; months_window: number;
price_factor?: number;
target_months?: number | null;
} }
export interface RecommendBucket { export interface RecommendBucket {
@ -216,6 +218,8 @@ export interface RecommendBucket {
price_p75_per_m2: number; price_p75_per_m2: number;
units_planned: number | null; units_planned: number | null;
revenue_planned_rub: number | null; revenue_planned_rub: number | null;
velocity_per_month: number | null;
months_to_sellout: number | null;
} }
export interface RecommendComparable { export interface RecommendComparable {
@ -239,6 +243,17 @@ export interface RecommendMixOutput {
effective_window_months: number; effective_window_months: number;
region_code: number; region_code: number;
total_deals: number; total_deals: number;
market_velocity_per_month: number | null;
velocity_source: "sale_graph" | "rosreestr_fallback";
velocity_observations: number;
velocity_objects: number;
elasticity: number;
elasticity_r2: number;
elasticity_n: number;
elasticity_source: "regression" | "fallback";
price_factor_applied: number;
required_price_factor: number | null;
target_months: number | null;
data_caveat?: string; data_caveat?: string;
error?: string; error?: string;
}; };
@ -246,6 +261,11 @@ export interface RecommendMixOutput {
summary: { summary: {
total_revenue_rub: number | null; total_revenue_rub: number | null;
weighted_avg_price_per_m2: number | null; weighted_avg_price_per_m2: number | null;
total_units_planned: number | null;
months_to_sellout_total: number | null;
avg_ticket_rub: number | null;
liquidity_score_24mo: number | null;
headline: string | null;
warnings: string[]; warnings: string[];
}; };
comparables: RecommendComparable[]; comparables: RecommendComparable[];

File diff suppressed because one or more lines are too long