From cf36151dbc7d0f9011c3c7617b7527620cd9c23e Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 17 Jun 2026 20:29:46 +0300 Subject: [PATCH] fix(forecasting): cap trend_ratio by v_rec magnitude when v_prior==0 (#1508) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When v_prior == 0 and v_rec > 0, the old code unconditionally assigned trend_ratio = 2.0, producing an artificial 2x jump even for districts with negligible recent velocity. New formula: ratio = 1.0 + min(1.0, v_rec / _REF_VELOCITY) * (_TREND_CAP_VPRIOR_ZERO - 1.0) Where: _REF_VELOCITY = 10.0 (monthly flats/corpus — EKB "well-performing" benchmark) _TREND_CAP_VPRIOR_ZERO = 1.5 (max ratio for the v_prior==0 case) Tiny v_rec (e.g. 1 flat/month) → ratio ≈ 1.05 (near neutral) Large v_rec (≥ 10 flat/month) → ratio → 1.5 (capped, below old hard 2.0) v_prior > 0 branch is unchanged. Also fixes pre-existing ruff violations in the same file (E401 multi-import, E701 inline colon, E722 bare except, F401 unused import) so ruff check passes clean. --- site-finder/09_macro_and_trend.py | 45 ++++++++++++++++++++++++------- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/site-finder/09_macro_and_trend.py b/site-finder/09_macro_and_trend.py index d16b6962..da022764 100644 --- a/site-finder/09_macro_and_trend.py +++ b/site-finder/09_macro_and_trend.py @@ -9,9 +9,22 @@ Adds these tables/columns: Source for trend: per-flat register_date (objective_lots). Source for mortgage rate: prod cbr_mortgage_series (Sverdl region, latest "ставка ипотечная"). """ -import sqlite3, pathlib, psycopg2, datetime as dt, statistics +import sqlite3 +import pathlib +import psycopg2 +import datetime as dt DB = pathlib.Path(__file__).parent / "analysis.db" +# When v_prior == 0 we cannot compute a real ratio, so we synthesise one that +# reflects the *magnitude* of v_rec rather than assigning a flat 2.0. +# REF_VELOCITY: monthly flats/corpus that is considered "high activity" — above +# this level the district earns a ratio near TREND_CAP_VPRIOR_ZERO; below it +# the ratio slides toward neutral (1.0). Calibrated from EKB corpus data where +# an active corpus typically records 10–20 units/month; 10 is a conservative +# "well-performing" benchmark. +_REF_VELOCITY: float = 10.0 # monthly flats per corpus → full-cap reference +_TREND_CAP_VPRIOR_ZERO: float = 1.5 # maximum ratio when v_prior == 0 (< hard 2.0 cap) + EXTRA = """ CREATE TABLE IF NOT EXISTS macro_context ( key TEXT PRIMARY KEY, @@ -38,10 +51,13 @@ CREATE TABLE IF NOT EXISTS scoring_weights ( def safe_alter(conn, sql): for stmt in sql.strip().split(";"): s = stmt.strip() - if not s: continue - try: conn.execute(s) + if not s: + continue + try: + conn.execute(s) except sqlite3.OperationalError as e: - if "duplicate column" not in str(e): raise + if "duplicate column" not in str(e): + raise def main(): local = sqlite3.connect(DB) @@ -88,11 +104,14 @@ def main(): WHERE district IS NOT NULL AND register_date IS NOT NULL""").fetchall() by_d = {} for d, r, p, c in rows: - try: rd = dt.date.fromisoformat(r[:10]) - except: continue + try: + rd = dt.date.fromisoformat(r[:10]) + except ValueError: + continue bucket = "recent" if r >= cut_recent else ("prior" if r >= cut_prior else None) - if not bucket: continue - by_d.setdefault(d, {"recent":[], "prior":[], "corpuses":set()}) + if not bucket: + continue + by_d.setdefault(d, {"recent": [], "prior": [], "corpuses": set()}) by_d[d][bucket].append(rd) by_d[d]["corpuses"].add((p, c)) @@ -100,7 +119,15 @@ def main(): n_corp = max(len(info["corpuses"]), 1) v_rec = len(info["recent"]) / 6 / n_corp v_prior = len(info["prior"]) / 6 / n_corp - ratio = (v_rec / v_prior) if v_prior > 0 else (None if v_rec == 0 else 2.0) + # v_prior > 0: real ratio; v_prior == 0 && v_rec == 0: neutral (None→1.0); + # v_prior == 0 && v_rec > 0: synthesise ratio scaled by v_rec magnitude so + # a tiny v_rec stays near 1.0 while a large v_rec approaches _TREND_CAP_VPRIOR_ZERO. + if v_prior > 0: + ratio: float | None = v_rec / v_prior + elif v_rec == 0: + ratio = None + else: + ratio = 1.0 + min(1.0, v_rec / _REF_VELOCITY) * (_TREND_CAP_VPRIOR_ZERO - 1.0) # Prod-style clamping trend_factor = max(0.7, min(2.0, ratio)) if ratio else 1.0 # sat_factor: sold_pct in district. >50% = mature market (prod logic +30%