Многоагентный аудит + имплементация: один воркер на файл, точечные правки. Верификация: py_compile (47/47 .py) + tsc --noEmit (0 ошибок). Unit-тесты не прогонялись (окружение не поднято: rollup native dep / нет pytest-venv). Полностью исправлено (169): #1336, #1337, #1339, #1340, #1341, #1342, #1343, #1345, #1346, #1348, #1349, #1350, #1351, #1354, #1356, #1358, #1359, #1360, #1362, #1364, #1365, #1366, #1367, #1368, #1369, #1370, #1371, #1372, #1373, #1374, #1375, #1376, #1377, #1378, #1379, #1380, #1381, #1382, #1384, #1385, #1386, #1387, #1388, #1389, #1390, #1391, #1392, #1394, #1395, #1396, #1397, #1399, #1400, #1401, #1402, #1403, #1404, #1408, #1409, #1410, #1411, #1412, #1413, #1414, #1415, #1416, #1417, #1418, #1420, #1423, #1425, #1426, #1427, #1428, #1429, #1430, #1431, #1432, #1433, #1434, #1435, #1437, #1438, #1439, #1440, #1441, #1442, #1443, #1444, #1445, #1446, #1447, #1448, #1449, #1450, #1451, #1452, #1453, #1454, #1455, #1456, #1457, #1458, #1459, #1460, #1461, #1462, #1463, #1464, #1465, #1466, #1467, #1468, #1469, #1471, #1472, #1473, #1474, #1476, #1478, #1479, #1481, #1482, #1483, #1484, #1485, #1487, #1488, #1489, #1490, #1491, #1492, #1493, #1494, #1495, #1496, #1497, #1499, #1500, #1501, #1502, #1504, #1505, #1506, #1507, #1510, #1514, #1515, #1516, #1517, #1518, #1519, #1521, #1522, #1523, #1524, #1525, #1526, #1527, #1528, #1529, #1531, #1532, #1533, #1534, #1535, #1536, #1537, #1538 Частично (9, in-file часть, остаток cross-file): #1361, #1419, #1422, #1424, #1470, #1475, #1477, #1480, #1498 Требуют cross-file (3, не тронуты): #1338, #1363, #1421 Пропущено (1): #1539 Не входило в партию: 22 needs-Leha issue (нужны решения владельца). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
203 lines
13 KiB
PL/PgSQL
203 lines
13 KiB
PL/PgSQL
-- 080_asking_to_sold_ratios.sql
|
||
-- #648 Stage 2 — persist the per-rooms asking→sold correction ratio.
|
||
--
|
||
-- ПРОБЛЕМА (#648). Оценщик (estimator.py) предсказывает цену квартиры по МЕДИАНЕ
|
||
-- *активных asking* цен аналогов (Tukey 1.5×IQR → median ₽/m²). Backtest-харнес
|
||
-- (scripts/backtest_estimator.py, Stage 1, merged) показал, что asking систематически
|
||
-- на +20-30% выше реально зарегистрированной цены ДКП (deals, source='rosreestr').
|
||
-- Per-rooms множитель ratio = median(SOLD ppm²) / median(ASKING ppm²) (≈0.73-0.85,
|
||
-- глобально ≈0.81 — sold ≈ 81% от asking) убирает этот перекос: out-of-sample bias
|
||
-- на held-out ДКП ушёл с +20.4% до −4.0%. Stage 2 = СОХРАНИТЬ этот ratio, чтобы
|
||
-- Stage 3 (estimator) читал его и домножал asking-median прогноз на sold/asking.
|
||
--
|
||
-- СОЗДАЁТ:
|
||
-- asking_to_sold_ratios — таблица коэффициентов (rooms_bucket, district) → ratio.
|
||
-- rooms_bucket: 0=студия,1,2,3,4(=4+); СПЕЦ-строка rooms_bucket=-1 = global fallback.
|
||
-- district: ЗАРЕЗЕРВИРОВАНО для #647 (geo-разбивка); в #648 ВСЕГДА '' (часть PK,
|
||
-- поэтому NOT NULL DEFAULT '' — '' можно положить в PK, NULL нельзя).
|
||
--
|
||
-- МЕТОДОЛОГИЯ (повторяет intent харнеса, см. _derive_room_ratios / _percentile):
|
||
-- sold_median = percentile_cont(0.5) по deals.price_per_m2, source='rosreestr',
|
||
-- ppm² ∈ [30000, 600000], deal_date >= CURRENT_DATE − 12 months, бакет по
|
||
-- LEAST(GREATEST(rooms,0),4). (percentile_cont = линейно-интерполированная медиана,
|
||
-- соответствует _percentile(sorted, 0.5) в харнесе.)
|
||
-- ask_median = percentile_cont(0.5) по listings.price_per_m2, is_active,
|
||
-- ppm² ∈ [30000, 600000], тот же бакет.
|
||
-- ratio = sold_median / ask_median. window_months = 12.
|
||
--
|
||
-- MIN-SAMPLE (вариант (a) — skip): бакет с n_deals < 30 ИЛИ n_listings < 30 НЕ получает
|
||
-- собственную per_rooms-строку. Estimator при чтении делает fallback: строки бакета
|
||
-- нет → берёт строку rooms_bucket = -1 (global). Так таблица не overfit'ит тонкие
|
||
-- бакеты (студия/3к/4+ обычно тонкие; 1к/2к — плотные). Глобальная -1 строка
|
||
-- пишется ВСЕГДА (basis='global_fallback'); per_rooms-строки — только для бакетов,
|
||
-- прошедших порог 30/30 (basis='per_rooms').
|
||
-- GUARD: бакет с ask_median 0/NULL пропускается (деление невозможно) — это и так
|
||
-- отсекается порогом n_listings>=30, но HAVING ask_median IS NOT NULL AND >0 страхует.
|
||
--
|
||
-- CAVEATS (важно — те же, что в шапке харнеса):
|
||
-- (a) TIME-MISMATCH: ask_median считается по ТЕКУЩИМ активным listings, а sold_median —
|
||
-- по сделкам за ПРОШЕДШИЕ 12 мес. Это не point-in-time: рынок дрейфует, ratio слегка
|
||
-- смещён. Честный point-in-time потребует listing_source_snapshots (#570), когда
|
||
-- накопится история asking «на дату сделки».
|
||
-- (b) ДКП = ЗАРЕГИСТРИРОВАННАЯ цена (что стороны заявили в Росреестр); может расходиться
|
||
-- с реальной (налоговая оптимизация, сделки со связанными лицами).
|
||
-- (c) PER-ROOMS ONLY: district зарезервирован под #647 (geo-разбивка), пока всегда ''.
|
||
--
|
||
-- STAGE-4 REUSE: блок derivation ниже (CTE deal_side / ask_side / per_bucket / global_row +
|
||
-- финальный INSERT ... ON CONFLICT DO UPDATE) — это ровно та логика, которую переиспользует
|
||
-- refresh-задача `asking_to_sold_ratio_refresh` (Stage 4): она прогоняет тот же
|
||
-- INSERT...SELECT по расписанию, чтобы ratio следовал за рынком. ON CONFLICT DO UPDATE
|
||
-- делает повторный прогон идемпотентным (refresh = re-seed). При вынесении в app-код
|
||
-- bind-параметры (порог, окно, ppm²-полоса) подаются через CAST(:x AS type) (psycopg v3).
|
||
--
|
||
-- ЗАВИСИМОСТИ: deals, listings (002_core_tables.sql). Применяется под gate _schema_migrations
|
||
-- (#637): ровно один раз по NN-порядку. Idempotent (CREATE TABLE IF NOT EXISTS,
|
||
-- ON CONFLICT DO UPDATE) — безопасно ре-apply / dry-run (BEGIN/ROLLBACK).
|
||
-- Apply after: 079_listing_source_history.sql
|
||
|
||
BEGIN;
|
||
|
||
-- ── asking_to_sold_ratios — коэффициенты asking→sold на (бакет комнат × район) ──
|
||
-- district NOT NULL DEFAULT '' — часть PK; #647 заполнит район, #648 всегда ''.
|
||
-- sold_median/ask_median nullable — диагностика; ratio NOT NULL (строку без ratio не пишем).
|
||
CREATE TABLE IF NOT EXISTS asking_to_sold_ratios (
|
||
rooms_bucket int NOT NULL, -- 0=студия..4=4+; -1 = global fallback row
|
||
district text NOT NULL DEFAULT '', -- RESERVED for #647 (always '' in #648)
|
||
ratio numeric NOT NULL, -- sold_median_ppm2 / ask_median_ppm2
|
||
sold_median bigint, -- median(deals.price_per_m2), диагностика
|
||
ask_median bigint, -- median(listings.price_per_m2), диагностика
|
||
n_deals int NOT NULL, -- сделок в окне/бакете
|
||
n_listings int NOT NULL, -- активных listings в бакете
|
||
window_months int NOT NULL, -- окно сделок (12)
|
||
basis text NOT NULL, -- 'per_rooms' | 'global_fallback'
|
||
computed_at timestamptz NOT NULL DEFAULT now(),
|
||
PRIMARY KEY (rooms_bucket, district)
|
||
);
|
||
|
||
COMMENT ON TABLE asking_to_sold_ratios IS
|
||
'Per-rooms asking→sold коэффициент (#648): ratio = median(SOLD ppm²)/median(ASKING ppm²). '
|
||
'rooms_bucket 0=студия..4=4+; -1 = global fallback (basis=global_fallback, пишется всегда). '
|
||
'Per-rooms строки только при n_deals>=30 AND n_listings>=30 (иначе estimator читает -1). '
|
||
'district зарезервирован под #647 (geo), в #648 всегда ''''. '
|
||
'Caveat: ask=ТЕКУЩИЕ listings vs sold=сделки за 12 мес (не point-in-time); ДКП=registered. '
|
||
'Refresh — Stage 4 asking_to_sold_ratio_refresh переиспользует derivation ниже.';
|
||
|
||
-- ── Derivation + seed ─────────────────────────────────────────────────────────
|
||
-- Вынесено как один INSERT...SELECT с CTE-«сторонами» (deal_side / ask_side), чтобы
|
||
-- Stage 4 (asking_to_sold_ratio_refresh) переиспользовал ровно эту логику. Окно сделок
|
||
-- = трейлинг 12 мес; listings — текущие активные. ppm²-полоса [30000,600000] и бакет
|
||
-- LEAST(GREATEST(rooms,0),4) — байт-в-байт как в харнесе (PPM2_MIN/PPM2_MAX, _bucketize_rooms).
|
||
WITH
|
||
-- SOLD медианы по бакетам комнат за трейлинг-12мес (ДКП Росреестра).
|
||
deal_side AS (
|
||
SELECT
|
||
LEAST(GREATEST(rooms, 0), 4) AS rooms_bucket,
|
||
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS sold_median,
|
||
COUNT(*) AS n_deals
|
||
FROM deals
|
||
WHERE source = 'rosreestr'
|
||
AND rooms IS NOT NULL
|
||
AND price_per_m2 BETWEEN 30000 AND 600000
|
||
AND deal_date >= CURRENT_DATE - INTERVAL '12 months'
|
||
GROUP BY LEAST(GREATEST(rooms, 0), 4)
|
||
),
|
||
-- ASKING медианы по бакетам комнат среди ТЕКУЩИХ активных объявлений.
|
||
ask_side AS (
|
||
SELECT
|
||
LEAST(GREATEST(rooms, 0), 4) AS rooms_bucket,
|
||
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS ask_median,
|
||
COUNT(*) AS n_listings
|
||
FROM listings
|
||
WHERE is_active
|
||
AND rooms IS NOT NULL
|
||
AND price_per_m2 BETWEEN 30000 AND 600000
|
||
-- novostroyki guard (#1364/#1186): NULL = legacy вторичка до м.011.
|
||
-- Secondary-only asking — sold-сторона ДКП вторичка; новостройки исказили бы ratio.
|
||
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
|
||
GROUP BY LEAST(GREATEST(rooms, 0), 4)
|
||
),
|
||
-- Per-rooms строки: только бакеты с обеими сторонами, прошедшие порог 30/30 и ask>0.
|
||
-- Тонкие бакеты (n<30) сюда НЕ попадают → estimator делает fallback на -1.
|
||
per_bucket AS (
|
||
SELECT
|
||
d.rooms_bucket,
|
||
''::text AS district,
|
||
(d.sold_median / a.ask_median)::numeric AS ratio,
|
||
round(d.sold_median)::bigint AS sold_median,
|
||
round(a.ask_median)::bigint AS ask_median,
|
||
d.n_deals::int AS n_deals,
|
||
a.n_listings::int AS n_listings,
|
||
12 AS window_months,
|
||
'per_rooms'::text AS basis
|
||
FROM deal_side d
|
||
JOIN ask_side a USING (rooms_bucket)
|
||
WHERE d.n_deals >= 30
|
||
AND a.n_listings >= 30
|
||
AND a.ask_median IS NOT NULL
|
||
AND a.ask_median > 0
|
||
AND d.sold_median IS NOT NULL -- divide-safety (n_deals>=30 это и так гарантирует)
|
||
AND d.sold_median > 0
|
||
),
|
||
-- SOLD медиана по ВСЕМ комнатам (без бакет-фильтра) за трейлинг-12мес — для global row.
|
||
deal_global AS (
|
||
SELECT
|
||
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS sold_median,
|
||
COUNT(*) AS n_deals
|
||
FROM deals
|
||
WHERE source = 'rosreestr'
|
||
AND rooms IS NOT NULL
|
||
AND price_per_m2 BETWEEN 30000 AND 600000
|
||
AND deal_date >= CURRENT_DATE - INTERVAL '12 months'
|
||
),
|
||
-- ASKING медиана по ВСЕМ активным listings (без бакет-фильтра) — для global row.
|
||
ask_global AS (
|
||
SELECT
|
||
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS ask_median,
|
||
COUNT(*) AS n_listings
|
||
FROM listings
|
||
WHERE is_active
|
||
AND rooms IS NOT NULL
|
||
AND price_per_m2 BETWEEN 30000 AND 600000
|
||
-- novostroyki guard (#1364/#1186): NULL = legacy вторичка до м.011.
|
||
-- Secondary-only asking — sold-сторона ДКП вторичка; новостройки исказили бы ratio.
|
||
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
|
||
),
|
||
-- Global fallback строка rooms_bucket=-1 (пишется всегда, если ask>0).
|
||
global_row AS (
|
||
SELECT
|
||
-1 AS rooms_bucket,
|
||
''::text AS district,
|
||
(d.sold_median / a.ask_median)::numeric AS ratio,
|
||
round(d.sold_median)::bigint AS sold_median,
|
||
round(a.ask_median)::bigint AS ask_median,
|
||
d.n_deals::int AS n_deals,
|
||
a.n_listings::int AS n_listings,
|
||
12 AS window_months,
|
||
'global_fallback'::text AS basis
|
||
FROM deal_global d
|
||
CROSS JOIN ask_global a
|
||
WHERE a.ask_median IS NOT NULL
|
||
AND a.ask_median > 0
|
||
AND d.sold_median IS NOT NULL -- защита от пустого окна сделок (иначе ratio=NULL)
|
||
AND d.sold_median > 0
|
||
)
|
||
INSERT INTO asking_to_sold_ratios (
|
||
rooms_bucket, district, ratio, sold_median, ask_median,
|
||
n_deals, n_listings, window_months, basis
|
||
)
|
||
SELECT rooms_bucket, district, ratio, sold_median, ask_median,
|
||
n_deals, n_listings, window_months, basis FROM global_row
|
||
UNION ALL
|
||
SELECT rooms_bucket, district, ratio, sold_median, ask_median,
|
||
n_deals, n_listings, window_months, basis FROM per_bucket
|
||
ON CONFLICT (rooms_bucket, district) DO UPDATE SET
|
||
ratio = EXCLUDED.ratio,
|
||
sold_median = EXCLUDED.sold_median,
|
||
ask_median = EXCLUDED.ask_median,
|
||
n_deals = EXCLUDED.n_deals,
|
||
n_listings = EXCLUDED.n_listings,
|
||
window_months = EXCLUDED.window_months,
|
||
basis = EXCLUDED.basis,
|
||
computed_at = now();
|
||
|
||
COMMIT;
|