[medium] velocity: monthly_velocity делит суммарный объём конкурентов на MAX месяцев одного конкурента — занижение при разнородных окнах #1354

Closed
opened 2026-06-14 18:09:01 +00:00 by bot-backend · 1 comment
Collaborator

Описание

total_sqm — СУММА объёма продаж по ВСЕМ mapped-конкурентам, а months_observed = max(...) — максимальное число месяцев данных у ОДНОГО конкурента. monthly_velocity = total_sqm / months_observed. Когда у конкурентов разное число месяцев данных (типично: старые ЖК — 6 мес, новые — 1-2 мес), деление суммы на max-месяцев размазывает объёмы новых конкурентов по всему окну и занижает совокупную месячную скорость района. velocity_score наследует занижение.

Корень

Некорректная агрегация: правильная агрегированная месячная скорость = Σ(объём_i / месяцев_i), а не Σ(объём_i) / max(месяцев_i). SQL (строки 210-212) возвращает per-row COUNT(DISTINCT report_month) — у каждого конкурента своё months_with_data, но они не используются индивидуально при делении.

Evidence

velocity.py:378-379, 434:

total_sqm = sum(float(r["total_sqm"] or 0.0) for r in mapped_sales_rows)
months_observed = max((int(r["months_with_data"] or 0) for r in mapped_sales_rows), default=0)
...
monthly_velocity = total_sqm / months_observed

Числовой пример: A=6000 м² за 6 мес, B=1000 м² за 1 мес → total=7000, months_observed=6, monthly_velocity=1167 м²/мес; корректно: 6000/6+1000/1=2000 м²/мес (занижение ~1.7x).

Suggested fix

Считать совокупный месячный темп как сумму per-competitor темпов:

monthly_velocity = sum(
    float(r['total_sqm'] or 0) / max(int(r['months_with_data'] or 0), 1)
    for r in mapped_sales_rows if (r['months_with_data'] or 0) > 0
)

months_observed оставить только для confidence/отображения периода.

Обоснование severity (medium)

Систематический дефект (не crash), приводит к недооценке горячих рынков с молодыми конкурентами — demand-сигнал «продастся ли» уходит пользователю заниженным. Confidence 0.92, путь достижим всегда при нескольких конкурентах с разной историей.


week ревью 1 · multi-agent аудит 2026-06-14 · severity=medium · category=logic

Затронутые файлы:

  • /Users/anton/Птица/gendesign/backend/app/services/site_finder/velocity.py
## Описание `total_sqm` — СУММА объёма продаж по ВСЕМ mapped-конкурентам, а `months_observed = max(...)` — максимальное число месяцев данных у ОДНОГО конкурента. `monthly_velocity = total_sqm / months_observed`. Когда у конкурентов разное число месяцев данных (типично: старые ЖК — 6 мес, новые — 1-2 мес), деление суммы на max-месяцев размазывает объёмы новых конкурентов по всему окну и занижает совокупную месячную скорость района. `velocity_score` наследует занижение. ## Корень Некорректная агрегация: правильная агрегированная месячная скорость = Σ(объём_i / месяцев_i), а не Σ(объём_i) / max(месяцев_i). SQL (строки 210-212) возвращает per-row `COUNT(DISTINCT report_month)` — у каждого конкурента своё `months_with_data`, но они не используются индивидуально при делении. ## Evidence `velocity.py:378-379, 434`: ```python total_sqm = sum(float(r["total_sqm"] or 0.0) for r in mapped_sales_rows) months_observed = max((int(r["months_with_data"] or 0) for r in mapped_sales_rows), default=0) ... monthly_velocity = total_sqm / months_observed ``` Числовой пример: A=6000 м² за 6 мес, B=1000 м² за 1 мес → total=7000, months_observed=6, monthly_velocity=1167 м²/мес; корректно: 6000/6+1000/1=2000 м²/мес (занижение ~1.7x). ## Suggested fix Считать совокупный месячный темп как сумму per-competitor темпов: ```python monthly_velocity = sum( float(r['total_sqm'] or 0) / max(int(r['months_with_data'] or 0), 1) for r in mapped_sales_rows if (r['months_with_data'] or 0) > 0 ) ``` `months_observed` оставить только для confidence/отображения периода. ## Обоснование severity (medium) Систематический дефект (не crash), приводит к недооценке горячих рынков с молодыми конкурентами — demand-сигнал «продастся ли» уходит пользователю заниженным. Confidence 0.92, путь достижим всегда при нескольких конкурентах с разной историей. --- _week ревью 1 · multi-agent аудит 2026-06-14 · severity=**medium** · category=`logic`_ Затронутые файлы: - `/Users/anton/Птица/gendesign/backend/app/services/site_finder/velocity.py`
bot-backend added the
week ревью 1
label 2026-06-14 18:09:01 +00:00
bot-backend added the
scope/backend
label 2026-06-15 11:01:51 +00:00
Owner

🔍 week ревью 1 — верификация (ПОДТВЕРЖДЕНО)

Текущее состояние кода: backend/app/services/site_finder/velocity.py:378-379total_sqm = sum(...) по всем mapped-конкурентам, months_observed = max((int(r["months_with_data"] or 0) ...), default=0); :434monthly_velocity = total_sqm / months_observed. SQL (bucket/sales query) возвращает per-row months_with_data (COUNT(DISTINCT report_month)), но при делении используется только max.

Корень: некорректная агрегация разнородных окон. Σ(объёмов) делится на max-месяцев ОДНОГО конкурента → объёмы конкурентов с короткой историей размазываются по всему окну. Корректно: Σ(объём_i / месяцев_i). Пример из issue (A=6000/6мес, B=1000/1мес): код даёт 1167 м²/мес, правильно 2000 м²/мес (занижение ~1.7x). velocity_score (:444) наследует занижение.

Что править:

  • Заменить :434 на сумму per-competitor темпов:
monthly_velocity = sum(
    float(r["total_sqm"] or 0) / max(int(r["months_with_data"] or 0), 1)
    for r in mapped_sales_rows if (r["months_with_data"] or 0) > 0
)
  • months_observed (:379) оставить только для confidence (:448) и отображения периода (period_start/period_end :382-383) — НЕ для деления.
  • Проверить, что guard months_observed == 0 or total_sqm <= 0 (:386) остаётся корректным fallback-триггером (он независим от формулы делителя).

Что посмотреть:

  • Код: velocity.py функция compute_velocity (:378-444); backend/tests/test_velocity.py — добавить тест на разнородные окна (2 конкурента с 6мес и 1мес → проверить Σ темпов, не Σ/max).
  • Vault: code/modules/Module_Velocity_Service.md (документирует formula min(1.0, monthly_velocity / (n_competitors × ekb_median × 2)) и per-room breakdown — обновить после фикса).
**🔍 week ревью 1 — верификация (ПОДТВЕРЖДЕНО)** **Текущее состояние кода:** `backend/app/services/site_finder/velocity.py:378-379` — `total_sqm = sum(...)` по всем mapped-конкурентам, `months_observed = max((int(r["months_with_data"] or 0) ...), default=0)`; `:434` — `monthly_velocity = total_sqm / months_observed`. SQL (`bucket/sales query`) возвращает per-row `months_with_data` (`COUNT(DISTINCT report_month)`), но при делении используется только `max`. **Корень:** некорректная агрегация разнородных окон. Σ(объёмов) делится на max-месяцев ОДНОГО конкурента → объёмы конкурентов с короткой историей размазываются по всему окну. Корректно: Σ(объём_i / месяцев_i). Пример из issue (A=6000/6мес, B=1000/1мес): код даёт 1167 м²/мес, правильно 2000 м²/мес (занижение ~1.7x). `velocity_score` (`:444`) наследует занижение. **Что править:** - Заменить `:434` на сумму per-competitor темпов: ```python monthly_velocity = sum( float(r["total_sqm"] or 0) / max(int(r["months_with_data"] or 0), 1) for r in mapped_sales_rows if (r["months_with_data"] or 0) > 0 ) ``` - `months_observed` (`:379`) оставить только для confidence (`:448`) и отображения периода (`period_start`/`period_end` :382-383) — НЕ для деления. - Проверить, что guard `months_observed == 0 or total_sqm <= 0` (`:386`) остаётся корректным fallback-триггером (он независим от формулы делителя). **Что посмотреть:** - Код: `velocity.py` функция `compute_velocity` (`:378-444`); `backend/tests/test_velocity.py` — добавить тест на разнородные окна (2 конкурента с 6мес и 1мес → проверить Σ темпов, не Σ/max). - Vault: `code/modules/Module_Velocity_Service.md` (документирует formula `min(1.0, monthly_velocity / (n_competitors × ekb_median × 2))` и per-room breakdown — обновить после фикса).
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#1354
No description provided.