"""Integration test for Issue #17 — velocity-anomaly detection SQL logic. Builds synthetic temp tables mirroring domrf_kn_sale_graph + domrf_kn_objects and runs the exact windowing / z-score query from ``app.services.analytics.velocity_alerts`` against a real PostgreSQL. Proves: 1. A ЖК with a sharp recent drop is flagged (z <= -2.0 AND drop_pct <= -30%). 2. A ЖК with stable velocity is NOT flagged (no false positive). 3. The lookback window anchors to the latest report_month (not snapshot_date), so a stale scrape date does not starve the prior window. Uses psycopg v3 (never psycopg2). All bind params use CAST(:x AS type). Skips cleanly off-CI when no Postgres is reachable. """ import os import psycopg import pytest def _get_dsn() -> str: raw = os.environ.get("TEST_DATABASE_URL") or os.environ.get( "DATABASE_URL", "postgresql://gendesign@localhost:15432/gendesign", ) return raw.replace("+psycopg", "") def _db_reachable() -> tuple[bool, str]: try: with psycopg.connect(_get_dsn(), connect_timeout=3): return True, "" except Exception as e: return False, str(e) _DB_OK, _DB_ERR = _db_reachable() pytestmark = pytest.mark.skipif( not _DB_OK, reason=( "Нет доступной postgres БД (TEST_DATABASE_URL/DATABASE_URL) — " f"тест #17 пропущен: {_DB_ERR}" ), ) # Core query under test — kept byte-identical in spirit to velocity_alerts.py. # Reads from temp tables sg (sale_graph) + obj (objects). _DETECT_SQL = """ WITH anchor AS ( SELECT MAX(report_month) AS max_month FROM sg WHERE type = 'apartments' AND snapshot_date = %(snap)s ), series AS ( SELECT g.obj_id, g.report_month, g.realised FROM sg g CROSS JOIN anchor a WHERE g.type = 'apartments' AND g.snapshot_date = %(snap)s AND g.report_month > (a.max_month - CAST(%(lookback)s AS interval)) ), ranked AS ( SELECT obj_id, realised, ROW_NUMBER() OVER (PARTITION BY obj_id ORDER BY report_month DESC) AS rn, COUNT(*) OVER (PARTITION BY obj_id) AS n_months FROM series ), windows AS ( SELECT obj_id, n_months, AVG(realised) FILTER (WHERE rn <= 3) AS recent_mean, AVG(realised) FILTER (WHERE rn > 3) AS prior_mean, STDDEV_SAMP(realised) FILTER (WHERE rn > 3) AS prior_std, COUNT(*) FILTER (WHERE rn > 3) AS prior_n FROM ranked GROUP BY obj_id, n_months ), scored AS ( SELECT w.obj_id, w.recent_mean, w.prior_mean, CASE WHEN w.prior_std > 0 THEN (w.recent_mean - w.prior_mean)/w.prior_std END AS z, CASE WHEN w.prior_mean > 0 THEN (w.recent_mean - w.prior_mean)/w.prior_mean*100.0 END AS drop_pct FROM windows w WHERE w.n_months >= 6 AND w.prior_n >= 3 AND w.prior_mean > 0 AND w.prior_std > 0 ) SELECT s.obj_id, ROUND(s.z::numeric, 2) AS z, ROUND(s.drop_pct::numeric, 1) AS drop_pct FROM scored s JOIN LATERAL (SELECT region_cd FROM obj WHERE obj_id = s.obj_id LIMIT 1) o ON TRUE WHERE o.region_cd = 66 AND s.z <= -2.0 AND s.drop_pct <= -30.0 ORDER BY s.z ASC """ @pytest.fixture() def conn(): with psycopg.connect(_get_dsn(), autocommit=False) as c: yield c c.rollback() def _setup(cur: psycopg.Cursor) -> None: cur.execute( """ CREATE TEMP TABLE sg ( obj_id bigint, report_month date, type text, realised int, snapshot_date date ) ON COMMIT DROP; CREATE TEMP TABLE obj (obj_id bigint, region_cd int) ON COMMIT DROP; """ ) snap = "2026-04-28" # stale scrape date; data months end 2025-12 (4-mo gap) months = [ "2025-04-01", "2025-05-01", "2025-06-01", "2025-07-01", "2025-08-01", "2025-09-01", "2025-10-01", "2025-11-01", "2025-12-01", ] # obj 1 — sharp drop: prior ~15/mo, recent ~4/mo -> alert dropper = [16, 14, 15, 17, 13, 14, 5, 4, 3] # obj 2 — stable ~10/mo -> NO alert stable = [10, 9, 11, 10, 12, 9, 10, 11, 10] rows = [] for m, v in zip(months, dropper, strict=True): rows.append((1, m, "apartments", v, snap)) for m, v in zip(months, stable, strict=True): rows.append((2, m, "apartments", v, snap)) cur.executemany( "INSERT INTO sg (obj_id, report_month, type, realised, snapshot_date) " "VALUES (%s, %s, %s, %s, %s)", rows, ) cur.executemany("INSERT INTO obj (obj_id, region_cd) VALUES (%s, %s)", [(1, 66), (2, 66)]) def test_sharp_drop_is_flagged(conn: psycopg.Connection) -> None: cur = conn.cursor() _setup(cur) cur.execute(_DETECT_SQL, {"snap": "2026-04-28", "lookback": "9 months"}) alerts = cur.fetchall() flagged_ids = {r[0] for r in alerts} # obj 1 (dropper) must be flagged; obj 2 (stable) must not. assert 1 in flagged_ids, f"expected dropper flagged, got {alerts}" assert 2 not in flagged_ids, f"stable obj must not be a false positive, got {alerts}" # And the drop must be a strong negative z-score with a material drop%. dropper_row = next(r for r in alerts if r[0] == 1) assert dropper_row[1] <= -2.0 # z assert dropper_row[2] <= -30.0 # drop_pct def test_lookback_anchors_to_latest_data_month(conn: psycopg.Connection) -> None: """If the window anchored to snapshot_date (2026-04-28) instead of the latest report_month (2025-12), the 4-month gap would trim the prior window below prior_n>=3 and the dropper would NOT be flagged. This guards the fix. """ cur = conn.cursor() _setup(cur) cur.execute(_DETECT_SQL, {"snap": "2026-04-28", "lookback": "9 months"}) flagged_ids = {r[0] for r in cur.fetchall()} assert 1 in flagged_ids, "anchor-to-latest-month regression: dropper lost"