gendesign/site-finder/06_match_economics.py
bot-backend 6d550e00b2
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
fix(site-finder): volume-weighted median_price_m2 (#1511) + consistent-period months_to_sellout (#1512)
#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).
2026-06-17 20:52:47 +03:00

234 lines
9.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Step 1.2: Match ЖК ↔ Objective projects by name; compute district economics.
Output tables:
jk_objective_match — site_id ↔ objective project name (best fuzzy match)
district_economics — per Objective-район aggregates over last 90 days
site_district — which Objective-район each site belongs to
"""
import math
import pathlib
import re
import sqlite3
from difflib import SequenceMatcher
DB = pathlib.Path(__file__).parent / "analysis.db"
SCHEMA = """
CREATE TABLE IF NOT EXISTS jk_objective_match (
site_id TEXT PRIMARY KEY REFERENCES sites(site_id),
project TEXT,
score REAL,
method TEXT
);
CREATE TABLE IF NOT EXISTS district_economics (
district TEXT PRIMARY KEY,
n_projects INTEGER,
n_corpuses INTEGER,
median_price_m2 REAL, -- тыс.Р/м²
weighted_price_m2 REAL, -- weighted by sold m²
avg_price_m2_offer REAL, -- prices in stock right now
deals_per_month_avg REAL, -- avg per корпус
sold_volume_m2_90d REAL, -- last 90 days sold m²
stock_m2 REAL, -- current stock
stock_lots INTEGER,
avg_area_sold_m2 REAL, -- average flat size sold
months_to_sellout REAL -- stock_m2 / monthly_velocity
);
CREATE TABLE IF NOT EXISTS site_district (
site_id TEXT PRIMARY KEY REFERENCES sites(site_id),
district TEXT,
method TEXT, -- 'name_match' | 'nearest_jk'
nearest_jk_obj_id INTEGER,
nearest_jk_dist_m REAL
);
"""
def normalize(s):
if not s:
return ""
s = s.lower().strip()
s = re.sub(r'^(жк|жилой\s+комплекс|жилой\s+квартал|жилые\s+кварталы)\s+', '', s)
s = re.sub(r'["«»\'`]+', '', s)
s = re.sub(r'\s+', ' ', s)
return s.strip()
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 main():
conn = sqlite3.connect(DB)
conn.executescript(SCHEMA)
# 1. ЖК ↔ Objective project name match
sites = conn.execute("SELECT site_id, name FROM sites WHERE kind='jk' AND name IS NOT NULL").fetchall()
objective_projects = [r[0] for r in conn.execute("SELECT DISTINCT project FROM objective_corp_month").fetchall() if r[0]]
print(f"Sites: {len(sites)}, Objective projects: {len(objective_projects)}")
conn.execute("DELETE FROM jk_objective_match")
matched = 0
for site_id, name in sites:
best = (0.0, None)
for proj in objective_projects:
s = fuzzy(name, 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"))
matched += 1
print(f"Name-matched: {matched}/{len(sites)}")
# 2. district_economics — last 90 days
# Last 3 calendar months from data
last3 = [r[0] for r in conn.execute(
"SELECT DISTINCT month FROM objective_corp_month ORDER BY month DESC LIMIT 3").fetchall()]
print(f"Months window: {last3}")
conn.execute("DELETE FROM district_economics")
placeholders = ",".join(["?"]*len(last3))
# 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("""
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
rows = conn.execute(f"""
SELECT district, project, corpus, deals_total, deals_priced,
sold_volume_m2, avg_price_m2, avg_area_m2, stock_avg_price_m2
FROM objective_corp_month
WHERE month IN ({placeholders})
""", last3).fetchall()
by_dist = {}
for r in rows:
d = r[0]
if not d:
continue
by_dist.setdefault(d, []).append(r)
for d, rs in by_dist.items():
projects = {r[1] for r in rs}
corpuses = {(r[1], r[2]) for r in rs}
# weighted price (by sold m²)
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 — 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
# velocity (avg deals_total per row, where row = corpus×month)
vels = [r[3] or 0 for r in rs]
avg_v = sum(vels)/len(vels) if vels else None
# sold volume 90d
sv = sum(r[5] or 0 for r in rs)
# stock from latest_stock for this district
stock_m2 = sum(s[1] for k, s in latest_stock.items() if s[0] == d)
stock_lots = sum(s[2] for k, s in latest_stock.items() if s[0] == d)
# avg area
areas = [r[7] for r in rs if r[7] and r[7] > 0]
avg_a = sum(areas)/len(areas) if areas else None
# months to sellout
mts = (stock_m2 / (sv / 3.0)) if sv > 0 and stock_m2 else None
conn.execute("""INSERT INTO district_economics VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""",
(d, len(projects), len(corpuses), med, wp, avg_off, avg_v, sv,
stock_m2 or None, stock_lots or None, avg_a, mts))
conn.commit()
print("\nDistrict economics (last 90d):")
for r in conn.execute("""SELECT district, n_projects, ROUND(weighted_price_m2,1) wp,
ROUND(deals_per_month_avg,1) v, ROUND(months_to_sellout,1) mts
FROM district_economics ORDER BY wp DESC""").fetchall():
print(f" {r[0]:<25} projects={r[1]:>3} price={r[2] or '':>7}тыс vel={r[3]:>5} to_sellout={r[4] or ''} мес")
# 3. site_district: name-matched gets Objective district directly; others — nearest matched ЖК
conn.execute("DELETE FROM site_district")
# 3a. matched sites — pull district from Objective directly
rows = conn.execute("""
SELECT s.site_id, s.lat, s.lon, m.project,
(SELECT district FROM objective_corp_month
WHERE project=m.project ORDER BY month DESC LIMIT 1) AS district
FROM sites s LEFT JOIN jk_objective_match m USING (site_id)
""").fetchall()
matched_with_dist = [(sid, lat, lon, proj, dist) for sid, lat, lon, proj, dist in rows
if dist is not None]
matched_idx = {sid: (lat, lon, dist) for sid, lat, lon, _, dist in matched_with_dist}
from collections import Counter
for sid, lat, lon, proj, dist in rows:
if dist:
conn.execute("INSERT INTO site_district VALUES (?,?,?,?,?)",
(sid, dist, "name_match", None, 0))
else:
# vote among k=5 nearest matched ЖК within 2 km
cands = []
for sid2, (lat2, lon2, dist2) in matched_idx.items():
if sid2 == sid:
continue
d = hav(lat, lon, lat2, lon2)
cands.append((d, sid2, dist2))
cands.sort()
top = [c for c in cands[:7] if c[0] <= 2500]
if top:
votes = Counter(c[2] for c in top)
# pick the most-voted district; tie-break: closest representative
top_dist, _ = votes.most_common(1)[0]
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 ValueError:
pass
conn.execute("INSERT INTO site_district VALUES (?,?,?,?,?)",
(sid, top_dist, "knn_vote_5", obj_id, rep[0]))
conn.commit()
parcel_dist = conn.execute(
"SELECT district, method, nearest_jk_dist_m FROM site_district WHERE site_id='parcel:66:41:0204016:10'"
).fetchone()
print(f"\nParcel district: {parcel_dist}")
print("\nSites assigned per district:")
for r in conn.execute("SELECT district, count(*) FROM site_district GROUP BY 1 ORDER BY 2 DESC LIMIT 15").fetchall():
print(f" {r[0]:<25} {r[1]}")
conn.close()
if __name__ == "__main__":
main()