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
192 lines
7.3 KiB
Python
192 lines
7.3 KiB
Python
"""Integration test for Issue #99 — ДДУ price indicator SQL logic.
|
|
|
|
Builds a synthetic temp table mirroring rosreestr_deals and runs the exact
|
|
bucketing + index computation from migration 152 / ddu_price_indicator against
|
|
a real PostgreSQL. Proves:
|
|
|
|
1. Per-unit area bucketing uses area/deal_count (packaged ДДУ), not raw area.
|
|
2. index_basis is median / first-period-median; index_previous is median /
|
|
previous-PRESENT-period median (min_deals=10 gates noisy quarters out).
|
|
3. prev_period_value honestly records which period index_previous compares to
|
|
(may NOT be the literal previous quarter if one was filtered out).
|
|
|
|
psycopg v3 only. CAST(:x AS type) everywhere. Skips cleanly off-CI.
|
|
"""
|
|
|
|
import os
|
|
from decimal import Decimal
|
|
|
|
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"тест #99 пропущен: {_DB_ERR}"
|
|
),
|
|
)
|
|
|
|
|
|
# Core indicator query under test — mirrors migration 152 (reads temp table rd).
|
|
_INDICATOR_SQL = """
|
|
WITH per_unit AS (
|
|
SELECT date_trunc('quarter', period_start_date)::date AS quarter_start,
|
|
(area / deal_count) AS area_per_unit,
|
|
CASE WHEN price_per_sqm IS NOT NULL THEN price_per_sqm
|
|
WHEN deal_price IS NOT NULL AND area > 0 THEN deal_price/area END AS price_m2
|
|
FROM rd
|
|
WHERE region_code = 66 AND realestate_type_code = '002001003000'
|
|
AND doc_type = 'ДДУ' AND deal_count > 0 AND area > 0
|
|
AND period_start_date >= DATE '2025-04-01'
|
|
),
|
|
filtered AS (
|
|
SELECT quarter_start, area_per_unit, price_m2 FROM per_unit
|
|
WHERE area_per_unit BETWEEN 10 AND 300 AND price_m2 BETWEEN 30000 AND 800000
|
|
),
|
|
bucketed AS (
|
|
SELECT quarter_start, price_m2,
|
|
CASE WHEN area_per_unit < 25 THEN 1 WHEN area_per_unit < 40 THEN 2
|
|
WHEN area_per_unit < 60 THEN 3 WHEN area_per_unit < 80 THEN 4
|
|
WHEN area_per_unit < 100 THEN 5 ELSE 6 END AS area_bucket
|
|
FROM filtered
|
|
UNION ALL
|
|
SELECT quarter_start, price_m2, 0 FROM filtered
|
|
),
|
|
agg AS (
|
|
SELECT area_bucket, quarter_start, COUNT(*) AS deals_count,
|
|
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price_m2) AS med
|
|
FROM bucketed GROUP BY area_bucket, quarter_start
|
|
HAVING COUNT(*) >= 10
|
|
),
|
|
indexed AS (
|
|
SELECT area_bucket, quarter_start, deals_count, med,
|
|
FIRST_VALUE(med) OVER w AS basis_med,
|
|
LAG(med) OVER w AS prev_med,
|
|
LAG(quarter_start) OVER w AS prev_q
|
|
FROM agg WINDOW w AS (PARTITION BY area_bucket ORDER BY quarter_start)
|
|
)
|
|
SELECT area_bucket,
|
|
(EXTRACT(YEAR FROM quarter_start)::int::text || '-Q'
|
|
|| EXTRACT(QUARTER FROM quarter_start)::int::text) AS period_value,
|
|
deals_count,
|
|
ROUND((med / NULLIF(basis_med, 0))::numeric, 4) AS index_basis,
|
|
ROUND((med / NULLIF(prev_med, 0))::numeric, 4) AS index_previous,
|
|
CASE WHEN prev_q IS NOT NULL THEN
|
|
(EXTRACT(YEAR FROM prev_q)::int::text || '-Q'
|
|
|| EXTRACT(QUARTER FROM prev_q)::int::text) END AS prev_period_value
|
|
FROM indexed
|
|
ORDER BY area_bucket, period_value
|
|
"""
|
|
|
|
|
|
@pytest.fixture()
|
|
def conn():
|
|
with psycopg.connect(_get_dsn(), autocommit=False) as c:
|
|
yield c
|
|
c.rollback()
|
|
|
|
|
|
def _insert_quarter(cur, q_start, bucket_area, price_m2, n) -> None:
|
|
"""Insert n single-flat ДДУ rows at given per-unit area + price/m²."""
|
|
rows = [
|
|
("002001003000", "ДДУ", 66, q_start, bucket_area, 1, price_m2)
|
|
for _ in range(n)
|
|
]
|
|
cur.executemany(
|
|
"INSERT INTO rd (realestate_type_code, doc_type, region_code, "
|
|
"period_start_date, area, deal_count, price_per_sqm) "
|
|
"VALUES (%s, %s, %s, %s, %s, %s, %s)",
|
|
rows,
|
|
)
|
|
|
|
|
|
def _setup(cur: psycopg.Cursor) -> None:
|
|
cur.execute(
|
|
"""
|
|
CREATE TEMP TABLE rd (
|
|
realestate_type_code text, doc_type varchar, region_code smallint,
|
|
period_start_date date, area numeric, deal_count int, price_per_sqm numeric,
|
|
deal_price numeric
|
|
) ON COMMIT DROP;
|
|
"""
|
|
)
|
|
# Bucket 3 (40-60 m²): three quarters, rising median, all >=10 deals.
|
|
_insert_quarter(cur, "2025-04-01", 50, 100000, 12) # basis
|
|
_insert_quarter(cur, "2025-07-01", 50, 110000, 12) # +10% vs basis & prev
|
|
_insert_quarter(cur, "2025-10-01", 50, 121000, 12) # +10% vs prev
|
|
# Bucket 4 (60-80 m²): 2025-Q3 present, 2025-Q4 SPARSE (<10 → filtered),
|
|
# 2026-Q1 present. index_previous for 2026-Q1 must compare to 2025-Q3.
|
|
_insert_quarter(cur, "2025-07-01", 70, 150000, 11)
|
|
_insert_quarter(cur, "2025-10-01", 70, 999999, 3) # below min_deals → dropped
|
|
_insert_quarter(cur, "2026-01-01", 70, 165000, 11)
|
|
# Packaged-deal trap: one row area=350 deal_count=7 → per-unit 50 m² (bucket 3),
|
|
# NOT bucket 6. Price chosen mid-range so it doesn't move the median much.
|
|
cur.execute(
|
|
"INSERT INTO rd (realestate_type_code, doc_type, region_code, "
|
|
"period_start_date, area, deal_count, price_per_sqm) "
|
|
"VALUES ('002001003000','ДДУ',66,'2025-04-01',350,7,100000)"
|
|
)
|
|
|
|
|
|
def _rows_by_key(rows) -> dict[tuple[int, str], tuple]:
|
|
return {(r[0], r[1]): r for r in rows}
|
|
|
|
|
|
def test_basis_and_previous_index(conn: psycopg.Connection) -> None:
|
|
cur = conn.cursor()
|
|
_setup(cur)
|
|
cur.execute(_INDICATOR_SQL)
|
|
by = _rows_by_key(cur.fetchall())
|
|
# Bucket 3 rising 100k→110k→121k.
|
|
assert by[(3, "2025-Q2")][3] == Decimal("1.0000") # basis self
|
|
assert by[(3, "2025-Q3")][4] == Decimal("1.1000") # prev: 110/100
|
|
assert by[(3, "2025-Q4")][4] == Decimal("1.1000") # prev: 121/110
|
|
assert by[(3, "2025-Q4")][3] == Decimal("1.2100") # basis: 121/100
|
|
|
|
|
|
def test_packaged_deal_bucketed_by_per_unit_area(conn: psycopg.Connection) -> None:
|
|
"""area=350/deal_count=7 → 50 m² → bucket 3, never bucket 6."""
|
|
cur = conn.cursor()
|
|
_setup(cur)
|
|
cur.execute(_INDICATOR_SQL)
|
|
by = _rows_by_key(cur.fetchall())
|
|
# Bucket 6 should have NO rows (the only 100+ candidate was the packaged
|
|
# row, which correctly lands in bucket 3).
|
|
assert not any(k[0] == 6 for k in by), "packaged ДДУ leaked into 100+ bucket"
|
|
# Bucket 3 basis quarter deal count includes the 7-unit packaged row.
|
|
assert by[(3, "2025-Q2")][2] >= 12
|
|
|
|
|
|
def test_prev_period_value_skips_filtered_quarter(conn: psycopg.Connection) -> None:
|
|
"""Bucket 4: 2025-Q4 is below min_deals and dropped; 2026-Q1's
|
|
index_previous must compare against 2025-Q3 (honest prev_period_value),
|
|
not the literal previous quarter.
|
|
"""
|
|
cur = conn.cursor()
|
|
_setup(cur)
|
|
cur.execute(_INDICATOR_SQL)
|
|
by = _rows_by_key(cur.fetchall())
|
|
assert (4, "2025-Q4") not in by, "sparse quarter should be filtered by min_deals"
|
|
q1 = by[(4, "2026-Q1")]
|
|
assert q1[5] == "2025-Q3", f"prev_period_value must skip dropped Q4, got {q1[5]}"
|
|
# 165000 / 150000 = 1.1000
|
|
assert q1[4] == Decimal("1.1000")
|