From 6d550e00b2db0f98b1304ebcf7316703a80a3f91 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 17 Jun 2026 20:52:47 +0300 Subject: [PATCH] fix(site-finder): volume-weighted median_price_m2 (#1511) + consistent-period months_to_sellout (#1512) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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). --- site-finder/06_match_economics.py | 76 +++++++++++++++++++++---------- 1 file changed, 51 insertions(+), 25 deletions(-) diff --git a/site-finder/06_match_economics.py b/site-finder/06_match_economics.py index 54241259..cc54a149 100644 --- a/site-finder/06_match_economics.py +++ b/site-finder/06_match_economics.py @@ -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) @@ -53,11 +57,13 @@ def normalize(s): 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) - 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)) +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) + 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)) def main(): conn = sqlite3.connect(DB) @@ -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 - FROM objective_corp_month - WHERE month IN ({placeholders}) - ) - SELECT project, corpus, district, stock_m2, stock_lots FROM ranked WHERE rn=1 - """, last3).fetchall(): + for r in conn.execute(""" + SELECT project, corpus, district, stock_m2, stock_lots + FROM objective_corp_month + 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()