-- 098_asking_to_sold_ratios_tiered.sql
-- #928 -- ppm2-tier segmentation of the asking->sold ratio pool.
--
-- ПРОБЛЕМА (#928). Единый per-rooms ratio (migration 080, asking_to_sold_ratios)
--   смешивает в одном коэффициенте квартиры разных ценовых сегментов. Backtest
--   показал, что ratio варьируется ~0.91 (эконом) -> 0.99 (премиум): global ratio
--   ~0.80 = средневзвешенное. ppm2-тир: binning listings ВНУТРИ каждого rooms_bucket
--   на три равных квартиля (33.33/66.67) -> три tier-строки с ratio + empirical-Bayes
--   shrink к per-rooms 'all' (K=150).
--
-- ПОЧЕМУ ПАРАЛЛЕЛЬНАЯ ТАБЛИЦА, а не ADD COLUMN на asking_to_sold_ratios:
--   Flag-OFF путь: SELECT ratio, basis FROM asking_to_sold_ratios
--     WHERE rooms_bucket=B AND district='' -> fetchone(). Добавление tier-строк
--   в ту же таблицу с другим PK -> fetchone() вернул бы произвольную строку ->
--   тихая продовая регрессия. Параллельная таблица изолирует tier-данные;
--   старый путь (flag OFF) остаётся байт-в-байт идентичным.
--
-- СОЗДАЁТ (additive, idempotent -- без DROP/ALTER любых существующих объектов):
--   asking_to_sold_tier_bounds -- ppm2-квантили (33.33/66.67 pct) per бакет.
--   asking_to_sold_ratios_tiered -- per-tier ratio, 'all' агрегаты, global -1.
--     ppm2_tier: 'low'|'mid'|'high' (tier) или 'all' (per-rooms агрегат / global).
--     basis: 'per_rooms_tier' | 'per_rooms_all' | 'global_all'.
--
-- МЕТОДОЛОГИЯ:
--   bounds: percentile_cont(0.3333/0.6667) по listings.price_per_m2
--     (is_active, ppm2 in [30000,1200000]) per rooms_bucket.
--   Tier rows: listings и deals биннятся в low/mid/high по t33/t66 СВОЕГО бакета.
--     ratio = percentile_cont(0.5) deals / percentile_cont(0.5) listings в ячейке.
--     Только ячейки n_deals>=30 AND n_listings>=30 AND ask_median>0 AND sold_median>0.
--   'all' строки: per-rooms 080-логика с полосой [30000,1200000], basis='per_rooms_all'.
--   global строка: (-1,'','all') 080-логика, basis='global_all'.
--
-- ВАЛИДАЦИЯ (продовые числа 2026-05-31): все 15 ячеек (5 rooms x 3 tier) имеют
--   n_deals>=169, n_listings>=583. rb=2 t33=129167 t66=163870;
--   rb=2 low~0.911, mid~0.978, high~0.991; rb=2 'all'~0.803; global 'all'~0.790.
--
-- ЗАВИСИМОСТИ: deals, listings (002_core_tables.sql).
-- Apply after: 097_index_hygiene.sql

BEGIN;

-- asking_to_sold_tier_bounds -- ppm2-квантили активных listings per бакет --------
-- t33/t66 -- 33.33/66.67 pct ppm2 в бакете. Refresh атомарно (DELETE + re-derive).
CREATE TABLE IF NOT EXISTS asking_to_sold_tier_bounds (
    rooms_bucket  int         NOT NULL,              -- 0=студия..4=4+
    district      text        NOT NULL DEFAULT '',   -- RESERVED для #647; всегда ''
    t33           bigint      NOT NULL,              -- 33.33-й pct ppm2 listings в бакете
    t66           bigint      NOT NULL,              -- 66.67-й pct ppm2 listings в бакете
    n_listings    int         NOT NULL,              -- число активных listings в бакете
    computed_at   timestamptz NOT NULL DEFAULT now(),
    PRIMARY KEY (rooms_bucket, district)
);

COMMENT ON TABLE asking_to_sold_tier_bounds IS
    'ppm2-квантили активных listings per rooms_bucket (#928): t33/t66 = 33.33/66.67 pct '
    'price_per_m2 среди is_active AND ppm2 in [30000,1200000]. Используется estimator-ом '
    'для tier-assignment (low/mid/high) при флаге tier_aware_ratio_enabled=True. '
    'Refresh -- asking_to_sold_ratio_refresh DELETE WHERE district='''' + re-derive. '
    'district RESERVED под #647 (geo), всегда ''''. '
    'Параллельная к asking_to_sold_ratios (#928 rationale: fetchone-safety).';

-- asking_to_sold_ratios_tiered -- tier-коэффициенты asking->sold (#928) -----------
-- ppm2_tier: 'low'|'mid'|'high' = tier-строки; 'all' = per-rooms агрегат / global.
-- basis: 'per_rooms_tier'|'per_rooms_all'|'global_all'.
-- MIN-SAMPLE: ячейки n_deals<30 OR n_listings<30 не пишутся -> estimator fallback.
CREATE TABLE IF NOT EXISTS asking_to_sold_ratios_tiered (
    rooms_bucket  int         NOT NULL,              -- 0=студия..4=4+; -1 = global
    district      text        NOT NULL DEFAULT '',   -- RESERVED под #647; всегда ''
    ppm2_tier     text        NOT NULL,              -- 'low'|'mid'|'high' | 'all'
    ratio         numeric     NOT NULL,              -- sold_median_ppm2 / ask_median_ppm2
    sold_median   bigint,                            -- медиана deals.price_per_m2
    ask_median    bigint,                            -- медиана listings.price_per_m2
    n_deals       int         NOT NULL,              -- сделок в ячейке за 12 мес
    n_listings    int         NOT NULL,              -- активных listings в ячейке
    window_months int         NOT NULL,              -- окно сделок (12)
    basis         text        NOT NULL,              -- 'per_rooms_tier'|'per_rooms_all'|'global_all'
    computed_at   timestamptz NOT NULL DEFAULT now(),
    PRIMARY KEY (rooms_bucket, district, ppm2_tier)
);

COMMENT ON TABLE asking_to_sold_ratios_tiered IS
    'Per-rooms-tier asking->sold коэффициент (#928): ratio = median(SOLD)/median(ASKING) '
    'внутри ppm2-тира бакета. ppm2_tier: low/mid/high (tier), all (per-rooms / global). '
    'rooms_bucket 0=студия..4=4+; -1 = global fallback (ppm2_tier=''all'', basis=''global_all''). '
    'Tier-строки только при n_deals>=30 AND n_listings>=30. '
    'Параллельная к asking_to_sold_ratios: flag OFF читает OLD таблицу (fetchone-safety). '
    'district RESERVED под #647 (geo), всегда ''''. '
    'Refresh -- asking_to_sold_ratio_refresh атомарно DELETE WHERE district='''' + re-derive.';

-- Seed derivation ----------------------------------------------------------------
-- ppm2-полоса [30000, 1200000] -- settings-default (#767). Окно = трейлинг 12 мес.
-- ON CONFLICT DO UPDATE -- идемпотентный re-apply / dry-run.
-- ppm² band literals MUST equal _PPM2_MIN (30000) and settings.asking_ratio_ppm2_max
-- default (1_200_000) — see app/tasks/asking_to_sold_ratio.py. If that setting's DEFAULT
-- changes, update these literals (or fresh-seed vs daily-refresh diverge).

-- Step 1: bounds (ppm2-квантили по listings per бакет) ---------------------------
WITH bounds AS (
    SELECT
        LEAST(GREATEST(rooms, 0), 4)                                            AS rooms_bucket,
        percentile_cont(0.3333) WITHIN GROUP (ORDER BY price_per_m2)::bigint    AS t33,
        percentile_cont(0.6667) WITHIN GROUP (ORDER BY price_per_m2)::bigint    AS t66,
        COUNT(*)::int                                                            AS n_listings
    FROM listings
    WHERE is_active
      AND rooms IS NOT NULL
      AND price_per_m2 BETWEEN 30000 AND 1200000
    GROUP BY LEAST(GREATEST(rooms, 0), 4)
    HAVING COUNT(*) > 0
)
INSERT INTO asking_to_sold_tier_bounds (rooms_bucket, district, t33, t66, n_listings)
SELECT rooms_bucket, ''::text, t33, t66, n_listings FROM bounds
ON CONFLICT (rooms_bucket, district) DO UPDATE SET
    t33         = EXCLUDED.t33,
    t66         = EXCLUDED.t66,
    n_listings  = EXCLUDED.n_listings,
    computed_at = now();

-- Step 2: tier rows (low/mid/high) + 'all' rows + global row --------------------
WITH
-- Bounds уже в таблице (Step 1). Читаем для join-а с listings и deals.
bnd AS (
    SELECT rooms_bucket, t33, t66 FROM asking_to_sold_tier_bounds WHERE district = ''
),
deal_tiered AS (
    SELECT
        LEAST(GREATEST(d.rooms, 0), 4)                 AS rooms_bucket,
        CASE
            WHEN d.price_per_m2 < b.t33 THEN 'low'
            WHEN d.price_per_m2 < b.t66 THEN 'mid'
            ELSE 'high'
        END                                            AS ppm2_tier,
        d.price_per_m2
    FROM deals d
    JOIN bnd b ON b.rooms_bucket = LEAST(GREATEST(d.rooms, 0), 4)
    WHERE d.source = 'rosreestr'
      AND d.rooms IS NOT NULL
      AND d.price_per_m2 BETWEEN 30000 AND 1200000
      AND d.deal_date >= CURRENT_DATE - INTERVAL '12 months'
),
ask_tiered AS (
    SELECT
        LEAST(GREATEST(l.rooms, 0), 4)                 AS rooms_bucket,
        CASE
            WHEN l.price_per_m2 < b.t33 THEN 'low'
            WHEN l.price_per_m2 < b.t66 THEN 'mid'
            ELSE 'high'
        END                                            AS ppm2_tier,
        l.price_per_m2
    FROM listings l
    JOIN bnd b ON b.rooms_bucket = LEAST(GREATEST(l.rooms, 0), 4)
    WHERE l.is_active
      AND l.rooms IS NOT NULL
      AND l.price_per_m2 BETWEEN 30000 AND 1200000
),
deal_side_tier AS (
    SELECT
        rooms_bucket,
        ppm2_tier,
        percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS sold_median,
        COUNT(*)::int                                              AS n_deals
    FROM deal_tiered
    GROUP BY rooms_bucket, ppm2_tier
),
ask_side_tier AS (
    SELECT
        rooms_bucket,
        ppm2_tier,
        percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS ask_median,
        COUNT(*)::int                                              AS n_listings
    FROM ask_tiered
    GROUP BY rooms_bucket, ppm2_tier
),
-- Tier строки -- только ячейки с n>=30 с обеих сторон.
tier_rows AS (
    SELECT
        d.rooms_bucket,
        ''::text                                  AS district,
        d.ppm2_tier,
        (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,
        a.n_listings,
        12                                        AS window_months,
        'per_rooms_tier'::text                    AS basis
    FROM deal_side_tier d
    JOIN ask_side_tier a USING (rooms_bucket, ppm2_tier)
    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
      AND d.sold_median > 0
),
-- 'all' строки: повторяет 080-логику с полосой [30000,1200000].
deal_side_all 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(*)::int                                                      AS n_deals
    FROM deals
    WHERE source = 'rosreestr'
      AND rooms IS NOT NULL
      AND price_per_m2 BETWEEN 30000 AND 1200000
      AND deal_date >= CURRENT_DATE - INTERVAL '12 months'
    GROUP BY LEAST(GREATEST(rooms, 0), 4)
),
ask_side_all 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(*)::int                                                      AS n_listings
    FROM listings
    WHERE is_active
      AND rooms IS NOT NULL
      AND price_per_m2 BETWEEN 30000 AND 1200000
    GROUP BY LEAST(GREATEST(rooms, 0), 4)
),
all_rows AS (
    SELECT
        d.rooms_bucket,
        ''::text                                  AS district,
        'all'::text                               AS ppm2_tier,
        (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,
        a.n_listings,
        12                                        AS window_months,
        'per_rooms_all'::text                     AS basis
    FROM deal_side_all d
    JOIN ask_side_all 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
      AND d.sold_median > 0
),
deal_global AS (
    SELECT
        percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS sold_median,
        COUNT(*)::int                                              AS n_deals
    FROM deals
    WHERE source = 'rosreestr'
      AND rooms IS NOT NULL
      AND price_per_m2 BETWEEN 30000 AND 1200000
      AND deal_date >= CURRENT_DATE - INTERVAL '12 months'
),
ask_global AS (
    SELECT
        percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS ask_median,
        COUNT(*)::int                                              AS n_listings
    FROM listings
    WHERE is_active
      AND rooms IS NOT NULL
      AND price_per_m2 BETWEEN 30000 AND 1200000
),
global_row AS (
    SELECT
        -1                                        AS rooms_bucket,
        ''::text                                  AS district,
        'all'::text                               AS ppm2_tier,
        (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,
        a.n_listings,
        12                                        AS window_months,
        'global_all'::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
      AND d.sold_median > 0
)
INSERT INTO asking_to_sold_ratios_tiered (
    rooms_bucket, district, ppm2_tier, ratio, sold_median, ask_median,
    n_deals, n_listings, window_months, basis
)
SELECT rooms_bucket, district, ppm2_tier, ratio, sold_median, ask_median,
       n_deals, n_listings, window_months, basis FROM global_row
UNION ALL
SELECT rooms_bucket, district, ppm2_tier, ratio, sold_median, ask_median,
       n_deals, n_listings, window_months, basis FROM all_rows
UNION ALL
SELECT rooms_bucket, district, ppm2_tier, ratio, sold_median, ask_median,
       n_deals, n_listings, window_months, basis FROM tier_rows
ON CONFLICT (rooms_bucket, district, ppm2_tier) 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;
