Some checks failed
CI / changes (push) Successful in 8s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 8s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 7m37s
CI / backend-tests (pull_request) Successful in 7m42s
Deploy / deploy (push) Blocked by required conditions
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / build-frontend (push) Blocked by required conditions
Deploy / changes (push) Has been cancelled
#17: detect_velocity_anomalies + GET /analytics/velocity-alerts — z-score drop-detection на domrf_kn_sale_graph (double-gate z≤-2 AND drop≤-30%, starvation-guards). Snapshot=richest (не MAX — prod новейшие частичные), lookback anchored на latest report_month (scrape лаг ~4мес). Prod: ЖК Центральный Парк -69%, ~14ms. #99: mv_ddu_price_indicator (миграция 152) + POST /market/ddu-indicator — ARN-mirror ценовой индекс per quarter×area_bucket из rosreestr_deals (ДДУ регион 66). MVP: subject-level, period Q, window 2025-Q2+, methods 1/2 (basis/previous, prev_period_value honesty). Q1-2026 headline 1.0185 vs ARN 1.03 (±5%). Method 3 blocked (нет pre-2025-Q2 данных) — задокументировано. Closes #17 Closes #99
154 lines
5.6 KiB
Python
154 lines
5.6 KiB
Python
"""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"
|