Merge pull request 'fix(forecasting): cap trend_ratio by v_rec magnitude when v_prior==0 (#1508)' (#1679) from fix/trend-ratio-vprior-zero-1508 into main

This commit is contained in:
lekss361 2026-06-17 17:45:13 +00:00
commit cf21ff76a9

View file

@ -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 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 = """
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%