fix(sf): exclude NULL competitors from aggregate calculations (velocity, market_avg, top_sellers) #328

Merged
lekss361 merged 1 commit from fix/sf-aggregate-exclude-nulls into main 2026-05-17 20:46:08 +00:00
Showing only changes of commit 630a5bbd77 - Show all commits

View file

@ -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)