Some checks failed
CI / changes (push) Successful in 8s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 8s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 7m37s
CI / backend-tests (pull_request) Successful in 7m42s
Deploy / deploy (push) Blocked by required conditions
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / build-frontend (push) Blocked by required conditions
Deploy / changes (push) Has been cancelled
#17: detect_velocity_anomalies + GET /analytics/velocity-alerts — z-score drop-detection на domrf_kn_sale_graph (double-gate z≤-2 AND drop≤-30%, starvation-guards). Snapshot=richest (не MAX — prod новейшие частичные), lookback anchored на latest report_month (scrape лаг ~4мес). Prod: ЖК Центральный Парк -69%, ~14ms. #99: mv_ddu_price_indicator (миграция 152) + POST /market/ddu-indicator — ARN-mirror ценовой индекс per quarter×area_bucket из rosreestr_deals (ДДУ регион 66). MVP: subject-level, period Q, window 2025-Q2+, methods 1/2 (basis/previous, prev_period_value honesty). Q1-2026 headline 1.0185 vs ARN 1.03 (±5%). Method 3 blocked (нет pre-2025-Q2 данных) — задокументировано. Closes #17 Closes #99
289 lines
11 KiB
Python
289 lines
11 KiB
Python
"""Analytics endpoints for the Sverdlovsk dashboard."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from typing import Annotated, Any, Literal
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.db import get_db
|
||
from app.schemas.complex_buildings import ComplexBuilding
|
||
from app.schemas.recommend import RecommendMixInput, RecommendMixOutput
|
||
from app.services import analytics_queries as q
|
||
from app.services.analytics import velocity_alerts as va
|
||
|
||
router = APIRouter()
|
||
|
||
DEV_ID_RE = re.compile(r"^\d+_\d+$")
|
||
|
||
|
||
def _validate_dev(dev_id: str) -> str:
|
||
if not DEV_ID_RE.match(dev_id):
|
||
raise HTTPException(status_code=400, detail="developer_id must match \\d+_\\d+")
|
||
return dev_id
|
||
|
||
|
||
# ---- Sverdlovsk region ------------------------------------------------------
|
||
|
||
|
||
@router.get("/sverdl/market-pulse")
|
||
def sverdl_market_pulse(db: Annotated[Session, Depends(get_db)]) -> list[dict[str, Any]]:
|
||
"""Monthly series Jan 2025 → present: total square (тыс м²), sold%, avg price."""
|
||
return q.market_pulse(db, region_code=66)
|
||
|
||
|
||
@router.get("/sverdl/quartirography")
|
||
def sverdl_quartirography(
|
||
db: Annotated[Session, Depends(get_db)],
|
||
source: Literal["portfolio", "deals"] = "portfolio",
|
||
) -> list[dict[str, Any]]:
|
||
"""Что строится (portfolio) vs что покупают (deals) по сегментам."""
|
||
return q.quartirography(db, source=source, region_id=66)
|
||
|
||
|
||
@router.get("/sverdl/pipeline")
|
||
def sverdl_pipeline(db: Annotated[Session, Depends(get_db)]) -> list[dict[str, Any]]:
|
||
"""По году ввода: total / sold% / unsold% / unopened%."""
|
||
return q.pipeline_by_year(db, region_code=66)
|
||
|
||
|
||
@router.get("/sverdl/districts")
|
||
def sverdl_districts(db: Annotated[Session, Depends(get_db)]) -> list[dict[str, Any]]:
|
||
"""ЕКБ-районы: ЖК-count, flat-count, area, цены."""
|
||
return q.districts(db)
|
||
|
||
|
||
@router.get("/sverdl/yandex-listings")
|
||
def sverdl_yandex_listings(db: Annotated[Session, Depends(get_db)]) -> dict[str, Any]:
|
||
"""Снимок активных новостроек Яндекс.Недвижимости."""
|
||
return q.yandex_listings(db)
|
||
|
||
|
||
# ---- Developers -------------------------------------------------------------
|
||
|
||
|
||
@router.get("/developers/top")
|
||
def developers_top(
|
||
db: Annotated[Session, Depends(get_db)],
|
||
limit: Annotated[int, Query(ge=1, le=50)] = 15,
|
||
) -> list[dict[str, Any]]:
|
||
"""Топ-девелоперы Свердл по объёму с Δ sold% за всю доступную историю."""
|
||
return q.top_developers(db, region_code=66, limit=limit)
|
||
|
||
|
||
@router.get("/developers/history")
|
||
def developers_history(
|
||
db: Annotated[Session, Depends(get_db)],
|
||
ids: Annotated[str, Query(min_length=1)],
|
||
) -> list[dict[str, Any]]:
|
||
"""Comma-separated developer_ids → time series sold_perc."""
|
||
dev_ids = [_validate_dev(i.strip()) for i in ids.split(",") if i.strip()]
|
||
if not dev_ids:
|
||
raise HTTPException(status_code=400, detail="ids must contain at least one developer_id")
|
||
return q.developer_history(db, developer_ids=dev_ids, region_code=66)
|
||
|
||
|
||
@router.get("/developers/{developer_id}")
|
||
def developer_detail(
|
||
db: Annotated[Session, Depends(get_db)],
|
||
developer_id: str,
|
||
) -> dict[str, Any]:
|
||
dev = _validate_dev(developer_id)
|
||
result = q.developer_detail(db, developer_id=dev)
|
||
if result is None:
|
||
raise HTTPException(status_code=404, detail="developer not found")
|
||
return result
|
||
|
||
|
||
@router.get("/developers/{developer_id}/portfolio")
|
||
def developer_portfolio(
|
||
db: Annotated[Session, Depends(get_db)],
|
||
developer_id: str,
|
||
) -> list[dict[str, Any]]:
|
||
dev = _validate_dev(developer_id)
|
||
return q.developer_portfolio(db, developer_id=dev)
|
||
|
||
|
||
# ---- Per-object drill-in (sale_graph / sales_agg / infra / photos) ----------
|
||
|
||
|
||
@router.get("/object/{obj_id}/sale_graph")
|
||
def object_sale_graph(
|
||
db: Annotated[Session, Depends(get_db)],
|
||
obj_id: int,
|
||
type_filter: Annotated[str | None, Query(alias="type")] = None,
|
||
) -> list[dict[str, Any]]:
|
||
"""Time-series продаж per-ЖК. type=apartments|parking, без фильтра — обе серии."""
|
||
return q.object_sale_graph(db, obj_id=obj_id, type_filter=type_filter)
|
||
|
||
|
||
@router.get("/object/{obj_id}/sales_agg")
|
||
def object_sales_agg(
|
||
db: Annotated[Session, Depends(get_db)],
|
||
obj_id: int,
|
||
) -> list[dict[str, Any]]:
|
||
"""3 строки: apartments + nonliv + parking (total / realised / sold%)."""
|
||
return q.object_sales_agg(db, obj_id=obj_id)
|
||
|
||
|
||
@router.get("/object/{obj_id}/infrastructure")
|
||
def object_infrastructure(
|
||
db: Annotated[Session, Depends(get_db)],
|
||
obj_id: int,
|
||
category: Annotated[str | None, Query()] = None,
|
||
max_distance: Annotated[int, Query(ge=0, le=10000)] = 5000,
|
||
) -> list[dict[str, Any]]:
|
||
"""POI вокруг ЖК. Фильтр по category (Спорт/Образование/...) и radius."""
|
||
return q.object_infrastructure(db, obj_id=obj_id, category=category, max_distance=max_distance)
|
||
|
||
|
||
@router.get("/object/{obj_id}/photos")
|
||
def object_photos(
|
||
db: Annotated[Session, Depends(get_db)],
|
||
obj_id: int,
|
||
limit: Annotated[int, Query(ge=1, le=500)] = 100,
|
||
) -> list[dict[str, Any]]:
|
||
"""Фото-метаданные с photo_url, отсортированные по period_dt DESC."""
|
||
return q.object_photos(db, obj_id=obj_id, limit=limit)
|
||
|
||
|
||
@router.get("/object/{obj_id}")
|
||
def object_detail(
|
||
db: Annotated[Session, Depends(get_db)],
|
||
obj_id: int,
|
||
) -> dict[str, Any]:
|
||
"""Базовая инфа объекта из domrf_kn_objects (имя, адрес, координаты, девелопер)."""
|
||
result = q.object_detail(db, obj_id=obj_id)
|
||
if result is None:
|
||
raise HTTPException(status_code=404, detail="object not found")
|
||
return result
|
||
|
||
|
||
@router.get("/object/{obj_id}/full")
|
||
def object_full_detail(
|
||
db: Annotated[Session, Depends(get_db)],
|
||
obj_id: int,
|
||
) -> dict[str, Any]:
|
||
"""Extended object info — все 30+ новых полей (22begh: specs/accessibility/metro/scores)."""
|
||
result = q.object_full_detail(db, obj_id=obj_id)
|
||
if result is None:
|
||
raise HTTPException(status_code=404, detail="object not found")
|
||
return result
|
||
|
||
|
||
@router.get("/object/{obj_id}/quartirography")
|
||
def object_quartirography(
|
||
db: Annotated[Session, Depends(get_db)],
|
||
obj_id: int,
|
||
) -> list[dict[str, Any]]:
|
||
"""Квартирография ЖК: группировка flats по комнатности с count/area/price."""
|
||
return q.object_flats_quartirography(db, obj_id=obj_id)
|
||
|
||
|
||
@router.get("/object/{obj_id}/checks")
|
||
def object_checks(
|
||
db: Annotated[Session, Depends(get_db)],
|
||
obj_id: int,
|
||
) -> list[dict[str, Any]]:
|
||
"""6 проверок «Проверено на наш.дом.рф» (22f)."""
|
||
return q.object_obj_checks(db, obj_id=obj_id)
|
||
|
||
|
||
@router.get("/object/{obj_id}/documents")
|
||
def object_documents(
|
||
db: Annotated[Session, Depends(get_db)],
|
||
obj_id: int,
|
||
) -> list[dict[str, Any]]:
|
||
"""PDF документы из domrf_kn_documents (22i): декларации, разрешения, отчётность."""
|
||
return q.object_documents(db, obj_id=obj_id)
|
||
|
||
|
||
@router.get("/object/{obj_id}/buildings", response_model=list[ComplexBuilding])
|
||
def object_buildings(
|
||
db: Annotated[Session, Depends(get_db)],
|
||
obj_id: int,
|
||
) -> list[dict[str, Any]]:
|
||
"""Список кадастровых зданий ЖК из cad_buildings."""
|
||
return q.complex_buildings(db, obj_id=obj_id)
|
||
|
||
|
||
# ---- Velocity alerts (Issue #17) -------------------------------------------
|
||
|
||
|
||
@router.get("/velocity-alerts")
|
||
def velocity_alerts(
|
||
db: Annotated[Session, Depends(get_db)],
|
||
region_code: Annotated[int, Query(ge=1, le=99)] = 66,
|
||
lookback_months: Annotated[int, Query(ge=6, le=24)] = 9,
|
||
min_zscore: Annotated[float, Query(ge=0.5, le=5.0)] = 2.0,
|
||
min_drop_pct: Annotated[float, Query(ge=0.0, le=100.0)] = 30.0,
|
||
limit: Annotated[int, Query(ge=1, le=200)] = 100,
|
||
) -> dict[str, Any]:
|
||
"""ЖК с statistical-significance провалом темпа продаж vs своя история.
|
||
|
||
Источник: domrf_kn_sale_graph (monthly realised). Детерминированная
|
||
z-score детекция: recent 3-mo mean vs trailing prior-period mean.
|
||
Алерт когда z <= -min_zscore И drop_pct <= -min_drop_pct (двойной gate
|
||
снижает false-positive на шумных low-volume ЖК). severity high/medium.
|
||
"""
|
||
return va.detect_velocity_anomalies(
|
||
db,
|
||
region_code=region_code,
|
||
lookback_months=lookback_months,
|
||
min_zscore=min_zscore,
|
||
min_drop_pct=min_drop_pct,
|
||
limit=limit,
|
||
)
|
||
|
||
|
||
# ---- PRINZIP-specific -------------------------------------------------------
|
||
|
||
|
||
@router.get("/prinzip/insights")
|
||
def prinzip_insights() -> dict[str, Any]:
|
||
"""Static recommendations + benchmarks (from PRINZIP_Strategy_Apr27)."""
|
||
return q.prinzip_insights()
|
||
|
||
|
||
@router.get("/prinzip/districts")
|
||
def prinzip_districts(db: Annotated[Session, Depends(get_db)]) -> list[dict[str, Any]]:
|
||
return q.prinzip_district_distribution(db, developer_id="6208_0")
|
||
|
||
|
||
@router.get("/prinzip/objects")
|
||
def prinzip_objects(db: Annotated[Session, Depends(get_db)]) -> list[dict[str, Any]]:
|
||
"""Список 28 ЖК PRINZIP с агрегатами sales_agg + sparkline-данными для UI таблицы."""
|
||
return q.prinzip_objects_with_velocity(db)
|
||
|
||
|
||
# Funnel-эндпойнты (CRM-данные) перенесены в backend/app/api/v1/admin_leads.py:
|
||
# /api/v1/admin/leads/funnel/{monthly,by-source,by-object} с X-Admin-Token.
|
||
|
||
|
||
# ---- Recommender (Уровень 1, rule-based) -----------------------------------
|
||
|
||
|
||
@router.post("/recommend/mix", response_model=RecommendMixOutput)
|
||
def recommend_mix(
|
||
payload: RecommendMixInput,
|
||
db: Annotated[Session, Depends(get_db)],
|
||
) -> dict[str, Any]:
|
||
"""Rule-based квартирография для участка в указанном районе ЕКБ.
|
||
|
||
На вход — district_name (8 районов ЕКБ), опционально area_total_m2 и
|
||
target_class. На выход — распределение по 5 бакетам комнатности с
|
||
ожидаемыми ценами/площадями/выручкой и список comparable ЖК.
|
||
"""
|
||
return q.recommend_mix(
|
||
db,
|
||
district_name=payload.district_name,
|
||
area_total_m2=payload.area_total_m2,
|
||
target_class=payload.target_class,
|
||
months_window=payload.months_window,
|
||
price_factor=payload.price_factor,
|
||
target_months=payload.target_months,
|
||
horizon_months=payload.horizon_months,
|
||
cad_num=payload.cad_num,
|
||
)
|