-- 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;
