"""Анализ активных конкурентов ЖК в радиусе от участка. Issue #112 — Demand: активные конкуренты, продажи ЖК в радиусе 1км за квартал. Источники: domrf_kn_objects — ЖК с lat/lon, flat_count, obj_class, site_status objective_complex_mapping — domrf_obj_id → objective_complex_name objective_corpus_room_month — monthly deals_total_count per project_name cad_parcels_geom — centroid участка (fallback: cad_quarters_geom) domrf_kn_flats — avg price_per_m2 по проданным квартирам Внимание: velocity coverage ~2.5% — большинство ЖК не имеют маппинга в objective_complex_mapping. LEFT JOIN гарантирует velocity=0 (не ошибку) для немаппированных объектов. """ from __future__ import annotations import logging from typing import Any from sqlalchemy import text from sqlalchemy.orm import Session from app.schemas.parcel import ( Competitor, CompetitorsRequest, CompetitorsResponse, CompetitorsSummary, ) logger = logging.getLogger(__name__) # Маппинг time_window → число месяцев (float для деления velocity) _TIME_WINDOW_MONTHS: dict[str, float] = { "last_month": 1.0, "last_quarter": 3.0, "last_year": 12.0, } # site_status значения, считающиеся «активными» _ACTIVE_STATUSES = frozenset({"sales", "construction"}) # #968 (949-A): радиус спатиального матча domrf↔complexes для velocity gap-fill. # 200м + tolerant-name + nearest-per-domrf → подтверждаем «тот же ЖК» И гео, И # именем. Чистый name-match даёт ~40 cross-city false positives; чистый spatial # рискует приписать velocity СОСЕДНЕГО ЖК. Подбор по проду: 148 → ~300-400 (#968). _VELOCITY_MATCH_RADIUS_M: float = 200.0 # ────────────────────────────────────────────────────────────────────────────── # #949 PR B (ТЗ §9.1, §16) — relevance_weight: детерминированная (без LLM) # взвешенная оценка релевантности конкурента 0..1 + explainability-breakdown. # ────────────────────────────────────────────────────────────────────────────── # Веса саб-скоров для итогового relevance_weight (named constants — tunable). # Сумма = 1.0; geo и stage_at_horizon — главные сигналы (близость + «будет ли # конкурировать к нашему запуску»), class/price — корректировки сопоставимости. _W_GEO: float = 0.30 _W_CLASS: float = 0.25 _W_PRICE: float = 0.20 _W_STAGE: float = 0.25 # Нейтральный саб-скор при недостатке данных (graceful: не штрафуем и не # завышаем конкурента, по которому нет сигнала — ТЗ §15 spirit). _NEUTRAL: float = 0.5 # Детерминированный порядок класса жилья (домрф): эконом < комфорт < комфорт+ < # бизнес < бизнес+ < премиум/элит. class_similarity считается по дистанции в этом # порядке. Ключи нормализуются через _normalize_class (lower + синонимы домрф/EN, # чтобы 'Комфорт'/'comfort'/'комфорт-класс' попадали в один индекс). _CLASS_ORDER: dict[str, int] = { "эконом": 0, "стандарт": 0, # домрф канон для эконома (см. domrf_kn._OBJ_CLASS_PATTERNS) "типовой": 0, "комфорт": 1, "комфорт+": 2, "бизнес": 3, "бизнес+": 4, "премиум": 5, "элит": 5, } # class_similarity по дистанции шагов в _CLASS_ORDER: 0 шагов (тот же класс) → 1.0, # 1 шаг (соседний, комфорт↔комфорт+) → 0.6, 2 → 0.3, ≥3 → 0.1. _CLASS_SIM_BY_STEPS: dict[int, float] = {0: 1.0, 1: 0.6, 2: 0.3} _CLASS_SIM_FAR: float = 0.1 # Синонимы для нормализации obj_class к ключам _CLASS_ORDER. obj_class в данных # приходит и кириллицей ('Комфорт'), и латиницей (фильтр API economy/comfort/ # business), и с суффиксами ('комфорт-класс'). Сводим к каноническому ключу. _CLASS_SYNONYMS: dict[str, str] = { "economy": "эконом", "econom": "эконом", "standard": "стандарт", "comfort": "комфорт", "comfortplus": "комфорт+", "business": "бизнес", "businessplus": "бизнес+", "premium": "премиум", "elite": "элит", "elit": "элит", } def _normalize_class(obj_class: str | None) -> str | None: """Привести obj_class к каноническому ключу _CLASS_ORDER. Терпимо к регистру, латинице (economy/comfort/business из API-фильтра), суффиксам ('комфорт-класс', '«Бизнес»') и плюс-формам ('комфорт +', 'комфорт плюс' → 'комфорт+'). Возвращает None если класс не распознан. """ if not obj_class: return None s = obj_class.strip().lower() # Унифицируем плюс-формы до проверки прямого вхождения. s = s.replace(" плюс", "+").replace("плюс", "+").replace(" +", "+") if s in _CLASS_ORDER: return s if s in _CLASS_SYNONYMS: return _CLASS_SYNONYMS[s] # Подстрочный матч по канон-ключам (длинные ключи первыми: 'комфорт+' # раньше 'комфорт', иначе 'комфорт-класс плюс' схлопнется в 'комфорт'). for key in sorted(_CLASS_ORDER, key=len, reverse=True): if key in s: return key for syn, key in _CLASS_SYNONYMS.items(): if syn in s: return key return None def _geo_proximity(distance_m: float | None, radius_m: float) -> float: """geo_proximity ∈ [0,1] — линейный decay расстояния. 1.0 на участке (distance 0), → 0.0 на краю радиуса. За радиусом / без данных → нейтрально не нужно: расстояние всегда известно (PostGIS), но защищаемся от None/нулевого радиуса. clamp в [0,1]. """ if distance_m is None or radius_m <= 0: return _NEUTRAL return max(0.0, min(1.0, 1.0 - distance_m / radius_m)) def _class_similarity(competitor_class: str | None, reference_class: str | None) -> float: """class_similarity ∈ [0,1] по дистанции в _CLASS_ORDER. Тот же класс → 1.0, соседний (1 шаг) → 0.6, 2 шага → 0.3, дальше → 0.1. Если класс конкурента или эталон неизвестен/нераспознан → нейтрально 0.5 (нет сигнала о сопоставимости — не штрафуем). reference_class: эталон сравнения. На этой стадии целевой класс участка ещё не выбран (нет target), поэтому caller передаёт ЛОКАЛЬНЫЙ ДОМИНИРУЮЩИЙ класс (самый частый среди конкурентов в радиусе) — так скор остаётся осмысленным «насколько конкурент типичен для этой локации». """ c = _normalize_class(competitor_class) r = _normalize_class(reference_class) if c is None or r is None: return _NEUTRAL steps = abs(_CLASS_ORDER[c] - _CLASS_ORDER[r]) return _CLASS_SIM_BY_STEPS.get(steps, _CLASS_SIM_FAR) def _price_similarity(competitor_ppm2: float | None, median_ppm2: float | None) -> float: """price_similarity ∈ [0,1] — близость цены конкурента к локальной медиане. 1 - min(1, |c_price - median| / median): на медиане → 1.0, отклонение в 100%+ → 0.0. Нет цены конкурента или невалидная медиана → нейтрально 0.5. """ if competitor_ppm2 is None or not median_ppm2 or median_ppm2 <= 0: return _NEUTRAL rel_diff = abs(competitor_ppm2 - median_ppm2) / median_ppm2 return max(0.0, 1.0 - min(1.0, rel_diff)) def _stage_at_horizon( available: float | None, velocity_per_month: float | None, horizon_months: int, flats_total: float | None, ) -> float: """stage_at_horizon ∈ [0,1] — horizon-aware ключевой фактор (ТЗ §9.1). Проецируем распродажу конкурента на горизонт нашего запуска: projected_remaining = max(0, available - velocity * horizon_months) Скор = доля проектируемого остатка от изначального объёма (projected_remaining / flats_total). Конкурент, у которого к запуску ещё значимый остаток → высокий скор (он БУДЕТ конкурировать); проектируемо распроданный → низкий. Graceful: если velocity или available/total неизвестны (тонкие данные) → нейтрально 0.5 — не переоцениваем и не недооцениваем (ТЗ §15). clamp [0,1]. """ if available is None or velocity_per_month is None or flats_total is None or flats_total <= 0: return _NEUTRAL if velocity_per_month <= 0: # Нет продаж: остаток не уменьшается — конкурент останется на рынке # (макс. релевантность по этому фактору). return max(0.0, min(1.0, available / flats_total)) projected_remaining = max(0.0, available - velocity_per_month * float(horizon_months)) return max(0.0, min(1.0, projected_remaining / flats_total)) def _relevance_weight(geo: float, class_sim: float, price: float, stage: float) -> float: """Взвешенное среднее 4 саб-скоров → relevance_weight ∈ [0,1]. Веса — named constants (_W_GEO/_W_CLASS/_W_PRICE/_W_STAGE), сумма 1.0. Все входы предполагаются уже clamped в [0,1]; результат тоже clamp для устойчивости к будущей подстройке весов. """ weighted = geo * _W_GEO + class_sim * _W_CLASS + price * _W_PRICE + stage * _W_STAGE return max(0.0, min(1.0, weighted)) def _dominant_class(obj_classes: list[str | None]) -> str | None: """Локальный доминирующий класс — самый частый нормализованный obj_class. Используется как эталон class_similarity, когда целевой класс участка ещё не задан. None если ни один класс не распознан. При ничьей берётся класс с наименьшим индексом в _CLASS_ORDER (детерминированно, без рандома). """ counts: dict[str, int] = {} for raw in obj_classes: norm = _normalize_class(raw) if norm is not None: counts[norm] = counts.get(norm, 0) + 1 if not counts: return None # Сортируем: больше частота → раньше; при равной частоте меньший order-индекс. return min(counts, key=lambda k: (-counts[k], _CLASS_ORDER[k])) def _median(values: list[float]) -> float | None: """Детерминированная медиана непустого списка. Пусто → None.""" if not values: return None ordered = sorted(values) n = len(ordered) mid = n // 2 if n % 2 == 1: return ordered[mid] return (ordered[mid - 1] + ordered[mid]) / 2.0 def _row_get(row: Any, key: str) -> Any: """Безопасно прочитать ключ из row-mapping → None если ключа нет. Используем ТОЛЬКО __getitem__: и SQLAlchemy RowMapping, и тестовые MagicMock-строки реализуют его корректно и кидают KeyError на отсутствующий ключ. (Не используем .get(): на MagicMock это авто-атрибут-заглушка, возвращающая MagicMock вместо реального значения.) Нужно для backward-safe чтения новой колонки flats_sold — старые моки её просто не отдают.""" try: return row[key] except (KeyError, TypeError, IndexError): return None # SQL для получения центроида участка _PARCEL_CENTROID_SQL = text(""" SELECT ST_X(pt) AS lon, ST_Y(pt) AS lat FROM ( SELECT ST_Centroid(geom) AS pt FROM cad_parcels_geom WHERE cad_num = :cad_num AND geom IS NOT NULL UNION ALL SELECT ST_Centroid(geom) AS pt FROM cad_quarters_geom WHERE cad_number = :quarter AND geom IS NOT NULL ) sub LIMIT 1 """) # Основной запрос конкурентов в радиусе. # Velocity через objective_corpus_room_month (актуальные данные, обновляется еженедельно). # domrf_kn_sale_graph устарел (данные до 2026-01) — не используется. # Coverage velocity ~2.5%: большинство obj_id нет в objective_complex_mapping → # LEFT JOIN → velocity=0 (не ошибка). _COMPETITORS_SQL = text(""" WITH latest_obj AS ( SELECT DISTINCT ON (obj_id) obj_id, comm_name, dev_name, obj_class, latitude, longitude, flat_count, site_status, snapshot_date FROM domrf_kn_objects WHERE latitude IS NOT NULL AND longitude IS NOT NULL ORDER BY obj_id, snapshot_date DESC NULLS LAST ), nearest_cx AS ( -- #968 (949-A) velocity gap-fill: для domrf-объектов, КОТОРЫХ НЕТ -- в явном objective_complex_mapping, ищем «тот же ЖК» в complexes по -- ГЕОГРАФИИ (≤ :velocity_match_radius_m м) И ТОЛЕРАНТНОМУ имени. -- DISTINCT ON (obj_id) ORDER BY distance → ровно ОДИН (ближайший) -- complex на конкурента: имя+гео подтверждают «тот же ЖК» (precision -- важна — velocity питает forecast). Спатиальный join дёшев, т.к. -- complexes мал (~1.5k строк с geom) — НЕ потому что latest_obj -- пре-фильтрован (радиус участка применяется ниже, в финальном SELECT). SELECT DISTINCT ON (o.obj_id) o.obj_id, c.id AS complex_id FROM latest_obj o JOIN complexes c ON c.latitude IS NOT NULL AND c.longitude IS NOT NULL AND c.canonical_name IS NOT NULL -- #968: только complexes, у которых ЕСТЬ objective velocity-данные → -- «ближайший» выбирается среди data-bearing. Иначе ближайший complex -- без objective_lots съедал бы матч (prod: +37 наивно → +181 так). AND EXISTS ( SELECT 1 FROM objective_lots ol WHERE ol.complex_id = c.id AND ol.project_name IS NOT NULL ) AND ST_DWithin( ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography, CAST(:velocity_match_radius_m AS float) ) AND ( lower(btrim(o.comm_name)) = lower(btrim(c.canonical_name)) OR lower(btrim(c.canonical_name)) LIKE '%' || lower(btrim(o.comm_name)) || '%' OR lower(btrim(o.comm_name)) LIKE '%' || lower(btrim(c.canonical_name)) || '%' ) WHERE o.comm_name IS NOT NULL AND btrim(o.comm_name) <> '' -- иначе LIKE '%%' матчит всё → spatial-only AND o.obj_id NOT IN (SELECT domrf_obj_id FROM objective_complex_mapping) ORDER BY o.obj_id, ST_Distance( ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography ) ASC ), mapped AS ( -- PRIMARY (unchanged): 148 явных маппингов — velocity для них byte-identical. SELECT cm.domrf_obj_id AS obj_id, cm.objective_complex_name FROM objective_complex_mapping cm UNION -- FALLBACK (gap-fill only): все project_name ОДНОГО ближайшего complex'а -- (у комплекса может быть несколько корпус-project_name → velocity -- легитимно суммируется по ним, но НЕ по нескольким комплексам). SELECT nc.obj_id, ol.project_name AS objective_complex_name FROM nearest_cx nc JOIN objective_lots ol ON ol.complex_id = nc.complex_id AND ol.project_name IS NOT NULL ), velocity AS ( SELECT m.obj_id, SUM(COALESCE(crm.deals_total_count, 0)) / CAST(:time_window_months AS float) AS velocity_per_month FROM objective_corpus_room_month crm JOIN mapped m ON m.objective_complex_name = crm.project_name WHERE crm.report_month >= (NOW() - CAST(:window_interval AS interval)) GROUP BY m.obj_id ), distances AS ( SELECT o.obj_id, o.comm_name, o.dev_name, o.obj_class, o.latitude, o.longitude, o.flat_count, o.site_status, ST_Distance( ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, ST_SetSRID( ST_MakePoint(CAST(:center_lon AS float), CAST(:center_lat AS float)), 4326 )::geography ) AS distance_m FROM latest_obj o ) SELECT d.obj_id, d.comm_name, d.dev_name, d.obj_class, d.latitude, d.longitude, d.flat_count, d.site_status, d.distance_m, COALESCE(v.velocity_per_month, 0.0) AS velocity_per_month FROM distances d LEFT JOIN velocity v ON v.obj_id = d.obj_id WHERE d.distance_m <= CAST(:radius_m AS float) AND ( CAST(:obj_class_filter AS text) IS NULL OR d.obj_class = CAST(:obj_class_filter AS text) ) ORDER BY d.distance_m ASC """) # Средняя цена м² по квартирам с известной ценой для набора obj_id. # Фильтр status='sold' убран: поле status в domrf_kn_flats заполнено в ~0.2% строк # (99.8% NULL) — фильтр давал 0 строк и avg_price_per_m2 всегда None (Issue #112/227). # AVG по всем квартирам с price_per_m2 IS NOT NULL даёт корректную среднюю цену ЖК. # # #949 PR B: добавлена колонка flats_sold (COUNT квартир со status LIKE '%прод%') # — нужна для stage_at_horizon (available = flat_count - flats_sold). status # заполнен слабо (~0.2%), поэтому flats_sold чаще будет 0/мало → stage_at_horizon # падает в нейтраль 0.5 (graceful, см. helper). Колонка добавлена в ТОТ ЖЕ запрос # (без 4-го db.execute), чтобы не ломать существующие callers/тесты. _AVG_PRICE_SQL = text(""" SELECT f.obj_id, AVG(f.price_per_m2) FILTER (WHERE f.price_per_m2 IS NOT NULL) AS avg_price_per_m2, COUNT(*) FILTER ( WHERE LOWER(COALESCE(f.status, '')) LIKE '%прод%' OR LOWER(COALESCE(f.status, '')) = 'sold' ) AS flats_sold FROM domrf_kn_flats f WHERE f.obj_id = ANY(:obj_ids) GROUP BY f.obj_id """) def _quarter_from_cad(cad_num: str) -> str: """Извлечь кадастровый квартал из номера участка/здания. 66:41:0303161:123 → 66:41:0303161 Если формат нестандартный — возвращаем cad_num как есть (fallback). """ parts = cad_num.split(":") if len(parts) >= 3: return ":".join(parts[:3]) return cad_num def get_competitors( db: Session, cad_num: str, request: CompetitorsRequest, ) -> CompetitorsResponse: """Получить список конкурентов ЖК в радиусе от участка. Шаги: 1. Найти центроид участка (cad_parcels_geom → cad_quarters_geom fallback). 2. Выбрать ЖК из domrf_kn_objects в радиусе с velocity из objective_corpus_room_month. 3. Применить exclude_obj_ids фильтр в Python (избегаем array cast). 4. Подтянуть avg_price_per_m2 из domrf_kn_flats. 5. Собрать CompetitorsResponse. Raises: ValueError: если центроид участка не найден (caller должен вернуть 404). """ quarter = _quarter_from_cad(cad_num) # ── 1. Центроид участка ────────────────────────────────────────────────── try: coord_row = ( db.execute( _PARCEL_CENTROID_SQL, {"cad_num": cad_num, "quarter": quarter}, ) .mappings() .first() ) except Exception: logger.exception("competitors: centroid query failed for cad_num=%s", cad_num) raise if not coord_row: raise ValueError(f"Геометрия для {cad_num} не найдена") center_lat = float(coord_row["lat"]) center_lon = float(coord_row["lon"]) # ── 2. Конкуренты в радиусе ────────────────────────────────────────────── time_window_months = _TIME_WINDOW_MONTHS[request.time_window] window_interval = f"{int(time_window_months)} months" try: rows = ( db.execute( _COMPETITORS_SQL, { "center_lat": center_lat, "center_lon": center_lon, "radius_m": request.radius_km * 1000.0, "time_window_months": time_window_months, "window_interval": window_interval, "obj_class_filter": request.obj_class_filter, "velocity_match_radius_m": _VELOCITY_MATCH_RADIUS_M, }, ) .mappings() .all() ) except Exception: logger.exception( "competitors: main query failed for cad_num=%s radius_km=%.2f", cad_num, request.radius_km, ) raise # ── 3. Применить exclude_obj_ids ───────────────────────────────────────── exclude_set = set(request.exclude_obj_ids) if exclude_set: rows = [r for r in rows if int(r["obj_id"]) not in exclude_set] if not rows: return CompetitorsResponse( competitors=[], summary=CompetitorsSummary( total_competitors=0, active_count=0, weighted_avg_velocity=0.0, radius_km=request.radius_km, time_window=request.time_window, ), ) obj_ids: list[int] = [int(r["obj_id"]) for r in rows] # ── 4. Средняя цена м² + sold-count (graceful — таблица может быть пуста) ─ # flats_sold нужен для stage_at_horizon (available = flat_count - flats_sold). # Читаем через _row_get: существующие тесты мокируют только avg_price_per_m2, # отсутствующий ключ flats_sold → None (не KeyError) → нейтральный stage. avg_price_map: dict[int, float] = {} sold_count_map: dict[int, int] = {} try: price_rows = db.execute(_AVG_PRICE_SQL, {"obj_ids": obj_ids}).mappings().all() for r in price_rows: oid = int(r["obj_id"]) price = _row_get(r, "avg_price_per_m2") if price is not None: avg_price_map[oid] = float(price) sold = _row_get(r, "flats_sold") if sold is not None: sold_count_map[oid] = int(sold) except Exception: logger.warning("competitors: avg_price query failed, continuing without prices") # ── 4b. Локальные эталоны для relevance (медиана цены, доминирующий класс) ─ radius_m = request.radius_km * 1000.0 local_median_ppm2 = _median(list(avg_price_map.values())) dominant_class = _dominant_class([r["obj_class"] for r in rows]) # ── 5. Сборка результата + relevance_weight (ТЗ §9.1) ──────────────────── # flats_sold: COUNT из domrf_kn_flats по status LIKE '%прод%' (заполнен слабо # ~0.2%, поэтому чаще 0/None). При known flats_sold считаем sold_pct и # available для stage_at_horizon; иначе оба поля None и stage → нейтраль 0.5. competitors: list[Competitor] = [] for r in rows: obj_id = int(r["obj_id"]) flats_total = int(r["flat_count"]) if r["flat_count"] is not None else None site_status = r["site_status"] is_active = site_status in _ACTIVE_STATUSES if site_status else False velocity = round(float(r["velocity_per_month"]), 2) # flats_sold / sold_pct / available — только если sold-count реально есть # И он осмыслен (не превышает total). Иначе None (graceful, не выдумываем). flats_sold: int | None = None sold_pct: float | None = None available: float | None = None raw_sold = sold_count_map.get(obj_id) if raw_sold is not None and flats_total and flats_total > 0 and raw_sold <= flats_total: flats_sold = raw_sold sold_pct = round(raw_sold / flats_total * 100.0, 1) available = float(flats_total - raw_sold) # ── relevance саб-скоры (каждый 0..1) + взвешенный weight ───────────── geo = _geo_proximity(float(r["distance_m"]), radius_m) class_sim = _class_similarity(r["obj_class"], dominant_class) price = _price_similarity(avg_price_map.get(obj_id), local_median_ppm2) stage = _stage_at_horizon( available=available, velocity_per_month=velocity, horizon_months=request.horizon_months, flats_total=float(flats_total) if flats_total else None, ) relevance_weight = _relevance_weight(geo, class_sim, price, stage) relevance_breakdown = { "geo_proximity": round(geo, 3), "class_similarity": round(class_sim, 3), "price_similarity": round(price, 3), "stage_at_horizon": round(stage, 3), } competitors.append( Competitor( obj_id=obj_id, comm_name=r["comm_name"], dev_name=r["dev_name"], obj_class=r["obj_class"], distance_m=round(float(r["distance_m"]), 1), lat=float(r["latitude"]), lng=float(r["longitude"]), stage=site_status, flats_total=flats_total, flats_sold=flats_sold, sold_pct=sold_pct, velocity_per_month=velocity, avg_price_per_m2=avg_price_map.get(obj_id), is_active=is_active, relevance_weight=round(relevance_weight, 3), relevance_breakdown=relevance_breakdown, ) ) # ── 5b. Сортировка по relevance_weight DESC (distance остаётся в полях) ──── # tie-break по distance ASC (ближе — раньше), затем obj_id для стабильности. competitors.sort(key=lambda c: (-(c.relevance_weight or 0.0), c.distance_m, c.obj_id)) # ── 6. Summary ─────────────────────────────────────────────────────────── active_count = sum(1 for c in competitors if c.is_active) n = len(competitors) # #949 audit fix: size-weight velocity by flats_total (count-weighted AVG, # sql.md principle) — ЖК на 500 квартир должен весить больше, чем на 20, а не # наравне (раньше было наивное среднее вопреки имени weighted_*). Конкуренты с # неизвестным flats_total исключаются из весов; если размеры неизвестны У ВСЕХ — # graceful fallback на простое среднее (den>0 guard = NULLIF-эквивалент). weight_num = sum(c.velocity_per_month * c.flats_total for c in competitors if c.flats_total) weight_den = sum(c.flats_total for c in competitors if c.flats_total) if weight_den > 0: weighted_avg_velocity = round(weight_num / weight_den, 2) elif n > 0: weighted_avg_velocity = round(sum(c.velocity_per_month for c in competitors) / n, 2) else: weighted_avg_velocity = 0.0 summary = CompetitorsSummary( total_competitors=n, active_count=active_count, weighted_avg_velocity=weighted_avg_velocity, radius_km=request.radius_km, time_window=request.time_window, ) return CompetitorsResponse(competitors=competitors, summary=summary)