From e872b1ebe46f8d0513a33411de21cb4e62d775e0 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 17 May 2026 20:46:07 +0000 Subject: [PATCH] fix(sf): exclude NULL competitors from aggregate calculations (velocity, market_avg, top_sellers) (#328) --- backend/app/api/v1/parcels.py | 57 +++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 497f5437..9a315b14 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -2143,6 +2143,57 @@ def analyze_parcel( except Exception as _ve: logger.warning("velocity compute failed for %s: %s", cad_num, _ve) + # OBJ-3 aggregate fix: market_pulse — агрегаты ТОЛЬКО по ЖК с ненулевыми ценами. + # Конкуренты без маппинга в Objective (NULL avg_price_per_m2_rub) остаются + # в competitor list для карты, но исключаются из расчётов рыночных метрик. + _competitors_with_price = [c for c in competitor_rows if c["avg_price_per_m2_rub"] is not None] + _competitors_total = len(competitor_rows) + _competitors_priced = len(_competitors_with_price) + if _competitors_with_price: + _prices = [float(c["avg_price_per_m2_rub"]) for c in _competitors_with_price] + _market_avg_price = round(sum(_prices) / len(_prices)) + # top_sellers: ЖК с ненулевыми units_sold, топ-5 по объёму + _with_sales = [ + c + for c in _competitors_with_price + if c["units_sold"] is not None and int(c["units_sold"]) > 0 + ] + _top_sellers = sorted( + _with_sales, + key=lambda c: int(c["units_sold"]), + reverse=True, + )[:5] + _top_sellers_list = [ + { + "obj_id": c["obj_id"], + "comm_name": c["comm_name"], + "dev_name": c["dev_name"], + "units_sold": int(c["units_sold"]), + "avg_price_per_m2_rub": int(c["avg_price_per_m2_rub"]), + } + for c in _top_sellers + ] + else: + _market_avg_price = None + _top_sellers_list = [] + + _coverage_pct = ( + round(_competitors_priced * 100.0 / _competitors_total, 1) + if _competitors_total > 0 + else 0.0 + ) + # avg_velocity_m2 — берём из velocity_data если есть; это уже только по + # ЖК с objective_corpus_room_month данными (non-null by construction). + _avg_velocity = velocity_data["monthly_velocity_sqm"] if velocity_data else None + market_pulse: dict[str, Any] = { + "avg_velocity_m2": _avg_velocity, + "market_avg_price_per_m2": _market_avg_price, + "competitors_total": _competitors_total, + "competitors_with_price": _competitors_priced, + "coverage_pct": _coverage_pct, + "top_sellers": _top_sellers_list, + } + return { "cad_num": cad_num, "source": source, @@ -2170,6 +2221,12 @@ def analyze_parcel( "note": "Бонус к score: <5км +3.0, 5-10км +1.5, 10-15км +0.5, >15км 0", }, "competitors": [dict(c) for c in competitor_rows], + # OBJ-3 fix: aggregate market metrics — только non-null competitors. + # ЖК без objective_mapping остаются на карте (competitors list), + # но исключены из avg/velocity/top_sellers расчётов. + "market_pulse": market_pulse, + "market_avg_price_per_m2": market_pulse["market_avg_price_per_m2"], + "market_data_coverage_pct": market_pulse["coverage_pct"], # D4 (#36): 24-month pipeline competition "pipeline_24mo": pipeline_24mo, # D2 (#34): velocity-score из objective_corpus_room_month (OBJ-3 migrated)