Some checks failed
CI / changes (push) Successful in 9s
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (push) Has been skipped
CI / openapi-codegen-check (push) Failing after 1m49s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Failing after 1m39s
CI / backend-tests (push) Failing after 8m57s
CI / backend-tests (pull_request) Failing after 9m6s
184 lines
9.3 KiB
PL/PgSQL
184 lines
9.3 KiB
PL/PgSQL
-- 164_mv_sales_tracker_velocity_absorption.sql
|
|
-- Issue #61 — Velocity materialized views for Site Finder Velocity Score (4th scoring
|
|
-- criterion) + recommend_mix smart unit-mix. Foundation for sellout forecast.
|
|
--
|
|
-- B2-1 data source ("шахматки" / sales-tracker): the Объектив scraper
|
|
-- (backend/app/workers/tasks/scrape_objective.py) → tables:
|
|
-- objective_lots — 1.12M rows, one row per tracked lot (current state),
|
|
-- carries district / rooms_int / area_pd / sales_start_date /
|
|
-- is_sold / registration_date / contract_date / price_per_m2_rub.
|
|
-- objective_lots_history — 974k rows, daily-ish per-lot snapshots
|
|
-- (snapshot_date, is_sold, status, prices).
|
|
-- Snapshot history depth (as of 2026-06-17): 3 captures 2026-05-17 / 05-19 / 06-03 (spans
|
|
-- >2 weeks, sold count moved 193188->194893 => measurable absorption). Cohort/absorption
|
|
-- resolution improves automatically as the weekly scraper accumulates more snapshots.
|
|
--
|
|
-- -- MV 1: mv_sales_tracker_velocity_by_district --------------------------------------
|
|
-- Grain: (district, sale_month). One row per district per month.
|
|
-- Dedup: a lot appears in multiple snapshots within a month -> we keep that lot's LATEST
|
|
-- snapshot within the month (DISTINCT ON lot, snapshot_date DESC) before
|
|
-- aggregating, so total_count is lots-tracked-that-month (not snapshot rows).
|
|
-- Metrics: total_count, sold_count, avg_sold_price_per_m2, avg_sold_price_total,
|
|
-- sold_share (velocity proxy for SF Velocity Score).
|
|
--
|
|
-- -- MV 2: mv_sales_tracker_absorption_curves ----------------------------------------
|
|
-- Grain: (rooms_int, area_bucket, months_since_start). Cumulative sold% as f(months
|
|
-- from first_seen). "first_seen" = objective_lots.sales_start_date (true sales
|
|
-- launch — richer/longer than the 3-snapshot window). Sold-month anchor =
|
|
-- COALESCE(registration_date, contract_date). months_since_start clamped >= 0
|
|
-- (712 noise rows have anchor < start). 99.98% of sold lots carry both dates.
|
|
-- cohort_size = all lots in (rooms, area_bucket) cohort; cum_sold = sold lots
|
|
-- whose months_since_start <= the row's bucket; cum_sold_pct = cum_sold/cohort.
|
|
-- This is snapshot-sparsity-independent (driven by registration dates, not snapshots),
|
|
-- so the curve is usable today and the foundation for sellout forecast.
|
|
--
|
|
-- REFRESH CONCURRENTLY: both MVs get a UNIQUE index on their full grain immediately after
|
|
-- creation (on empty MV -> instant), enabling non-blocking weekly REFRESH CONCURRENTLY.
|
|
-- Scheduled via Celery beat `mv-sales-tracker-refresh-weekly` (Mon 04:30 MSK) ->
|
|
-- task app.workers.tasks.mv_sales_tracker_refresh.refresh_sales_tracker_mvs.
|
|
--
|
|
-- Deploy: auto-applied by deploy.yml via _schema_migrations tracking (one-shot, NN order).
|
|
-- Dependencies on existing objects: objective_lots, objective_lots_history (read-only).
|
|
-- No views depend on these MVs at creation time.
|
|
--
|
|
-- WARN: re-apply (DR / lost _schema_migrations / dev local) DROP ... CASCADE снесёт MV +
|
|
-- зависимости. После re-apply ПЕРВЫЙ refresh = non-concurrent (CONCURRENTLY падает
|
|
-- на пустой/не-populated MV). _schema_migrations нормально предотвращает re-apply.
|
|
|
|
BEGIN;
|
|
|
|
-- ====================================================================================
|
|
-- MV 1: velocity by district x month
|
|
-- ====================================================================================
|
|
DROP MATERIALIZED VIEW IF EXISTS mv_sales_tracker_velocity_by_district CASCADE;
|
|
|
|
CREATE MATERIALIZED VIEW mv_sales_tracker_velocity_by_district AS
|
|
WITH lot_month AS (
|
|
-- One row per (lot, month): the lot's latest snapshot within that month.
|
|
SELECT DISTINCT ON (h.objective_lot_id, date_trunc('month', h.snapshot_date))
|
|
l.district AS district,
|
|
date_trunc('month', h.snapshot_date)::date AS sale_month,
|
|
h.objective_lot_id,
|
|
h.is_sold,
|
|
h.price_per_m2_rub,
|
|
h.price_calculated_total_rub
|
|
FROM objective_lots_history h
|
|
JOIN objective_lots l ON l.objective_lot_id = h.objective_lot_id
|
|
WHERE l.district IS NOT NULL
|
|
ORDER BY h.objective_lot_id,
|
|
date_trunc('month', h.snapshot_date),
|
|
h.snapshot_date DESC
|
|
)
|
|
SELECT
|
|
district,
|
|
sale_month,
|
|
count(*)::int AS total_count,
|
|
count(*) FILTER (WHERE is_sold)::int AS sold_count,
|
|
round(
|
|
count(*) FILTER (WHERE is_sold)::numeric
|
|
/ NULLIF(count(*), 0), 4
|
|
) AS sold_share,
|
|
round(avg(price_per_m2_rub) FILTER (WHERE is_sold), 2) AS avg_sold_price_per_m2,
|
|
round(avg(price_calculated_total_rub) FILTER (WHERE is_sold), 2) AS avg_sold_price_total
|
|
FROM lot_month
|
|
GROUP BY district, sale_month
|
|
WITH NO DATA;
|
|
|
|
-- UNIQUE index on full grain -> enables REFRESH CONCURRENTLY (created on empty MV = instant)
|
|
CREATE UNIQUE INDEX mv_sales_tracker_velocity_by_district_pk
|
|
ON mv_sales_tracker_velocity_by_district (district, sale_month);
|
|
|
|
CREATE INDEX mv_sales_tracker_velocity_district_idx
|
|
ON mv_sales_tracker_velocity_by_district (district);
|
|
|
|
REFRESH MATERIALIZED VIEW mv_sales_tracker_velocity_by_district;
|
|
|
|
COMMENT ON MATERIALIZED VIEW mv_sales_tracker_velocity_by_district IS
|
|
'Issue #61. Per (district, month) sold/total/avg-sold-price from objective_lots_history '
|
|
'snapshots (Obektiv shahmatka), deduped to latest snapshot per lot per month. '
|
|
'Feeds Site Finder Velocity Score. Refresh weekly CONCURRENTLY.';
|
|
|
|
-- ====================================================================================
|
|
-- MV 2: absorption curves by room_count x area_bucket x months-from-first-seen
|
|
-- ====================================================================================
|
|
DROP MATERIALIZED VIEW IF EXISTS mv_sales_tracker_absorption_curves CASCADE;
|
|
|
|
CREATE MATERIALIZED VIEW mv_sales_tracker_absorption_curves AS
|
|
WITH base AS (
|
|
-- One row per lot. area_bucket from area_pd; months_since_start = whole months between
|
|
-- sales_start_date and the sold anchor (reg/contract). Unsold lots have NULL anchor.
|
|
SELECT
|
|
l.rooms_int,
|
|
CASE
|
|
WHEN l.area_pd < 30 THEN '<30'
|
|
WHEN l.area_pd < 45 THEN '30-45'
|
|
WHEN l.area_pd < 60 THEN '45-60'
|
|
WHEN l.area_pd < 80 THEN '60-80'
|
|
ELSE '80+'
|
|
END AS area_bucket,
|
|
l.is_sold,
|
|
CASE
|
|
WHEN l.is_sold
|
|
AND l.sales_start_date IS NOT NULL
|
|
AND COALESCE(l.registration_date, l.contract_date) IS NOT NULL
|
|
THEN GREATEST(
|
|
0,
|
|
(date_part('year', age(COALESCE(l.registration_date, l.contract_date),
|
|
l.sales_start_date)) * 12
|
|
+ date_part('month', age(COALESCE(l.registration_date, l.contract_date),
|
|
l.sales_start_date)))::int
|
|
)
|
|
END AS months_since_start
|
|
FROM objective_lots l
|
|
WHERE l.rooms_int IS NOT NULL
|
|
AND l.area_pd IS NOT NULL
|
|
AND l.sales_start_date IS NOT NULL
|
|
),
|
|
cohort AS (
|
|
SELECT rooms_int, area_bucket, count(*)::int AS cohort_size
|
|
FROM base
|
|
GROUP BY rooms_int, area_bucket
|
|
),
|
|
sold_at_month AS (
|
|
SELECT rooms_int, area_bucket, months_since_start, count(*)::int AS sold_in_month
|
|
FROM base
|
|
WHERE is_sold AND months_since_start IS NOT NULL
|
|
GROUP BY rooms_int, area_bucket, months_since_start
|
|
)
|
|
SELECT
|
|
s.rooms_int,
|
|
s.area_bucket,
|
|
s.months_since_start,
|
|
c.cohort_size,
|
|
-- cumulative sold up to and including this month-offset (per cohort)
|
|
SUM(s.sold_in_month) OVER (
|
|
PARTITION BY s.rooms_int, s.area_bucket
|
|
ORDER BY s.months_since_start
|
|
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
|
|
)::int AS cum_sold,
|
|
round(
|
|
SUM(s.sold_in_month) OVER (
|
|
PARTITION BY s.rooms_int, s.area_bucket
|
|
ORDER BY s.months_since_start
|
|
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
|
|
)::numeric / NULLIF(c.cohort_size, 0), 4
|
|
) AS cum_sold_pct
|
|
FROM sold_at_month s
|
|
JOIN cohort c ON c.rooms_int = s.rooms_int AND c.area_bucket = s.area_bucket
|
|
WITH NO DATA;
|
|
|
|
-- UNIQUE index on full grain -> enables REFRESH CONCURRENTLY
|
|
CREATE UNIQUE INDEX mv_sales_tracker_absorption_curves_pk
|
|
ON mv_sales_tracker_absorption_curves (rooms_int, area_bucket, months_since_start);
|
|
|
|
CREATE INDEX mv_sales_tracker_absorption_cohort_idx
|
|
ON mv_sales_tracker_absorption_curves (rooms_int, area_bucket);
|
|
|
|
REFRESH MATERIALIZED VIEW mv_sales_tracker_absorption_curves;
|
|
|
|
COMMENT ON MATERIALIZED VIEW mv_sales_tracker_absorption_curves IS
|
|
'Issue #61. Cumulative sold-pct as f(months from sales_start_date) per (rooms_int, '
|
|
'area_bucket). Anchor = COALESCE(registration_date, contract_date) from objective_lots. '
|
|
'Foundation for recommend_mix + sellout forecast. Refresh weekly CONCURRENTLY.';
|
|
|
|
COMMIT;
|