feat(db): v_bucket_success_score view (success-driven mix #25)

This commit is contained in:
lekss361 2026-05-11 22:04:22 +03:00
parent 8be539ddc6
commit 369b5a4706

View file

@ -0,0 +1,102 @@
-- 86_v_bucket_success_score.sql
-- Context: Success-driven bucket ranking view for recommend_mix (issue #25).
-- Per-(district × class × room_bucket) aggregates from domrf_kn_flats + domrf_kn_objects
-- over the last 24 months, with Z-scores normalised per (district, class) group.
-- Used in recommend_mix to bias recommendations toward buckets with high velocity + price.
--
-- Dependencies:
-- domrf_kn_flats — total_area, rooms, price_per_m2, obj_id, region_cd, snapshot_date
-- domrf_kn_objects — obj_id, district_name, obj_class, region_cd, snapshot_date
-- domrf_kn_sale_graph — obj_id, report_month, contracted, snapshot_date
--
-- Deploy order: standalone, no prior migration needed.
-- Idempotent: CREATE OR REPLACE VIEW.
BEGIN;
CREATE OR REPLACE VIEW v_bucket_success_score AS
WITH
-- Latest snapshot per object (both tables are time-series)
latest_obj AS (
SELECT DISTINCT ON (obj_id)
obj_id, district_name, obj_class, region_cd
FROM domrf_kn_objects
WHERE district_name IS NOT NULL
ORDER BY obj_id, snapshot_date DESC
),
bucket_aggs AS (
SELECT
o.district_name,
COALESCE(o.obj_class, 'Comfort') AS obj_class,
CASE
WHEN f.rooms = 0 OR f.total_area < 30 THEN 'Студии 15-30'
WHEN f.total_area < 45 THEN '1-к 30-45'
WHEN f.total_area < 60 THEN '2-к 45-60'
WHEN f.total_area < 80 THEN '3-к 60-80'
ELSE '80+ м²'
END AS bucket,
AVG(NULLIF(f.price_per_m2, 0)) AS avg_price_per_m2,
AVG(NULLIF(f.total_area, 0)) AS avg_area_m2,
COUNT(*) AS n_deals,
-- Velocity proxy: avg monthly contracted units per object, over last 24 months
AVG(
(SELECT AVG(sg.contracted)
FROM domrf_kn_sale_graph sg
WHERE sg.obj_id = o.obj_id
AND sg.report_month > NOW() - INTERVAL '24 months')
) AS avg_velocity_per_month
FROM domrf_kn_flats f
JOIN latest_obj o ON o.obj_id = f.obj_id
WHERE f.total_area BETWEEN 15 AND 200
AND f.price_per_m2 BETWEEN 30000 AND 500000
AND o.region_cd = 66
AND f.snapshot_date > CURRENT_DATE - INTERVAL '24 months'
GROUP BY 1, 2, 3
HAVING COUNT(*) >= 30 -- статистически значимая выборка
),
z_scores AS (
SELECT
district_name, obj_class, bucket,
n_deals, avg_price_per_m2, avg_area_m2, avg_velocity_per_month,
-- Z-score нормированный по группе (district × class)
(avg_velocity_per_month
- AVG(avg_velocity_per_month) OVER (PARTITION BY district_name, obj_class))
/ NULLIF(STDDEV(avg_velocity_per_month) OVER (PARTITION BY district_name, obj_class), 0)
AS velocity_z,
(avg_price_per_m2
- AVG(avg_price_per_m2) OVER (PARTITION BY district_name, obj_class))
/ NULLIF(STDDEV(avg_price_per_m2) OVER (PARTITION BY district_name, obj_class), 0)
AS price_z,
(avg_area_m2
- AVG(avg_area_m2) OVER (PARTITION BY district_name, obj_class))
/ NULLIF(STDDEV(avg_area_m2) OVER (PARTITION BY district_name, obj_class), 0)
AS area_z
FROM bucket_aggs
)
SELECT
district_name,
obj_class,
bucket,
n_deals,
avg_price_per_m2,
avg_area_m2,
avg_velocity_per_month,
COALESCE(velocity_z, 0) * 0.5
+ COALESCE(price_z, 0) * 0.3
- COALESCE(area_z, 0) * 0.2 AS success_score,
COALESCE(velocity_z, 0) AS velocity_z,
COALESCE(price_z, 0) AS price_z,
COALESCE(area_z, 0) AS area_z
FROM z_scores
ORDER BY district_name, obj_class, success_score DESC;
COMMENT ON VIEW v_bucket_success_score IS
'Success-driven bucket ranking per (district, class). '
'Z-scores: velocity (+) приоритет, price (+) приоритет, area (-) приоритет. '
'Min 30 сделок в группе. '
'Используется recommend_mix для смещения рекомендации (issue #25).';
COMMIT;