fix(forecasting): cap trend_ratio by v_rec magnitude when v_prior==0 (#1508)
All checks were successful
CI / changes (push) Successful in 10s
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

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.
This commit is contained in:
bot-backend 2026-06-17 20:29:46 +03:00
parent 18da92ccc7
commit cf36151dbc

View file

@ -9,9 +9,22 @@ Adds these tables/columns:
Source for trend: per-flat register_date (objective_lots). Source for trend: per-flat register_date (objective_lots).
Source for mortgage rate: prod cbr_mortgage_series (Sverdl region, latest "ставка ипотечная"). 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" 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 1020 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 = """ EXTRA = """
CREATE TABLE IF NOT EXISTS macro_context ( CREATE TABLE IF NOT EXISTS macro_context (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
@ -38,10 +51,13 @@ CREATE TABLE IF NOT EXISTS scoring_weights (
def safe_alter(conn, sql): def safe_alter(conn, sql):
for stmt in sql.strip().split(";"): for stmt in sql.strip().split(";"):
s = stmt.strip() s = stmt.strip()
if not s: continue if not s:
try: conn.execute(s) continue
try:
conn.execute(s)
except sqlite3.OperationalError as e: except sqlite3.OperationalError as e:
if "duplicate column" not in str(e): raise if "duplicate column" not in str(e):
raise
def main(): def main():
local = sqlite3.connect(DB) local = sqlite3.connect(DB)
@ -88,11 +104,14 @@ def main():
WHERE district IS NOT NULL AND register_date IS NOT NULL""").fetchall() WHERE district IS NOT NULL AND register_date IS NOT NULL""").fetchall()
by_d = {} by_d = {}
for d, r, p, c in rows: for d, r, p, c in rows:
try: rd = dt.date.fromisoformat(r[:10]) try:
except: continue rd = dt.date.fromisoformat(r[:10])
except ValueError:
continue
bucket = "recent" if r >= cut_recent else ("prior" if r >= cut_prior else None) bucket = "recent" if r >= cut_recent else ("prior" if r >= cut_prior else None)
if not bucket: continue if not bucket:
by_d.setdefault(d, {"recent":[], "prior":[], "corpuses":set()}) continue
by_d.setdefault(d, {"recent": [], "prior": [], "corpuses": set()})
by_d[d][bucket].append(rd) by_d[d][bucket].append(rd)
by_d[d]["corpuses"].add((p, c)) by_d[d]["corpuses"].add((p, c))
@ -100,7 +119,15 @@ def main():
n_corp = max(len(info["corpuses"]), 1) n_corp = max(len(info["corpuses"]), 1)
v_rec = len(info["recent"]) / 6 / n_corp v_rec = len(info["recent"]) / 6 / n_corp
v_prior = len(info["prior"]) / 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 # Prod-style clamping
trend_factor = max(0.7, min(2.0, ratio)) if ratio else 1.0 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% # sat_factor: sold_pct in district. >50% = mature market (prod logic +30%