Merge pull request 'fix(site-finder): volume-weighted median_price_m2 (#1511) + consistent-period MTS (#1512)' (#1690) from fix/match-economics-1511-1512 into main
This commit is contained in:
commit
40c58c15b1
1 changed files with 51 additions and 25 deletions
|
|
@ -5,7 +5,10 @@ Output tables:
|
||||||
district_economics — per Objective-район aggregates over last 90 days
|
district_economics — per Objective-район aggregates over last 90 days
|
||||||
site_district — which Objective-район each site belongs to
|
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
|
from difflib import SequenceMatcher
|
||||||
|
|
||||||
DB = pathlib.Path(__file__).parent / "analysis.db"
|
DB = pathlib.Path(__file__).parent / "analysis.db"
|
||||||
|
|
@ -43,7 +46,8 @@ CREATE TABLE IF NOT EXISTS site_district (
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def normalize(s):
|
def normalize(s):
|
||||||
if not s: return ""
|
if not s:
|
||||||
|
return ""
|
||||||
s = s.lower().strip()
|
s = s.lower().strip()
|
||||||
s = re.sub(r'^(жк|жилой\s+комплекс|жилой\s+квартал|жилые\s+кварталы)\s+', '', s)
|
s = re.sub(r'^(жк|жилой\s+комплекс|жилой\s+квартал|жилые\s+кварталы)\s+', '', s)
|
||||||
s = re.sub(r'["«»\'`]+', '', s)
|
s = re.sub(r'["«»\'`]+', '', s)
|
||||||
|
|
@ -53,11 +57,13 @@ def normalize(s):
|
||||||
def fuzzy(a, b):
|
def fuzzy(a, b):
|
||||||
return SequenceMatcher(None, normalize(a), normalize(b)).ratio()
|
return SequenceMatcher(None, normalize(a), normalize(b)).ratio()
|
||||||
|
|
||||||
def hav(la1,lo1,la2,lo2):
|
def hav(la1, lo1, la2, lo2):
|
||||||
R=6371000; p1,p2=math.radians(la1),math.radians(la2)
|
R = 6371000
|
||||||
dp=math.radians(la2-la1); dl=math.radians(lo2-lo1)
|
p1, p2 = math.radians(la1), math.radians(la2)
|
||||||
a=math.sin(dp/2)**2+math.cos(p1)*math.cos(p2)*math.sin(dl/2)**2
|
dp = math.radians(la2 - la1)
|
||||||
return 2*R*math.asin(math.sqrt(a))
|
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():
|
def main():
|
||||||
conn = sqlite3.connect(DB)
|
conn = sqlite3.connect(DB)
|
||||||
|
|
@ -74,7 +80,8 @@ def main():
|
||||||
best = (0.0, None)
|
best = (0.0, None)
|
||||||
for proj in objective_projects:
|
for proj in objective_projects:
|
||||||
s = fuzzy(name, proj)
|
s = fuzzy(name, proj)
|
||||||
if s > best[0]: best = (s, proj)
|
if s > best[0]:
|
||||||
|
best = (s, proj)
|
||||||
if best[0] >= 0.80:
|
if best[0] >= 0.80:
|
||||||
conn.execute("INSERT INTO jk_objective_match VALUES (?,?,?,?)",
|
conn.execute("INSERT INTO jk_objective_match VALUES (?,?,?,?)",
|
||||||
(site_id, best[1], best[0], "fuzzy"))
|
(site_id, best[1], best[0], "fuzzy"))
|
||||||
|
|
@ -90,17 +97,17 @@ def main():
|
||||||
conn.execute("DELETE FROM district_economics")
|
conn.execute("DELETE FROM district_economics")
|
||||||
placeholders = ",".join(["?"]*len(last3))
|
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 = {}
|
latest_stock = {}
|
||||||
for r in conn.execute(f"""
|
for r in conn.execute("""
|
||||||
WITH ranked AS (
|
SELECT project, corpus, district, stock_m2, stock_lots
|
||||||
SELECT project, corpus, district, month, stock_m2, stock_lots,
|
FROM objective_corp_month
|
||||||
ROW_NUMBER() OVER (PARTITION BY project, corpus ORDER BY month DESC) rn
|
WHERE month = ?
|
||||||
FROM objective_corp_month
|
""", (latest_month,)).fetchall():
|
||||||
WHERE month IN ({placeholders})
|
|
||||||
)
|
|
||||||
SELECT project, corpus, district, stock_m2, stock_lots FROM ranked WHERE rn=1
|
|
||||||
""", last3).fetchall():
|
|
||||||
latest_stock[(r[0], r[1])] = (r[2], r[3] or 0, r[4] or 0)
|
latest_stock[(r[0], r[1])] = (r[2], r[3] or 0, r[4] or 0)
|
||||||
|
|
||||||
# Per-district aggregates from window
|
# Per-district aggregates from window
|
||||||
|
|
@ -114,7 +121,8 @@ def main():
|
||||||
by_dist = {}
|
by_dist = {}
|
||||||
for r in rows:
|
for r in rows:
|
||||||
d = r[0]
|
d = r[0]
|
||||||
if not d: continue
|
if not d:
|
||||||
|
continue
|
||||||
by_dist.setdefault(d, []).append(r)
|
by_dist.setdefault(d, []).append(r)
|
||||||
|
|
||||||
for d, rs in by_dist.items():
|
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)
|
wn = sum((r[6] or 0) * (r[5] or 0) for r in rs)
|
||||||
wd = sum((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
|
wp = wn / wd if wd else None
|
||||||
# median price (priced deals only)
|
# median price — volume-weighted by sold_volume_m2 (#1511)
|
||||||
prices = sorted([r[6] for r in rs if r[6] and (r[4] or 0) > 0])
|
# Each corpus×month row contributes avg_price_m2 weighted by sold_volume_m2 so that
|
||||||
med = prices[len(prices)//2] if prices else None
|
# 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
|
# offer prices
|
||||||
offers = [r[8] for r in rs if r[8] and r[8] > 0]
|
offers = [r[8] for r in rs if r[8] and r[8] > 0]
|
||||||
avg_off = sum(offers)/len(offers) if offers else None
|
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
|
# vote among k=5 nearest matched ЖК within 2 km
|
||||||
cands = []
|
cands = []
|
||||||
for sid2, (lat2, lon2, dist2) in matched_idx.items():
|
for sid2, (lat2, lon2, dist2) in matched_idx.items():
|
||||||
if sid2 == sid: continue
|
if sid2 == sid:
|
||||||
|
continue
|
||||||
d = hav(lat, lon, lat2, lon2)
|
d = hav(lat, lon, lat2, lon2)
|
||||||
cands.append((d, sid2, dist2))
|
cands.append((d, sid2, dist2))
|
||||||
cands.sort()
|
cands.sort()
|
||||||
|
|
@ -187,8 +211,10 @@ def main():
|
||||||
rep = next(c for c in top if c[2] == top_dist)
|
rep = next(c for c in top if c[2] == top_dist)
|
||||||
obj_id = None
|
obj_id = None
|
||||||
if rep[1] and rep[1].startswith("jk:"):
|
if rep[1] and rep[1].startswith("jk:"):
|
||||||
try: obj_id = int(rep[1][3:])
|
try:
|
||||||
except: pass
|
obj_id = int(rep[1][3:])
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
conn.execute("INSERT INTO site_district VALUES (?,?,?,?,?)",
|
conn.execute("INSERT INTO site_district VALUES (?,?,?,?,?)",
|
||||||
(sid, top_dist, "knn_vote_5", obj_id, rep[0]))
|
(sid, top_dist, "knn_vote_5", obj_id, rep[0]))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue