gendesign/backend/app/api/v1/analytics.py
Light1YT 658d724075
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
feat(analytics): velocity-anomaly alerts (#17) + ARN ДДУ price indicator (#99)
#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
2026-06-13 22:04:49 +05:00

289 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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,
)