fix(site-finder): volume-weighted median_price_m2 (#1511) + consistent-period months_to_sellout (#1512)
All checks were successful
CI / changes (push) Successful in 7s
CI / changes (pull_request) Successful in 7s
CI / backend-tests (push) Has been skipped
CI / frontend-tests (push) Has been skipped
CI / openapi-codegen-check (push) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped

#1511: replace equal-weight per-row median with volume-weighted median
(sorted by price, cumulative sold_volume_m2 weight, stop at 50th percentile).
Each corpus×month row now counts proportionally to its deal volume instead of
contributing equal weight regardless of how many flats were sold.

#1512: pin latest_stock to the single most-recent month in the window (last3[0])
instead of per-corpus ROW_NUMBER latest. Stale stock from inactive corpuses no
longer inflates the MTS numerator; stock and sold_volume_m2 denominator now
refer to the same consistent period.

Also clean pre-existing ruff E401/E701/E702/E722 violations (no logic change).
This commit is contained in:
bot-backend 2026-06-17 20:52:47 +03:00
parent ba83c36bf4
commit 6d550e00b2

View file

@ -5,7 +5,10 @@ Output tables:
district_economics per Objective-район aggregates over last 90 days
site_district which Objective-район each site belongs to
"""
import sqlite3, pathlib, re, math
import math
import pathlib
import re
import sqlite3
from difflib import SequenceMatcher
DB = pathlib.Path(__file__).parent / "analysis.db"
@ -43,7 +46,8 @@ CREATE TABLE IF NOT EXISTS site_district (
"""
def normalize(s):
if not s: return ""
if not s:
return ""
s = s.lower().strip()
s = re.sub(r'^(жк|жилой\s+комплекс|жилой\s+квартал|жилые\s+кварталы)\s+', '', s)
s = re.sub(r'["«»\'`]+', '', s)
@ -54,8 +58,10 @@ def fuzzy(a, b):
return SequenceMatcher(None, normalize(a), normalize(b)).ratio()
def hav(la1, lo1, la2, lo2):
R=6371000; p1,p2=math.radians(la1),math.radians(la2)
dp=math.radians(la2-la1); dl=math.radians(lo2-lo1)
R = 6371000
p1, p2 = math.radians(la1), math.radians(la2)
dp = math.radians(la2 - la1)
dl = math.radians(lo2 - lo1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * R * math.asin(math.sqrt(a))
@ -74,7 +80,8 @@ def main():
best = (0.0, None)
for proj in objective_projects:
s = fuzzy(name, proj)
if s > best[0]: best = (s, proj)
if s > best[0]:
best = (s, proj)
if best[0] >= 0.80:
conn.execute("INSERT INTO jk_objective_match VALUES (?,?,?,?)",
(site_id, best[1], best[0], "fuzzy"))
@ -90,17 +97,17 @@ def main():
conn.execute("DELETE FROM district_economics")
placeholders = ",".join(["?"]*len(last3))
# Latest stock per (project, corpus) — taken from the latest month each corpus appears
# Latest stock per (project, corpus) — taken from the single most-recent month in the window
# (#1512): using a per-corpus latest caused mixed periods (stale stock for inactive corpuses).
# Fix: pin stock to last3[0] (the most recent month) so numerator and denominator of MTS
# are aligned to the same period for all corpuses in the district.
latest_month = last3[0]
latest_stock = {}
for r in conn.execute(f"""
WITH ranked AS (
SELECT project, corpus, district, month, stock_m2, stock_lots,
ROW_NUMBER() OVER (PARTITION BY project, corpus ORDER BY month DESC) rn
for r in conn.execute("""
SELECT project, corpus, district, stock_m2, stock_lots
FROM objective_corp_month
WHERE month IN ({placeholders})
)
SELECT project, corpus, district, stock_m2, stock_lots FROM ranked WHERE rn=1
""", last3).fetchall():
WHERE month = ?
""", (latest_month,)).fetchall():
latest_stock[(r[0], r[1])] = (r[2], r[3] or 0, r[4] or 0)
# Per-district aggregates from window
@ -114,7 +121,8 @@ def main():
by_dist = {}
for r in rows:
d = r[0]
if not d: continue
if not d:
continue
by_dist.setdefault(d, []).append(r)
for d, rs in by_dist.items():
@ -124,9 +132,24 @@ def main():
wn = sum((r[6] or 0) * (r[5] or 0) for r in rs)
wd = sum((r[5] or 0) for r in rs)
wp = wn / wd if wd else None
# median price (priced deals only)
prices = sorted([r[6] for r in rs if r[6] and (r[4] or 0) > 0])
med = prices[len(prices)//2] if prices else None
# median price — volume-weighted by sold_volume_m2 (#1511)
# Each corpus×month row contributes avg_price_m2 weighted by sold_volume_m2 so that
# high-volume months/rooms count proportionally (not equal-weight per row).
# Falls back to weight=1 for rows with priced deals but missing volume figure.
pw = [(r[6], max(r[5] or 0, 1.0)) for r in rs if r[6] and (r[4] or 0) > 0]
if pw:
pw.sort(key=lambda x: x[0])
total_w = sum(w for _, w in pw)
half = total_w / 2.0
cum = 0.0
med = None
for price, w in pw:
cum += w
if cum >= half:
med = price
break
else:
med = None
# offer prices
offers = [r[8] for r in rs if r[8] and r[8] > 0]
avg_off = sum(offers)/len(offers) if offers else None
@ -175,7 +198,8 @@ def main():
# vote among k=5 nearest matched ЖК within 2 km
cands = []
for sid2, (lat2, lon2, dist2) in matched_idx.items():
if sid2 == sid: continue
if sid2 == sid:
continue
d = hav(lat, lon, lat2, lon2)
cands.append((d, sid2, dist2))
cands.sort()
@ -187,8 +211,10 @@ def main():
rep = next(c for c in top if c[2] == top_dist)
obj_id = None
if rep[1] and rep[1].startswith("jk:"):
try: obj_id = int(rep[1][3:])
except: pass
try:
obj_id = int(rep[1][3:])
except ValueError:
pass
conn.execute("INSERT INTO site_district VALUES (?,?,?,?,?)",
(sid, top_dist, "knn_vote_5", obj_id, rep[0]))
conn.commit()