feat(site-finder): D2 velocity-score (#34 sub-PR 1/2) #146

Merged
lekss361 merged 1 commit from feat/34-velocity-score-backend into main 2026-05-14 22:24:54 +00:00
lekss361 commented 2026-05-14 22:19:46 +00:00 (Migrated from github.com)

Summary

#34 D2 Velocity-score — sub-PR 1 of 2 (backend service).

Closes audit gap: sale_graph 15876 rows не использовались.

Service: backend/app/services/site_finder/velocity.py

compute_velocity(db, parcel_geom_wkt, radius_km=3.0, obj_class=None)VelocityResult:

  • ST_DWithin → competitor obj_ids в радиусе
  • JOIN domrf_kn_sale_graph (6 мес) → aggregate
  • Normalize vs ЕКБ median → score 0..1
  • confidence: high/medium/low
  • sample_competitors[5]

Schema corrections vs spec

Spec Actual (from grep)
obj_name comm_name
region_code region_cd
geom column latitude/longitude + ST_MakePoint
contracted area_sq (м²) primary, realised*45 fallback

Tests

102/102 pass.

Sub-PR 2 (next)

Frontend MarketTab block.

Vault

code/modules/Module_Velocity_Service.md

Closes #144
Refs: #34

## Summary **#34 D2 Velocity-score — sub-PR 1 of 2** (backend service). Closes audit gap: `sale_graph` 15876 rows **не использовались**. ## Service: `backend/app/services/site_finder/velocity.py` `compute_velocity(db, parcel_geom_wkt, radius_km=3.0, obj_class=None)` → `VelocityResult`: - ST_DWithin → competitor obj_ids в радиусе - JOIN domrf_kn_sale_graph (6 мес) → aggregate - Normalize vs ЕКБ median → score 0..1 - confidence: high/medium/low - sample_competitors[5] ## Schema corrections vs spec | Spec | Actual (from grep) | |---|---| | obj_name | `comm_name` | | region_code | `region_cd` | | geom column | `latitude`/`longitude` + ST_MakePoint | | contracted | `area_sq` (м²) primary, `realised*45` fallback | ## Tests 102/102 pass. ## Sub-PR 2 (next) Frontend MarketTab block. ## Vault `code/modules/Module_Velocity_Service.md` Closes #144 Refs: #34
lekss361 commented 2026-05-14 22:24:38 +00:00 (Migrated from github.com)

Bot review (SHA 62836d9, CI )

Закрывает audit gap (15876 строк sale_graph неиспользуемых). Sub-PR 1/2 — backend service inert до UI.

Algorithm

5-шаговый pipeline корректно реализован:

  1. Competitors via ST_DWithin (lat/lon→geography in metres)
  2. Latest snapshot через DISTINCT ON (obj_id) ORDER BY snapshot_date DESC NULLS LAST — NULL-safe
  3. Aggregate по obj_id за окно months_window=6 с filter type='apartments'
  4. Normalize vs ЕКБ-median через PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY velocity) + HAVING COUNT(DISTINCT report_month) >= 3 (stable signal only)
  5. Confidence ladder: high (≥10 comps & ≥5 months) / medium (≥5 & ≥3) / low

Score formula: min(1.0, monthly_velocity / (n_with_sales × ekb_median × 2)) — saturated at 2× median per competitor. Sensible cap.

Schema awareness

PR body документирует разрыв spec ↔ actual:

  • obj_namecomm_name
  • region_coderegion_cd
  • No geom column → ST_MakePoint(longitude, latitude)
  • area_sq primary + realised*45 fallback

Author грепнул, не доверился спеке. Это правильный подход.

Safety

Все DB queries обёрнуты try/except → return None — graceful degradation для bad data
parcels.py integration с outer try/except → analyze_parcel не крашится если velocity падает
@dataclass(frozen=True) — immutable result, no accidental mutation
Fallback median константа (4500 м²/мес) если в БД нет данных за window

Tests (10 unit + 102/102 общих)

Coverage всех edge cases: no competitors / no sales / healthy → high / few comps → low / medium threshold / median fallback / score cap=1.0 / total_sqm=0 → None / as_dict structure / sample top-5 sorted.

Минор (не блокеры — про future iterations)

  1. realised*45 heuristic: 45 м² для конверсии contract count → м² если area_sq NULL. Studios 25-30 м², premium 80-100 м² — может смещать total для ЖК с пустым area_sq. Для агрегированного regional score приемлемо.

  2. SRID assumption: ST_GeomFromText(:parcel_wkt, 4326) не верифицирует input SRID. Если caller передаст МСК-66 WKT (SRID 100000), distances ломаются. Сейчас parcels.py передаёт стандартный WGS84 geom_wkt. Желателен docstring assertion / runtime check в follow-up.

  3. region_cd = 66 hardcoded — ЕКБ-only. Расширение на другие регионы — параметр в follow-up.

approve merge

## Bot review (SHA `62836d9`, CI ✅) Закрывает audit gap (15876 строк `sale_graph` неиспользуемых). Sub-PR 1/2 — backend service inert до UI. ### Algorithm 5-шаговый pipeline корректно реализован: 1. **Competitors via ST_DWithin** (lat/lon→geography in metres) 2. **Latest snapshot** через `DISTINCT ON (obj_id) ORDER BY snapshot_date DESC NULLS LAST` — NULL-safe 3. **Aggregate** по `obj_id` за окно `months_window=6` с filter `type='apartments'` 4. **Normalize vs ЕКБ-median** через `PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY velocity)` + `HAVING COUNT(DISTINCT report_month) >= 3` (stable signal only) 5. **Confidence ladder**: high (≥10 comps & ≥5 months) / medium (≥5 & ≥3) / low Score formula: `min(1.0, monthly_velocity / (n_with_sales × ekb_median × 2))` — saturated at 2× median per competitor. Sensible cap. ### Schema awareness PR body документирует разрыв spec ↔ actual: - `obj_name` → `comm_name` - `region_code` → `region_cd` - No geom column → `ST_MakePoint(longitude, latitude)` - `area_sq` primary + `realised*45` fallback Author грепнул, не доверился спеке. Это правильный подход. ### Safety ✅ Все DB queries обёрнуты try/except → return None — graceful degradation для bad data ✅ `parcels.py` integration с outer try/except → analyze_parcel не крашится если velocity падает ✅ `@dataclass(frozen=True)` — immutable result, no accidental mutation ✅ Fallback median константа (4500 м²/мес) если в БД нет данных за window ### Tests (10 unit + 102/102 общих) Coverage всех edge cases: no competitors / no sales / healthy → high / few comps → low / medium threshold / median fallback / score cap=1.0 / total_sqm=0 → None / as_dict structure / sample top-5 sorted. ### Минор (не блокеры — про future iterations) 1. **`realised*45` heuristic**: 45 м² для конверсии contract count → м² если `area_sq` NULL. Studios 25-30 м², premium 80-100 м² — может смещать total для ЖК с пустым area_sq. Для агрегированного regional score приемлемо. 2. **SRID assumption**: `ST_GeomFromText(:parcel_wkt, 4326)` не верифицирует input SRID. Если caller передаст МСК-66 WKT (SRID 100000), distances ломаются. Сейчас parcels.py передаёт стандартный WGS84 `geom_wkt`. Желателен docstring assertion / runtime check в follow-up. 3. **`region_cd = 66` hardcoded** — ЕКБ-only. Расширение на другие регионы — параметр в follow-up. approve merge
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
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#146
No description provided.