import http.server
from html import escape as html_escape
import json
import math
import os
import re
import subprocess
import sys
import threading
import time
import urllib.request
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import timedelta
from urllib.parse import urlparse, parse_qs
from zoneinfo import ZoneInfo
from tv_enrichment import atomic_write_json, atomic_write_text
from neobdm_common import (
    datetime,
    is_market_open as is_idx_market_open,
    load_env,
    load_pa_telegram_config,
    market_elapsed_minutes,
    market_total_minutes,
    market_sessions,
    notify_telegram,
    round_idx_tick,
)
from trend_break_pa_alert import trend_break_pa_alert_status

# Safe stubs for unused legacy strategies
class _DummyModule:
    def __getattr__(self, name):
        return lambda *args, **kwargs: {}

try:
    from pa_decision import (
        STATUS_CANDIDATE,
        STATUS_READY,
        STATUS_SKIP,
        STATUS_WAIT,
        classify_snapshot,
    )
except ImportError:
    STATUS_CANDIDATE = STATUS_READY = STATUS_SKIP = STATUS_WAIT = ""
    classify_snapshot = lambda *args, **kwargs: {}

try:
    from pa_quality import snapshot_quality
except ImportError:
    snapshot_quality = lambda *args, **kwargs: {}

try:
    from pa_risk import rr_is_valid
except ImportError:
    rr_is_valid = lambda *args, **kwargs: True

try:
    from external_signal_rules import RULES as EXTERNAL_SIGNAL_RULES
except ImportError:
    EXTERNAL_SIGNAL_RULES = {}

try:
    from konglo_leadership import load_konglo_leadership_payload
except ImportError:
    load_konglo_leadership_payload = lambda *args, **kwargs: {}

try:
    from konglo_flight_performance import (
        load_flight_performance_summary,
        update_flight_performance,
    )
except ImportError:
    load_flight_performance_summary = update_flight_performance = lambda *args, **kwargs: {}

try:
    from vcp_performance import load_vcp_performance_payload
except ImportError:
    load_vcp_performance_payload = lambda *args, **kwargs: {}

try:
    import pb1m_breakout as pb1m
except ImportError:
    pb1m = _DummyModule()

try:
    import pb1m_research
except ImportError:
    pb1m_research = _DummyModule()

try:
    import pb1m_v2
except ImportError:
    pb1m_v2 = _DummyModule()

try:
    import ara_hunter_v2
except ImportError:
    ara_hunter_v2 = _DummyModule()

try:
    import momentum_ignition
except ImportError:
    momentum_ignition = _DummyModule()

try:
    import pb1m_telegram_alert
except ImportError:
    pb1m_telegram_alert = _DummyModule()

try:
    import trending_pa_telegram_alert
except ImportError:
    trending_pa_telegram_alert = _DummyModule()

try:
    from sr_trend_analysis import (
        analyze_daily_sr_trend,
        finite_number,
        normalize_input_items,
    )
except ImportError:
    analyze_daily_sr_trend = lambda *args, **kwargs: {}
    finite_number = lambda x, d=0.0: d
    normalize_input_items = lambda x: []

try:
    from dmas_pipeline_adapter import (
        available_dates as dmas_available_dates,
        build_payload as build_dmas_payload,
        dmas_job_status,
        start_dmas_pipeline_job,
    )
except ImportError:
    dmas_available_dates = lambda: []
    build_dmas_payload = lambda *args, **kwargs: {}
    dmas_job_status = lambda: {}
    start_dmas_pipeline_job = lambda *args, **kwargs: {}

PROJECT_DIR = os.path.dirname(os.path.abspath(__file__))
RUNTIME_DIR = os.path.join(PROJECT_DIR, ".runtime")
os.makedirs(RUNTIME_DIR, exist_ok=True)
PORT = int(os.environ.get('PORT', 8090))
JOB_STATUS_FILE = os.path.join(RUNTIME_DIR, "screener_job.json")
AUTO_SCAN_STATE_FILE = os.path.join(RUNTIME_DIR, "trendbreak_auto.json")
TREND_BREAK_LIVE_STATE_FILE = os.path.join(RUNTIME_DIR, "trend_break_live.json")
TREND_BREAK_LIVE_LOG_FILE = os.path.join(RUNTIME_DIR, "trend_break_live.log")
TRENDING_RADAR_HISTORY_FILE = os.path.join(
    PROJECT_DIR, "data", "trending_radar_history.json"
)
TRENDING_RADAR_HISTORY_LOCK = threading.RLock()
TRENDING_PA_TRADE_HISTORY_FILE = os.path.join(PROJECT_DIR, "data", "trending_pa_trade_history.json")
TRENDING_PA_TRADE_HISTORY_LOCK = threading.RLock()
TRENDING_PA_AB_FILE = os.path.join(PROJECT_DIR, "data", "trending_pa_ab_history.json")
TRENDING_PA_AB_LOCK = threading.RLock()
PB1M_CACHE = {"created_at": 0.0, "payload": None}
PB1M_CACHE_LOCK = threading.Lock()
PB1M_BUILD_LOCK = threading.Lock()
PB1M_CACHE_TTL = 60
PB1M_V2_CACHE = {"created_at": 0.0, "payload": None}
PB1M_V2_CACHE_LOCK = threading.Lock()
PB1M_V2_BUILD_LOCK = threading.Lock()
PB1M_V2_CACHE_TTL = 60
ARA_HUNTER_V2_CACHE = {"created_at": 0.0, "payload": None}
ARA_HUNTER_V2_CACHE_LOCK = threading.Lock()
ARA_HUNTER_V2_BUILD_LOCK = threading.Lock()
ARA_HUNTER_V2_CACHE_TTL = 60
FULL_IDX_INTRADAY_CACHE = {"created_at": 0.0, "payload": None}
FULL_IDX_INTRADAY_CACHE_LOCK = threading.Lock()
FULL_IDX_INTRADAY_BUILD_LOCK = threading.Lock()
FULL_IDX_INTRADAY_CACHE_TTL = 60
MOMENTUM_IGNITION_CACHE = {"created_at": 0.0, "payload": None}
MOMENTUM_IGNITION_CACHE_LOCK = threading.Lock()
MOMENTUM_IGNITION_BUILD_LOCK = threading.Lock()
MOMENTUM_IGNITION_CACHE_TTL = 60
TRENDING_RADAR_CACHE = {"created_at": 0.0, "payload": None}
TRENDING_RADAR_CACHE_LOCK = threading.Lock()
TRENDING_RADAR_BUILD_LOCK = threading.Lock()
TRENDING_RADAR_CACHE_TTL = 300
TRENDING_RADAR_PA_LIMIT = 20
# Radar/ledger yang sebelumnya hanya berjalan ketika endpoint dashboard dipanggil
# sekarang dipanaskan oleh worker headless di dalam proses server.  Default aktif
# supaya histori paper tidak bergantung pada browser yang sedang terbuka.
BACKGROUND_AUTOMATION_STATE_FILE = os.path.join(RUNTIME_DIR, "background_automation.json")
BACKGROUND_AUTOMATION_ENABLED = os.environ.get(
    "BACKGROUND_AUTOMATION_ENABLED", "1"
).strip().lower() not in {"0", "false", "no", "off"}
BACKGROUND_AUTOMATION_POLL_SEC = max(
    15, int(os.environ.get("BACKGROUND_AUTOMATION_POLL_SEC", "15"))
)
BACKGROUND_AUTOMATION_FAST_INTERVAL_SEC = max(
    60, int(os.environ.get("BACKGROUND_AUTOMATION_FAST_INTERVAL_SEC", "60"))
)
BACKGROUND_AUTOMATION_TRENDING_INTERVAL_SEC = max(
    300, int(os.environ.get("BACKGROUND_AUTOMATION_TRENDING_INTERVAL_SEC", "300"))
)
BACKGROUND_AUTOMATION_LOCK = threading.Lock()
TREND_BREAK_WATCHLIST_FILE = os.path.join(
    PROJECT_DIR, "data", "trend_break_watchlist.json"
)
TREND_BREAK_WATCHLIST_LOCK = threading.RLock()
TREND_BREAK_WATCHLIST_MAX = 500
# Daftar sumber hanya dipakai bila auto-scan diaktifkan secara eksplisit.
AUTO_SCAN_SOURCES = [s.strip() for s in os.environ.get(
    "TRENDBREAK_AUTO_SOURCES", "trendbreak"
).split(",") if s.strip()]
AUTO_SCAN_SOURCE = AUTO_SCAN_SOURCES[0] if AUTO_SCAN_SOURCES else "trendbreak"
# Interval ini hanya berlaku bila auto-scan diaktifkan secara eksplisit.
AUTO_SCAN_INTERVAL_MIN = max(1, int(os.environ.get("TRENDBREAK_AUTO_INTERVAL_MIN", "15")))
# Scanner hanya dijalankan dari perintah pengguna. Opt-in ini mencegah server
# dashboard memulai scan berulang hanya karena ia sedang hidup.
AUTO_SCAN_ENABLED = os.environ.get("TRENDBREAK_AUTO_ENABLED", "0").strip().lower() not in {"0", "false", "no", "off"}
AUTO_SCAN_POLL_SEC = max(15, int(os.environ.get("TRENDBREAK_AUTO_POLL_SEC", "20")))
LIGHTWEIGHT_LIVE_SCAN_LOCK = threading.Lock()
# Trend Break memakai scanner TradingView market-wide yang ringan dan terpisah
# dari pipeline broker. Default aktif supaya dashboard memperbarui kandidat baru
# ketika sesi IDX berjalan.
TREND_BREAK_LIVE_ENABLED = os.environ.get(
    "TREND_BREAK_LIVE_ENABLED", "1"
).strip().lower() not in {"0", "false", "no", "off"}
TREND_BREAK_LIVE_INTERVAL_SEC = max(
    30, int(os.environ.get("TREND_BREAK_LIVE_INTERVAL_SEC", "60"))
)
TREND_BREAK_LIVE_POLL_SEC = max(
    5, int(os.environ.get("TREND_BREAK_LIVE_POLL_SEC", "10"))
)
TREND_BREAK_LIVE_TIMEOUT_SEC = max(
    20, int(os.environ.get("TREND_BREAK_LIVE_TIMEOUT_SEC", "90"))
)
TREND_BREAK_LIVE_LOCK = threading.Lock()
# PA paper collector: records TradingView observations and resolves paper-only
# outcomes while the dashboard server is running.  It never places orders.
PA_PAPER_ENABLED = os.environ.get(
    "PA_PAPER_ENABLED", "1"
).strip().lower() not in {"0", "false", "no", "off"}
PA_PAPER_TELEGRAM_ENABLED = str(
    os.environ.get(
        "PA_PAPER_TELEGRAM_ENABLED",
        "1" if load_pa_telegram_config()["enabled"] else "0",
    )
).strip().lower() not in {"0", "false", "no", "off"}
PA_PAPER_INTERVAL_SEC = max(
    300, int(os.environ.get("PA_PAPER_INTERVAL_SEC", "300"))
)
PA_PAPER_POLL_SEC = max(
    15, int(os.environ.get("PA_PAPER_POLL_SEC", "30"))
)
PA_PAPER_STATE_FILE = os.path.join(RUNTIME_DIR, "pa_paper.json")
PA_PAPER_LOCK = threading.Lock()
PA_HISTORY_LOCK = threading.RLock()
PA_ALERT_KEYS = set()
PA_ALERT_LOCK = threading.Lock()
PA_TRIGGER_STATES = {}
PA_TRIGGER_LOCK = threading.Lock()
PA_TRIGGER_STATE_LOADED = False
VCP_SCAN_JOB_FILE = os.path.join(RUNTIME_DIR, "vcp_scan_job.json")
VCP_SCAN_LOCK = threading.Lock()
VCP_AUTO_ENABLED = os.environ.get(
    "VCP_AUTO_ENABLED", "1"
).strip().lower() not in {"0", "false", "no", "off"}
# VCP is a daily pattern scanner.  Keep the automatic job out of the session
# and wait briefly after the official close so Yahoo Finance can publish the
# completed daily OHLCV bar.  VCP_AUTO_INTERVAL_SEC remains a compatibility
# fallback for existing deployments; the new name describes the behavior.
VCP_AUTO_CLOSE_DELAY_SEC = max(
    0,
    int(os.environ.get(
        "VCP_AUTO_CLOSE_DELAY_SEC",
        os.environ.get("VCP_AUTO_INTERVAL_SEC", "900"),
    )),
)
# Backward-compatible alias for callers that still import the old setting.
VCP_AUTO_INTERVAL_SEC = VCP_AUTO_CLOSE_DELAY_SEC
VCP_AUTO_POLL_SEC = max(
    15, int(os.environ.get("VCP_AUTO_POLL_SEC", "30"))
)
VCP_PA_ALERT_ENABLED = os.environ.get(
    "VCP_PA_ALERT_ENABLED", "1"
).strip().lower() not in {"0", "false", "no", "off"}
VCP_PA_ALERT_POLL_SEC = max(
    10, int(os.environ.get("VCP_PA_ALERT_POLL_SEC", "10"))
)
VCP_PA_ALERT_STATE_FILE = os.environ.get(
    "VCP_PA_ALERT_STATE_FILE", os.path.join(RUNTIME_DIR, "vcp_pa_alert.json")
)
VCP_PA_ALERT_LOCK = threading.RLock()
MARKET_TZ = ZoneInfo("Asia/Jakarta")
SCREENER_SOURCES = {
    "market": "Market Summary",
    "dashboard": "Dashboard - Top Akum",
    "konglo": "Watchlist Konglo",
    "lq45": "Watchlist LQ45",
    "kompas100": "Kompas100 · Technical Gate",
    "topscore": "Chamid Top Score",
    "api": "Fast Mode",
    "reentry_review": "Re-entry Review",
    "trendbreak": "Patah Tren",
    "all": "Semua Screener",
}


def reward_risk_ratio(entry_price, sl_price, target_price):
    risk = float(entry_price or 0) - float(sl_price or 0)
    reward = float(target_price or 0) - float(entry_price or 0)
    if risk <= 0 or reward <= 0:
        return 0.0
    return reward / risk


def min_rr_for_setup(setup_type):
    return 2.0


SWING_ALERT_KEYS = set()
SWING_ALERT_LOCK = threading.Lock()


def swing_breakout_telegram_config():
    """Return the configured Telegram destination used by Swing alerts.

    The PA destination intentionally has its own Swing enable switch.  This
    keeps the PA dashboard alert path disabled while allowing Swing alerts to
    use the PA Telegram bot/chat when requested.
    """
    env = load_env()
    raw_enabled = os.environ.get(
        "SWING_BREAKOUT_TELEGRAM_ENABLED",
        env.get("SWING_BREAKOUT_TELEGRAM_ENABLED", "false"),
    )
    swing_enabled = str(raw_enabled).strip().lower() not in {"0", "false", "no", "off"}
    destination = str(os.environ.get(
        "SWING_BREAKOUT_TELEGRAM_DESTINATION",
        env.get("SWING_BREAKOUT_TELEGRAM_DESTINATION", "GENERAL"),
    )).strip().upper()
    if destination == "PA":
        pa_config = load_pa_telegram_config()
        return {
            "enabled": swing_enabled and bool(pa_config["token"] and pa_config["chat_id"]),
            "general_enabled": swing_enabled,
            "token": pa_config["token"],
            "chat_id": pa_config["chat_id"],
            "destination": "Price Action",
        }
    raw_general = os.environ.get("TELEGRAM_ENABLED", env.get("TELEGRAM_ENABLED", "true"))
    general_enabled = str(raw_general).strip().lower() not in {"0", "false", "no", "off"}
    return {
        "enabled": swing_enabled and general_enabled,
        "general_enabled": general_enabled,
        "token": env.get("TELEGRAM_TOKEN", ""),
        "chat_id": env.get("TELEGRAM_CHAT_ID", ""),
        "destination": "General",
    }


def format_swing_breakout_telegram_alert(trade, test=False):
    """Format one Swing Breakout paper alert from persisted trade data."""
    trade = trade or {}
    ticker = str(trade.get("ticker") or "-").strip().upper()
    entry = swing_breakout_number(trade.get("entry"))
    stop = swing_breakout_number(trade.get("sl"))
    target = swing_breakout_number(trade.get("target"))
    if not ticker or entry is None or stop is None or target is None:
        raise ValueError("data MIKA/alert tidak memiliki entry, SL, atau target valid")
    risk_per_share = entry - stop
    reward_per_share = target - entry
    if entry <= 0 or risk_per_share <= 0 or reward_per_share <= 0:
        raise ValueError("hubungan entry, SL, dan target tidak valid")
    risk_pct = risk_per_share / entry * 100
    reward_pct = reward_per_share / entry * 100
    rr = reward_per_share / risk_per_share
    if rr < 1.5 - 1e-9:
        raise ValueError("RR Swing Breakout di bawah 1,5R")
    prefix = "[TEST] " if test else ""
    stockbit_url = f"https://stockbit.com/symbol/{ticker}"
    return (
        f"{prefix}[READY BUY SWING PAPER] 🚨 SIAP ENTRY\n"
        f"📌 Ticker: {ticker}\n"
        f"🔗 <a href=\"{stockbit_url}\">Buka saham {ticker} di Stockbit</a>\n"
        f"🕒 Terdeteksi: {trade.get('signal_date') or trade.get('date') or '-'} WIB\n\n"
        f"💰 Entry plan: {entry:,.0f}\n"
        f"🛑 Stop Loss: {stop:,.0f}\n"
        f"🎯 Target: {target:,.0f}\n\n"
        f"⚖️ Risk: {risk_pct:.2f}% ({risk_per_share:,.0f} per saham)\n"
        f"📈 Reward: {reward_pct:.2f}% ({reward_per_share:,.0f} per saham)\n"
        f"📐 Risk/Reward: {rr:.2f}R\n\n"
        f"✅ Setup: {trade.get('setup') or 'BREAKOUT/RETEST'}\n"
        f"✅ Trigger: {trade.get('trigger') or 'Daily breakout confirmed + retest resistance'}\n"
        f"📊 Volume: {float(trade.get('volume_ratio_10d') or 0):.2f}x\n"
        f"📈 Trend: {trade.get('trend') or '-'}"
    )


def _swing_alert_price_text(value):
    number = swing_breakout_number(value)
    return f"{number:,.0f}" if number is not None else "—"


def _swing_alert_pct_text(value):
    number = swing_breakout_number(value)
    return f"{number:+.2f}%" if number is not None else "—"


def format_swing_breakout_outcome_telegram_alert(trade, test=False):
    """Format one deduplicated paper-trade outcome alert for Swing."""
    trade = trade or {}
    ticker = str(trade.get("ticker") or "-").strip().upper()
    outcome = str(trade.get("status") or trade.get("outcome") or "").strip().lower()
    if outcome in {"win", "tp", "tp1", "tp2"}:
        title = "TP HIT"
        icon = "✅"
    elif outcome in {"loss", "sl"}:
        title = "STOP LOSS HIT"
        icon = "🛑"
    else:
        raise ValueError("outcome Swing harus berupa TP atau stop loss")

    entry = swing_breakout_number(trade.get("entry"))
    exit_price = swing_breakout_number(trade.get("exit_price"))
    if not ticker or entry is None or entry <= 0 or exit_price is None or exit_price <= 0:
        raise ValueError("data outcome Swing tidak memiliki entry atau exit valid")

    stop = swing_breakout_number(trade.get("sl"))
    target = swing_breakout_number(trade.get("target"))
    risk_per_share = entry - stop if stop is not None else None
    reward_per_share = target - entry if target is not None else None
    risk_pct = swing_breakout_number(trade.get("risk_pct"))
    reward_pct = swing_breakout_number(trade.get("reward_pct"))
    rr = swing_breakout_number(trade.get("rr"))
    result_pct = swing_breakout_number(
        trade.get("net_return_pct", trade.get("profit_pct"))
    )
    rr_line = f"📐 Risk/Reward plan: {rr:.2f}R\n\n" if rr is not None else ""
    stockbit_url = f"https://stockbit.com/symbol/{ticker}"
    prefix = "[TEST] " if test else ""
    return (
        f"{prefix}[SWING PAPER OUTCOME] {icon} {title}\n"
        f"📌 Ticker: {ticker}\n"
        f"🔗 <a href=\"{stockbit_url}\">Buka saham {ticker} di Stockbit</a>\n"
        f"🕒 Entry: {trade.get('signal_date') or trade.get('entry_date') or trade.get('date') or '-'} WIB\n"
        f"🏁 Exit: {trade.get('exit_time') or trade.get('exit_date') or '-'} WIB\n\n"
        f"💰 Harga entry: {_swing_alert_price_text(entry)}\n"
        f"🏁 Harga exit: {_swing_alert_price_text(exit_price)}\n"
        f"🛑 Stop Loss: {_swing_alert_price_text(stop)}\n"
        f"🎯 Target: {_swing_alert_price_text(target)}\n\n"
        f"📊 Hasil net: {_swing_alert_pct_text(result_pct)}\n"
        f"⚖️ Risk plan: {_swing_alert_pct_text(risk_pct)}"
        f" ({_swing_alert_price_text(risk_per_share)} per saham)\n"
        f"📈 Reward plan: {_swing_alert_pct_text(reward_pct)}"
        f" ({_swing_alert_price_text(reward_per_share)} per saham)\n"
        f"{rr_line}"
    ) + (
        f"📝 Alasan: {trade.get('outcome_reason') or trade.get('reason') or '-'}"
    )


def send_swing_breakout_outcome_telegram_alert(trade, test=False):
    """Schedule one deduplicated TP/SL outcome alert for a Swing trade."""
    config = swing_breakout_telegram_config()
    if not config["enabled"]:
        return False, "Swing Breakout Telegram dinonaktifkan"
    if not config["general_enabled"] or not config["token"] or not config["chat_id"]:
        return False, f"Telegram {config.get('destination', 'tujuan')} belum dikonfigurasi/aktif"
    message = format_swing_breakout_outcome_telegram_alert(trade, test=test)
    outcome = str((trade or {}).get("status") or (trade or {}).get("outcome") or "").strip().lower()
    base_key = str((trade or {}).get("signal_key") or (trade or {}).get("id") or "").strip()
    exit_key = str((trade or {}).get("exit_time") or (trade or {}).get("exit_date") or "").strip()
    key = f"OUTCOME|{base_key}|{outcome}|{exit_key}|{(trade or {}).get('exit_price', '')}"
    if not test:
        if not base_key:
            return False, "signal_key trade Swing kosong untuk outcome"
        with SWING_ALERT_LOCK:
            if key in SWING_ALERT_KEYS:
                return True, "alert outcome Swing sudah pernah dijadwalkan"
            SWING_ALERT_KEYS.add(key)

        def deliver():
            ok = notify_telegram(
                config["token"],
                config["chat_id"],
                message,
                enabled=config["general_enabled"],
                alert_mode="ALL",
            )
            if not ok:
                with SWING_ALERT_LOCK:
                    SWING_ALERT_KEYS.discard(key)

        threading.Thread(target=deliver, daemon=True).start()
        return True, "alert outcome Swing dijadwalkan"
    ok = notify_telegram(
        config["token"],
        config["chat_id"],
        message,
        enabled=config["general_enabled"],
        alert_mode="ALL",
    )
    return (True, "test alert outcome Swing terkirim") if ok else (False, "test alert outcome Swing gagal dikirim")


def send_swing_breakout_telegram_alert(trade, test=False):
    """Schedule one deduplicated Swing Breakout alert; test sends synchronously."""
    config = swing_breakout_telegram_config()
    if not config["enabled"]:
        return False, "Swing Breakout Telegram dinonaktifkan"
    if not config["general_enabled"] or not config["token"] or not config["chat_id"]:
        return False, f"Telegram {config.get('destination', 'tujuan')} belum dikonfigurasi/aktif"
    message = format_swing_breakout_telegram_alert(trade, test=test)
    key = str((trade or {}).get("signal_key") or (trade or {}).get("id") or "").strip()
    if not test:
        if not key:
            return False, "signal_key trade Swing kosong"
        with SWING_ALERT_LOCK:
            if key in SWING_ALERT_KEYS:
                return True, "alert Swing sudah pernah dijadwalkan"
            SWING_ALERT_KEYS.add(key)

        def deliver():
            ok = notify_telegram(
                config["token"],
                config["chat_id"],
                message,
                enabled=config["general_enabled"],
            )
            if not ok:
                with SWING_ALERT_LOCK:
                    SWING_ALERT_KEYS.discard(key)

        threading.Thread(target=deliver, daemon=True).start()
        return True, "alert Swing dijadwalkan"
    ok = notify_telegram(
        config["token"],
        config["chat_id"],
        message,
        enabled=config["general_enabled"],
    )
    return (True, "test alert Swing terkirim") if ok else (False, "test alert Swing gagal dikirim")


def konglo_telegram_enabled(env=None):
    env = load_env() if env is None else env
    raw = os.environ.get(
        "KONGLO_TELEGRAM_ENABLED",
        env.get("KONGLO_TELEGRAM_ENABLED", env.get("TELEGRAM_ENABLED", "true")),
    )
    return str(raw).strip().strip("'\"").lower() not in {"0", "false", "no", "off"}


def send_konglo_telegram_alert(params):
    env = load_env()
    token = env.get("TELEGRAM_TOKEN", "")
    chat_id = env.get("TELEGRAM_CHAT_ID", "")
    if not konglo_telegram_enabled(env):
        return False, "Konglo Telegram dinonaktifkan"
    if not token or not chat_id:
        return False, "Telegram belum dikonfigurasi"
    ticker = str(params.get("ticker", "-")).upper()
    text = (
        f"🔥 <b>HAKA TERDETEKSI</b>\n"
        f"Ticker: <b>{ticker}</b>\n"
        f"Harga: Rp {float(params.get('price') or 0):,.0f}\n"
        f"Perubahan: {float(params.get('change_pct') or 0):+.2f}%\n"
        f"Volume: {float(params.get('vol_ratio') or 0):.1f}x\n"
        f"Posisi range: {float(params.get('price_pos') or 0) * 100:.0f}%\n"
        f"Waktu: {params.get('time', '-') } WIB"
    )
    payload = json.dumps({"chat_id": chat_id, "text": text, "parse_mode": "HTML"}).encode()
    req = urllib.request.Request(
        f"https://api.telegram.org/bot{token}/sendMessage",
        data=payload,
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    try:
        with urllib.request.urlopen(req, timeout=10) as response:
            return response.status == 200, "ok"
    except Exception as exc:
        return False, str(exc)


def send_konglo_leader_alert(params):
    """Notify when a live Konglo tactical leader changes."""
    env = load_env()
    token = env.get("TELEGRAM_TOKEN", "")
    chat_id = env.get("TELEGRAM_CHAT_ID", "")
    if not konglo_telegram_enabled(env):
        return False, "Konglo Telegram dinonaktifkan"
    if not token or not chat_id:
        return False, "Telegram belum dikonfigurasi"
    ticker = str(params.get("ticker", "-")).upper()
    signal = str(params.get("signal", "-")).upper()
    message = (
        f"👑 <b>PEMIMPIN KONGLO LIVE</b>\n"
        f"Saham: <b>{ticker}</b>\n"
        f"Grup: {params.get('group', '-')}\n"
        f"Signal: <b>{signal}</b>\n"
        f"Flight score: {float(params.get('score') or 0):.0f}/100\n"
        f"Harga: Rp {float(params.get('price') or 0):,.0f}\n"
        f"Change: {float(params.get('change_pct') or 0):+.2f}%\n"
        f"Volume: {float(params.get('vol_ratio') or 0):.1f}x\n"
        f"Waktu: {params.get('time', '-')} WIB"
    )
    payload = json.dumps({"chat_id": chat_id, "text": message, "parse_mode": "HTML"}).encode()
    req = urllib.request.Request(
        f"https://api.telegram.org/bot{token}/sendMessage", data=payload,
        headers={"Content-Type": "application/json"}, method="POST",
    )
    try:
        with urllib.request.urlopen(req, timeout=10) as response:
            return response.status == 200, "ok"
    except Exception as exc:
        return False, str(exc)


def python_bin():
    for candidate in ("venv", ".venv"):
        p = os.path.join(os.getcwd(), candidate, "bin", "python")
        if os.path.exists(p):
            return p
    return sys.executable


def read_job_status():
    if not os.path.exists(JOB_STATUS_FILE):
        return {"status": "idle", "message": "Belum ada job scanner."}
    try:
        with open(JOB_STATUS_FILE, "r") as f:
            status = json.load(f)
    except Exception as exc:
        return {"status": "unknown", "message": str(exc)}

    pid = status.get("pid")
    if status.get("status") == "running" and pid:
        try:
            os.kill(int(pid), 0)
        except OSError:
            status["status"] = "unknown"
            status["message"] = "Job sebelumnya tidak aktif lagi."
    return status


def read_vcp_scan_job():
    if not os.path.exists(VCP_SCAN_JOB_FILE):
        return {"status": "idle", "message": "Belum ada scan VCP."}
    try:
        with open(VCP_SCAN_JOB_FILE, "r", encoding="utf-8") as handle:
            return json.load(handle)
    except Exception as exc:
        return {"status": "unknown", "message": str(exc)}


def write_vcp_scan_job(**updates):
    state = read_vcp_scan_job()
    state.update(updates)
    atomic_write_json(VCP_SCAN_JOB_FILE, state, indent=2)
    return state


def vcp_scan_status_payload(now=None):
    """Return the VCP job state without exposing subprocess output or secrets."""
    state = read_vcp_scan_job()
    pid = state.get("pid")
    if state.get("status") == "running" and pid:
        result_path = os.path.join(os.getcwd(), "hasil_scan_vcp.csv")
        started_ts = float(state.get("started_ts", 0) or 0)
        fresh_result = os.path.exists(result_path) and (
            not started_ts or os.path.getmtime(result_path) >= started_ts
        )
        if fresh_result:
            state = write_vcp_scan_job(
                status="completed",
                message="Scan VCP selesai; hasil CSV/Excel sudah diperbarui.",
                finished_at=now_jakarta().isoformat(timespec="seconds"),
            )
        else:
            try:
                os.kill(int(pid), 0)
            except OSError:
                state = write_vcp_scan_job(
                    status="failed",
                    message="Proses scan VCP berhenti tanpa hasil CSV.",
                    finished_at=now_jakarta().isoformat(timespec="seconds"),
                )
    result_path = os.path.join(os.getcwd(), "hasil_scan_vcp.csv")
    state["result_available"] = os.path.exists(result_path)
    state["result_mtime"] = int(os.path.getmtime(result_path)) if state["result_available"] else 0
    now = now or now_jakarta()
    state["auto_enabled"] = VCP_AUTO_ENABLED
    state["auto_mode"] = "market_close"
    state["close_delay_sec"] = VCP_AUTO_CLOSE_DELAY_SEC
    state["interval_sec"] = VCP_AUTO_CLOSE_DELAY_SEC
    state["market_open"] = is_market_open(now)
    close_date = vcp_auto_close_date(now)
    state["close_scan_due"] = bool(
        close_date and state.get("last_auto_close_date") != close_date
    )
    state["next_scan_in_sec"] = 0 if state["close_scan_due"] else None
    return state


def start_vcp_scan_job(trigger="manual", auto_close_date=""):
    """Start one explicit daily VCP scan; dashboard load never starts it."""
    with VCP_SCAN_LOCK:
        current = vcp_scan_status_payload()
        if current.get("status") == "running":
            return False, {
                "status": "running",
                "message": "Scan VCP masih berjalan.",
                "job": current,
            }
        started_at = now_jakarta().isoformat(timespec="seconds")
        log_path = os.path.join(os.getcwd(), "vcp_scan.log")
        state = {
            "status": "running",
            "message": "Memindai OHLCV harian Yahoo Finance.",
            "trigger": trigger,
            "started_at": started_at,
            "finished_at": "",
            "started_ts": time.time(),
            "last_attempt_ts": time.time(),
            "pid": None,
            "log_file": "vcp_scan.log",
            "last_auto_close_date": auto_close_date or "",
        }
        write_vcp_scan_job(**state)
        try:
            with open(log_path, "a", encoding="utf-8") as log_handle:
                process = subprocess.Popen(
                    [python_bin(), "vcp_scanner.py", "--tickers-file", "daftar_saham_bei.csv", "--out-dir", os.getcwd()],
                    cwd=os.getcwd(),
                    stdout=log_handle,
                    stderr=subprocess.STDOUT,
                    start_new_session=True,
                )
            state = write_vcp_scan_job(pid=process.pid)
            return True, {
                "status": "started",
                "message": "Scan VCP dimulai. Dashboard akan membaca hasil setelah proses selesai.",
                "job": state,
            }
        except Exception as exc:
            state = write_vcp_scan_job(
                status="failed",
                message=f"Tidak dapat memulai scan VCP: {exc}",
                finished_at=now_jakarta().isoformat(timespec="seconds"),
            )
            return False, state


def vcp_scan_due(state=None, now_ts=None):
    state = state or {}
    now_ts = time.time() if now_ts is None else float(now_ts)
    last_attempt_ts = float(state.get("last_attempt_ts", 0) or 0)
    return now_ts - last_attempt_ts >= VCP_AUTO_INTERVAL_SEC


def vcp_auto_close_date(now=None):
    """Return today's date only after a valid IDX session has finished.

    The 10:00 probe rejects weekends and dates listed as IDX holidays.  This
    avoids treating a server restart before market open as a post-close scan.
    """
    now = now or now_jakarta()
    if now.weekday() >= 5:
        return ""
    session_probe = now.replace(hour=10, minute=0, second=0, microsecond=0)
    if not is_idx_market_open(session_probe):
        return ""
    sessions = market_sessions(now)
    close_minute = max(close for _, close in sessions)
    close_at = now.replace(
        hour=close_minute // 60,
        minute=close_minute % 60,
        second=0,
        microsecond=0,
    )
    if now < close_at + timedelta(seconds=VCP_AUTO_CLOSE_DELAY_SEC):
        return ""
    return now.strftime("%Y-%m-%d")


def vcp_auto_tick(now=None, now_ts=None):
    """Start at most one VCP scan after each completed IDX session."""
    if not VCP_AUTO_ENABLED:
        return False, read_vcp_scan_job()
    now = now or now_jakarta()
    state = vcp_scan_status_payload(now=now)
    close_date = vcp_auto_close_date(now)
    if (
        not close_date
        or state.get("status") == "running"
        or state.get("last_auto_close_date") == close_date
    ):
        return False, state
    return start_vcp_scan_job(
        trigger="auto-close",
        auto_close_date=close_date,
    )


def vcp_auto_loop():
    while True:
        try:
            vcp_auto_tick()
        except Exception as exc:
            print(f"[VCP_AUTO] {exc}")
        time.sleep(VCP_AUTO_POLL_SEC)


def load_vcp_results_payload():
    """Read only the dashboard's two actionable VCP ranking tiers.

    The CSV and downloadable files remain complete, while the dashboard API
    avoids sending hundreds of rejected rows that are not shown in the view.
    """
    result_path = os.path.join(os.getcwd(), "hasil_scan_vcp.csv")
    state = vcp_scan_status_payload()
    if not os.path.exists(result_path):
        return {
            "status": "not_ready",
            "message": "Belum ada hasil scan VCP. Klik Jalankan scan.",
            "rows": [],
            "counts": {},
            "generated_at": "",
            "data_through": "",
            "files": {},
            "job": state,
        }
    import csv

    try:
        with open(result_path, "r", encoding="utf-8-sig", newline="") as handle:
            rows = list(csv.DictReader(handle))
        counts = {}
        for row in rows:
            status = row.get("status") or "Rejected"
            counts[status] = counts.get(status, 0) + 1
        display_statuses = {"Strong VCP Candidate", "Possible VCP"}
        display_rows = [row for row in rows if row.get("status") in display_statuses]
        generated_at = ""
        data_through = ""
        state_path = os.path.join(os.getcwd(), "vcp_scan_state.json")
        if os.path.exists(state_path):
            with open(state_path, "r", encoding="utf-8") as handle:
                scan_state = json.load(handle)
            generated_at = scan_state.get("generated_at", "")
            data_through = scan_state.get("data_through", "")
            files = scan_state.get("files", {}) or {}
        else:
            files = {"csv": "hasil_scan_vcp.csv", "xlsx": "hasil_scan_vcp.xlsx"}
        return {
            "status": "ok",
            "message": "Hasil scan VCP tersedia; dashboard menampilkan Strong dan Possible saja.",
            "rows": display_rows,
            "total_rows": len(rows),
            "displayed_rows": len(display_rows),
            "counts": counts,
            "generated_at": generated_at,
            "data_through": data_through,
            "files": files,
            "job": state,
        }
    except Exception as exc:
        return {
            "status": "error",
            "message": f"Gagal membaca hasil_scan_vcp.csv: {exc}",
            "rows": [],
            "counts": {},
            "generated_at": "",
            "data_through": "",
            "files": {},
            "job": state,
        }


def read_auto_scan_state():
    if not os.path.exists(AUTO_SCAN_STATE_FILE):
        return {}
    try:
        with open(AUTO_SCAN_STATE_FILE, "r") as f:
            return json.load(f)
    except Exception:
        return {}


def write_auto_scan_state(**updates):
    state = read_auto_scan_state()
    state.update(updates)
    atomic_write_json(AUTO_SCAN_STATE_FILE, state, indent=2)


def read_trend_break_live_state():
    if not os.path.exists(TREND_BREAK_LIVE_STATE_FILE):
        return {}
    try:
        with open(TREND_BREAK_LIVE_STATE_FILE, "r") as handle:
            return json.load(handle)
    except Exception:
        return {}


def write_trend_break_live_state(**updates):
    state = read_trend_break_live_state()
    state.update(updates)
    atomic_write_json(TREND_BREAK_LIVE_STATE_FILE, state, indent=2)
    return state


def normalize_trend_break_watchlist_ticker(value):
    """Normalize one user-selected Trend Break ticker without trusting input."""
    ticker = str(value or "").strip().upper()
    if ticker.endswith(".JK"):
        ticker = ticker[:-3].strip()
    return ticker if re.fullmatch(r"[A-Z0-9._-]{1,20}", ticker) else ""


def _read_trend_break_watchlist_unlocked():
    if not os.path.exists(TREND_BREAK_WATCHLIST_FILE):
        return {
            "tickers": [],
            "updated_at": "",
            "persisted": False,
        }
    with open(TREND_BREAK_WATCHLIST_FILE, "r", encoding="utf-8") as handle:
        raw = json.load(handle)
    if isinstance(raw, dict):
        values = raw.get("tickers", [])
        updated_at = str(raw.get("updated_at") or "")
    elif isinstance(raw, list):
        # Accept the early list-only shape if it was created manually.
        values = raw
        updated_at = ""
    else:
        raise ValueError("format watchlist backend tidak valid")
    if not isinstance(values, list):
        raise ValueError("field tickers watchlist backend harus berupa list")
    tickers = sorted({
        normalized
        for value in values
        if (normalized := normalize_trend_break_watchlist_ticker(value))
    })
    return {
        "tickers": tickers[:TREND_BREAK_WATCHLIST_MAX],
        "updated_at": updated_at,
        "persisted": True,
    }


def _write_trend_break_watchlist_unlocked(tickers):
    os.makedirs(os.path.dirname(TREND_BREAK_WATCHLIST_FILE), exist_ok=True)
    updated_at = now_jakarta().isoformat(timespec="seconds")
    payload = {
        "version": 1,
        "updated_at": updated_at,
        "tickers": sorted(tickers),
    }
    if not atomic_write_json(TREND_BREAK_WATCHLIST_FILE, payload, indent=2):
        raise OSError("watchlist Trend Break gagal disimpan")
    return {
        "tickers": payload["tickers"],
        "updated_at": updated_at,
        "persisted": True,
    }


def trend_break_watchlist_payload():
    with TREND_BREAK_WATCHLIST_LOCK:
        state = _read_trend_break_watchlist_unlocked()
    return {
        "status": "ok",
        "tickers": state["tickers"],
        "count": len(state["tickers"]),
        "updated_at": state["updated_at"],
        "persisted": state["persisted"],
        "source": "backend",
    }


def update_trend_break_watchlist(action, ticker="", tickers=None):
    """Apply an idempotent backend mutation to the local Trend Break list."""
    action = str(action or "").strip().lower()
    allowed_actions = {"add", "remove", "toggle", "clear", "replace"}
    if action not in allowed_actions:
        raise ValueError("action harus add, remove, toggle, clear, atau replace")

    with TREND_BREAK_WATCHLIST_LOCK:
        current = set(_read_trend_break_watchlist_unlocked()["tickers"])
        normalized = normalize_trend_break_watchlist_ticker(ticker)
        if action in {"add", "remove", "toggle"} and not normalized:
            raise ValueError("ticker watchlist tidak valid")
        if action == "add":
            current.add(normalized)
        elif action == "remove":
            current.discard(normalized)
        elif action == "toggle":
            if normalized in current:
                current.remove(normalized)
            else:
                current.add(normalized)
        elif action == "clear":
            current.clear()
        elif action == "replace":
            if not isinstance(tickers, list):
                raise ValueError("tickers untuk replace harus berupa list")
            if len(tickers) > TREND_BREAK_WATCHLIST_MAX:
                raise ValueError(
                    f"watchlist maksimal {TREND_BREAK_WATCHLIST_MAX} saham"
                )
            current = set()
            for value in tickers:
                normalized_value = normalize_trend_break_watchlist_ticker(value)
                if not normalized_value:
                    raise ValueError("ada ticker watchlist yang tidak valid")
                current.add(normalized_value)
        if len(current) > TREND_BREAK_WATCHLIST_MAX:
            raise ValueError(
                f"watchlist maksimal {TREND_BREAK_WATCHLIST_MAX} saham"
            )
        state = _write_trend_break_watchlist_unlocked(current)
    return {
        "status": "ok",
        "tickers": state["tickers"],
        "count": len(state["tickers"]),
        "updated_at": state["updated_at"],
        "persisted": True,
        "source": "backend",
    }


def read_pa_paper_state():
    if not os.path.exists(PA_PAPER_STATE_FILE):
        return {}
    try:
        with open(PA_PAPER_STATE_FILE, "r") as handle:
            return json.load(handle)
    except Exception:
        return {}


def write_pa_paper_state(**updates):
    state = read_pa_paper_state()
    state.update(updates)
    atomic_write_json(PA_PAPER_STATE_FILE, state, indent=2)
    return state


def pa_paper_due(state=None, now_ts=None):
    state = state or {}
    now_ts = time.time() if now_ts is None else float(now_ts)
    last_attempt = float(state.get("last_attempt_ts", 0) or 0)
    return now_ts - last_attempt >= PA_PAPER_INTERVAL_SEC


def now_jakarta():
    return datetime.now(MARKET_TZ)


def is_market_open(now=None):
    return is_idx_market_open(now or now_jakarta())


def trend_break_live_due(state=None, now_ts=None):
    state = state or {}
    now_ts = time.time() if now_ts is None else float(now_ts)
    last_attempt = float(state.get("last_attempt_ts", 0) or 0)
    return now_ts - last_attempt >= TREND_BREAK_LIVE_INTERVAL_SEC


def trend_break_live_payload():
    state = read_trend_break_live_state()
    now_ts = time.time()
    last_attempt = float(state.get("last_attempt_ts", 0) or 0)
    next_scan_in = 0
    if last_attempt:
        next_scan_in = max(
            0, int(TREND_BREAK_LIVE_INTERVAL_SEC - (now_ts - last_attempt))
        )
    data_timestamp = ""
    data_age_sec = None
    candidate_count = None
    universe_count = None
    try:
        with open(os.path.join(os.getcwd(), "trend_break_results.json"), "r") as handle:
            current = json.load(handle)
        data_timestamp = current.get("timestamp_iso") or current.get("timestamp") or ""
        candidate_count = current.get("candidate_count")
        universe_count = current.get("universe_count")
        if data_timestamp:
            normalized = str(data_timestamp).replace("Z", "+00:00")
            parsed = datetime.fromisoformat(normalized)
            if parsed.tzinfo is None:
                parsed = parsed.replace(tzinfo=MARKET_TZ)
            data_age_sec = max(0, int((now_jakarta() - parsed).total_seconds()))
    except (FileNotFoundError, json.JSONDecodeError, OSError, ValueError, TypeError):
        pass
    return {
        "enabled": TREND_BREAK_LIVE_ENABLED,
        "market_open": is_market_open(),
        "interval_sec": TREND_BREAK_LIVE_INTERVAL_SEC,
        "next_scan_in_sec": next_scan_in,
        "data_timestamp": data_timestamp,
        "data_age_sec": data_age_sec,
        "candidate_count": candidate_count,
        "universe_count": universe_count,
        "pa_telegram": trend_break_pa_alert_status(),
        "state": state,
    }


def run_trend_break_live_scan(trigger="auto"):
    """Run only the market-wide Trend Break scanner and its audit outputs."""
    if not LIGHTWEIGHT_LIVE_SCAN_LOCK.acquire(blocking=False):
        return False, {"status": "waiting", "message": "Scanner market-wide lain masih berjalan."}
    if not TREND_BREAK_LIVE_LOCK.acquire(blocking=False):
        LIGHTWEIGHT_LIVE_SCAN_LOCK.release()
        return False, {"status": "running", "message": "Trend Break live scan masih berjalan."}
    try:
        current_job = read_job_status()
        if current_job.get("status") == "running":
            state = write_trend_break_live_state(
                status="waiting",
                message=(
                    "Menunggu job lain selesai: "
                    f"{current_job.get('source_label', current_job.get('source', '-'))}"
                ),
                updated_at=now_jakarta().isoformat(timespec="seconds"),
            )
            return False, state

        started_ts = time.time()
        started_at = now_jakarta().isoformat(timespec="seconds")
        write_trend_break_live_state(
            status="running",
            trigger=trigger,
            message="Memindai patah tren market-wide via TradingView.",
            last_attempt_ts=started_ts,
            last_started_at=started_at,
            updated_at=started_at,
            error="",
        )
        try:
            completed = subprocess.run(
                [python_bin(), "scan_trend_break.py"],
                cwd=os.getcwd(),
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
                text=True,
                timeout=TREND_BREAK_LIVE_TIMEOUT_SEC,
            )
        except subprocess.TimeoutExpired as exc:
            output = exc.stdout or ""
            with open(TREND_BREAK_LIVE_LOG_FILE, "a", encoding="utf-8") as handle:
                handle.write(f"\n[{started_at}] TIMEOUT\n{output}\n")
            finished_at = now_jakarta().isoformat(timespec="seconds")
            state = write_trend_break_live_state(
                status="failed",
                message="Trend Break live scan melewati batas waktu.",
                updated_at=finished_at,
                last_finished_at=finished_at,
                error="timeout",
            )
            return False, state

        with open(TREND_BREAK_LIVE_LOG_FILE, "a", encoding="utf-8") as handle:
            handle.write(
                f"\n[{started_at}] trigger={trigger} exit={completed.returncode}\n"
            )
            handle.write(completed.stdout or "")
            if completed.stdout and not completed.stdout.endswith("\n"):
                handle.write("\n")

        finished_at = now_jakarta().isoformat(timespec="seconds")
        if completed.returncode != 0:
            state = write_trend_break_live_state(
                status="failed",
                message=f"Trend Break live scan gagal (exit {completed.returncode}).",
                updated_at=finished_at,
                last_finished_at=finished_at,
                error=f"exit {completed.returncode}",
            )
            return False, state

        result = {}
        try:
            with open(os.path.join(os.getcwd(), "trend_break_results.json"), "r") as handle:
                result = json.load(handle)
        except (FileNotFoundError, json.JSONDecodeError, OSError):
            pass
        state = write_trend_break_live_state(
            status="completed",
            message="Trend Break live scan selesai.",
            updated_at=finished_at,
            last_finished_at=finished_at,
            last_success_at=finished_at,
            duration_sec=round(time.time() - started_ts, 2),
            candidate_count=result.get("candidate_count"),
            universe_count=result.get("universe_count"),
            data_timestamp=result.get("timestamp_iso") or result.get("timestamp") or "",
            phase=result.get("snapshot_phase") or "",
            error="",
        )
        return True, state
    finally:
        TREND_BREAK_LIVE_LOCK.release()
        LIGHTWEIGHT_LIVE_SCAN_LOCK.release()


def start_screener_job(source, trigger="manual"):
    if source not in SCREENER_SOURCES:
        return False, {"status": "error", "message": "Sumber scanner tidak valid."}
    if LIGHTWEIGHT_LIVE_SCAN_LOCK.locked():
        return False, {
            "status": "running",
            "message": "Scanner market-wide live sedang berjalan; coba lagi sesaat.",
        }

    current = read_job_status()
    if current.get("status") == "running":
        return False, {
            "status": "running",
            "message": f"Scanner masih jalan: {current.get('source_label', current.get('source', ''))}",
            "job": current,
        }

    started_at = now_jakarta().isoformat(timespec="seconds")
    initial_status = {
        "status": "running",
        "source": source,
        "source_label": SCREENER_SOURCES[source],
        "step": "Menyalakan scanner",
        "started_at": started_at,
        "updated_at": started_at,
        "finished_at": "",
        "error": "",
        "log_file": os.path.join(RUNTIME_DIR, "screener_pipeline.log"),
        "trigger": trigger,
    }
    atomic_write_json(JOB_STATUS_FILE, initial_status, indent=2)

    subprocess.Popen(
        [python_bin(), "run_screener_pipeline.py", "--source", source],
        cwd=os.getcwd(),
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
        start_new_session=True,
    )
    return True, {"status": "started", "message": f"Scanner {SCREENER_SOURCES[source]} mulai jalan.", "trigger": trigger}


def start_broker_summary_job(source="trendbreak", trigger="manual"):
    if source not in SCREENER_SOURCES:
        return False, {"status": "error", "message": "Sumber broker summary tidak valid."}

    current = read_job_status()
    if current.get("status") == "running":
        return False, {
            "status": "running",
            "message": f"Job masih jalan: {current.get('source_label', current.get('source', ''))}",
            "job": current,
        }

    started_at = now_jakarta().isoformat(timespec="seconds")
    initial_status = {
        "status": "running",
        "source": source,
        "source_label": f"Broksum {SCREENER_SOURCES[source]}",
        "step": "Menyalakan broker summary",
        "started_at": started_at,
        "updated_at": started_at,
        "finished_at": "",
        "error": "",
        "log_file": os.path.join(RUNTIME_DIR, "screener_pipeline.log"),
        "trigger": trigger,
        "task": "broker_summary",
    }
    atomic_write_json(JOB_STATUS_FILE, initial_status, indent=2)

    subprocess.Popen(
        [python_bin(), "run_broker_summary_job.py", "--source", source],
        cwd=os.getcwd(),
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
        start_new_session=True,
    )
    return True, {"status": "started", "message": f"Broker summary {SCREENER_SOURCES[source]} mulai jalan.", "trigger": trigger}


def start_broker_ticker_job(ticker, source="trendbreak", trigger="manual"):
    """Ambil broksum 1 hari untuk satu ticker yang dipilih dari dashboard."""
    ticker = str(ticker or "").strip().upper()
    if not (3 <= len(ticker) <= 5 and ticker.isalpha()):
        return False, {"status": "error", "message": "Ticker tidak valid."}
    if source not in SCREENER_SOURCES:
        return False, {"status": "error", "message": "Sumber broker summary tidak valid."}

    current = read_job_status()
    if current.get("status") == "running":
        return False, {
            "status": "running",
            "message": f"Job masih jalan: {current.get('source_label', current.get('source', ''))}",
            "job": current,
        }

    started_at = now_jakarta().isoformat(timespec="seconds")
    initial_status = {
        "status": "running",
        "source": source,
        "source_label": f"Broksum manual ${ticker}",
        "step": "Mengambil broker summary 1 hari",
        "started_at": started_at,
        "updated_at": started_at,
        "finished_at": "",
        "error": "",
        "log_file": os.path.join(RUNTIME_DIR, "screener_pipeline.log"),
        "trigger": trigger,
        "task": "broker_ticker",
        "ticker": ticker,
    }
    atomic_write_json(JOB_STATUS_FILE, initial_status, indent=2)
    subprocess.Popen(
        [python_bin(), "run_broker_summary_job.py", "--source", source, "--tickers", ticker],
        cwd=os.getcwd(), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
        start_new_session=True,
    )
    return True, {"status": "started", "message": f"Broker summary 1 hari ${ticker} mulai diambil.", "trigger": trigger}


def fetch_broker_summary_date_cached(ticker, start_date="", end_date=""):
    import urllib.request
    from run_broker_summary_job import get_api_credentials, build_broker_table, build_broker_table_net, format_data_date

    ticker = (ticker or "").strip().upper()
    start_date = (start_date or "").strip()
    end_date = (end_date or "").strip()

    if not start_date and not end_date:
        cache_key = f"{ticker}_latest"
    else:
        start_date = start_date or end_date
        end_date = end_date or start_date
        cache_key = f"{ticker}_{start_date}_{end_date}"

    cache_dir = os.path.join(PROJECT_DIR, ".runtime", "broker_cache")
    os.makedirs(cache_dir, exist_ok=True)
    cache_file = os.path.join(cache_dir, f"{cache_key}.json")

    if os.path.exists(cache_file):
        try:
            with open(cache_file, "r", encoding="utf-8") as f:
                cached = json.load(f)
                if cached.get("status") == "ok" and (cached.get("tables_net") or cached.get("tables_1d")):
                    return cached
        except Exception:
            pass

    api_key, base_url = get_api_credentials()
    if not api_key:
        raise RuntimeError("API Key IDX Edge tidak ditemukan di idx_edge.env atau .env")

    url = f"{base_url}/api/broker-summary/{ticker}?all_data=true&flow=all"
    if start_date and end_date:
        url += f"&start_date={start_date}&end_date={end_date}"

    req = urllib.request.Request(
        url,
        headers={
            "X-API-Key": api_key,
            "Accept": "application/json",
            "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko)",
        },
    )
    with urllib.request.urlopen(req, timeout=15) as resp:
        if resp.status != 200:
            raise RuntimeError(f"HTTP {resp.status} dari IDX Edge API")
        raw = resp.read().decode("utf-8")
        api_data = json.loads(raw)

    tables_gross = build_broker_table(api_data)
    tables_net = build_broker_table_net(api_data)

    b_start = api_data.get("broker_start_date") or start_date
    b_end = api_data.get("broker_end_date") or end_date
    if b_start and b_end and b_start != b_end:
        data_date_label = f"{format_data_date(b_start)} - {format_data_date(b_end)}"
    elif b_end:
        data_date_label = format_data_date(b_end)
    elif b_start:
        data_date_label = format_data_date(b_start)
    else:
        data_date_label = "Terbaru"

    result = {
        "status": "ok",
        "ticker": ticker,
        "start_date": b_start or "",
        "end_date": b_end or "",
        "data_date": data_date_label,
        "tables_1d": tables_net,
        "tables_gross": tables_gross,
        "tables_net": tables_net,
    }

    try:
        atomic_write_json(cache_file, result, indent=2)
    except Exception:
        pass

    return result


def auto_scan_loop():
    # Job scanner cuma bisa 1 jalan bersamaan (lihat start_screener_job), jadi rotasi
    # bergiliran per-source — masing-masing dapat jam-terakhir-jalan sendiri supaya
    # satu source lambat tidak menunda source lain selamanya.
    while True:
        try:
            if AUTO_SCAN_ENABLED and is_market_open():
                state = read_auto_scan_state()
                per_source = state.get("per_source", {}) or {}
                for source in AUTO_SCAN_SOURCES:
                    last_run_ts = float(per_source.get(source, 0) or 0)
                    if time.time() - last_run_ts < AUTO_SCAN_INTERVAL_MIN * 60:
                        continue
                    started, _ = start_screener_job(source, trigger="auto")
                    if started:
                        per_source[source] = time.time()
                        write_auto_scan_state(
                            per_source=per_source,
                            last_run_ts=time.time(),
                            last_run_at=now_jakarta().strftime("%Y-%m-%d %H:%M:%S"),
                            interval_min=AUTO_SCAN_INTERVAL_MIN,
                            source=source,
                            sources=AUTO_SCAN_SOURCES,
                        )
                    break  # satu job per tick — job lain nunggu giliran polling berikutnya
        except Exception as exc:
            print(f"[AUTO_SCAN] {exc}")

        time.sleep(AUTO_SCAN_POLL_SEC)


def trend_break_live_tick(now=None, now_ts=None):
    market_open = is_market_open(now)
    state = read_trend_break_live_state()
    if (
        TREND_BREAK_LIVE_ENABLED
        and market_open
        and trend_break_live_due(state, now_ts=now_ts)
    ):
        return run_trend_break_live_scan(trigger="auto-market")
    if not market_open and state.get("status") not in {"market_closed", "running"}:
        state = write_trend_break_live_state(
            status="market_closed",
            message="Auto realtime siap; menunggu sesi IDX buka.",
            updated_at=now_jakarta().isoformat(timespec="seconds"),
        )
    return False, state


def trend_break_live_loop():
    while True:
        try:
            trend_break_live_tick()
        except Exception as exc:
            write_trend_break_live_state(
                status="failed",
                message="Trend Break live scheduler mengalami error.",
                updated_at=now_jakarta().isoformat(timespec="seconds"),
                error=str(exc),
            )
            print(f"[TREND_BREAK_LIVE] {exc}")
        time.sleep(TREND_BREAK_LIVE_POLL_SEC)


def sync_pa_trigger_states_from_history(history_data=None):
    """Refresh the in-process trigger cache after the paper collector writes history."""
    global PA_TRIGGER_STATE_LOADED
    if history_data is None:
        from pa_tracker import load_history
        history_data = load_history()
    from pa_watchlists import registered_pa_tickers
    allowed = set(registered_pa_tickers())
    PA_TRIGGER_STATES.clear()
    PA_TRIGGER_STATES.update({
        str(ticker).strip().upper(): state
        for ticker, state in (history_data.get("break_retests") or {}).items()
        if str(ticker).strip().upper() in allowed
    })
    PA_TRIGGER_STATE_LOADED = True


def pa_paper_tick(now=None, now_ts=None):
    """Run one idempotent paper-collection attempt when the IDX market is open."""
    if not PA_PAPER_ENABLED:
        state = write_pa_paper_state(
            status="disabled",
            message="PA paper collector dinonaktifkan oleh PA_PAPER_ENABLED.",
            updated_at=now_jakarta().isoformat(timespec="seconds"),
        )
        return False, state

    market_open = is_market_open(now)
    state = read_pa_paper_state()
    if not market_open:
        if state.get("status") not in {"market_closed", "running"}:
            state = write_pa_paper_state(
                status="market_closed",
                message="Menunggu sesi IDX buka; tidak mengambil snapshot paper.",
                market_open=False,
                updated_at=now_jakarta().isoformat(timespec="seconds"),
            )
        return False, state

    if not pa_paper_due(state, now_ts=now_ts):
        return False, state
    if not PA_PAPER_LOCK.acquire(blocking=False):
        return False, read_pa_paper_state()

    attempt_ts = time.time() if now_ts is None else float(now_ts)
    started_at = now_jakarta().isoformat(timespec="seconds")
    write_pa_paper_state(
        status="running",
        message="Mengambil snapshot PA paper dari TradingView.",
        market_open=True,
        last_attempt_ts=attempt_ts,
        last_started_at=started_at,
        updated_at=started_at,
        error="",
    )
    try:
        import pa_paper_signal_alert
        # Serialize file writes with /api/pa_snapshot trigger-state updates.
        with PA_TRIGGER_LOCK, PA_HISTORY_LOCK:
            pa_paper_signal_alert.run_once(
                send_notifications=PA_PAPER_TELEGRAM_ENABLED
            )
            history_data = pa_paper_signal_alert.pa_tracker.load_history()
            sync_pa_trigger_states_from_history(history_data)
        finished_at = now_jakarta().isoformat(timespec="seconds")
        state = write_pa_paper_state(
            status="ok",
            message=(
                "Collector PA paper aktif; histori diperbarui. "
                + (
                    "Notifikasi Telegram aktif."
                    if PA_PAPER_TELEGRAM_ENABLED
                    else "Notifikasi Telegram nonaktif."
                )
            ),
            market_open=True,
            telegram_notifications=PA_PAPER_TELEGRAM_ENABLED,
            last_success_at=finished_at,
            updated_at=finished_at,
            error="",
            history_count=len(history_data.get("history") or []),
            observation_count=len(history_data.get("observations") or []),
            break_retest_count=len(history_data.get("break_retests") or {}),
        )
        return True, state
    except Exception as exc:
        finished_at = now_jakarta().isoformat(timespec="seconds")
        state = write_pa_paper_state(
            status="failed",
            message="Collector PA paper gagal; dashboard tetap memakai snapshot realtime.",
            market_open=True,
            updated_at=finished_at,
            error=str(exc),
        )
        print(f"[PA_PAPER] {exc}")
        return False, state
    finally:
        PA_PAPER_LOCK.release()


def pa_paper_loop():
    while True:
        try:
            pa_paper_tick()
        except Exception as exc:
            write_pa_paper_state(
                status="failed",
                message="Collector PA paper mengalami error.",
                updated_at=now_jakarta().isoformat(timespec="seconds"),
                error=str(exc),
            )
            print(f"[PA_PAPER] {exc}")
        time.sleep(PA_PAPER_POLL_SEC)


def send_json(handler, status_code, payload):
    handler.send_response(status_code)
    handler.send_header('Content-Type', 'application/json')
    handler.send_header('Access-Control-Allow-Origin', '*')
    handler.end_headers()
    handler.wfile.write(json.dumps(payload).encode())


def send_pa_telegram_alert(params):
    """Send one PA READY alert per ticker and 5m candle."""
    ticker = str(params.get("ticker", "")).strip().upper()
    candle_key = str(params.get("candle_key", "")).strip()
    if not ticker or not candle_key:
        return False, "ticker/candle_key kosong"
    try:
        rr = float(params.get("rr")); entry = float(params.get("entry"))
        sl = float(params.get("sl")); target = float(params.get("target"))
    except (TypeError, ValueError):
        return False, "level entry/SL/target/RR tidak valid"
    if not rr_is_valid(rr) or entry <= 0 or sl <= 0 or target <= entry:
        return False, "gate risk/reward tidak valid"
    config = load_pa_telegram_config()
    if not config["enabled"]:
        return False, "PA Telegram dinonaktifkan"
    token = config["token"]
    chat_id = config["chat_id"]
    if not token or not chat_id:
        return False, "PA_TELEGRAM_TOKEN/PA_TELEGRAM_CHAT_ID belum tersedia"
    key = f"{ticker}:{candle_key}"
    with PA_ALERT_LOCK:
        if key in PA_ALERT_KEYS:
            return True, "alert sudah pernah dikirim"
        PA_ALERT_KEYS.add(key)
    stockbit_url = f"https://stockbit.com/symbol/{ticker}"
    message = (
        "[READY BUY] 🚨 SIAP ENTRY\n"
        f"📌 Ticker: {ticker}\n"
        "🧭 Sumber: Dashboard Price Action (PA)\n"
        f"🔗 <a href=\"{stockbit_url}\">Buka {ticker} di Stockbit</a>\n\n"
        f"💰 Entry: {entry:.2f}\n"
        f"🛑 Stop Loss: {sl:.2f}\n"
        f"🎯 Target: {target:.2f}\n\n"
        f"⚖️ Risk: {(entry-sl)/entry*100:.2f}% (Rp {(entry-sl):.2f}/saham)\n"
        f"📈 Reward: {(target-entry)/entry*100:.2f}%\n"
        f"📐 Risk/Reward: {rr:.2f}R\n\n"
        "✅ Trigger: close 5m + volume mendukung\n"
        "🚫 Invalidasi: close di bawah Stop Loss\n"
        "⚠️ Verifikasi candle 5m sebelum eksekusi"
    )
    threading.Thread(
        target=notify_telegram,
        args=(token, chat_id, message),
        kwargs={"enabled": config["enabled"]},
        daemon=True,
    ).start()
    return True, "alert Telegram dijadwalkan"


def load_active_pa_trades(tickers=None):
    """Return accepted paper trades that are still open for display locking.

    This is a presentation state, not a replacement for the PA entry gate:
    only records already accepted by the paper collector as ``open`` or
    ``pending`` are returned. Closed, rejected, or merely proposed records
    cannot keep a ticker in ``SIAP ENTRY``.
    """
    try:
        import pa_tracker
        history = pa_tracker.load_dashboard_history().get("history") or []
    except Exception:
        return {}
    wanted = {str(t).strip().upper() for t in (tickers or []) if str(t).strip()}
    active = {}
    for trade in history:
        if str(trade.get("status", "")).lower() not in {"open", "pending"}:
            continue
        ticker = str(trade.get("ticker", "")).strip().upper()
        if not ticker or (wanted and ticker not in wanted):
            continue
        # If a malformed/duplicate ledger contains more than one active row,
        # keep the newest entry rather than allowing an older trade to win.
        previous = active.get(ticker)
        if previous and str(previous.get("date") or "") >= str(trade.get("date") or ""):
            continue
        active[ticker] = {
            key: trade.get(key) for key in (
                "id", "date", "ticker", "entry", "sl", "target", "rr",
                "status", "type", "action", "entry_candle_timestamp",
            )
        }
    return active


def tradingview_scan(tickers, columns):
    env_path = os.path.join(PROJECT_DIR, ".env")
    if "TRADINGVIEW_SESSIONID" not in os.environ and os.path.exists(env_path):
        with open(env_path, "r") as f:
            for line in f:
                line = line.strip()
                if line and not line.startswith("#") and "=" in line:
                    k, v = line.split("=", 1)
                    os.environ[k.strip()] = v.strip()

    headers = {"Content-Type": "application/json", "User-Agent": "Mozilla/5.0"}
    session_id = os.environ.get("TRADINGVIEW_SESSIONID")
    if session_id:
        headers["Cookie"] = f"sessionid={session_id}"

    merged = {"data": []}
    for i in range(0, len(tickers), 100):
        chunk = tickers[i:i + 100]
        payload = {
            "symbols": {"tickers": [f"IDX:{t}" for t in chunk], "query": {"types": []}},
            "columns": columns,
        }
        req = urllib.request.Request(
            "https://scanner.tradingview.com/indonesia/scan",
            data=json.dumps(payload).encode(),
            headers=headers,
        )
        with urllib.request.urlopen(req, timeout=15) as resp:
            data = json.loads(resp.read())
        merged["data"].extend(data.get("data", []))
    return merged


def fetch_tradingview_quotes(tickers):
    data = tradingview_scan(
        tickers,
        [
            "name", "close", "change", "volume", "average_volume_10d_calc",
            "high", "low", "relative_volume_intraday|5", "time",
        ],
    )
    quotes = {}
    for item in data.get("data", []):
        vals = item.get("d") or []
        if len(vals) != 9:
            continue
        sym = item.get("s", "").split(":")[-1].upper()
        close, change, vol, avg_vol, high, low, rvol_intraday, data_timestamp = (
            vals[1], vals[2], vals[3], vals[4], vals[5], vals[6], vals[7], vals[8]
        )
        daily_ratio = round((vol or 0) / avg_vol, 2) if avg_vol else 0
        intraday_ratio = round(rvol_intraday, 2) if rvol_intraday else None
        live_ratio = intraday_ratio if intraday_ratio is not None else daily_ratio
        data_as_of = None
        try:
            timestamp_number = float(data_timestamp)
            if timestamp_number > 1_000_000_000_000:
                timestamp_number /= 1000
            data_as_of = datetime.fromtimestamp(
                timestamp_number, ZoneInfo("Asia/Jakarta")
            ).isoformat(timespec="seconds")
        except (TypeError, ValueError, OSError, OverflowError):
            data_timestamp = None
        quotes[sym] = {
            "price": close,
            "change_pct": round(change or 0, 2),
            "volume": vol,
            "vol_ratio": live_ratio,
            "vol_ratio_intraday": live_ratio,
            "vol_ratio_daily": daily_ratio,
            "volume_pace_source": "intraday" if intraday_ratio is not None else "daily_fallback",
            "high": high,
            "low": low,
            "data_timestamp": data_timestamp,
            "data_as_of": data_as_of,
            "source": "TradingView Scanner",
        }
    return quotes


_QUOTES_CACHE = {}
_QUOTES_CACHE_LOCK = threading.Lock()
QUOTES_CACHE_TTL = 10  # detik — banyak dashboard berbagi satu panggilan TradingView


def fetch_quotes_cached(tickers):
    now = time.time()
    missing = []
    with _QUOTES_CACHE_LOCK:
        for t in tickers:
            hit = _QUOTES_CACHE.get(t)
            if not hit or now - hit["ts"] > QUOTES_CACHE_TTL:
                missing.append(t)
    if missing:
        fresh = fetch_tradingview_quotes(missing)
        fetched_at = time.time()
        with _QUOTES_CACHE_LOCK:
            for t in missing:
                # ticker tanpa data tetap dicache (None) agar tidak difetch ulang tiap request
                _QUOTES_CACHE[t] = {"ts": fetched_at, "q": fresh.get(t)}
    with _QUOTES_CACHE_LOCK:
        hits = {
            t: _QUOTES_CACHE[t]
            for t in tickers
            if _QUOTES_CACHE.get(t) and _QUOTES_CACHE[t].get("q")
        }
        quotes = {}
        for ticker, hit in hits.items():
            quote = dict(hit["q"])
            quote["retrieved_at"] = datetime.fromtimestamp(
                hit["ts"], ZoneInfo("Asia/Jakarta")
            ).isoformat(timespec="seconds")
            quote["cache_age_seconds"] = round(max(0, time.time() - hit["ts"]), 2)
            quotes[ticker] = quote
        latest_cache_ts = max((hit["ts"] for hit in hits.values()), default=now)
    quote_as_of = datetime.fromtimestamp(
        latest_cache_ts, ZoneInfo("Asia/Jakarta")
    ).strftime("%H:%M:%S")
    return quotes, quote_as_of


SWING_BREAKOUT_CACHE = {}
SWING_BREAKOUT_CACHE_LOCK = threading.Lock()
SWING_BREAKOUT_CACHE_TTL = 120
SWING_BREAKOUT_CHART_LOOKBACK = 120
SWING_BREAKOUT_CHART_CACHE = {}
SWING_BREAKOUT_CHART_CACHE_LOCK = threading.Lock()
SWING_BREAKOUT_CHART_CACHE_TTL = 90
TREND_BREAK_DAILY_CLOSE_CACHE = {}
TREND_BREAK_DAILY_CLOSE_CACHE_LOCK = threading.Lock()
TREND_BREAK_DAILY_CLOSE_CACHE_TTL = 900
SWING_BREAKOUT_MARKET_CACHE = {}
SWING_BREAKOUT_MARKET_CACHE_LOCK = threading.Lock()
SWING_BREAKOUT_MARKET_CACHE_TTL = 120
SWING_BREAKOUT_HISTORY_FILE = "swing_breakout_history.json"
SWING_BREAKOUT_HISTORY_LOCK = threading.RLock()
SWING_BREAKOUT_AUTO_ENABLED = os.environ.get(
    "SWING_BREAKOUT_AUTO_ENABLED", "1"
).strip().lower() not in {"0", "false", "no", "off"}
SWING_BREAKOUT_AUTO_INTERVAL_SEC = max(
    30, int(os.environ.get("SWING_BREAKOUT_AUTO_INTERVAL_SEC", "60"))
)
SWING_BREAKOUT_AUTO_WATCHLIST = os.environ.get(
    "SWING_BREAKOUT_AUTO_WATCHLIST", "ALL"
).strip().upper() or "ALL"
SUPPORT_PULLBACK_HISTORY_FILE = "support_pullback_history.json"
SUPPORT_PULLBACK_HISTORY_LOCK = threading.RLock()
SUPPORT_PULLBACK_AUTO_ENABLED = os.environ.get(
    "SUPPORT_PULLBACK_AUTO_ENABLED", "1"
).strip().lower() not in {"0", "false", "no", "off"}
SUPPORT_PULLBACK_AUTO_INTERVAL_SEC = max(
    60, int(os.environ.get("SUPPORT_PULLBACK_AUTO_INTERVAL_SEC", "180"))
)
SUPPORT_PULLBACK_AUTO_WATCHLIST = os.environ.get(
    "SUPPORT_PULLBACK_AUTO_WATCHLIST", "ALL_IDX_EX_FCA"
).strip().upper() or "ALL_IDX_EX_FCA"
SIDEWAYS_BREAKOUT_CACHE = {}
SIDEWAYS_BREAKOUT_CACHE_LOCK = threading.Lock()
SIDEWAYS_BREAKOUT_CACHE_TTL = 180
SIDEWAYS_BREAKOUT_HISTORY_FILE = "sideways_breakout_history.json"
SIDEWAYS_BREAKOUT_HISTORY_LOCK = threading.RLock()
SIDEWAYS_BREAKOUT_ALL_IDX_WATCHLIST = "ALL_IDX_EX_FCA"
SIDEWAYS_BREAKOUT_AUTO_ENABLED = os.environ.get(
    "SIDEWAYS_BREAKOUT_AUTO_ENABLED", "1"
).strip().lower() not in {"0", "false", "no", "off"}
SIDEWAYS_BREAKOUT_AUTO_INTERVAL_SEC = max(
    60, int(os.environ.get("SIDEWAYS_BREAKOUT_AUTO_INTERVAL_SEC", "180"))
)
SIDEWAYS_BREAKOUT_AUTO_WATCHLIST = os.environ.get(
    "SIDEWAYS_BREAKOUT_AUTO_WATCHLIST", SIDEWAYS_BREAKOUT_ALL_IDX_WATCHLIST
).strip().upper() or SIDEWAYS_BREAKOUT_ALL_IDX_WATCHLIST
SIDEWAYS_BREAKOUT_LOOKBACK_DAYS = 60
SIDEWAYS_BREAKOUT_MIN_BASE_DAYS = 30
# Breakout awal hanya relevan pada tiga sesi bursa setelah daily close.
# D0 tetap screening; eksekusi paling cepat dimulai pada D+1 lewat full PA.
SIDEWAYS_BREAKOUT_EARLY_MAX_SESSIONS = 3
# Keep the screening floor aligned with the shared PA score bands: <40 is SKIP.
SIDEWAYS_BREAKOUT_MIN_SCREENING_SCORE = 40
SIDEWAYS_BREAKOUT_OHLC_CACHE = {}
SIDEWAYS_BREAKOUT_OHLC_CACHE_LOCK = threading.Lock()
SIDEWAYS_BREAKOUT_OHLC_CACHE_TTL = 900
SIDEWAYS_BREAKOUT_OHLC_EMPTY_TTL = 45
SIDEWAYS_BREAKOUT_OHLC_BATCH_SIZE = 100
SIDEWAYS_BREAKOUT_OHLC_RETRY_BATCH_SIZE = 20
SIDEWAYS_BREAKOUT_UNIVERSE_FILE = "daftar_saham_bei.csv"
SIDEWAYS_BREAKOUT_FCA_FILE = "sideways_breakout_fca.json"
SIDEWAYS_BREAKOUT_UNIVERSE_CACHE = None
SIDEWAYS_BREAKOUT_UNIVERSE_CACHE_MTIME = None
SIDEWAYS_BREAKOUT_UNIVERSE_CACHE_LOCK = threading.Lock()
SIDEWAYS_BREAKOUT_UNIVERSE_CACHE_TTL = 900
SWING_ENTRY_COMPARE_CACHE = {}
SWING_ENTRY_COMPARE_CACHE_LOCK = threading.Lock()
SWING_ENTRY_COMPARE_CACHE_TTL = 21600
SUPPORT_STRENGTH_CACHE = {}
SUPPORT_STRENGTH_CACHE_LOCK = threading.Lock()
SUPPORT_STRENGTH_CACHE_TTL = 180
SUPPORT_STRENGTH_LOOKBACK_DAYS = 90
SUPPORT_STRENGTH_MIN_HISTORY_DAYS = 35
SUPPORT_NEXT_SESSION_MAX_STALE_SECONDS = 72 * 60 * 60

# Daily S/R + Trend is a research-only view. It has its own short-lived cache
# because users can paste a different batch on every request and it must not
# read or write any trade ledger.
SR_TREND_CACHE = {}
SR_TREND_CACHE_LOCK = threading.Lock()
SR_TREND_CACHE_TTL = 180

# Support Pullback is an additional radar layer.  It does not replace the
# existing support-strength or PA gates; it supplies a deterministic sequence
# from approach to rejection/trigger using only bars available at scan time.
SUPPORT_PULLBACK_CONFIG = {
    "lookback_days": 90,
    "support_tolerance_pct": 1.0,
    "support_atr_factor": 0.5,
    "far_distance_pct": 5.0,
    "near_distance_pct": 2.0,
    "breakdown_atr_factor": 0.25,
    "breakdown_volume_ratio": 1.3,
    "pullback_volume_bars": 3,
    "rebound_volume_ratio": 1.2,
    "trigger_atr_factor": 0.05,
    "minimum_rr": 1.5,
}
SUPPORT_PULLBACK_STATUS_PRIORITY = {
    "TRIGGER": 0,
    "REJECT": 1,
    "TEST": 2,
    "NEAR": 3,
    "WATCH": 4,
    "FAR": 5,
    "INVALID": 6,
}


def swing_breakout_watchlist_tickers(watchlist):
    """Resolve the swing dashboard universe without sharing PA state."""
    from neobdm_common import KONGLO_TICKERS, LQ45_TICKERS

    key = str(watchlist or "KONGLO").strip().upper()
    if key == "LQ45":
        return tuple(sorted(set(LQ45_TICKERS)))
    if key == "KOMPAS100":
        try:
            from kompas100_universe import KOMPAS100_TICKERS
            return tuple(sorted(set(KOMPAS100_TICKERS)))
        except Exception:
            return tuple()
    if key == SIDEWAYS_BREAKOUT_ALL_IDX_WATCHLIST:
        return tuple(load_sideways_breakout_idx_universe().get("tickers") or [])
    if key == "ALL_WATCHLISTS":
        try:
            from kompas100_universe import KOMPAS100_TICKERS
        except Exception:
            return tuple()
        return tuple(sorted(set(KONGLO_TICKERS) | set(LQ45_TICKERS) | set(KOMPAS100_TICKERS)))
    if key == "ALL":
        # Keep the broad swing universe aligned with the authoritative IDX
        # discovery used by the sideways breakout dashboard.  The helper
        # removes the current FCA registry and falls back to the local IDX
        # CSV when live discovery is unavailable.
        return tuple(load_sideways_breakout_idx_universe().get("tickers") or [])
    return tuple(sorted(set(KONGLO_TICKERS)))


def tradingview_market_scan(columns):
    """Scan the current IDX stock universe without a hardcoded ticker list."""
    env_path = os.path.join(PROJECT_DIR, ".env")
    if "TRADINGVIEW_SESSIONID" not in os.environ and os.path.exists(env_path):
        with open(env_path, "r") as handle:
            for line in handle:
                line = line.strip()
                if line and not line.startswith("#") and "=" in line:
                    key, value = line.split("=", 1)
                    os.environ[key.strip()] = value.strip()

    headers = {"Content-Type": "application/json", "User-Agent": "Mozilla/5.0"}
    session_id = os.environ.get("TRADINGVIEW_SESSIONID")
    if session_id:
        headers["Cookie"] = f"sessionid={session_id}"
    payload = {
        "filter": [
            {"left": "exchange", "operation": "equal", "right": "IDX"},
            {"left": "type", "operation": "equal", "right": "stock"},
        ],
        "options": {"lang": "en"},
        "markets": ["indonesia"],
        "symbols": {"query": {"types": []}, "tickers": []},
        "columns": columns,
        "sort": {"sortBy": "Value.Traded", "sortOrder": "desc"},
        "range": [0, 2000],
    }
    request = urllib.request.Request(
        "https://scanner.tradingview.com/indonesia/scan",
        data=json.dumps(payload).encode(),
        headers=headers,
    )
    with urllib.request.urlopen(request, timeout=25) as response:
        return json.loads(response.read())


def _trending_number(value):
    try:
        number = float(value)
    except (TypeError, ValueError):
        return None
    return number if math.isfinite(number) else None


def _trending_score(row):
    """Score current momentum from disclosed TradingView fields.

    This is a ranking score, not a probability and not an entry signal.
    """
    close = row.get("price")
    change = row.get("change_pct")
    rvol = row.get("volume_ratio")
    vwap = row.get("vwap")
    sma20 = row.get("sma20")
    sma50 = row.get("sma50")
    rsi = row.get("rsi")
    adx = row.get("adx")
    week = row.get("performance_week_pct")
    month = row.get("performance_month_pct")

    parts = {
        "momentum": min(20.0, max(0.0, (change or 0.0) * 4.0)),
        "volume": min(20.0, max(0.0, ((rvol or 0.0) - 0.5) * 13.34)),
        "vwap": 10.0 if close and vwap and close > vwap else 0.0,
        "trend": 0.0,
        "rsi": 0.0,
        "adx": 0.0,
        "persistence": 0.0,
    }
    if close and sma20 and close > sma20:
        parts["trend"] += 12.0
    if sma20 and sma50 and sma20 > sma50:
        parts["trend"] += 8.0
    if rsi is not None:
        parts["rsi"] = 10.0 if 52 <= rsi <= 70 else 5.0 if 45 <= rsi < 75 else 0.0
    if adx is not None:
        parts["adx"] = 10.0 if adx >= 25 else 6.0 if adx >= 18 else 0.0
    if week is not None and week > 0:
        parts["persistence"] += 5.0
    if month is not None and month > 0:
        parts["persistence"] += 5.0
    score = round(min(100.0, sum(parts.values())), 1)
    return score, {key: round(value, 1) for key, value in parts.items()}


def _trending_phase(row):
    close = row.get("price") or 0.0
    vwap = row.get("vwap") or 0.0
    distance_vwap = ((close / vwap) - 1.0) * 100 if close > 0 and vwap > 0 else None
    if (
        (row.get("change_pct") or 0.0) >= 8.0
        or (row.get("rsi") or 0.0) >= 75.0
        or (distance_vwap is not None and distance_vwap >= 8.0)
    ):
        return "TERLALU PANAS", distance_vwap
    if (row.get("change_pct") or 0.0) <= 4.0 and (row.get("volume_ratio") or 0.0) >= 1.2:
        return "BARU MUNCUL", distance_vwap
    return "TREND NAIK", distance_vwap


def _load_trending_history():
    try:
        with open(TRENDING_RADAR_HISTORY_FILE, "r", encoding="utf-8") as handle:
            data = json.load(handle)
        return data if isinstance(data, dict) else {"sessions": {}}
    except (OSError, ValueError, TypeError):
        return {"sessions": {}}


def _trending_bucket(current):
    minute = current.minute - (current.minute % 5)
    return current.replace(minute=minute, second=0, microsecond=0).strftime("%H:%M")


def _track_trending_rows(rows, current):
    """Persist compact five-minute snapshots and return today's lifecycle."""
    session_key = current.date().isoformat()
    bucket = _trending_bucket(current)
    compact = [
        {
            "ticker": row["ticker"],
            "rank": index + 1,
            "score": row["trend_score"],
            "phase": row["phase"],
            "change_pct": row["change_pct"],
            "volume_ratio": row["volume_ratio"],
        }
        for index, row in enumerate(rows)
    ]
    with TRENDING_RADAR_HISTORY_LOCK:
        history = _load_trending_history()
        sessions = history.setdefault("sessions", {})
        session = sessions.setdefault(session_key, {"observations": {}})
        session["observations"][bucket] = compact
        session["updated_at"] = current.isoformat(timespec="seconds")
        for old_key in sorted(sessions)[:-10]:
            sessions.pop(old_key, None)
        history.update({
            "schema_version": 1,
            "source": "TradingView Scanner",
            "retention_sessions": 10,
        })
        atomic_write_json(TRENDING_RADAR_HISTORY_FILE, history, indent=2)

        observations = session.get("observations") or {}
        lifecycle = {}
        for observed_at, observed_rows in sorted(observations.items()):
            for observed in observed_rows:
                ticker = observed.get("ticker")
                if not ticker:
                    continue
                item = lifecycle.setdefault(ticker, {
                    "first_seen": observed_at,
                    "last_seen": observed_at,
                    "appearances": 0,
                    "best_rank": observed.get("rank"),
                    "peak_score": observed.get("score"),
                })
                item["last_seen"] = observed_at
                item["appearances"] += 1
                rank = observed.get("rank")
                score = observed.get("score")
                if rank is not None:
                    item["best_rank"] = min(item.get("best_rank") or rank, rank)
                if score is not None:
                    item["peak_score"] = max(item.get("peak_score") or score, score)
        return lifecycle


def load_trending_radar_history():
    with TRENDING_RADAR_HISTORY_LOCK:
        history = _load_trending_history()
    sessions = history.get("sessions") or {}
    return {
        "status": "ok",
        "source": history.get("source") or "TradingView Scanner",
        "retention_sessions": history.get("retention_sessions") or 10,
        "sessions": sessions,
        "as_of": now_jakarta().isoformat(timespec="seconds"),
    }


def load_trending_pa_trade_history():
    """Return the dedicated paper-trade ledger for Trending PA signals."""
    with TRENDING_PA_TRADE_HISTORY_LOCK:
        try:
            with open(TRENDING_PA_TRADE_HISTORY_FILE, "r", encoding="utf-8") as handle:
                data = json.load(handle)
        except FileNotFoundError:
            data = {}
        except (OSError, ValueError, TypeError) as exc:
            return {"status": "error", "history": [], "source": os.path.basename(TRENDING_PA_TRADE_HISTORY_FILE), "message": f"Ledger Trending PA tidak dapat dibaca: {exc}"}
    return {"status": "ok", "history": [item for item in data.get("history", []) if isinstance(item, dict)], "source": os.path.basename(TRENDING_PA_TRADE_HISTORY_FILE), "message": "Ledger auto-paper khusus sinyal Trending PA."}


def _save_trending_pa_trade_history(history):
    atomic_write_json(TRENDING_PA_TRADE_HISTORY_FILE, {"schema_version": 1, "history": history, "updated_at": now_jakarta().isoformat(timespec="seconds"), "message": "Ledger auto-paper khusus sinyal Trending PA."}, indent=2)


def auto_track_trending_pa_trades(rows, current):
    """Open SIAP ENTRY paper trades and resolve them on later radar prices."""
    now_text = current.strftime("%Y-%m-%d %H:%M:%S")
    today = current.date().isoformat()
    rows_by_ticker = {str(row.get("ticker") or "").upper(): row for row in rows}
    with TRENDING_PA_TRADE_HISTORY_LOCK:
        payload = load_trending_pa_trade_history()
        if payload.get("status") != "ok":
            return {"inserted": 0, "closed": 0, "error": payload.get("message")}
        history = payload.get("history") or []
        inserted = closed = 0
        changed = False
        for trade in history:
            if str(trade.get("status") or "").lower() not in {"open", "pending"}:
                continue
            row = rows_by_ticker.get(str(trade.get("ticker") or "").upper())
            price = _trending_number((row or {}).get("price"))
            entry = _trending_number(trade.get("entry")); stop = _trending_number(trade.get("sl")); target = _trending_number(trade.get("target"))
            if not row or price is None or entry is None or stop is None or target is None or str(trade.get("entry_date") or "")[:19] >= now_text:
                continue
            outcome = "loss" if price <= stop else "win" if price >= target else ""
            if outcome:
                exit_price = stop if outcome == "loss" else target
                gross = (exit_price - entry) / entry * 100
                trade.update({"status": outcome, "exit_date": now_text, "exit_time": now_text, "exit_price": exit_price, "gross_return_pct": gross, "net_return_pct": gross, "profit_pct": gross, "outcome_reason": "SL tersentuh" if outcome == "loss" else "Target tersentuh"})
                closed += 1; changed = True
        for row in rows:
            pa = row.get("pa") or {}
            if pa.get("status") != "SIAP ENTRY" or str(pa.get("candle_close_status") or "").lower() != "closed" or not is_market_open(current):
                continue
            ticker = str(row.get("ticker") or "").upper()
            entry = _trending_number(pa.get("entry")); stop = _trending_number(pa.get("sl")); target = _trending_number(pa.get("target")); rr = _trending_number(pa.get("rr"))
            if not ticker or None in {entry, stop, target, rr} or not (stop < entry < target) or rr < 1.5:
                continue
            signal_key = f"{ticker}|{today}|{entry:g}|{stop:g}|{target:g}"
            if any(str(item.get("signal_key") or "") == signal_key for item in history) or any(str(item.get("ticker") or "").upper() == ticker and str(item.get("status") or "").lower() in {"open", "pending"} for item in history):
                continue
            history.append({"id": f"TPA_{ticker}_{int(time.time() * 1000)}", "ticker": ticker, "date": now_text, "entry_date": now_text, "entry": entry, "sl": stop, "target": target, "rr": rr, "status": "open", "type": "buy", "action": "SIAP ENTRY", "signal_key": signal_key, "reason": str(pa.get("reason") or "Seluruh gerbang Price Action dan RR telah valid"), "trigger": "Trending radar + konfirmasi PA tertutup", "paper_trade": True, "execution_mode": "auto-paper", "source": "trending_pa_dashboard", "reference_price": _trending_number(row.get("price")), "trend_score": row.get("trend_score")})
            inserted += 1; changed = True
        if changed:
            _save_trending_pa_trade_history(history)
    return {"inserted": inserted, "closed": closed, "history_count": len(history)}


def load_trending_pa_ab_history():
    with TRENDING_PA_AB_LOCK:
        try:
            with open(TRENDING_PA_AB_FILE, "r", encoding="utf-8") as handle:
                data = json.load(handle)
        except FileNotFoundError:
            data = {}
        except (OSError, ValueError, TypeError) as exc:
            return {"status": "error", "message": str(exc), "trades": [], "states": {"1m": {}, "1m_momentum": {}, "5m": {}}}
    states = data.get("states") if isinstance(data.get("states"), dict) else {}
    return {"status": "ok", "source": os.path.basename(TRENDING_PA_AB_FILE), "started_at": data.get("started_at"), "updated_at": data.get("updated_at"), "trades": [item for item in data.get("trades", []) if isinstance(item, dict)], "states": {"1m": states.get("1m") or {}, "1m_momentum": states.get("1m_momentum") or {}, "5m": states.get("5m") or {}}}


def _save_trending_pa_ab_history(payload):
    payload = dict(payload); payload.update({"schema_version": 1, "updated_at": now_jakarta().isoformat(timespec="seconds")})
    payload["started_at"] = payload.get("started_at") or payload["updated_at"]
    atomic_write_json(TRENDING_PA_AB_FILE, payload, indent=2)


def _trending_ab_summary(trades, timeframe):
    rows = [item for item in trades if item.get("timeframe") == timeframe]
    closed = [item for item in rows if item.get("status") in {"win", "loss"}]
    wins = [item for item in closed if item.get("status") == "win"]
    returns = [_trending_number(item.get("net_return_pct")) for item in closed]
    returns = [value for value in returns if value is not None]
    return {"trades": len(rows), "open": len(rows) - len(closed), "closed": len(closed), "wins": len(wins), "losses": len(closed) - len(wins), "winrate_pct": round(len(wins) / len(closed) * 100, 2) if closed else None, "net_return_pct": round(sum(returns), 2) if returns else None, "avg_return_pct": round(sum(returns) / len(returns), 2) if returns else None}


def trending_pa_ab_payload():
    payload = load_trending_pa_ab_history(); trades = payload.get("trades") or []
    payload["summary"] = {tf: _trending_ab_summary(trades, tf) for tf in ("1m", "1m_momentum", "5m")}
    payload["minimum_sample"] = 30
    payload["comparison_ready"] = all(payload["summary"][tf]["closed"] >= 30 for tf in ("1m", "1m_momentum", "5m"))
    return payload


def auto_track_trending_pa_ab(tickers, current):
    """Paper-test identical PA gates with only the closed trigger timeframe changed."""
    if not is_market_open(current): return {"inserted": 0, "closed": 0}
    preloaded = load_trending_pa_ab_history()
    retained = {
        str(item.get("ticker") or "").upper()
        for item in (preloaded.get("trades") or [])
        if item.get("status") in {"open", "pending"}
    }
    # Keep the expensive PA refresh scoped to the current radar candidates.
    # Only open/pending trades are retained when they fall out of the top list,
    # so exits continue to be monitored without rescanning every old state.
    tickers = sorted(set(tickers) | {ticker for ticker in retained if ticker})
    if not tickers: return {"inserted": 0, "closed": 0}
    try:
        from pa_tracker import trigger_event
        pa_payload = load_pa_snapshot_payload(tickers)
    except Exception as exc:
        return {"inserted": 0, "closed": 0, "error": str(exc)}
    snapshots = pa_payload.get("snapshots") or {}; sr = pa_payload.get("sr") or {}
    with TRENDING_PA_AB_LOCK:
        stored = load_trending_pa_ab_history()
        if stored.get("status") != "ok": return {"inserted": 0, "closed": 0, "error": stored.get("message")}
        trades = stored.get("trades") or []; states = stored.get("states") or {"1m": {}, "1m_momentum": {}, "5m": {}}
        states.setdefault("1m_momentum", {})
        inserted = closed = 0; changed = False; now_text = current.strftime("%Y-%m-%d %H:%M:%S")
        for timeframe, candle_key, interval in (("1m", "1m_closed", 60), ("1m_momentum", "1m_closed", 60), ("5m", "5m_closed", 300)):
            for ticker in tickers:
                snapshot = snapshots.get(ticker) or {}; candle = snapshot.get(candle_key) or {}
                timestamp = _trending_number(candle.get("candle_timestamp")); age = time.time() - timestamp if timestamp else None
                if not timestamp or age is None or age < interval or age > interval * 3: continue
                for trade in trades:
                    entry_ts = _trending_number(trade.get("entry_candle_timestamp")) or 0
                    if trade.get("timeframe") != timeframe or trade.get("ticker") != ticker or trade.get("status") not in {"open", "pending"} or timestamp <= entry_ts: continue
                    high = _trending_number(candle.get("high")); low = _trending_number(candle.get("low")); stop = _trending_number(trade.get("sl")); target = _trending_number(trade.get("target")); entry = _trending_number(trade.get("entry"))
                    if None in {high, low, stop, target, entry}: continue
                    outcome = "loss" if low <= stop else "win" if high >= target else ""
                    if outcome:
                        exit_price = stop if outcome == "loss" else target; result = (exit_price - entry) / entry * 100
                        trade.update({"status": outcome, "exit_date": now_text, "exit_price": exit_price, "gross_return_pct": result, "net_return_pct": result})
                        closed += 1; changed = True
                if timeframe != "1m_momentum" and classify_snapshot(snapshot) != STATUS_WAIT: continue
                previous = states[timeframe].get(ticker) or {}
                sr_timeframe = "1m" if timeframe == "1m_momentum" else timeframe
                state, reason = trigger_event(previous, candle, (sr.get(ticker) or {}).get(sr_timeframe), ticker, timeframe)
                states[timeframe][ticker] = state; changed = changed or state != previous
                if not state.get("ready") or state.get("ab_entered"): continue
                if any(item.get("ticker") == ticker and item.get("timeframe") == timeframe and item.get("status") in {"open", "pending"} for item in trades): continue
                if timeframe == "1m_momentum":
                    entry = _trending_number(candle.get("close")); stop = _trending_number(candle.get("EMA20")) or _trending_number(candle.get("low")); risk = entry - stop if entry is not None and stop is not None else 0; plan = {"entry": entry, "sl": stop, "target": entry + risk * 1.5, "rr": 1.5} if entry and stop and risk > 0 else None
                else:
                    plan = _vcp_pa_calc_rr(snapshot, (sr.get(ticker) or {}).get(timeframe), STATUS_READY, state.get("ready_candle") or candle)
                if not plan or not rr_is_valid(plan.get("rr")): continue
                trades.append({"id": f"TPA_AB_{timeframe}_{ticker}_{int(timestamp)}", "ticker": ticker, "timeframe": timeframe, "date": now_text, "entry_date": now_text, "entry_candle_timestamp": timestamp, "entry": plan["entry"], "sl": plan["sl"], "target": plan["target"], "rr": plan["rr"], "status": "open", "reason": "Momentum 1m-only closed candle" if timeframe == "1m_momentum" else f"Closed {timeframe} break-retest + RVOL; eksperimen A/B", "paper_trade": True, "source": "TradingView Scanner"})
                state["ab_entered"] = True; inserted += 1; changed = True
        if changed: _save_trending_pa_ab_history({"started_at": stored.get("started_at"), "trades": trades, "states": states})
    return {"inserted": inserted, "closed": closed, "history_count": len(trades)}


def _build_trending_radar_snapshot():
    now_mono = time.monotonic()
    with TRENDING_RADAR_CACHE_LOCK:
        cached = TRENDING_RADAR_CACHE.get("payload")
        if cached and now_mono - TRENDING_RADAR_CACHE["created_at"] < TRENDING_RADAR_CACHE_TTL:
            return cached

    columns = [
        "name", "close", "change", "volume", "average_volume_10d_calc",
        "relative_volume_intraday|5", "Value.Traded", "VWAP", "SMA20", "SMA50",
        "RSI", "ADX", "high", "low", "open", "Perf.W", "Perf.1M", "sector", "time",
    ]
    scan = tradingview_market_scan(columns)
    allowed = set(load_sideways_breakout_idx_universe().get("tickers") or [])
    rows = []
    for item in scan.get("data", []):
        values = item.get("d") or []
        if len(values) != len(columns):
            continue
        raw = dict(zip(columns, values))
        ticker = str(item.get("s") or "").split(":")[-1].upper()
        if not ticker or (allowed and ticker not in allowed):
            continue
        volume = _trending_number(raw.get("volume"))
        average_volume = _trending_number(raw.get("average_volume_10d_calc"))
        intraday_rvol = _trending_number(raw.get("relative_volume_intraday|5"))
        daily_rvol = volume / average_volume if volume and average_volume else None
        volume_ratio = intraday_rvol if intraday_rvol is not None else daily_rvol
        row = {
            "ticker": ticker,
            "name": str(raw.get("name") or ticker),
            "price": _trending_number(raw.get("close")),
            "change_pct": _trending_number(raw.get("change")),
            "volume": volume,
            "volume_ratio": round(volume_ratio, 2) if volume_ratio is not None else None,
            "volume_ratio_source": "intraday_5m" if intraday_rvol is not None else "daily_fallback",
            "value_traded": _trending_number(raw.get("Value.Traded")),
            "vwap": _trending_number(raw.get("VWAP")),
            "sma20": _trending_number(raw.get("SMA20")),
            "sma50": _trending_number(raw.get("SMA50")),
            "rsi": _trending_number(raw.get("RSI")),
            "adx": _trending_number(raw.get("ADX")),
            "high": _trending_number(raw.get("high")),
            "low": _trending_number(raw.get("low")),
            "open": _trending_number(raw.get("open")),
            "performance_week_pct": _trending_number(raw.get("Perf.W")),
            "performance_month_pct": _trending_number(raw.get("Perf.1M")),
            "sector": str(raw.get("sector") or "Tidak tersedia"),
            "data_timestamp": _trending_number(raw.get("time")),
        }
        if (
            not row["price"]
            or row["change_pct"] is None
            or row["change_pct"] <= 0.5
            or (row["value_traded"] or 0.0) < 1_000_000_000
            or (row["volume_ratio"] or 0.0) < 0.8
        ):
            continue
        score, components = _trending_score(row)
        row["trend_score"] = score
        row["score_components"] = components
        row["phase"], row["distance_vwap_pct"] = _trending_phase(row)
        if score >= 45.0:
            rows.append(row)

    rows.sort(key=lambda row: (row["trend_score"], row["value_traded"] or 0.0), reverse=True)
    current = now_jakarta()
    lifecycle = _track_trending_rows(rows, current)
    for index, row in enumerate(rows, start=1):
        row["rank"] = index
        row["tracking"] = lifecycle.get(row["ticker"], {})

    pa_tickers = [row["ticker"] for row in rows[:TRENDING_RADAR_PA_LIMIT]]
    pa_statuses = {}
    pa_error = None
    if pa_tickers:
        try:
            pa_statuses = load_vcp_pa_status_payload(pa_tickers).get("statuses") or {}
        except Exception as exc:
            pa_error = str(exc)
    for row in rows:
        row["pa"] = pa_statuses.get(row["ticker"]) or {
            "status": "BELUM VALIDASI PA",
            "reason": "Validasi PA dijalankan untuk kandidat peringkat teratas.",
            "candle_close_status": "unknown",
        }

    history_payload = load_trending_pa_trade_history()
    history = history_payload.get("history") if history_payload.get("status") == "ok" else []
    previous_trade_states = {
        str(item.get("id")): str(item.get("status") or "").lower()
        for item in history
        if isinstance(item, dict) and item.get("id")
    }
    trade_tracking = auto_track_trending_pa_trades(rows, current)
    new_trades = []
    closed_trades = []
    if trade_tracking.get("inserted") or trade_tracking.get("closed"):
        after_payload = load_trending_pa_trade_history()
        history = after_payload.get("history") if after_payload.get("status") == "ok" else history
        new_trades = [
            dict(item)
            for item in history
            if isinstance(item, dict)
            and str(item.get("id")) not in previous_trade_states
            and str(item.get("status") or "").lower() in {"open", "pending"}
        ]
        closed_trades = [
            dict(item)
            for item in history
            if isinstance(item, dict)
            and str(item.get("id")) in previous_trade_states
            and previous_trade_states.get(str(item.get("id"))) in {"open", "pending"}
            and str(item.get("status") or "").lower() in {"win", "loss"}
        ]
    trade_tracking = dict(trade_tracking)
    trade_tracking["telegram_notifications_scheduled"] = _schedule_trending_pa_telegram_alerts(new_trades, closed_trades)
    trade_tracking["telegram_outcome_notifications_scheduled"] = len(closed_trades)
    ab_tracking = auto_track_trending_pa_ab(pa_tickers, current)

    payload = {
        "status": "ok",
        "rows": rows,
        "as_of": current.isoformat(timespec="seconds"),
        "market_open": is_market_open(current),
        "source": "TradingView Scanner",
        "universe": "IDX saham, FCA dikecualikan",
        "method": {
            "scan_interval": "5 menit via worker background; dashboard tidak diperlukan",
            "minimum_change_pct": 0.5,
            "minimum_value_traded": 1_000_000_000,
            "minimum_volume_ratio": 0.8,
            "minimum_trend_score": 45,
            "score_is_probability": False,
            "pa_timeframes": ["1D", "1H", "15m", "5m closed"],
        },
        "pa_error": pa_error,
        "trade_tracking": trade_tracking,
        "ab_tracking": ab_tracking,
    }
    with TRENDING_RADAR_CACHE_LOCK:
        TRENDING_RADAR_CACHE.update({"created_at": time.monotonic(), "payload": payload})
    return payload


def _pb1m_open_tickers():
    payload = pb1m.load_pb1m_trade_history()
    if payload.get("status") != "ok":
        return []
    return [
        str(item.get("ticker") or "").upper()
        for item in payload.get("history") or []
        if str(item.get("status") or "").lower() in {"open", "pending"}
    ]


def _pb1m_v2_open_tickers():
    payload = pb1m_v2.load_pb1m_v2_trade_history()
    if payload.get("status") != "ok":
        return []
    return [
        str(item.get("ticker") or "").upper()
        for item in payload.get("history") or []
        if str(item.get("status") or "").lower() in {"open", "pending"}
    ]


def _pb1m_universe_rows():
    """Use the full trending radar, not a top-20 slice, so new names stay eligible."""
    with TRENDING_RADAR_CACHE_LOCK:
        cached = TRENDING_RADAR_CACHE.get("payload")
    extras = list(dict.fromkeys(_pb1m_open_tickers() + _pb1m_v2_open_tickers()))
    if cached and cached.get("rows"):
        return pb1m.select_pb1m_universe(cached["rows"], extra_tickers=extras), {
            "source": cached.get("source") or "TradingView Scanner",
            "as_of": cached.get("as_of"),
        }
    history = _load_trending_history()
    sessions = history.get("sessions") or {}
    if not sessions:
        return pb1m.select_pb1m_universe([], extra_tickers=extras), None
    latest_date = sorted(sessions)[-1]
    observations = (sessions.get(latest_date) or {}).get("observations") or {}
    if not observations:
        return pb1m.select_pb1m_universe([], extra_tickers=extras), None
    latest_bucket = sorted(observations)[-1]
    compact = observations.get(latest_bucket) or []
    rows = []
    for item in compact:
        ticker = str(item.get("ticker") or "").upper()
        if not ticker:
            continue
        rows.append({
            "ticker": ticker,
            "name": ticker,
            "rank": item.get("rank"),
            "trend_score": item.get("score"),
            "phase": item.get("phase"),
            "change_pct": item.get("change_pct"),
            "volume_ratio": item.get("volume_ratio"),
        })
    return pb1m.select_pb1m_universe(rows, extra_tickers=extras), {
        "source": "TradingView Scanner",
        "as_of": f"{latest_date} {latest_bucket}",
        "from_history": True,
    }


def _schedule_pb1m_telegram_alerts(trades, outcome_trades=None):
    """Deliver PB1M entry and TP/SL alerts outside the radar request."""
    candidates = [dict(trade) for trade in (trades or []) if isinstance(trade, dict)]
    outcomes = [dict(trade) for trade in (outcome_trades or []) if isinstance(trade, dict)]
    if not candidates and not outcomes:
        return 0

    def deliver():
        try:
            ready_result = pb1m_telegram_alert.notify_ready_trades(candidates)
            outcome_result = pb1m_telegram_alert.notify_outcome_trades(outcomes)
            print(
                "[PB1M_TELEGRAM] "
                f"ready_status={ready_result.get('status')} "
                f"ready_sent={ready_result.get('sent', 0)} "
                f"outcome_status={outcome_result.get('status')} "
                f"outcome_sent={outcome_result.get('sent', 0)}",
                flush=True,
            )
        except Exception as exc:
            print(f"[PB1M_TELEGRAM] gagal: {type(exc).__name__}", flush=True)

    threading.Thread(
        target=deliver,
        name="pb1m-telegram-alert",
        daemon=True,
    ).start()
    return len(candidates)


def _schedule_trending_pa_telegram_alerts(trades, outcome_trades=None):
    """Deliver Trending PA entry and TP/SL alerts outside the radar request."""
    candidates = [dict(trade) for trade in (trades or []) if isinstance(trade, dict)]
    outcomes = [dict(trade) for trade in (outcome_trades or []) if isinstance(trade, dict)]
    if not candidates and not outcomes:
        return 0

    def deliver():
        try:
            ready_result = trending_pa_telegram_alert.notify_ready_trades(candidates)
            outcome_result = trending_pa_telegram_alert.notify_outcome_trades(outcomes)
            print(
                "[TRENDING_PA_TELEGRAM] "
                f"ready_status={ready_result.get('status')} "
                f"ready_sent={ready_result.get('sent', 0)} "
                f"outcome_status={outcome_result.get('status')} "
                f"outcome_sent={outcome_result.get('sent', 0)}",
                flush=True,
            )
        except Exception as exc:
            print(f"[TRENDING_PA_TELEGRAM] gagal: {type(exc).__name__}", flush=True)

    threading.Thread(
        target=deliver,
        name="trending-pa-telegram-alert",
        daemon=True,
    ).start()
    return len(candidates)


def _pb1m_cached_market_context():
    """Reuse an existing IHSG cache for research without adding a network call."""
    with SWING_BREAKOUT_MARKET_CACHE_LOCK:
        cached = SWING_BREAKOUT_MARKET_CACHE.get("COMPOSITE")
        if not cached or not isinstance(cached.get("payload"), dict):
            return {
                "available": False,
                "reason": "IHSG cache belum tersedia; baseline PB1M tidak ditahan",
            }
        payload = dict(cached["payload"])
        payload["cache_age_seconds"] = round(
            max(0.0, time.monotonic() - float(cached.get("created_at") or 0.0)),
            2,
        )
        payload["research_only"] = True
        return payload


def _build_pb1m_radar_snapshot():
    current = now_jakarta()
    universe, meta = _pb1m_universe_rows()
    source_label = (meta or {}).get("source") or "TradingView Scanner"
    if not universe:
        payload = {
            "status": "ok",
            "rows": [],
            "counts": pb1m.summarize_pb1m([]),
            "as_of": current.isoformat(timespec="seconds"),
            "market_open": is_market_open(current),
            "source": source_label,
            "universe": "Belum ada jejak radar trending untuk dibaca.",
            "history_source": os.path.basename(pb1m.PB1M_TRADE_HISTORY_FILE),
            "method": {
                "name": "1M Pullback Breakout",
                "timeframe": "1m closed",
                "max_pullback_retrace": 0.5,
                "min_breakout_rvol": 1.2,
                "max_chase_pct": 1.0,
                "min_rr": 1.5,
                "scan_interval": "60 detik via worker background; dashboard tidak diperlukan",
                "bar_source": "TradingView 1m",
                "quote_source": "TradingView Scanner",
                "differs_from": "Break-retest 5m pada Trending PA Radar",
            },
            "trade_tracking": {
                "inserted": 0,
                "closed": 0,
                "telegram_notifications_scheduled": 0,
                "telegram_outcome_notifications_scheduled": 0,
            },
        }
        with PB1M_CACHE_LOCK:
            PB1M_CACHE.update({"created_at": time.monotonic(), "payload": payload})
        return payload
    tickers = [row["ticker"] for row in universe if row.get("ticker")]
    scan_error = None
    scanned = {}
    try:
        scanned = pb1m.fetch_pb1m_bars(tradingview_scan, tickers)
    except Exception as exc:
        scan_error = str(exc)
    history_payload = pb1m.load_pb1m_trade_history()
    history = history_payload.get("history") if history_payload.get("status") == "ok" else []
    previous_trade_states = {
        str(item.get("id")): str(item.get("status") or "").lower()
        for item in history
        if isinstance(item, dict) and item.get("id")
    }
    rows = pb1m.evaluate_pb1m_rows(universe, scanned, history=history, current=current)
    tracking = pb1m.auto_track_pb1m_trades(rows, current)
    new_trades = []
    closed_trades = []
    if tracking.get("inserted") or tracking.get("closed"):
        history = pb1m.load_pb1m_trade_history().get("history") or history
        new_trades = [
            dict(item)
            for item in history
            if isinstance(item, dict)
            and str(item.get("id")) not in previous_trade_states
            and str(item.get("status") or "").lower() in {"open", "pending"}
        ]
        closed_trades = [
            dict(item)
            for item in history
            if isinstance(item, dict)
            and str(item.get("id")) in previous_trade_states
            and previous_trade_states.get(str(item.get("id"))) in {"open", "pending"}
            and str(item.get("status") or "").lower() in {"win", "loss"}
        ]
        for row in rows:
            pb1m.apply_trade_overlay(row, history, current.date().isoformat())
    tracking = dict(tracking)
    try:
        tracking["research"] = pb1m_research.sync_pb1m_research(
            history,
            rows,
            scanned,
            current,
            new_trade_ids=[item.get("id") for item in new_trades],
            market_context=_pb1m_cached_market_context(),
        )
    except Exception as exc:
        tracking["research"] = {
            "status": "error",
            "message": f"Telemetry riset PB1M gagal ({type(exc).__name__}); baseline tetap berjalan",
        }
    tracking["telegram_notifications_scheduled"] = _schedule_pb1m_telegram_alerts(new_trades, closed_trades)
    tracking["telegram_outcome_notifications_scheduled"] = len(closed_trades)
    payload = {
        "status": "ok",
        "rows": rows,
        "counts": pb1m.summarize_pb1m(rows),
        "as_of": current.isoformat(timespec="seconds"),
        "market_open": is_market_open(current),
        "source": source_label,
        "universe": f"Semua saham radar trending sesi ini, termasuk yang baru muncul, maksimum {pb1m.PB1M_TICKER_LIMIT} ticker",
        "universe_from_history": bool((meta or {}).get("from_history")),
        "scan_error": scan_error,
        "history_source": os.path.basename(pb1m.PB1M_TRADE_HISTORY_FILE),
        "method": {
            "name": "1M Pullback Breakout",
            "timeframe": "1m closed",
            "max_pullback_retrace": 0.5,
            "min_breakout_rvol": 1.2,
            "max_chase_pct": 1.0,
            "min_rr": 1.5,
            "scan_interval": "60 detik via worker background; dashboard tidak diperlukan",
            "differs_from": "Break-retest 5m pada Trending PA Radar",
        },
        "trade_tracking": tracking,
    }
    with PB1M_CACHE_LOCK:
        PB1M_CACHE.update({"created_at": time.monotonic(), "payload": payload})
    return payload


def load_pb1m_radar_snapshot():
    """Return a 60-second 1m pullback-breakout snapshot for the experiment dashboard."""
    now_mono = time.monotonic()
    with PB1M_CACHE_LOCK:
        cached = PB1M_CACHE.get("payload")
        if cached and now_mono - PB1M_CACHE["created_at"] < PB1M_CACHE_TTL:
            return cached
    with PB1M_BUILD_LOCK:
        now_mono = time.monotonic()
        with PB1M_CACHE_LOCK:
            cached = PB1M_CACHE.get("payload")
            if cached and now_mono - PB1M_CACHE["created_at"] < PB1M_CACHE_TTL:
                return cached
        return _build_pb1m_radar_snapshot()


def _build_pb1m_v2_radar_snapshot():
    current = now_jakarta()
    universe, meta = _pb1m_universe_rows()
    source_label = (meta or {}).get("source") or "TradingView Scanner"
    history_payload = pb1m_v2.load_pb1m_v2_trade_history()
    history = history_payload.get("history") if history_payload.get("status") == "ok" else []
    scanned = {}
    scan_error = None
    if universe:
        try:
            scanned = pb1m.fetch_pb1m_bars(
                tradingview_scan,
                [row["ticker"] for row in universe if row.get("ticker")],
            )
        except Exception as exc:
            scan_error = str(exc)
    rows = pb1m_v2.evaluate_pb1m_v2_rows(universe, scanned, history=history, current=current)
    tracking = pb1m_v2.auto_track_pb1m_v2_trades(rows, current)
    if tracking.get("inserted") or tracking.get("closed") or tracking.get("updated"):
        history = pb1m_v2.load_pb1m_v2_trade_history().get("history") or history
        for row in rows:
            pb1m_v2.apply_trade_overlay(row, history, current.date().isoformat())
    payload = {
        "status": "ok",
        "rows": rows,
        "counts": pb1m_v2.summarize_pb1m_v2(rows, history),
        "as_of": current.isoformat(timespec="seconds"),
        "market_open": is_market_open(current),
        "source": source_label,
        "scan_error": scan_error,
        "history_source": os.path.basename(pb1m_v2.PB1M_V2_TRADE_HISTORY_FILE),
        "telegram_enabled": False,
        "method": {
            "name": "PB1M V2 Quality Retest",
            "mode": "paper trade challenger",
            "min_breakout_rvol": pb1m_v2.MIN_BREAKOUT_RVOL,
            "max_retest_bars": pb1m_v2.MAX_RETEST_BARS,
            "atr_stop_buffer": pb1m_v2.ATR_STOP_BUFFER,
            "max_stop_atr": pb1m_v2.MAX_STOP_ATR,
            "max_risk_pct": pb1m_v2.MAX_RISK_PCT,
            "tp1_r": pb1m_v2.TP1_R,
            "tp2_r": pb1m_v2.TP2_R,
            "time_exit_bars": pb1m_v2.TIME_EXIT_BARS,
            "overnight": False,
            "scan_interval": "60 detik via worker background; dashboard tidak diperlukan",
        },
        "trade_tracking": tracking,
    }
    with PB1M_V2_CACHE_LOCK:
        PB1M_V2_CACHE.update({"created_at": time.monotonic(), "payload": payload})
    return payload


def load_pb1m_v2_radar_snapshot():
    now_mono = time.monotonic()
    with PB1M_V2_CACHE_LOCK:
        cached = PB1M_V2_CACHE.get("payload")
        if cached and now_mono - PB1M_V2_CACHE["created_at"] < PB1M_V2_CACHE_TTL:
            return cached
    with PB1M_V2_BUILD_LOCK:
        now_mono = time.monotonic()
        with PB1M_V2_CACHE_LOCK:
            cached = PB1M_V2_CACHE.get("payload")
            if cached and now_mono - PB1M_V2_CACHE["created_at"] < PB1M_V2_CACHE_TTL:
                return cached
        return _build_pb1m_v2_radar_snapshot()


def _build_full_idx_intraday_snapshot(periods=20):
    universe = load_sideways_breakout_idx_universe()
    allowed = set(universe.get("tickers") or [])
    base_columns = pb1m.pb1m_scan_columns(periods)
    extras = ["close[1]", "relative_volume_intraday|5", "Value.Traded", "open", "high", "low", "bid", "ask", "volume", "average_volume_10d_calc", "name"]
    raw = tradingview_market_scan(base_columns + extras)
    packs = pb1m.parse_pb1m_scan(raw, base_columns, periods)
    contexts = {}
    offset = len(base_columns)
    keys = ("previous_close", "rvol_intraday", "value_traded", "open", "high", "low", "bid", "ask", "volume", "average_volume_10d", "name")
    for item in raw.get("data") or []:
        ticker = str(item.get("s") or "").split(":")[-1].upper()
        values = item.get("d") or []
        if ticker not in allowed or len(values) < offset + len(extras):
            continue
        context = dict(zip(keys, values[offset:offset + len(extras)]))
        pack = packs.get(ticker) or {}
        context.update({"close": pack.get("price"), "change_pct": pack.get("change_pct"), "vwap": pack.get("vwap"), "board": "REGULAR_NON_FCA"})
        contexts[ticker] = context
    packs = {ticker: pack for ticker, pack in packs.items() if ticker in allowed and ticker in contexts}
    # TradingView's market-wide scanner exposes only the latest two 1m bars.
    # Every ticker is still screened above; only names showing real liquidity
    # and movement are promoted to the deeper websocket history request.
    def deep_priority(ticker):
        item = contexts[ticker]
        change = float(item.get("change_pct") or 0)
        rvol = float(item.get("rvol_intraday") or 0)
        value = float(item.get("value_traded") or 0)
        return (rvol * 12) + (max(0, change) * 4) + min(25, math.log10(max(1, value)) * 2)
    deep_candidates = [ticker for ticker in contexts
                       if 0.5 <= float(contexts[ticker].get("change_pct") or 0) <= 15
                       and float(contexts[ticker].get("value_traded") or 0) >= 1_000_000_000]
    open_tickers = {
        str(item.get("ticker") or "").upper()
        for loader in (ara_hunter_v2.load_trade_history, momentum_ignition.load_history)
        for item in (loader().get("history") or [])
        if str(item.get("status") or "").lower() == "open"
    }
    deep_candidates = list(dict.fromkeys(sorted(deep_candidates, key=deep_priority, reverse=True)[:40] + sorted(open_tickers & allowed)))
    deep_error = None
    if deep_candidates:
        try:
            deep = pb1m.fetch_pb1m_bars(tradingview_scan, deep_candidates, periods=periods)
            packs.update({ticker: pack for ticker, pack in deep.items() if ticker in allowed})
        except Exception as exc:
            deep_error = str(exc)
    return {"packs": packs, "contexts": contexts, "universe": universe,
            "as_of": now_jakarta().isoformat(timespec="seconds"), "periods": periods,
            "broad_loaded_count": len(contexts), "deep_candidate_count": len(deep_candidates),
            "deep_loaded_count": sum(len((packs.get(ticker) or {}).get("bars") or []) >= 8 for ticker in deep_candidates),
            "deep_error": deep_error}


def load_full_idx_intraday_snapshot():
    now_mono = time.monotonic()
    with FULL_IDX_INTRADAY_CACHE_LOCK:
        cached = FULL_IDX_INTRADAY_CACHE.get("payload")
        if cached and now_mono - FULL_IDX_INTRADAY_CACHE["created_at"] < FULL_IDX_INTRADAY_CACHE_TTL:
            return cached
    with FULL_IDX_INTRADAY_BUILD_LOCK:
        now_mono = time.monotonic()
        with FULL_IDX_INTRADAY_CACHE_LOCK:
            cached = FULL_IDX_INTRADAY_CACHE.get("payload")
            if cached and now_mono - FULL_IDX_INTRADAY_CACHE["created_at"] < FULL_IDX_INTRADAY_CACHE_TTL:
                return cached
        payload = _build_full_idx_intraday_snapshot()
        with FULL_IDX_INTRADAY_CACHE_LOCK:
            FULL_IDX_INTRADAY_CACHE.update({"created_at": time.monotonic(), "payload": payload})
        return payload


def _ara_sources_from_full_idx(snapshot):
    rows = []
    for ticker, context in (snapshot.get("contexts") or {}).items():
        pack = (snapshot.get("packs") or {}).get(ticker) or {}
        bars = [bar for bar in pack.get("bars") or [] if bar.get("closed", True)]
        latest_volume = float((bars[-1] if bars else {}).get("volume") or 0)
        prior_volume = float((bars[-2] if len(bars) > 1 else {}).get("volume") or 0)
        high, low, close = (float(context.get(key) or 0) for key in ("high", "low", "close"))
        position = (close - low) / (high - low) * 100 if high > low > 0 else 0
        bid, ask = context.get("bid"), context.get("ask")
        spread = (float(ask) - float(bid)) / float(bid) * 100 if bid and ask and float(ask) >= float(bid) else None
        rows.append({"ticker": ticker, "name": context.get("name") or ticker, "close": close,
                     "change_pct": context.get("change_pct"), "previous_close": context.get("previous_close"),
                     "rvol_time": context.get("rvol_intraday"), "vol_ratio": context.get("rvol_intraday"),
                     "vol_accel": latest_volume / prior_volume - 1 if prior_volume else 0,
                     "is_accelerating": bool(prior_volume and latest_volume >= prior_volume * 1.08),
                     "value_traded": context.get("value_traded"), "range_position": round(position, 2),
                     "vwap": context.get("vwap"), "above_vwap": bool(close and context.get("vwap") and close >= float(context["vwap"])),
                     "spread_pct": round(spread, 3) if spread is not None else None, "board": "REGULAR_NON_FCA"})
    return rows


def _build_ara_hunter_v2_snapshot():
    current = now_jakarta()
    full = load_full_idx_intraday_snapshot()
    anomalies = _ara_sources_from_full_idx(full)
    history_payload = ara_hunter_v2.load_trade_history()
    history = history_payload.get("history") if history_payload.get("status") == "ok" else []
    rows = ara_hunter_v2.evaluate_rows(anomalies, full.get("packs"), full.get("contexts"), history=history, current=current)
    tracking = ara_hunter_v2.auto_track_trades(rows, current)
    if tracking.get("inserted") or tracking.get("closed") or tracking.get("updated"):
        history = ara_hunter_v2.load_trade_history().get("history") or history
        for row in rows:
            ara_hunter_v2.apply_trade_overlay(row, history, current.date().isoformat())
    payload = {
        "status": "ok", "rows": rows, "counts": ara_hunter_v2.summarize(rows, history),
        "as_of": current.isoformat(timespec="seconds"), "source_as_of": full.get("as_of"),
        "market_open": is_market_open(current), "source": "TradingView seluruh IDX 1m, FCA dikecualikan",
        "source_error": None, "scan_error": None,
        "history_source": os.path.basename(ara_hunter_v2.TRADE_HISTORY_FILE),
        "telegram_enabled": False, "trade_tracking": tracking,
        "universe": {"listed_count": full["universe"].get("listed_count"), "eligible_count": len(full["universe"].get("tickers") or []),
                     "loaded_count": len(full.get("packs") or {}), "fca_excluded_count": full["universe"].get("fca_in_universe_count"),
                     "fca_as_of": full["universe"].get("fca_as_of"), "source": full["universe"].get("universe_source")},
        "method": {
            "name": "ARA Hunter V2 Early Reclaim", "mode": "paper trade challenger",
            "change_window_pct": [ara_hunter_v2.MIN_CHANGE_PCT, ara_hunter_v2.MAX_CHANGE_PCT],
            "min_ara_room_pct": ara_hunter_v2.MIN_ARA_ROOM_PCT, "min_rvol": ara_hunter_v2.MIN_RVOL,
            "min_value_traded": ara_hunter_v2.MIN_VALUE_TRADED, "min_range_position": ara_hunter_v2.MIN_RANGE_POSITION,
            "max_retest_bars": ara_hunter_v2.MAX_RETEST_BARS, "max_risk_pct": ara_hunter_v2.MAX_RISK_PCT,
            "overnight": False, "telegram": False,
        },
    }
    with ARA_HUNTER_V2_CACHE_LOCK:
        ARA_HUNTER_V2_CACHE.update({"created_at": time.monotonic(), "payload": payload})
    return payload


def load_ara_hunter_v2_snapshot():
    now_mono = time.monotonic()
    with ARA_HUNTER_V2_CACHE_LOCK:
        cached = ARA_HUNTER_V2_CACHE.get("payload")
        if cached and now_mono - ARA_HUNTER_V2_CACHE["created_at"] < ARA_HUNTER_V2_CACHE_TTL:
            return cached
    with ARA_HUNTER_V2_BUILD_LOCK:
        now_mono = time.monotonic()
        with ARA_HUNTER_V2_CACHE_LOCK:
            cached = ARA_HUNTER_V2_CACHE.get("payload")
            if cached and now_mono - ARA_HUNTER_V2_CACHE["created_at"] < ARA_HUNTER_V2_CACHE_TTL:
                return cached
        return _build_ara_hunter_v2_snapshot()


def _build_momentum_ignition_snapshot():
    current = now_jakarta(); full = load_full_idx_intraday_snapshot()
    history_payload = momentum_ignition.load_history()
    history = history_payload.get("history") if history_payload.get("status") == "ok" else []
    rows = momentum_ignition.evaluate_rows(full.get("packs") or {}, full.get("contexts") or {},
                                           full["universe"].get("tickers") or [], history=history, current=current)
    tracking = momentum_ignition.auto_track(rows, current)
    if tracking.get("inserted") or tracking.get("closed") or tracking.get("updated"):
        history = momentum_ignition.load_history().get("history") or history
        for row in rows: momentum_ignition.overlay_trades(row, history, current.date().isoformat())
    visible = [row for row in rows if row["score"] >= 50 or row.get("trades")][:150]
    payload = {"status": "ok", "rows": visible, "scanned_count": len(full.get("contexts") or {}),
               "deep_analyzed_count": len(rows),
               "ready_count": sum(row["status"] == "READY" for row in rows),
               "as_of": current.isoformat(timespec="seconds"), "source_as_of": full.get("as_of"),
               "market_open": is_market_open(current), "source": "TradingView seluruh IDX 1m, FCA dikecualikan",
               "comparison": momentum_ignition.comparison(history), "tracking": tracking,
               "telegram_enabled": False,
               "universe": {"listed_count": full["universe"].get("listed_count"), "eligible_count": len(full["universe"].get("tickers") or []),
                            "loaded_count": full.get("broad_loaded_count"), "deep_candidate_count": full.get("deep_candidate_count"),
                            "deep_loaded_count": full.get("deep_loaded_count"), "deep_error": full.get("deep_error"),
                            "fca_excluded_count": full["universe"].get("fca_in_universe_count"),
                            "fca_as_of": full["universe"].get("fca_as_of"), "source": full["universe"].get("universe_source")}}
    with MOMENTUM_IGNITION_CACHE_LOCK:
        MOMENTUM_IGNITION_CACHE.update({"created_at": time.monotonic(), "payload": payload})
    return payload


def load_momentum_ignition_snapshot():
    now_mono = time.monotonic()
    with MOMENTUM_IGNITION_CACHE_LOCK:
        cached = MOMENTUM_IGNITION_CACHE.get("payload")
        if cached and now_mono - MOMENTUM_IGNITION_CACHE["created_at"] < MOMENTUM_IGNITION_CACHE_TTL: return cached
    with MOMENTUM_IGNITION_BUILD_LOCK:
        now_mono = time.monotonic()
        with MOMENTUM_IGNITION_CACHE_LOCK:
            cached = MOMENTUM_IGNITION_CACHE.get("payload")
            if cached and now_mono - MOMENTUM_IGNITION_CACHE["created_at"] < MOMENTUM_IGNITION_CACHE_TTL: return cached
        return _build_momentum_ignition_snapshot()


def load_trending_radar_snapshot():
    """Return one shared five-minute snapshot without duplicate live scans."""
    now_mono = time.monotonic()
    with TRENDING_RADAR_CACHE_LOCK:
        cached = TRENDING_RADAR_CACHE.get("payload")
        if cached and now_mono - TRENDING_RADAR_CACHE["created_at"] < TRENDING_RADAR_CACHE_TTL:
            return cached
    with TRENDING_RADAR_BUILD_LOCK:
        now_mono = time.monotonic()
        with TRENDING_RADAR_CACHE_LOCK:
            cached = TRENDING_RADAR_CACHE.get("payload")
            if cached and now_mono - TRENDING_RADAR_CACHE["created_at"] < TRENDING_RADAR_CACHE_TTL:
                return cached
        return _build_trending_radar_snapshot()


def read_background_automation_state():
    if not os.path.exists(BACKGROUND_AUTOMATION_STATE_FILE):
        return {}
    try:
        with open(BACKGROUND_AUTOMATION_STATE_FILE, "r", encoding="utf-8") as handle:
            payload = json.load(handle)
        return payload if isinstance(payload, dict) else {}
    except (OSError, ValueError, TypeError):
        return {}


def write_background_automation_state(**updates):
    state = read_background_automation_state()
    state.update(updates)
    atomic_write_json(BACKGROUND_AUTOMATION_STATE_FILE, state, indent=2)
    return state


def _background_automation_error(exc):
    """Keep worker diagnostics useful without ever persisting the TV cookie."""
    message = str(exc)
    session_id = os.environ.get("TRADINGVIEW_SESSIONID")
    if session_id:
        message = message.replace(session_id, "[redacted]")
    return message[:500]


def _background_tracker_summary(name, payload):
    """Persist only compact tracker metrics, never the full radar rows."""
    payload = payload if isinstance(payload, dict) else {}
    tracking = payload.get("trade_tracking")
    if not isinstance(tracking, dict):
        tracking = payload.get("tracking") if isinstance(payload.get("tracking"), dict) else {}
    summary = {
        "status": payload.get("status") or "ok",
        "as_of": payload.get("as_of"),
        "market_open": payload.get("market_open"),
    }
    for key in (
        "inserted",
        "closed",
        "updated",
        "history_count",
        "telegram_notifications_scheduled",
        "telegram_outcome_notifications_scheduled",
    ):
        value = tracking.get(key)
        if isinstance(value, (bool, int, float, str)):
            summary[key] = value
    if name == "trending_radar" and isinstance(payload.get("ab_tracking"), dict):
        summary["ab_tracking"] = {
            key: value
            for key, value in payload["ab_tracking"].items()
            if isinstance(value, (bool, int, float, str))
        }
    for key in ("scan_error", "pa_error"):
        if payload.get(key):
            summary[key] = _background_automation_error(payload[key])
    if tracking.get("error"):
        summary["tracking_error"] = _background_automation_error(tracking["error"])
    return summary


def background_automation_payload():
    current = now_jakarta()
    return {
        "status": "ok",
        "enabled": BACKGROUND_AUTOMATION_ENABLED,
        "worker": "background_market_automation",
        "server_time": current.isoformat(timespec="seconds"),
        "market_open": is_market_open(current),
        "poll_sec": BACKGROUND_AUTOMATION_POLL_SEC,
        "task_intervals_sec": {
            "trending_radar": BACKGROUND_AUTOMATION_TRENDING_INTERVAL_SEC,
            "pb1m": BACKGROUND_AUTOMATION_FAST_INTERVAL_SEC,
            "pb1m_v2": BACKGROUND_AUTOMATION_FAST_INTERVAL_SEC,
            "ara_hunter_v2": BACKGROUND_AUTOMATION_FAST_INTERVAL_SEC,
            "momentum_ignition": BACKGROUND_AUTOMATION_FAST_INTERVAL_SEC,
        },
        "state": read_background_automation_state(),
    }


def background_market_automation_tick(task_due, now=None, now_mono=None):
    """Run dashboard-independent paper trackers when the IDX session is open.

    The public loaders retain their own caches/build locks.  This worker only
    schedules them, so a dashboard request and the headless worker cannot start
    duplicate builds.  Each tracker is isolated: one TradingView/network error
    must not stop the other ledgers from being updated.
    """
    task_due = dict(task_due or {})
    if not BACKGROUND_AUTOMATION_ENABLED:
        write_background_automation_state(
            status="disabled",
            worker="background_market_automation",
            updated_at=now_jakarta().isoformat(timespec="seconds"),
            message="Background automation dimatikan oleh BACKGROUND_AUTOMATION_ENABLED.",
        )
        return task_due

    current = now or now_jakarta()
    current_mono = time.monotonic() if now_mono is None else float(now_mono)
    market_open = is_market_open(current)
    state = read_background_automation_state()
    if not market_open:
        write_background_automation_state(
            status="market_closed",
            worker="background_market_automation",
            market_open=False,
            market_date=current.date().isoformat(),
            updated_at=current.isoformat(timespec="seconds"),
            last_cycle_at=current.isoformat(timespec="seconds"),
            last_run_tasks=[],
            next_run_in_sec={},
            current_errors={},
            message="Worker aktif; menunggu sesi IDX berikutnya.",
        )
        return task_due

    if not BACKGROUND_AUTOMATION_LOCK.acquire(blocking=False):
        return task_due
    try:
        state = read_background_automation_state()
        try:
            cycle_count = int(state.get("cycle_count") or 0) + 1
        except (TypeError, ValueError):
            cycle_count = 1
        last_errors = dict(state.get("last_errors") or {})
        ran = []
        current_errors = {}
        tasks = ()
        for name, interval_sec, loader in tasks:
            if current_mono < float(task_due.get(name, 0.0) or 0.0):
                continue
            try:
                payload = loader()
                results[name] = _background_tracker_summary(name, payload)
                ran.append(name)
                last_errors.pop(name, None)
                task_due[name] = time.monotonic() + interval_sec
            except Exception as exc:
                error = {
                    "type": type(exc).__name__,
                    "message": _background_automation_error(exc),
                }
                current_errors[name] = error
                last_errors[name] = error
                # Retry a failed task within one minute, without hammering a
                # dependency that is timing out or temporarily unavailable.
                task_due[name] = time.monotonic() + min(interval_sec, 60)
                print(
                    f"[BACKGROUND_AUTO] {name} gagal: {error['type']}: {error['message']}",
                    flush=True,
                )

        now_text = current.isoformat(timespec="seconds")
        next_runs = {
            name: round(max(0.0, float(due) - time.monotonic()), 1)
            for name, due in task_due.items()
        }
        updates = {
            "status": "degraded" if last_errors else "ok",
            "worker": "background_market_automation",
            "market_open": True,
            "market_date": current.date().isoformat(),
            "updated_at": now_text,
            "last_cycle_at": now_text,
            "cycle_count": cycle_count,
            "last_run_tasks": ran,
            "last_results": results,
            "last_errors": last_errors,
            "current_errors": current_errors,
            "next_run_in_sec": next_runs,
            "poll_sec": BACKGROUND_AUTOMATION_POLL_SEC,
        }
        if ran:
            updates["last_run_at"] = now_text
        if not last_errors and ran:
            updates["last_healthy_at"] = now_text
        if current_errors:
            updates["last_error_at"] = now_text
        write_background_automation_state(**updates)
    finally:
        BACKGROUND_AUTOMATION_LOCK.release()
    return task_due


def background_market_automation_loop():
    task_due = {}
    while True:
        try:
            task_due = background_market_automation_tick(task_due)
        except Exception as exc:
            error = _background_automation_error(exc)
            write_background_automation_state(
                status="degraded",
                worker="background_market_automation",
                updated_at=now_jakarta().isoformat(timespec="seconds"),
                last_errors={"worker": {"type": type(exc).__name__, "message": error}},
            )
            print(f"[BACKGROUND_AUTO] worker gagal: {type(exc).__name__}: {error}", flush=True)
        time.sleep(BACKGROUND_AUTOMATION_POLL_SEC)


def load_sideways_breakout_idx_universe():
    """Load all current IDX stocks and remove the current FCA registry.

    TradingView supplies the live IDX ``type=stock`` universe. The local CSV
    remains a fallback when that discovery request is unavailable. The FCA
    registry is kept separately with its effective date and source URL so the
    dashboard never presents an unlabelled or invented exclusion.
    """
    global SIDEWAYS_BREAKOUT_UNIVERSE_CACHE, SIDEWAYS_BREAKOUT_UNIVERSE_CACHE_MTIME
    universe_path = os.path.join(os.getcwd(), SIDEWAYS_BREAKOUT_UNIVERSE_FILE)
    fca_path = os.path.join(os.getcwd(), SIDEWAYS_BREAKOUT_FCA_FILE)
    try:
        universe_mtime = os.path.getmtime(universe_path)
    except OSError:
        universe_mtime = None
    try:
        fca_mtime = os.path.getmtime(fca_path)
    except OSError:
        fca_mtime = None
    cache_mtime = (universe_mtime, fca_mtime)
    now_mono = time.monotonic()
    with SIDEWAYS_BREAKOUT_UNIVERSE_CACHE_LOCK:
        if (
            SIDEWAYS_BREAKOUT_UNIVERSE_CACHE is not None
            and SIDEWAYS_BREAKOUT_UNIVERSE_CACHE_MTIME == cache_mtime
            and now_mono - SIDEWAYS_BREAKOUT_UNIVERSE_CACHE.get("created_at", 0)
            < SIDEWAYS_BREAKOUT_UNIVERSE_CACHE_TTL
        ):
            return SIDEWAYS_BREAKOUT_UNIVERSE_CACHE

        import csv

        listed = set()
        if universe_mtime is not None:
            with open(universe_path, "r", encoding="utf-8-sig", newline="") as handle:
                for row in csv.DictReader(handle):
                    ticker = str(row.get("ticker") or "").strip().upper()
                    if ticker and ticker.replace(".", "").isalnum():
                        listed.add(ticker)

        fca_meta = {
            "as_of": "",
            "effective_from": "",
            "source": "",
            "source_label": "",
            "tickers": [],
        }
        if fca_mtime is not None:
            try:
                with open(fca_path, "r", encoding="utf-8") as handle:
                    raw = json.load(handle)
                if isinstance(raw, dict):
                    fca_meta.update(raw)
            except (OSError, ValueError, TypeError):
                pass
        fca = {
            str(ticker).strip().upper()
            for ticker in (fca_meta.get("tickers") or [])
            if str(ticker).strip()
        }
        live_listed = set()
        live_universe_error = ""
        try:
            live_data = tradingview_market_scan(["name", "close"])
            for item in live_data.get("data", []):
                ticker = str(item.get("s") or "").split(":")[-1].strip().upper()
                if ticker and ticker.replace(".", "").isalnum():
                    live_listed.add(ticker)
        except Exception as exc:
            live_universe_error = type(exc).__name__

        if live_listed:
            listed = live_listed
            universe_source = "TradingView Scanner · IDX stock universe"
        else:
            universe_source = SIDEWAYS_BREAKOUT_UNIVERSE_FILE
        filtered = tuple(sorted(listed - fca))
        SIDEWAYS_BREAKOUT_UNIVERSE_CACHE = {
            "created_at": now_mono,
            "tickers": filtered,
            "listed_count": len(listed),
            "fca_count": len(fca),
            "fca_in_universe_count": len(listed & fca),
            "fca_excluded": sorted(listed & fca),
            "universe_source": universe_source,
            "universe_retrieved_at": now_jakarta().isoformat(timespec="seconds"),
            "universe_error": live_universe_error,
            "fca_source": fca_meta.get("source") or SIDEWAYS_BREAKOUT_FCA_FILE,
            "fca_source_label": fca_meta.get("source_label") or "Daftar FCA lokal",
            "fca_as_of": fca_meta.get("as_of") or "",
            "fca_effective_from": fca_meta.get("effective_from") or "",
        }
        SIDEWAYS_BREAKOUT_UNIVERSE_CACHE_MTIME = cache_mtime
        return SIDEWAYS_BREAKOUT_UNIVERSE_CACHE


def swing_breakout_number(value):
    try:
        number = float(value)
    except (TypeError, ValueError):
        return None
    return number if number == number and number not in (float("inf"), float("-inf")) else None


def _swing_breakout_market_context_from_values(values, columns):
    """Build an explainable IHSG regime snapshot for continuation ranking."""
    if len(values) != len(columns):
        return {"available": False, "regime": "UNKNOWN", "reason": "Data IHSG tidak lengkap"}
    raw = dict(zip(columns, values))
    close = swing_breakout_number(raw.get("close"))
    sma20 = swing_breakout_number(raw.get("SMA20"))
    sma50 = swing_breakout_number(raw.get("SMA50"))
    change_pct = swing_breakout_number(raw.get("change"))
    if close is None or sma20 is None or sma50 is None or change_pct is None:
        return {"available": False, "regime": "UNKNOWN", "reason": "Field IHSG utama tidak tersedia"}
    if close > sma20 > sma50 > 0 and change_pct > -1.0:
        regime = "BULLISH"
    elif (0 < close < sma20 < sma50) or change_pct <= -1.5:
        regime = "BEARISH"
    else:
        regime = "NEUTRAL"
    return {
        "available": True,
        "symbol": "COMPOSITE",
        "regime": regime,
        "close": close,
        "change_pct": change_pct,
        "sma20": sma20,
        "sma50": sma50,
        "perf_week_pct": swing_breakout_number(raw.get("Perf.W")),
        "perf_month_pct": swing_breakout_number(raw.get("Perf.1M")),
        "rsi": swing_breakout_number(raw.get("RSI")),
        "adx": swing_breakout_number(raw.get("ADX")),
        "reason": "Konteks IHSG dari TradingView Scanner",
    }


def load_swing_breakout_market_context():
    """Fetch cached IHSG context without making it a source of entry signals."""
    now_mono = time.monotonic()
    with SWING_BREAKOUT_MARKET_CACHE_LOCK:
        cached = SWING_BREAKOUT_MARKET_CACHE.get("COMPOSITE")
        if cached and now_mono - cached["created_at"] < SWING_BREAKOUT_MARKET_CACHE_TTL:
            return dict(cached["payload"])
    columns = ["close", "change", "SMA20", "SMA50", "Perf.W", "Perf.1M", "RSI", "ADX"]
    try:
        data = tradingview_scan(["COMPOSITE"], columns)
        item = next(
            (
                row for row in (data.get("data") or [])
                if str(row.get("s") or "").split(":")[-1].upper() == "COMPOSITE"
            ),
            None,
        )
        context = _swing_breakout_market_context_from_values(
            (item or {}).get("d") or [], columns
        )
    except Exception as exc:
        context = {
            "available": False,
            "regime": "UNKNOWN",
            "reason": f"Konteks IHSG gagal dimuat ({type(exc).__name__})",
        }
    context["retrieved_at"] = now_jakarta().isoformat(timespec="seconds")
    context["data_source"] = "TradingView Scanner"
    with SWING_BREAKOUT_MARKET_CACHE_LOCK:
        SWING_BREAKOUT_MARKET_CACHE["COMPOSITE"] = {
            "created_at": time.monotonic(),
            "payload": dict(context),
        }
    return context


def _swing_breakout_median(values):
    numbers = sorted(
        value for value in (swing_breakout_number(item) for item in values)
        if value is not None
    )
    if not numbers:
        return None
    middle = len(numbers) // 2
    if len(numbers) % 2:
        return numbers[middle]
    return (numbers[middle - 1] + numbers[middle]) / 2


def apply_swing_breakout_entry_mode(row):
    """Classify breakout execution review without creating an entry signal."""
    status = str(row.get("status") or "")
    candle_closed = str(row.get("candle_close_status") or "").lower() == "closed"
    gate_failures = list(row.get("continuation_gate_failures") or [])

    def assign(mode, label, reason, checks=None):
        row["breakout_entry_mode"] = mode
        row["breakout_entry_label"] = label
        row["breakout_entry_reason"] = reason
        row["breakout_entry_checks"] = list(checks or [])
        row["breakout_entry_review_only"] = True
        return row

    if not row.get("data_complete") or status == "DATA INCOMPLETE":
        return assign(
            "DATA_INCOMPLETE",
            "DATA BELUM LENGKAP",
            "Lengkapi data daily sebelum menentukan breakout langsung atau retest.",
        )
    if not candle_closed or status == "WAIT DAILY CLOSE":
        return assign(
            "WAIT_DAILY_CLOSE",
            "TUNGGU DAILY CLOSE",
            "Breakout masih berjalan. Pilih mode hanya setelah candle daily closed.",
        )
    if status == "NEAR BREAKOUT":
        return assign(
            "WAIT_BREAKOUT",
            "TUNGGU BREAKOUT",
            "Belum ada daily close terkonfirmasi di atas resistance.",
        )
    if status == "NO SIGNAL":
        return assign(
            "NO_SETUP",
            "BELUM ADA SETUP",
            "Harga belum dekat area breakout. Tidak perlu memilih mode entry.",
        )
    if status == "BREAKOUT WEAK":
        weak_reasons = []
        weak_breakout_pct = swing_breakout_number(row.get("breakout_pct"))
        weak_volume_ratio = swing_breakout_number(row.get("volume_ratio_10d"))
        weak_rsi = swing_breakout_number(row.get("rsi"))
        weak_trend = str(row.get("trend") or "UNKNOWN")
        if weak_breakout_pct is not None and weak_breakout_pct > 6:
            weak_reasons.append(f"Breakout {weak_breakout_pct:.2f}% terlalu extended")
        if weak_volume_ratio is None or weak_volume_ratio < 1.5:
            weak_reasons.append(
                "Volume belum tersedia"
                if weak_volume_ratio is None
                else f"Volume {weak_volume_ratio:.2f}x di bawah 1,5x"
            )
        if weak_trend not in {"UPTREND", "RECOVERING"}:
            weak_reasons.append(f"Trend daily {weak_trend} tidak mendukung")
        if weak_rsi is None or not 40 <= weak_rsi <= 70:
            weak_reasons.append(
                "RSI belum tersedia"
                if weak_rsi is None
                else f"RSI {weak_rsi:.1f} di luar 40 sampai 70"
            )
        weak_reasons.extend(
            failure
            for failure in gate_failures
            if failure != "Belum confirmed breakout" and failure not in weak_reasons
        )
        return assign(
            "DO_NOT_CHASE",
            "JANGAN KEJAR",
            weak_reasons[0] if weak_reasons else "Breakout belum memenuhi gate kelayakan.",
            weak_reasons[:4],
        )
    if status != "CONFIRMED BREAKOUT":
        return assign(
            "DO_NOT_CHASE",
            "JANGAN KEJAR",
            "Belum ada struktur breakout yang layak untuk direview.",
            gate_failures[:3],
        )

    fatal_gate_prefixes = (
        "Data daily",
        "Trend daily",
        "Breakout terlalu extended",
        "RSI di luar",
        "Nilai transaksi",
        "IHSG bearish",
        "Konteks IHSG",
        "Belum unggul relatif",
    )
    fatal_failures = [
        failure
        for failure in gate_failures
        if str(failure).startswith(fatal_gate_prefixes)
    ]
    if fatal_failures:
        return assign(
            "DO_NOT_CHASE",
            "JANGAN KEJAR",
            fatal_failures[0],
            fatal_failures[:3],
        )

    if row.get("retest_confirmed"):
        if row.get("continuation_candidate"):
            return assign(
                "RETEST_REVIEW",
                "REVIEW RETEST",
                "Retest daily terdeteksi. Lanjutkan validasi 1H, 15m, dan trigger 5m closed.",
            )
        return assign(
            "DO_NOT_CHASE",
            "JANGAN KEJAR",
            gate_failures[0] if gate_failures else "Retest belum memperbaiki kelayakan setup.",
            gate_failures[:3],
        )

    direct_failures = []
    volume_ratio = swing_breakout_number(row.get("volume_ratio_10d"))
    breakout_pct = swing_breakout_number(row.get("breakout_pct"))
    rsi = swing_breakout_number(row.get("rsi"))
    rr = swing_breakout_number(row.get("rr"))
    close_position = swing_breakout_number(row.get("close_position_pct"))
    body_to_range = swing_breakout_number(row.get("body_to_range_pct"))
    upper_wick = swing_breakout_number(row.get("upper_wick_pct"))
    trend = str(row.get("trend") or "UNKNOWN")
    market_regime = str(row.get("market_regime") or "UNKNOWN")

    if not row.get("continuation_candidate"):
        direct_failures.extend(gate_failures[:2])
    if trend != "UPTREND":
        direct_failures.append(f"Trend masih {trend}")
    if volume_ratio is None or volume_ratio < 2.0:
        direct_failures.append(
            "Volume belum mencapai 2,0x"
            if volume_ratio is None
            else f"Volume {volume_ratio:.2f}x belum mencapai 2,0x"
        )
    if breakout_pct is None or breakout_pct <= 0 or breakout_pct > 2.5:
        direct_failures.append(
            "Penetrasi breakout belum ideal"
            if breakout_pct is None
            else f"Breakout {breakout_pct:.2f}% di luar zona langsung 0 sampai 2,5%"
        )
    if rsi is None or not 40 <= rsi <= 65:
        direct_failures.append(
            "RSI belum tersedia"
            if rsi is None
            else f"RSI {rsi:.1f} di luar zona langsung 40 sampai 65"
        )
    if rr is None or rr < 1.5:
        direct_failures.append(
            "RR belum tersedia"
            if rr is None
            else f"RR {rr:.2f}R di bawah 1,5R"
        )
    if (
        close_position is None
        or body_to_range is None
        or upper_wick is None
        or close_position < 75
        or body_to_range < 50
        or upper_wick > 25
    ):
        direct_failures.append("Candle breakout belum premium")
    if market_regime != "BULLISH":
        direct_failures.append(f"IHSG masih {market_regime}")

    direct_failures = list(dict.fromkeys(direct_failures))
    if not direct_failures:
        return assign(
            "BREAKOUT_DIRECT_REVIEW",
            "REVIEW BREAKOUT LANGSUNG",
            "Breakout daily memenuhi gate premium. Entry tetap menunggu full PA dan trigger 5m closed.",
        )
    return assign(
        "WAIT_RETEST",
        "TUNGGU RETEST",
        direct_failures[0],
        direct_failures[:4],
    )


def apply_swing_continuation_score(row, market_context, sector_benchmark=None):
    """Apply deterministic continuation ranking, never a probability or entry gate."""
    sector_benchmark = sector_benchmark or {}
    market_context = market_context or {}
    score_components = {
        "market_sector": 0,
        "relative_strength": 0,
        "trend": 0,
        "breakout_structure": 0,
        "candle_volume": 0,
        "room_rr": 0,
    }
    strengths = []

    market_regime = str(market_context.get("regime") or "UNKNOWN").upper()
    if market_regime == "BULLISH":
        score_components["market_sector"] += 10
        strengths.append("IHSG bullish")
    elif market_regime == "NEUTRAL":
        score_components["market_sector"] += 6

    perf_week = swing_breakout_number(row.get("perf_week_pct"))
    perf_month = swing_breakout_number(row.get("perf_month_pct"))
    market_week = swing_breakout_number(market_context.get("perf_week_pct"))
    market_month = swing_breakout_number(market_context.get("perf_month_pct"))
    week_alpha = perf_week - market_week if perf_week is not None and market_week is not None else None
    month_alpha = perf_month - market_month if perf_month is not None and market_month is not None else None
    if month_alpha is not None:
        if month_alpha >= 5:
            score_components["relative_strength"] += 8
        elif month_alpha >= 2:
            score_components["relative_strength"] += 6
        elif month_alpha >= 0:
            score_components["relative_strength"] += 4
        elif month_alpha > -2:
            score_components["relative_strength"] += 2
    if week_alpha is not None:
        if week_alpha >= 3:
            score_components["relative_strength"] += 7
        elif week_alpha >= 1:
            score_components["relative_strength"] += 5
        elif week_alpha >= 0:
            score_components["relative_strength"] += 3
        elif week_alpha > -1:
            score_components["relative_strength"] += 1
    if (month_alpha is not None and month_alpha >= 0) or (week_alpha is not None and week_alpha >= 0):
        strengths.append("relative strength unggul dari IHSG")

    sector_sample_size = int(sector_benchmark.get("sample_size") or 0)
    sector_week = swing_breakout_number(sector_benchmark.get("perf_week_median"))
    sector_month = swing_breakout_number(sector_benchmark.get("perf_month_median"))
    sector_week_alpha = perf_week - sector_week if perf_week is not None and sector_week is not None else None
    sector_month_alpha = perf_month - sector_month if perf_month is not None and sector_month is not None else None
    if sector_sample_size >= 2:
        sector_leads_week = sector_week_alpha is not None and sector_week_alpha >= 0
        sector_leads_month = sector_month_alpha is not None and sector_month_alpha >= 0
        if sector_leads_week and sector_leads_month:
            score_components["market_sector"] += 5
            strengths.append("memimpin peer sektor")
        elif sector_leads_week or sector_leads_month:
            score_components["market_sector"] += 3

    close = swing_breakout_number(row.get("close"))
    sma20 = swing_breakout_number(row.get("sma20"))
    sma50 = swing_breakout_number(row.get("sma50"))
    sma200 = swing_breakout_number(row.get("sma200"))
    if close is not None and sma20 is not None and sma50 is not None and close > sma20 > sma50:
        score_components["trend"] += 9 if sma200 is not None and sma50 > sma200 else 7
        strengths.append("trend stack daily sehat")
    elif close is not None and sma50 is not None and close > sma50:
        score_components["trend"] += 4
    adx = swing_breakout_number(row.get("adx"))
    if adx is not None and adx >= 25:
        score_components["trend"] += 3
    elif adx is not None and adx >= 20:
        score_components["trend"] += 2
    plus_di = swing_breakout_number(row.get("plus_di"))
    minus_di = swing_breakout_number(row.get("minus_di"))
    if plus_di is not None and minus_di is not None and plus_di > minus_di:
        score_components["trend"] += 3
        strengths.append("buyer direction dominan")

    status = str(row.get("status") or "")
    breakout_pct = swing_breakout_number(row.get("breakout_pct"))
    distance_pct = swing_breakout_number(row.get("distance_to_breakout_pct"))
    if status == "CONFIRMED BREAKOUT":
        score_components["breakout_structure"] += 12
    elif status == "WAIT DAILY CLOSE":
        score_components["breakout_structure"] += 8
    elif status == "NEAR BREAKOUT":
        score_components["breakout_structure"] += 7
    elif status == "BREAKOUT WEAK":
        score_components["breakout_structure"] += 4
    if breakout_pct is not None and 0 < breakout_pct <= 4:
        score_components["breakout_structure"] += 5
        strengths.append("breakout belum extended")
    elif breakout_pct is not None and 0 < breakout_pct <= 6:
        score_components["breakout_structure"] += 3
    elif distance_pct is not None and 0 <= distance_pct <= 3:
        score_components["breakout_structure"] += 3
    if row.get("retest_confirmed"):
        score_components["breakout_structure"] += 3

    volume_ratio = swing_breakout_number(row.get("volume_ratio_10d"))
    if volume_ratio is not None and 1.5 <= volume_ratio <= 3.5:
        score_components["candle_volume"] += 5
        strengths.append("volume breakout mendukung")
    elif volume_ratio is not None and volume_ratio > 3.5:
        score_components["candle_volume"] += 3
    elif volume_ratio is not None and volume_ratio >= 1.2:
        score_components["candle_volume"] += 2
    close_position = swing_breakout_number(row.get("close_position_pct"))
    upper_wick = swing_breakout_number(row.get("upper_wick_pct"))
    body_to_range = swing_breakout_number(row.get("body_to_range_pct"))
    if close_position is not None and close_position >= 75:
        score_components["candle_volume"] += 4
    elif close_position is not None and close_position >= 60:
        score_components["candle_volume"] += 3
    if upper_wick is not None and upper_wick <= 25:
        score_components["candle_volume"] += 3
    elif upper_wick is not None and upper_wick <= 35:
        score_components["candle_volume"] += 2
    if body_to_range is not None and body_to_range >= 50:
        score_components["candle_volume"] += 3
    elif body_to_range is not None and body_to_range >= 35:
        score_components["candle_volume"] += 2

    rr = swing_breakout_number(row.get("rr"))
    reward_pct = swing_breakout_number(row.get("reward_pct"))
    if rr is not None and rr >= 2:
        score_components["room_rr"] += 12
        strengths.append("RR minimal 2R")
    elif rr is not None and rr >= 1.5:
        score_components["room_rr"] += 9
    elif rr is not None and rr >= 1:
        score_components["room_rr"] += 3
    if reward_pct is not None and reward_pct >= 8:
        score_components["room_rr"] += 8
    elif reward_pct is not None and reward_pct >= 5:
        score_components["room_rr"] += 6
    elif reward_pct is not None and reward_pct >= 3:
        score_components["room_rr"] += 3

    score_components = {
        "market_sector": min(15, score_components["market_sector"]),
        "relative_strength": min(15, score_components["relative_strength"]),
        "trend": min(15, score_components["trend"]),
        "breakout_structure": min(20, score_components["breakout_structure"]),
        "candle_volume": min(15, score_components["candle_volume"]),
        "room_rr": min(20, score_components["room_rr"]),
    }
    continuation_score = max(0, min(100, sum(score_components.values())))

    gate_failures = []
    if not row.get("data_complete"):
        gate_failures.append("Data daily belum lengkap")
    if str(row.get("candle_close_status") or "").lower() != "closed":
        gate_failures.append("Daily candle belum closed")
    if status != "CONFIRMED BREAKOUT":
        gate_failures.append("Belum confirmed breakout")
    if not row.get("volume_supported"):
        gate_failures.append("Volume di bawah 1,5x")
    if str(row.get("trend") or "") not in {"UPTREND", "RECOVERING"}:
        gate_failures.append("Trend daily tidak mendukung")
    if rr is None:
        gate_failures.append("Target atau RR belum tersedia")
    elif rr < 1.5:
        gate_failures.append("RR di bawah 1,5R")
    if breakout_pct is not None and breakout_pct > 6:
        gate_failures.append("Breakout terlalu extended")
    rsi = swing_breakout_number(row.get("rsi"))
    if rsi is None or not 40 <= rsi <= 70:
        gate_failures.append("RSI di luar 40 sampai 70")
    value_traded = swing_breakout_number(row.get("value_traded"))
    if value_traded is None or value_traded < 2_000_000_000:
        gate_failures.append("Nilai transaksi di bawah Rp2 miliar")
    if close_position is None or upper_wick is None or close_position < 60 or upper_wick > 35:
        gate_failures.append("Close candle belum kuat")
    if market_regime == "BEARISH":
        gate_failures.append("IHSG bearish")
    elif market_regime == "UNKNOWN" or not market_context.get("available"):
        gate_failures.append("Konteks IHSG belum tersedia")
    if not (
        (month_alpha is not None and month_alpha >= 0)
        or (week_alpha is not None and week_alpha >= 0)
    ):
        gate_failures.append("Belum unggul relatif terhadap IHSG")
    if continuation_score < 75:
        gate_failures.append("Skor kelanjutan di bawah 75")

    is_candidate = not gate_failures
    continuation_tier = (
        "PRIORITAS REVIEW"
        if is_candidate and continuation_score >= 85
        else "KANDIDAT REVIEW"
        if is_candidate
        else "GAGAL GATE"
    )
    row["breakout_quality_score"] = row.get("quality_score")
    row["quality_score"] = continuation_score
    row["continuation_score"] = continuation_score
    row["continuation_score_version"] = "swing_continuation_v2"
    row["continuation_score_components"] = score_components
    row["continuation_candidate"] = is_candidate
    row["continuation_tier"] = continuation_tier
    row["screening_status"] = "KANDIDAT FULL PA" if is_candidate else "TIDAK LAYAK DITUNGGU"
    row["continuation_gate_failures"] = gate_failures
    row["continuation_strengths"] = strengths[:5]
    row["continuation_explanation"] = (
        "Lolos gate daily. Lanjutkan full PA 1D, 1H, 15m, dan trigger 5m."
        if is_candidate
        else gate_failures[0]
    )
    row["market_regime"] = market_regime
    row["relative_strength_week_pct"] = week_alpha
    row["relative_strength_month_pct"] = month_alpha
    row["sector_peer_week_pct"] = sector_week_alpha
    row["sector_peer_month_pct"] = sector_month_alpha
    row["sector_peer_sample_size"] = sector_sample_size
    return apply_swing_breakout_entry_mode(row)


def apply_swing_continuation_scores(rows, market_context):
    """Attach sector medians and continuation scores to a snapshot batch."""
    sector_groups = {}
    for row in rows:
        sector = str(row.get("sector") or "").strip()
        if not sector:
            continue
        sector_groups.setdefault(sector, []).append(row)
    sector_benchmarks = {}
    for sector, members in sector_groups.items():
        sector_benchmarks[sector] = {
            "sample_size": len(members),
            "perf_week_median": _swing_breakout_median(
                member.get("perf_week_pct") for member in members
            ),
            "perf_month_median": _swing_breakout_median(
                member.get("perf_month_pct") for member in members
            ),
        }
    for row in rows:
        apply_swing_continuation_score(
            row,
            market_context,
            sector_benchmarks.get(str(row.get("sector") or "").strip()),
        )
    return rows


def build_swing_breakout_row(ticker, values, columns, market_open, retrieved_at):
    """Build one deterministic daily breakout row from a Scanner snapshot."""
    if len(values) != len(columns):
        return None
    raw = dict(zip(columns, values))
    close = swing_breakout_number(raw.get("close"))
    if not close or close <= 0:
        return None

    previous_highs = [
        swing_breakout_number(raw.get(f"high[{offset}]"))
        for offset in range(1, 21)
    ]
    previous_lows = [
        swing_breakout_number(raw.get(f"low[{offset}]"))
        for offset in range(1, 21)
    ]
    previous_highs = [value for value in previous_highs if value and value > 0]
    previous_lows = [value for value in previous_lows if value and value > 0]
    resistance = max(previous_highs) if previous_highs else None
    support = min(previous_lows) if previous_lows else None
    current_low = swing_breakout_number(raw.get("low"))
    previous_close = swing_breakout_number(raw.get("close[1]"))
    two_days_ago_close = swing_breakout_number(raw.get("close[2]"))
    breakout_bar_timestamp = swing_breakout_number(raw.get("time[1]"))
    pre_breakout_highs = [
        swing_breakout_number(raw.get(f"high[{offset}]"))
        for offset in range(2, 21)
    ]
    pre_breakout_highs = [value for value in pre_breakout_highs if value and value > 0]
    pre_breakout_resistance = max(pre_breakout_highs) if pre_breakout_highs else None
    atr = swing_breakout_number(raw.get("ATR"))
    high_1m = swing_breakout_number(raw.get("High.1M"))
    high_3m = swing_breakout_number(raw.get("High.3M"))
    high_52w = swing_breakout_number(raw.get("price_52_week_high"))
    sma20 = swing_breakout_number(raw.get("SMA20"))
    sma50 = swing_breakout_number(raw.get("SMA50"))
    sma200 = swing_breakout_number(raw.get("SMA200"))
    volume = swing_breakout_number(raw.get("volume"))
    average_volume = swing_breakout_number(raw.get("average_volume_10d_calc"))
    volume_ratio = volume / average_volume if volume and average_volume else None
    bar_open = swing_breakout_number(raw.get("open"))
    bar_high = swing_breakout_number(raw.get("high"))
    bar_low = swing_breakout_number(raw.get("low"))
    candle_range = (bar_high - bar_low) if bar_high is not None and bar_low is not None and bar_high > bar_low else None
    close_position_pct = (
        (close - bar_low) / candle_range * 100
        if candle_range and bar_low is not None
        else None
    )
    body_to_range_pct = (
        abs(close - bar_open) / candle_range * 100
        if candle_range and bar_open is not None
        else None
    )
    upper_wick_pct = (
        (bar_high - max(close, bar_open)) / candle_range * 100
        if candle_range and bar_high is not None and bar_open is not None
        else None
    )
    trend = "UNKNOWN"
    if sma50 and sma200:
        if close > sma200 and sma50 >= sma200:
            trend = "UPTREND"
        elif close > sma50:
            trend = "RECOVERING"
        else:
            trend = "DOWNTREND"
    elif sma50:
        trend = "RECOVERING" if close > sma50 else "DOWNTREND"

    breakout_pct = ((close - resistance) / resistance * 100) if resistance else None
    distance_to_breakout_pct = ((resistance - close) / resistance * 100) if resistance else None
    volume_supported = volume_ratio is not None and volume_ratio >= 1.5
    trend_supported = trend in {"UPTREND", "RECOVERING"}
    complete = bool(resistance and atr and volume_ratio is not None and sma50)
    candle_close_status = "running" if market_open else "closed"
    reasons = []
    if resistance:
        reasons.append("resistance high 20D tersedia")
    if volume_supported:
        reasons.append("volume >= 1.5x rata-rata 10D")
    if trend_supported:
        reasons.append(f"trend {trend}")
    if breakout_pct is not None and breakout_pct <= 6:
        reasons.append("belum terlalu extended")

    if not complete:
        status = "DATA INCOMPLETE"
        verdict = "Data harian belum lengkap"
    elif breakout_pct is not None and breakout_pct > 0:
        if market_open:
            status = "WAIT DAILY CLOSE"
            verdict = "Breakout berjalan; tunggu candle daily close"
        elif volume_supported and trend_supported and breakout_pct <= 6:
            status = "CONFIRMED BREAKOUT"
            verdict = "Close daily di atas resistance + volume mendukung"
        else:
            status = "BREAKOUT WEAK"
            verdict = "Close di atas resistance, tetapi volume/trend belum cukup"
    elif distance_to_breakout_pct is not None and distance_to_breakout_pct <= 3:
        status = "NEAR BREAKOUT"
        verdict = "Dekat resistance; tunggu close daily di atas level"
    else:
        status = "NO SIGNAL"
        verdict = "Belum dekat area breakout"

    breakout_level = round_idx_tick(resistance) if resistance else None
    retest_buffer = max(
        (atr * 0.75) if atr else 0,
        (pre_breakout_resistance * 0.01) if pre_breakout_resistance else 0,
    )
    retest_confirmed = bool(
        not market_open
        and pre_breakout_resistance
        and previous_close
        and two_days_ago_close is not None
        and current_low
        and previous_close > pre_breakout_resistance
        and two_days_ago_close <= pre_breakout_resistance
        and close > pre_breakout_resistance
        and current_low <= pre_breakout_resistance * 1.01
        and current_low >= pre_breakout_resistance - retest_buffer
    )
    entry_level = (
        round_idx_tick(pre_breakout_resistance)
        if retest_confirmed and pre_breakout_resistance
        else breakout_level
    )
    risk_buffer = max((atr * 1.2) if atr else 0, (entry_level * 0.02) if entry_level else 0)
    stop_loss = round_idx_tick(entry_level - risk_buffer) if entry_level and risk_buffer else None
    if stop_loss is not None and stop_loss <= 0:
        stop_loss = None
    risk_pct = ((breakout_level - stop_loss) / breakout_level * 100) if breakout_level and stop_loss else None
    target_candidates = []
    if entry_level:
        for label, level in (("high 1M", high_1m), ("high 3M", high_3m), ("high 52W", high_52w)):
            if level and level > entry_level:
                target_candidates.append((level, label))
    target_candidates.sort(key=lambda item: item[0])
    target = round_idx_tick(target_candidates[0][0]) if target_candidates else None
    target_source = target_candidates[0][1] if target_candidates else "resistance overhead belum tersedia"
    if target is not None and (not entry_level or target <= entry_level):
        target = None
        target_source = "resistance overhead belum tersedia"
    reward_pct = ((target - entry_level) / entry_level * 100) if entry_level and target else None
    risk_amount = (entry_level - stop_loss) if entry_level and stop_loss else None
    reward_amount = (target - entry_level) if entry_level and target else None
    rr = (reward_amount / risk_amount) if risk_amount and reward_amount and risk_amount > 0 and reward_amount > 0 else None
    entry_distance_pct = ((close - entry_level) / entry_level * 100) if entry_level else None
    ready_entry = bool(
        status == "CONFIRMED BREAKOUT"
        and
        retest_confirmed
        and target
        and rr is not None
        and rr >= 1.5
        and volume_supported
        and trend_supported
        and entry_distance_pct is not None
        and entry_distance_pct <= 3
    )
    entry_status = "RETEST CONFIRMED" if ready_entry else ""
    breakout_signal_key = ""
    if retest_confirmed and entry_level:
        event_part = (
            str(int(breakout_bar_timestamp))
            if breakout_bar_timestamp is not None else "timestamp-missing"
        )
        breakout_signal_key = (
            f"{str(ticker).upper()}|BREAKOUT_RETEST|{event_part}|{entry_level:g}"
        )
    if ready_entry:
        ready_entry_reason = "Retest resistance bertahan; volume, trend, target, dan RR valid"
    elif retest_confirmed:
        ready_entry_reason = "Retest terdeteksi, tetapi RR atau konfirmasi belum memenuhi syarat"
    else:
        ready_entry_reason = "Menunggu retest resistance setelah breakout"
    quality_score = 0
    if breakout_pct is not None and breakout_pct > 0:
        quality_score += 35
    elif distance_to_breakout_pct is not None and distance_to_breakout_pct <= 3:
        quality_score += 20
    if volume_supported:
        quality_score += 25
    if trend == "UPTREND":
        quality_score += 20
    elif trend == "RECOVERING":
        quality_score += 12
    if breakout_pct is not None and 0 < breakout_pct <= 6:
        quality_score += 10
    if sma20 and close > sma20:
        quality_score += 10

    return {
        "ticker": str(ticker).upper(),
        "name": raw.get("name") or str(ticker).upper(),
        "status": status,
        "verdict": verdict,
        "quality_score": quality_score,
        "close": close,
        "change_pct": swing_breakout_number(raw.get("change")),
        "resistance_20d": resistance,
        "support_20d": support,
        "breakout_level": breakout_level,
        "breakout_pct": breakout_pct,
        "distance_to_breakout_pct": distance_to_breakout_pct,
        "entry_plan": entry_level,
        "entry_distance_pct": entry_distance_pct,
        "stop_loss": stop_loss,
        "target_resistance": target,
        "target_source": target_source,
        "risk_pct": risk_pct,
        "reward_pct": reward_pct,
        "rr": rr,
        "retest_confirmed": retest_confirmed,
        "ready_entry": ready_entry,
        "entry_status": entry_status,
        "breakout_bar_timestamp": breakout_bar_timestamp,
        "breakout_signal_key": breakout_signal_key,
        "ready_entry_reason": ready_entry_reason,
        "atr": atr,
        "rsi": swing_breakout_number(raw.get("RSI")),
        "adx": swing_breakout_number(raw.get("ADX")),
        "sma20": sma20,
        "sma50": sma50,
        "sma200": sma200,
        "high_1m": high_1m,
        "high_3m": high_3m,
        "high_52w": high_52w,
        "trend": trend,
        "volume": volume,
        "average_volume_10d": average_volume,
        "volume_ratio_10d": volume_ratio,
        "volume_supported": volume_supported,
        "bar_open": bar_open,
        "bar_high": bar_high,
        "bar_low": bar_low,
        "bar_close": close,
        "close_position_pct": close_position_pct,
        "body_to_range_pct": body_to_range_pct,
        "upper_wick_pct": upper_wick_pct,
        "plus_di": swing_breakout_number(raw.get("ADX+DI")),
        "minus_di": swing_breakout_number(raw.get("ADX-DI")),
        "vwap": swing_breakout_number(raw.get("VWAP")),
        "sector": str(raw.get("sector") or ""),
        "industry": str(raw.get("industry") or ""),
        "perf_week_pct": swing_breakout_number(raw.get("Perf.W")),
        "perf_month_pct": swing_breakout_number(raw.get("Perf.1M")),
        "perf_3m_pct": swing_breakout_number(raw.get("Perf.3M")),
        "value_traded": swing_breakout_number(raw.get("Value.Traded")),
        "quality_reasons": reasons,
        "holding_plan": "hold 2–15 sesi; evaluasi ulang saat daily close",
        "timeframe": {"context": "1W", "breakout": "1D", "management": "1D"},
        "candle_close_status": candle_close_status,
        "data_source": "TradingView Scanner",
        "retrieved_at": retrieved_at,
        "data_complete": complete,
    }


def load_swing_breakout_snapshot(tickers, watchlist="KONGLO", universe_meta=None):
    watchlist_key = str(watchlist or "KONGLO").strip().upper() or "KONGLO"
    market_open_now = is_idx_market_open()
    if watchlist_key == "ALL":
        universe_meta = universe_meta or load_sideways_breakout_idx_universe()
        allowed = set(universe_meta.get("tickers") or [])
        normalized = tuple(sorted({
            str(ticker).strip().upper()
            for ticker in tickers
            if str(ticker).strip().upper() in allowed
        }))
    else:
        normalized = tuple(sorted({str(ticker).strip().upper() for ticker in tickers if str(ticker).strip()}))
    universe_payload = None
    if watchlist_key == "ALL":
        universe_payload = {
            "label": "Semua IDX · FCA dikecualikan",
            "listed_count": int(universe_meta.get("listed_count") or 0),
            "eligible_count": len(universe_meta.get("tickers") or []),
            "fca_excluded_count": int(universe_meta.get("fca_in_universe_count") or 0),
            "fca_registry_count": int(universe_meta.get("fca_count") or 0),
            "fca_as_of": universe_meta.get("fca_as_of") or "",
            "fca_effective_from": universe_meta.get("fca_effective_from") or "",
            "fca_source": universe_meta.get("fca_source") or "",
            "fca_source_label": universe_meta.get("fca_source_label") or "",
            "universe_source": universe_meta.get("universe_source") or "",
            "universe_retrieved_at": universe_meta.get("universe_retrieved_at") or "",
            "universe_error": universe_meta.get("universe_error") or "",
        }
    if not normalized:
        payload = {
            "status": "ok",
            "watchlist": watchlist_key,
            "rows": [],
            "as_of": now_jakarta().isoformat(timespec="seconds"),
            "market_open": market_open_now,
        }
        if universe_payload is not None:
            universe_payload["loaded_count"] = 0
            payload["universe"] = universe_payload
        return payload
    cache_key = (watchlist_key, normalized)
    now_mono = time.monotonic()
    with SWING_BREAKOUT_CACHE_LOCK:
        cached = SWING_BREAKOUT_CACHE.get(cache_key)
        cached_payload = cached.get("payload") if isinstance(cached, dict) else None
        cached_market_open = bool((cached_payload or {}).get("market_open"))
        closed_snapshot = not market_open_now and cached_payload and not cached_market_open
        fresh_open_snapshot = market_open_now and cached and now_mono - cached["created_at"] < SWING_BREAKOUT_CACHE_TTL
        if closed_snapshot or fresh_open_snapshot:
            cached_payload = cached["payload"]
            auto_track_swing_breakout_snapshot(cached_payload)
            return cached_payload

    close_history_columns = ["close[1]", "close[2]", "time[1]"]
    history_columns = []
    for offset in range(1, 21):
        history_columns.extend([f"high[{offset}]", f"low[{offset}]"])
    columns = [
        "name", "close", "open", "high", "low", "change", "volume",
        "average_volume_10d_calc", "SMA20", "SMA50", "SMA200", "ATR",
        "RSI", "ADX", "ADX+DI", "ADX-DI", "VWAP", "sector", "industry",
        "Perf.W", "Perf.1M", "Perf.3M", "Value.Traded",
        "High.1M", "High.3M", "price_52_week_high",
    ] + close_history_columns + history_columns
    retrieved_at = now_jakarta().isoformat(timespec="seconds")
    market_open = market_open_now
    data = tradingview_scan(list(normalized), columns)
    rows = {}
    for item in data.get("data", []):
        symbol = item.get("s", "").split(":")[-1].upper()
        if symbol not in normalized:
            continue
        row = build_swing_breakout_row(symbol, item.get("d") or [], columns, market_open, retrieved_at)
        if row:
            rows[symbol] = row

    ordered = [rows[ticker] for ticker in normalized if ticker in rows]
    market_context = load_swing_breakout_market_context()
    apply_swing_continuation_scores(ordered, market_context)
    priority = {
        "CONFIRMED BREAKOUT": 0,
        "WAIT DAILY CLOSE": 1,
        "NEAR BREAKOUT": 2,
        "BREAKOUT WEAK": 3,
        "DATA INCOMPLETE": 4,
        "NO SIGNAL": 5,
    }
    ordered.sort(key=lambda row: (
        0 if row.get("continuation_candidate") else 1,
        -int(row.get("continuation_score") or 0),
        priority.get(row["status"], 9),
        row["ticker"],
    ))
    payload = {
        "status": "ok",
        "watchlist": watchlist_key,
        "rows": ordered,
        "timeframes": {"context": "1W", "breakout": "1D", "management": "1D"},
        "rules": {
            "resistance": "highest high 20 daily bars sebelum hari ini",
            "volume": "minimal 1.5x average volume 10D untuk breakout confirmed",
            "target": "resistance overhead terdekat dari high 1M/3M/52W; RR dikosongkan jika tidak tersedia",
            "continuation_score": "ranking 0-100, bukan probabilitas; market/sector 15, relative strength 15, trend 15, breakout 20, candle/volume 15, room/RR 20",
            "continuation_gate": "KANDIDAT FULL PA hanya jika daily closed, breakout confirmed, volume/trend/RSI/liquidity/candle kuat, IHSG dan relative strength mendukung, serta RR minimal 1.5R",
            "breakout_entry_mode": "mode review, bukan sinyal entry; breakout langsung hanya jika kandidat continuation, UPTREND, IHSG bullish, RVOL minimal 2x, candle premium, RSI 40-65, breakout maksimal 2.5%, dan RR minimal 1.5R; selain itu tunggu retest atau jangan kejar",
            "execution": "auto-paper entry saat SIAP ENTRY; TP/SL dipantau otomatis; tidak mengirim order broker",
        },
        "as_of": retrieved_at,
        "market_open": market_open,
        "market_context": market_context,
        "data_source": "TradingView Scanner",
    }
    if universe_payload is not None:
        universe_payload["loaded_count"] = len(ordered)
        payload["universe"] = universe_payload
    with SWING_BREAKOUT_CACHE_LOCK:
        SWING_BREAKOUT_CACHE[cache_key] = {"created_at": time.monotonic(), "payload": payload}
    auto_track_swing_breakout_snapshot(payload)
    return payload


def _swing_breakout_chart_date(value):
    """Convert a TradingView daily-bar time value to a WIB calendar date."""
    if value is None or value == "":
        return ""
    try:
        numeric = float(value)
        if numeric > 10_000_000_000:
            numeric /= 1000
        if numeric > 0:
            return datetime.fromtimestamp(numeric, MARKET_TZ).date().isoformat()
    except (TypeError, ValueError, OverflowError, OSError):
        pass
    text = str(value).strip()
    match = re.match(r"^(\d{4}-\d{2}-\d{2})", text)
    return match.group(1) if match else ""


def _fetch_swing_breakout_chart_history(ticker):
    """Fetch dated historical daily bars for the chart without changing the scanner feed."""
    url = (
        "https://query2.finance.yahoo.com/v8/finance/chart/"
        f"{ticker}.JK?range=1y&interval=1d&events=history"
    )
    request = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
    with urllib.request.urlopen(request, timeout=20) as response:
        payload = json.loads(response.read().decode("utf-8"))
    result = ((payload.get("chart") or {}).get("result") or [None])[0]
    if not result:
        return []
    timestamps = result.get("timestamp") or []
    quote = ((((result.get("indicators") or {}).get("quote") or [{}])[0]) or {})
    records = []
    for index, stamp in enumerate(timestamps):
        try:
            record = {
                "date": _swing_breakout_chart_date(stamp),
                "open": swing_breakout_number((quote.get("open") or [])[index]),
                "high": swing_breakout_number((quote.get("high") or [])[index]),
                "low": swing_breakout_number((quote.get("low") or [])[index]),
                "close": swing_breakout_number((quote.get("close") or [])[index]),
                "volume": swing_breakout_number((quote.get("volume") or [])[index]),
            }
        except (IndexError, TypeError, ValueError):
            continue
        if not record["date"] or any(record[key] is None or record[key] <= 0 for key in ("open", "high", "low", "close")):
            continue
        if record["high"] < max(record["open"], record["close"]) or record["low"] > min(record["open"], record["close"]):
            continue
        records.append(record)
    return records[-SWING_BREAKOUT_CHART_LOOKBACK:]


def fetch_trend_break_daily_closes(tickers):
    """Return dated daily closes for Trend Break outcome columns.

    This is an outcome-data helper only. It does not feed the Trend Break
    scanner or alter candidate qualification.
    """
    symbols = sorted({
        str(ticker).replace(".JK", "").strip().upper()
        for ticker in tickers
        if str(ticker).strip()
    })[:300]
    if not symbols:
        return {}

    now = time.monotonic()
    missing = []
    with TREND_BREAK_DAILY_CLOSE_CACHE_LOCK:
        for ticker in symbols:
            cached = TREND_BREAK_DAILY_CLOSE_CACHE.get(ticker)
            if not cached or now - cached["created_at"] >= TREND_BREAK_DAILY_CLOSE_CACHE_TTL:
                missing.append(ticker)

    def fetch_one(ticker):
        try:
            records = _fetch_swing_breakout_chart_history(ticker)
            closes = {
                str(record.get("date") or "")[:10]: float(record["close"])
                for record in records
                if str(record.get("date") or "")
                and swing_breakout_number(record.get("close"))
                and swing_breakout_number(record.get("close")) > 0
            }
            return ticker, closes
        except Exception:
            return ticker, {}

    if missing:
        worker_count = min(8, len(missing))
        with ThreadPoolExecutor(max_workers=worker_count) as executor:
            futures = [executor.submit(fetch_one, ticker) for ticker in missing]
            fresh = dict(future.result() for future in as_completed(futures))
        with TREND_BREAK_DAILY_CLOSE_CACHE_LOCK:
            for ticker in missing:
                TREND_BREAK_DAILY_CLOSE_CACHE[ticker] = {
                    "created_at": now,
                    "closes": fresh.get(ticker, {}),
                }

    with TREND_BREAK_DAILY_CLOSE_CACHE_LOCK:
        return {
            ticker: dict(TREND_BREAK_DAILY_CLOSE_CACHE.get(ticker, {}).get("closes") or {})
            for ticker in symbols
            if ticker in TREND_BREAK_DAILY_CLOSE_CACHE
        }


def load_swing_breakout_chart(ticker):
    """Return daily OHLCV bars from the same scanner feed as the swing table."""
    symbol = str(ticker or "").strip().upper()
    if not re.fullmatch(r"[A-Z0-9._-]{1,20}", symbol):
        return {"status": "error", "message": "Ticker chart tidak valid."}

    now_mono = time.monotonic()
    with SWING_BREAKOUT_CHART_CACHE_LOCK:
        cached = SWING_BREAKOUT_CHART_CACHE.get(symbol)
        if cached and now_mono - cached["created_at"] < SWING_BREAKOUT_CHART_CACHE_TTL:
            return cached["payload"]

    columns = ["time", "open", "high", "low", "close", "volume"]
    data = tradingview_scan([symbol], columns)
    item = next(
        (
            entry for entry in data.get("data", [])
            if str(entry.get("s", "")).split(":")[-1].upper() == symbol
        ),
        None,
    )
    values = (item.get("d") or []) if item else []
    if len(values) != len(columns):
        return {
            "status": "error",
            "message": "TradingView Scanner tidak mengembalikan OHLC harian terbaru.",
        }

    raw = dict(zip(columns, values))
    latest_date = _swing_breakout_chart_date(raw.get("time"))
    latest_bar = {
        "date": latest_date,
        "open": swing_breakout_number(raw.get("open")),
        "high": swing_breakout_number(raw.get("high")),
        "low": swing_breakout_number(raw.get("low")),
        "close": swing_breakout_number(raw.get("close")),
        "volume": swing_breakout_number(raw.get("volume")),
    }
    latest_valid = bool(
        latest_date
        and all(latest_bar[key] is not None and latest_bar[key] > 0 for key in ("open", "high", "low", "close"))
        and latest_bar["high"] >= max(latest_bar["open"], latest_bar["close"])
        and latest_bar["low"] <= min(latest_bar["open"], latest_bar["close"])
    )
    try:
        history = _fetch_swing_breakout_chart_history(symbol)
    except Exception:
        history = []
    bars = []
    for record in history:
        bar = {
            "date": str(record.get("date") or "")[:10],
            "open": swing_breakout_number(record.get("open")),
            "high": swing_breakout_number(record.get("high")),
            "low": swing_breakout_number(record.get("low")),
            "close": swing_breakout_number(record.get("close")),
            "volume": swing_breakout_number(record.get("volume")),
        }
        if not bar["date"] or any(bar[key] is None or bar[key] <= 0 for key in ("open", "high", "low", "close")):
            continue
        if bar["high"] < max(bar["open"], bar["close"]) or bar["low"] > min(bar["open"], bar["close"]):
            continue
        bars.append(bar)
    if latest_valid:
        bars = [bar for bar in bars if bar["date"] < latest_date]
        bars.append(latest_bar)
    bars.sort(key=lambda bar: bar["date"])

    if not bars:
        return {"status": "error", "message": "Belum ada candle daily valid dari TradingView Scanner."}

    retrieved_at = now_jakarta().isoformat(timespec="seconds")
    payload = {
        "status": "ok",
        "ticker": symbol,
        "timeframe": "1D",
        "bars": bars,
        "latest_date": bars[-1]["date"],
        "current_bar_status": "running" if is_idx_market_open() else "closed",
        "retrieved_at": retrieved_at,
        "latest_source": "TradingView Scanner" if latest_valid else "Yahoo Finance daily OHLC",
        "history_source": "Yahoo Finance daily OHLC",
        "data_source": "TradingView Scanner terbaru + Yahoo Finance daily OHLC historis" if latest_valid else "Yahoo Finance daily OHLC",
    }
    with SWING_BREAKOUT_CHART_CACHE_LOCK:
        SWING_BREAKOUT_CHART_CACHE[symbol] = {
            "created_at": now_mono,
            "payload": payload,
        }
    return payload


def fetch_sideways_breakout_ohlcv(tickers):
    """Fetch a bounded daily OHLCV history for base-shape calculations."""
    normalized = tuple(sorted({str(ticker).strip().upper() for ticker in tickers if str(ticker).strip()}))
    now = time.time()
    missing = []
    with SIDEWAYS_BREAKOUT_OHLC_CACHE_LOCK:
        for ticker in normalized:
            cached = SIDEWAYS_BREAKOUT_OHLC_CACHE.get(ticker)
            cache_ttl = (
                SIDEWAYS_BREAKOUT_OHLC_CACHE_TTL
                if cached and cached.get("records")
                else SIDEWAYS_BREAKOUT_OHLC_EMPTY_TTL
            )
            if not cached or now - cached["ts"] > cache_ttl:
                missing.append(ticker)
    if missing:
        fresh = {}

        def download_batch(batch):
            """Download one bounded Yahoo batch and keep only usable bars."""
            if not batch:
                return {}
            symbols = [f"{ticker}.JK" for ticker in batch]
            try:
                import yfinance as yf

                frame = yf.download(
                    symbols,
                    period="6mo",
                    interval="1d",
                    group_by="ticker",
                    auto_adjust=False,
                    progress=False,
                    # One bounded request is enough here. Threaded Yahoo
                    # downloads can exhaust the dashboard process' file
                    # descriptors when the user selects the full IDX universe.
                    threads=False,
                )
            except Exception:
                return {}

            result = {}
            for ticker, symbol in zip(batch, symbols):
                try:
                    if len(symbols) == 1:
                        ticker_frame = frame
                    elif hasattr(frame.columns, "get_level_values") and symbol in frame.columns.get_level_values(0):
                        ticker_frame = frame[symbol]
                    elif hasattr(frame.columns, "get_level_values") and symbol in frame.columns.get_level_values(1):
                        ticker_frame = frame.xs(symbol, axis=1, level=1)
                    else:
                        ticker_frame = None
                    if ticker_frame is None:
                        continue
                    ticker_frame = ticker_frame.dropna(subset=["High", "Low", "Close"])
                    records = []
                    for index, row in ticker_frame.tail(SWING_BREAKOUT_CHART_LOOKBACK + 5).iterrows():
                        records.append({
                            "date": index.strftime("%Y-%m-%d") if hasattr(index, "strftime") else str(index),
                            "open": swing_breakout_number(row.get("Open")),
                            "close": swing_breakout_number(row.get("Close")),
                            "high": swing_breakout_number(row.get("High")),
                            "low": swing_breakout_number(row.get("Low")),
                            "volume": swing_breakout_number(row.get("Volume")),
                        })
                    usable = [record for record in records if record["close"] and record["high"] and record["low"]]
                    if usable:
                        result[ticker] = usable
                except Exception:
                    continue
            return result

        try:
            for start in range(0, len(missing), SIDEWAYS_BREAKOUT_OHLC_BATCH_SIZE):
                batch = missing[start:start + SIDEWAYS_BREAKOUT_OHLC_BATCH_SIZE]
                fresh.update(download_batch(batch))
        except Exception:
            pass

        unresolved = [ticker for ticker in missing if not fresh.get(ticker)]
        # A transient Yahoo response must not turn a large portion of the
        # dashboard into permanent empty rows. Retry only unresolved symbols
        # with smaller batches; this also handles occasional malformed batch
        # responses without making every ticker an individual network call.
        for start in range(0, len(unresolved), SIDEWAYS_BREAKOUT_OHLC_RETRY_BATCH_SIZE):
            batch = unresolved[start:start + SIDEWAYS_BREAKOUT_OHLC_RETRY_BATCH_SIZE]
            fresh.update(download_batch(batch))

        with SIDEWAYS_BREAKOUT_OHLC_CACHE_LOCK:
            for ticker in missing:
                records = fresh.get(ticker) or []
                SIDEWAYS_BREAKOUT_OHLC_CACHE[ticker] = {
                    "ts": now,
                    "records": records,
                    "attempted": True,
                }
    with SIDEWAYS_BREAKOUT_OHLC_CACHE_LOCK:
        return {
            ticker: SIDEWAYS_BREAKOUT_OHLC_CACHE[ticker]["records"]
            for ticker in normalized
            if ticker in SIDEWAYS_BREAKOUT_OHLC_CACHE
        }


def _sr_trend_quote_time(value):
    """Convert TradingView's epoch field without exposing any credentials."""
    timestamp = finite_number(value)
    if timestamp is None:
        return None
    if timestamp > 1_000_000_000_000:
        timestamp /= 1000
    try:
        return datetime.fromtimestamp(timestamp, ZoneInfo("Asia/Jakarta")).isoformat(timespec="seconds")
    except (OSError, OverflowError, ValueError):
        return None


def _fetch_sr_trend_quotes(tickers, retrieved_at):
    """Fetch one daily price snapshot for the research dashboard."""
    columns = [
        "name", "close", "change", "time", "SMA20", "SMA50", "SMA200",
        "RSI", "ADX", "ATR", "high", "low", "volume",
    ]
    data = tradingview_scan(list(tickers), columns)
    quotes = {}
    for item in data.get("data", []):
        values = item.get("d") or []
        if len(values) != len(columns):
            continue
        symbol = str(item.get("s") or "").split(":")[-1].upper()
        if not symbol:
            continue
        raw = dict(zip(columns, values))
        price = finite_number(raw.get("close"))
        if price is None or price <= 0:
            continue
        data_as_of = _sr_trend_quote_time(raw.get("time"))
        quotes[symbol] = {
            "name": str(raw.get("name") or symbol),
            "price": price,
            "change_pct": finite_number(raw.get("change")),
            "data_as_of": data_as_of or retrieved_at,
            "retrieved_at": retrieved_at,
            "source": "TradingView Scanner",
            "market_indicators": {
                key: finite_number(raw.get(key))
                for key in ("SMA20", "SMA50", "SMA200", "RSI", "ADX", "ATR", "high", "low", "volume")
            },
        }
    return quotes


def load_sr_trend_snapshot(items):
    """Build the user-pasted daily S/R + trend research batch."""
    normalized = normalize_input_items(raw_items=items)
    accepted_items = normalized["items"]
    tickers = tuple(item["ticker"] for item in accepted_items)
    retrieved_at = now_jakarta().isoformat(timespec="seconds")
    market_open = is_idx_market_open()
    if not tickers:
        return {
            "status": "ok",
            "rows": [],
            "input": normalized,
            "timeframe": "1D",
            "as_of": retrieved_at,
            "retrieved_at": retrieved_at,
            "market_open": market_open,
            "warnings": [],
        }

    cache_key = tickers
    now_mono = time.monotonic()
    with SR_TREND_CACHE_LOCK:
        cached = SR_TREND_CACHE.get(cache_key)
        if cached:
            age = now_mono - cached.get("created_at", 0)
            cached_market_open = bool((cached.get("payload") or {}).get("market_open"))
            if (not market_open and not cached_market_open) or (market_open and age < SR_TREND_CACHE_TTL):
                payload = json.loads(json.dumps(cached["payload"]))
                payload["input"] = normalized
                for row, item in zip(payload.get("rows") or [], accepted_items):
                    row["source"] = item.get("source", "")
                    row["reason"] = item.get("reason", "")
                return payload

    warnings = []
    try:
        quotes = _fetch_sr_trend_quotes(tickers, retrieved_at)
    except Exception as exc:
        quotes = {}
        warnings.append(f"Quote TradingView tidak tersedia: {exc}")

    try:
        histories = fetch_sideways_breakout_ohlcv(tickers)
    except Exception as exc:
        histories = {}
        warnings.append(f"OHLC historis tidak tersedia: {exc}")

    rows = []
    for item in accepted_items:
        ticker = item["ticker"]
        quote = quotes.get(ticker) or {}
        try:
            row = analyze_daily_sr_trend(
                ticker,
                histories.get(ticker) or [],
                price=quote.get("price"),
                quote=quote,
                source=item.get("source", ""),
                reason=item.get("reason", ""),
                retrieved_at=retrieved_at,
                market_open=market_open,
            )
            row["name"] = quote.get("name") or ticker
            change_pct = finite_number(quote.get("change_pct"))
            row["change_pct"] = round(change_pct, 2) if change_pct is not None else None
            row["processing_status"] = "selesai"
            rows.append(row)
        except Exception as exc:
            warnings.append(f"{ticker}: analisis gagal: {exc}")
            rows.append({
                "ticker": ticker,
                "name": ticker,
                "source": item.get("source", ""),
                "reason": item.get("reason", ""),
                "price": None,
                "price_source": "Tidak tersedia",
                "price_data_as_of": retrieved_at,
                "historical_source": "Yahoo Finance daily OHLC",
                "data_through": "",
                "candles_used": 0,
                "candle_close_status": "running" if market_open else "closed",
                "retrieved_at": retrieved_at,
                "timeframe": "1D",
                "method": "Analisis gagal sebelum level dapat dihitung.",
                "data_flags": [f"Analisis gagal: {exc}"],
                "level_too_close": False,
                "level_too_far": False,
                "trend_code": "DATA_INSUFFICIENT",
                "trend": "Data tidak cukup",
                "support_1": None,
                "support_2": None,
                "resistance_1": None,
                "resistance_2": None,
                "position_code": "DATA_INSUFFICIENT",
                "position": "Data tidak cukup",
                "distance_to_support_pct": None,
                "distance_to_resistance_pct": None,
                "space_to_resistance_pct": None,
                "breakout_trigger": "Data belum tersedia.",
                "cancel_level": None,
                "cancel_text": "Batal: data belum tersedia.",
                "status": "DATA TIDAK CUKUP",
                "status_reason": "Analisis gagal karena sumber data tidak lengkap.",
                "note": "Tidak ada angka yang dibuat-buat.",
                "trend_detail": {},
                "level_detail": {},
                "processing_status": "gagal",
            })

    payload = {
        "status": "ok",
        "rows": rows,
        "input": normalized,
        "timeframe": "1D",
        "as_of": retrieved_at,
        "retrieved_at": retrieved_at,
        "market_open": market_open,
        "data_source": "TradingView Scanner untuk harga terakhir; Yahoo Finance daily OHLC untuk level dan trend historis.",
        "historical_source": "Yahoo Finance daily OHLC",
        "method": (
            "Support dan resistance memakai pivot high/low 2 candle di kiri dan kanan, "
            "dikelompokkan dalam toleransi maksimum 1% atau 0,55 ATR, dengan ekstrem range 20D sebagai acuan tambahan. "
            "Trend memakai struktur HH/HL/LH/LL, SMA20/SMA50, dan slope SMA20 5 candle."
        ),
        "rules": {
            "minimum_candles": 35,
            "lookback_candles": 90,
            "near_support_pct": 2.0,
            "breakout_area_pct": 1.5,
            "near_resistance_pct": 2.5,
            "minimum_room_to_resistance_pct_for_menarik": 4.0,
            "status_guard": "MENARIK hanya untuk UPTREND dekat support dengan ruang resistance minimal 4%; tengah range tidak menjadi MENARIK.",
            "research_only": "Tidak ada status SIAP ENTRY dan tidak membaca trade history.",
        },
        "warnings": warnings,
    }
    with SR_TREND_CACHE_LOCK:
        SR_TREND_CACHE[cache_key] = {
            "created_at": time.monotonic(),
            "payload": payload,
        }
    return payload


def _sideways_mean(values):
    valid = [value for value in values if value is not None and value > 0]
    return sum(valid) / len(valid) if valid else None


def _sideways_window(raw, length):
    closes = [swing_breakout_number(raw.get(f"close[{offset}]")) for offset in range(1, length + 1)]
    highs = [swing_breakout_number(raw.get(f"high[{offset}]")) for offset in range(1, length + 1)]
    lows = [swing_breakout_number(raw.get(f"low[{offset}]")) for offset in range(1, length + 1)]
    if min(len([value for value in series if value and value > 0]) for series in (closes, highs, lows)) < length:
        return None
    range_high = max(highs)
    range_low = min(lows)
    width_pct = ((range_high - range_low) / range_low * 100) if range_low > 0 else None
    edge = min(5, len(closes))
    recent_mean = _sideways_mean(closes[:edge])
    older_mean = _sideways_mean(closes[-edge:])
    slope_pct = (abs(recent_mean - older_mean) / older_mean * 100) if recent_mean and older_mean else None
    return {
        "length": length,
        "closes": closes,
        "highs": highs,
        "lows": lows,
        "range_high": range_high,
        "range_low": range_low,
        "width_pct": width_pct,
        "slope_pct": slope_pct,
    }


def build_sideways_breakout_row(ticker, values, columns, market_open, retrieved_at, history=None):
    """Build a long-base breakout watch row from deterministic daily data."""
    if len(values) != len(columns):
        return None
    raw = dict(zip(columns, values))
    close = swing_breakout_number(raw.get("close"))
    if not close or close <= 0:
        return None

    history_records = list(history or [])
    latest_history_date = ""
    if history_records and isinstance(history_records[-1], dict):
        latest_history_date = str(history_records[-1].get("date") or "").strip()[:10]
    # Yahoo's last daily row is the current close after the session ends and
    # the previous close while IDX is open. Keep the scanner snapshot as the
    # current bar and map only prior OHLCV rows to [1]...[60].
    if not market_open and history_records:
        history_records = history_records[:-1]
    prior_records = list(reversed(history_records[-SIDEWAYS_BREAKOUT_LOOKBACK_DAYS:]))
    for offset, record in enumerate(prior_records, start=1):
        raw[f"close[{offset}]"] = record.get("close")
        raw[f"high[{offset}]"] = record.get("high")
        raw[f"low[{offset}]"] = record.get("low")
        raw[f"volume[{offset}]"] = record.get("volume")

    lookback = SIDEWAYS_BREAKOUT_LOOKBACK_DAYS
    available = min(
        len([value for value in (swing_breakout_number(raw.get(f"close[{offset}]")) for offset in range(1, lookback + 1)) if value and value > 0]),
        len([value for value in (swing_breakout_number(raw.get(f"high[{offset}]")) for offset in range(1, lookback + 1)) if value and value > 0]),
        len([value for value in (swing_breakout_number(raw.get(f"low[{offset}]")) for offset in range(1, lookback + 1)) if value and value > 0]),
    )
    atr = swing_breakout_number(raw.get("ATR"))
    atr_pct = (atr / close * 100) if atr and close else None
    max_width_pct = max(8.0, min(25.0, (atr_pct * 10) if atr_pct else 15.0))
    max_slope_pct = max(3.0, min(10.0, (atr_pct * 2.5) if atr_pct else 5.0))

    base_window = None
    if available >= SIDEWAYS_BREAKOUT_MIN_BASE_DAYS:
        for length in range(min(available, lookback), SIDEWAYS_BREAKOUT_MIN_BASE_DAYS - 1, -1):
            candidate = _sideways_window(raw, length)
            if candidate and candidate["width_pct"] is not None and candidate["slope_pct"] is not None:
                if candidate["width_pct"] <= max_width_pct and candidate["slope_pct"] <= max_slope_pct:
                    base_window = candidate
                    break
    display_window = base_window or (_sideways_window(raw, min(available, lookback)) if available else None)
    base_days = base_window["length"] if base_window else 0

    range_high = display_window["range_high"] if display_window else None
    range_low = display_window["range_low"] if display_window else None
    range_width_pct = display_window["width_pct"] if display_window else None
    range_width = (range_high - range_low) if range_high and range_low else None
    range_position_pct = ((close - range_low) / range_width * 100) if range_low and range_width and range_width > 0 else None
    distance_to_breakout_pct = ((range_high - close) / range_high * 100) if range_high else None
    distance_to_breakout_atr = ((range_high - close) / atr) if range_high and atr else None
    near_limit_pct = max(1.5, min(3.0, (atr_pct * 1.25) if atr_pct else 3.0))

    window_highs = display_window["highs"] if display_window else []
    window_lows = display_window["lows"] if display_window else []
    touch_buffer_pct = max(0.6, (atr_pct * 0.75) if atr_pct else 1.0)
    touches_resistance = sum(
        1 for value in window_highs
        if range_high and abs(value - range_high) / range_high * 100 <= touch_buffer_pct
    ) if range_high else 0
    touches_support = sum(
        1 for value in window_lows
        if range_low and abs(value - range_low) / range_low * 100 <= touch_buffer_pct
    ) if range_low else 0

    previous_volumes = [
        swing_breakout_number(raw.get(f"volume[{offset}]"))
        for offset in range(1, 21)
    ]
    previous_volumes = [value for value in previous_volumes if value and value > 0]
    current_volume = swing_breakout_number(raw.get("volume"))
    average_volume_20d = _sideways_mean(previous_volumes)
    volume_ratio = current_volume / average_volume_20d if current_volume and average_volume_20d else None
    recent_volume = _sideways_mean(previous_volumes[:10])
    older_volume = _sideways_mean(previous_volumes[10:20])
    volume_dryup_pct = ((recent_volume / older_volume) - 1) * 100 if recent_volume and older_volume else None
    volume_contraction = bool(volume_dryup_pct is not None and volume_dryup_pct <= -10)

    previous_closes = [
        swing_breakout_number(raw.get(f"close[{offset}]"))
        for offset in range(1, 21)
    ]
    previous_closes = [value for value in previous_closes if value and value > 0]
    recent_returns = []
    for index in range(len(previous_closes) - 1):
        if previous_closes[index + 1] > 0:
            recent_returns.append(abs(previous_closes[index] - previous_closes[index + 1]) / previous_closes[index + 1] * 100)
    average_daily_move_pct = _sideways_mean(recent_returns)
    bb_closes = [close] + previous_closes[:19]
    bb_mean = _sideways_mean(bb_closes)
    bb_width_pct = None
    if bb_mean and len(bb_closes) >= 10:
        variance = sum((value - bb_mean) ** 2 for value in bb_closes) / len(bb_closes)
        bb_width_pct = 2 * math.sqrt(variance) / bb_mean * 100

    sma20 = swing_breakout_number(raw.get("SMA20"))
    sma50 = swing_breakout_number(raw.get("SMA50"))
    sma200 = swing_breakout_number(raw.get("SMA200"))
    sma_compression_pct = abs(sma20 - sma50) / close * 100 if sma20 and sma50 and close else None
    recent_sma_mean = _sideways_mean(previous_closes[:5])
    older_sma_mean = _sideways_mean(previous_closes[15:20])
    sma20_slope_pct = (recent_sma_mean - older_sma_mean) / older_sma_mean * 100 if recent_sma_mean and older_sma_mean else None
    adx = swing_breakout_number(raw.get("ADX"))
    rsi = swing_breakout_number(raw.get("RSI"))

    shape_valid = bool(
        base_window
        and range_width_pct is not None
        and range_width_pct >= 0.5
        and range_width_pct <= max_width_pct
        and display_window["slope_pct"] is not None
        and display_window["slope_pct"] <= max_slope_pct
    )
    touches_valid = touches_resistance >= 2 and touches_support >= 2
    base_valid = shape_valid and touches_valid
    breakout_pct = ((close - range_high) / range_high * 100) if range_high else None
    breakout_in_progress = bool(range_high and close > range_high)
    volume_supported = bool(volume_ratio is not None and volume_ratio >= 1.5)

    if not display_window or not range_high or not range_low:
        phase = "DATA INCOMPLETE"
        verdict = "Data daily 60 hari belum lengkap"
    elif breakout_in_progress:
        if market_open:
            phase = "BREAKOUT IN PROGRESS"
            verdict = "Harga menembus base, tetapi candle daily masih berjalan"
        elif volume_supported and base_valid:
            phase = "BREAKOUT CONFIRMED"
            verdict = "Close daily di atas base dengan volume mendukung"
        else:
            phase = "BREAKOUT WEAK"
            verdict = "Close di atas base, tetapi volume atau kualitas base belum cukup"
    elif base_valid and distance_to_breakout_pct is not None and distance_to_breakout_pct <= near_limit_pct:
        phase = "NEAR BREAKOUT"
        verdict = "Base valid dan harga mendekati resistance"
    elif base_valid:
        phase = "BASE SIDEWAYS"
        verdict = "Base sideways valid; masih menunggu pendekatan resistance"
    else:
        phase = "NOT SIDEWAYS"
        verdict = "Durasi, lebar range, atau touch support/resistance belum memenuhi"

    breakout_level = round_idx_tick(range_high) if range_high else None
    retest_buffer = max((atr * 0.75) if atr else 0, (range_high * 0.01) if range_high else 0)
    retest_zone_low = round_idx_tick(range_high - retest_buffer) if range_high and retest_buffer else None
    retest_zone_high = round_idx_tick(range_high + retest_buffer * 0.35) if range_high and retest_buffer else None
    stop_buffer = max((atr * 1.0) if atr else 0, (range_high * 0.02) if range_high else 0)
    stop_loss = round_idx_tick(range_high - stop_buffer) if range_high and stop_buffer else None

    high_1m = swing_breakout_number(raw.get("High.1M"))
    high_3m = swing_breakout_number(raw.get("High.3M"))
    high_52w = swing_breakout_number(raw.get("price_52_week_high"))
    measured_target = range_high + range_width if range_high and range_width else None
    target_candidates = []
    if measured_target and measured_target > range_high:
        target_candidates.append((measured_target, "measured move tinggi base"))
    for label, level in (("high 1M", high_1m), ("high 3M", high_3m), ("high 52W", high_52w)):
        if level and range_high and level > range_high:
            target_candidates.append((level, label))
    target_candidates.sort(key=lambda item: item[0])
    target = round_idx_tick(target_candidates[0][0]) if target_candidates else None
    target_source = target_candidates[0][1] if target_candidates else "target belum tersedia"
    risk_pct = ((range_high - stop_loss) / range_high * 100) if range_high and stop_loss else None
    reward_pct = ((target - range_high) / range_high * 100) if range_high and target else None
    risk_amount = (range_high - stop_loss) if range_high and stop_loss else None
    reward_amount = (target - range_high) if range_high and target else None
    rr = (reward_amount / risk_amount) if risk_amount and reward_amount and risk_amount > 0 else None

    quality_score = 0
    if base_window:
        quality_score += min(25, round(base_days / 60 * 25))
    if range_width_pct is not None and max_width_pct > 0:
        quality_score += max(0, min(20, round((1 - range_width_pct / max_width_pct) * 20)))
    quality_score += min(20, (7 if touches_resistance >= 2 else 0) + (7 if touches_support >= 2 else 0) + (6 if touches_resistance >= 3 and touches_support >= 3 else 0))
    if volume_contraction:
        quality_score += 10
    if sma_compression_pct is not None and sma_compression_pct <= 5:
        quality_score += 10
    if breakout_in_progress or (distance_to_breakout_pct is not None and 0 <= distance_to_breakout_pct <= near_limit_pct):
        quality_score += 15
    quality_score = max(0, min(100, quality_score))
    if not base_valid:
        quality_score = min(39, quality_score)

    if quality_score >= 80:
        quality_band = "KUAT"
    elif quality_score >= 60:
        quality_band = "CUKUP"
    elif quality_score >= SIDEWAYS_BREAKOUT_MIN_SCREENING_SCORE:
        quality_band = "LEMAH"
    else:
        quality_band = "SKIP"
    screening_status = (
        "KANDIDAT FULL PA"
        if base_valid and quality_score >= SIDEWAYS_BREAKOUT_MIN_SCREENING_SCORE
        else "TIDAK LAYAK DITUNGGU"
    )
    if base_valid and quality_score < SIDEWAYS_BREAKOUT_MIN_SCREENING_SCORE:
        verdict = "Base sideways ada, tetapi quality score di bawah minimum screening"

    if phase == "BASE SIDEWAYS":
        trigger_hint = f"Tunggu close daily di atas {breakout_level or 'resistance'}; lanjut retest 1H/15m dan trigger 5m."
    elif phase == "NEAR BREAKOUT":
        trigger_hint = f"Jangan kejar; tunggu close daily di atas {breakout_level or 'resistance'} + volume >= 1.5x."
    elif phase == "BREAKOUT IN PROGRESS":
        trigger_hint = "Candle daily masih berjalan; belum boleh dianggap breakout confirmed."
    elif phase == "BREAKOUT CONFIRMED":
        trigger_hint = "Breakout daily confirmed; tunggu retest 1H/15m lalu close 5m valid."
    elif phase == "BREAKOUT WEAK":
        trigger_hint = "Breakout belum sehat; volume atau kualitas base belum mendukung."
    else:
        trigger_hint = "Tidak ada base sideways yang cukup kuat untuk ditunggu."

    reasons = []
    if base_days:
        reasons.append(f"base {base_days}D")
    if range_width_pct is not None:
        reasons.append(f"range {range_width_pct:.1f}%")
    if touches_resistance or touches_support:
        reasons.append(f"touch R/S {touches_resistance}/{touches_support}")
    if volume_contraction:
        reasons.append(f"volume contraction {volume_dryup_pct:.1f}%")
    if adx is not None:
        reasons.append(f"ADX {adx:.1f}")
    reasons.append(f"quality {quality_band}")

    return {
        "ticker": str(ticker).upper(),
        "name": raw.get("name") or str(ticker).upper(),
        "phase": phase,
        "screening_status": screening_status,
        "verdict": verdict,
        "quality_score": quality_score,
        "quality_band": quality_band,
        "close": close,
        "change_pct": swing_breakout_number(raw.get("change")),
        "sideways_days": base_days if shape_valid else 0,
        "range_high": range_high,
        "range_low": range_low,
        "range_width_pct": range_width_pct,
        "range_position_pct": range_position_pct,
        "distance_to_breakout_pct": distance_to_breakout_pct,
        "distance_to_breakout_atr": distance_to_breakout_atr,
        "near_limit_pct": near_limit_pct,
        "touches_resistance": touches_resistance,
        "touches_support": touches_support,
        "bb_width_pct": bb_width_pct,
        "average_daily_move_pct": average_daily_move_pct,
        "atr": atr,
        "atr_pct": atr_pct,
        "adx": adx,
        "rsi": rsi,
        "sma20": sma20,
        "sma50": sma50,
        "sma200": sma200,
        "sma_compression_pct": sma_compression_pct,
        "sma20_slope_pct": sma20_slope_pct,
        "volume_ratio_20d": volume_ratio,
        "volume_dryup_pct": volume_dryup_pct,
        "volume_contraction": volume_contraction,
        "volume_supported": volume_supported,
        "breakout_pct": breakout_pct,
        "breakout_level": breakout_level,
        "retest_zone_low": retest_zone_low,
        "retest_zone_high": retest_zone_high,
        "entry_plan": breakout_level,
        # Sideways is a 1D screening layer.  It must not promote a daily
        # breakout into SIAP ENTRY until a separate PA handoff supplies the
        # 1D/1H/15m alignment and closed 5m trigger evidence.
        "ready_entry": False,
        "entry_status": "",
        "ready_entry_reason": "Screening 1D saja; 1H/15m dan trigger close 5m belum tersedia.",
        "early_breakout": phase == "BREAKOUT CONFIRMED",
        "early_breakout_session": 0 if phase == "BREAKOUT CONFIRMED" else None,
        "breakout_late": False,
        "pa_alignment_1d_1h_15m": False,
        "trigger_5m_valid": False,
        "trigger_5m_close_status": "unavailable",
        "volume_5m_supported": False,
        "invalidation": "",
        "stop_loss": stop_loss,
        "target": target,
        "target_source": target_source,
        "risk_pct": risk_pct,
        "reward_pct": reward_pct,
        "risk_amount": risk_amount,
        "reward_amount": reward_amount,
        "rr": rr,
        "trigger_hint": trigger_hint,
        "quality_reasons": reasons,
        "high_1m": high_1m,
        "high_3m": high_3m,
        "high_52w": high_52w,
        "bar_date": latest_history_date or retrieved_at[:10],
        "bar_open": swing_breakout_number(raw.get("open")),
        "bar_high": swing_breakout_number(raw.get("high")),
        "bar_low": swing_breakout_number(raw.get("low")),
        "bar_close": close,
        "candle_close_status": "running" if market_open else "closed",
        "data_source": "TradingView Scanner + Yahoo Finance daily OHLC",
        "retrieved_at": retrieved_at,
        "timeframe": {"base": "1D", "setup": "1H", "confirm": "15m", "trigger": "5m"},
    }


def _support_median(values):
    ordered = sorted(value for value in values if value is not None and value > 0)
    if not ordered:
        return None
    middle = len(ordered) // 2
    if len(ordered) % 2:
        return ordered[middle]
    return (ordered[middle - 1] + ordered[middle]) / 2


def _support_pivot_lows(bars):
    """Find distinct daily swing lows, keeping the support evidence deterministic."""
    pivots = []
    for index in range(2, len(bars) - 2):
        low = bars[index].get("low")
        if not low or low <= 0:
            continue
        neighbors = [
            bars[offset].get("low")
            for offset in (index - 2, index - 1, index + 1, index + 2)
        ]
        neighbors = [value for value in neighbors if value and value > 0]
        if len(neighbors) < 4 or low > min(neighbors):
            continue
        candidate = {"index": index, "low": float(low)}
        if pivots and index - pivots[-1]["index"] <= 2:
            if low < pivots[-1]["low"]:
                pivots[-1] = candidate
            continue
        pivots.append(candidate)
    return pivots


def _support_cluster_pivots(pivots, tolerance_pct):
    clusters = []
    for pivot in sorted(pivots, key=lambda item: item["low"]):
        selected = None
        selected_distance = None
        for cluster in clusters:
            center = _support_median([item["low"] for item in cluster])
            distance = abs(pivot["low"] - center) / center * 100 if center else 999
            if distance <= tolerance_pct and (selected_distance is None or distance < selected_distance):
                selected = cluster
                selected_distance = distance
        if selected is None:
            clusters.append([pivot])
        else:
            selected.append(pivot)
    return clusters


def _support_trend(close, sma20, sma50):
    if close and sma20 and sma50:
        if close > sma20 > sma50:
            return "UPTREND"
        if close < sma20 and sma20 < sma50:
            return "DOWNTREND"
        if close >= sma50 and sma20 >= sma50:
            return "RECOVERING"
        return "FLAT"
    if close and sma20 and close > sma20:
        return "RECOVERING"
    return "UNKNOWN"


def _support_pullback_bar(record):
    if not isinstance(record, dict):
        return None
    bar = {
        "date": str(record.get("date") or "")[:10],
        "open": swing_breakout_number(record.get("open")),
        "high": swing_breakout_number(record.get("high")),
        "low": swing_breakout_number(record.get("low")),
        "close": swing_breakout_number(record.get("close")),
        "volume": swing_breakout_number(record.get("volume")),
    }
    if not bar["date"] or any(
        bar[key] is None or bar[key] <= 0
        for key in ("open", "high", "low", "close")
    ):
        return None
    if bar["high"] < max(bar["open"], bar["close"]) or bar["low"] > min(bar["open"], bar["close"]):
        return None
    return bar


def _support_pullback_completed_bars(history, market_open=False):
    """Normalize daily bars and exclude a running daily candle from evidence."""
    bars = []
    today = now_jakarta().date().isoformat()
    for record in history or []:
        bar = _support_pullback_bar(record)
        if not bar:
            continue
        if market_open and bar["date"] >= today:
            continue
        bars.append(bar)
    bars.sort(key=lambda item: item["date"])
    return bars[-int(SUPPORT_PULLBACK_CONFIG["lookback_days"]):]


def calculate_support_zone(support_price, atr14, source="SWING_LOW"):
    """Return an adaptive support zone instead of a single price level."""
    mid = swing_breakout_number(support_price)
    atr = swing_breakout_number(atr14) or 0
    if mid is None or mid <= 0:
        return None
    tolerance = max(
        mid * SUPPORT_PULLBACK_CONFIG["support_tolerance_pct"] / 100,
        atr * SUPPORT_PULLBACK_CONFIG["support_atr_factor"],
    )
    return {
        "support_lower": max(0.0, mid - tolerance),
        "support_mid": mid,
        "support_upper": mid + tolerance,
        "support_tolerance": tolerance,
        "source": str(source or "SWING_LOW"),
    }


def detect_support(bars, atr14):
    """Find prioritized support candidates using only bars through the scan."""
    bars = list(bars or [])
    if len(bars) < SUPPORT_STRENGTH_MIN_HISTORY_DAYS:
        return []
    atr = swing_breakout_number(atr14) or 0
    close = swing_breakout_number(bars[-1].get("close"))
    atr_pct = atr / close * 100 if atr and close else 1.25
    cluster_tolerance = max(
        SUPPORT_PULLBACK_CONFIG["support_tolerance_pct"],
        min(3.0, atr_pct * 0.55),
    )
    candidates = []
    pivots = _support_pivot_lows(bars)
    for cluster in _support_cluster_pivots(pivots, cluster_tolerance):
        if not cluster:
            continue
        level = _support_median([item["low"] for item in cluster])
        source = "MULTI_TEST" if len(cluster) >= 2 else "SWING_LOW"
        zone = calculate_support_zone(level, atr, source)
        if not zone:
            continue
        last_index = max(item["index"] for item in cluster)
        broken = any(
            (bar.get("close") or 0)
            < zone["support_lower"] - atr * SUPPORT_PULLBACK_CONFIG["breakdown_atr_factor"]
            for bar in bars[last_index + 1:]
        )
        candidates.append({
            **zone,
            "source": source,
            "source_priority": 2 if source == "MULTI_TEST" else 1,
            "support_tests": len(cluster),
            "last_touch_index": last_index,
            "breakout_level": False,
            "broken_history": broken,
            "pivot_indices": [item["index"] for item in cluster],
        })

    # A prior resistance that was closed above becomes a higher-priority
    # support candidate only after the historical breakout is observable.
    for index in range(20, len(bars)):
        prior = bars[max(0, index - 20):index]
        resistance = max((bar.get("high") or 0) for bar in prior)
        breakout = bars[index]
        if not resistance or not breakout.get("close"):
            continue
        breakout_buffer = max(resistance * 0.005, atr * 0.25)
        if breakout["close"] <= resistance + breakout_buffer:
            continue
        zone = calculate_support_zone(resistance, atr, "BREAKOUT_FLIP")
        if not zone:
            continue
        after = bars[index + 1:]
        tests = sum(
            1 for bar in after
            if (bar.get("low") or 0) <= zone["support_upper"]
            and (bar.get("close") or 0) >= zone["support_lower"]
        )
        broken = any(
            (bar.get("close") or 0)
            < zone["support_lower"] - atr * SUPPORT_PULLBACK_CONFIG["breakdown_atr_factor"]
            for bar in after
        )
        candidates.append({
            **zone,
            "source": "BREAKOUT_FLIP",
            "source_priority": 0,
            "support_tests": max(1, tests),
            "last_touch_index": max(index, len(bars) - 2),
            "breakout_index": index,
            "breakout_level": True,
            "broken_history": broken,
            "pivot_indices": [],
        })

    # A compact, previously traded base is a lower-priority support source.
    window = bars[-20:]
    if len(window) >= 10:
        range_low = min((bar.get("low") or 0) for bar in window)
        range_high = max((bar.get("high") or 0) for bar in window)
        range_pct = (range_high - range_low) / range_low * 100 if range_low else 999
        if range_low and range_pct <= 8:
            zone = calculate_support_zone(range_low, atr, "CONSOLIDATION")
            if zone:
                candidates.append({
                    **zone,
                    "source": "CONSOLIDATION",
                    "source_priority": 3,
                    "support_tests": sum(
                        1 for bar in window
                        if (bar.get("low") or 0) <= zone["support_upper"]
                        and (bar.get("close") or 0) >= zone["support_lower"]
                    ),
                    "last_touch_index": len(bars) - 1,
                    "breakout_level": False,
                    "broken_history": False,
                    "pivot_indices": [],
                })

    # Keep the strongest source when two candidates are effectively the same
    # zone. This prevents one level from becoming several signals.
    unique = []
    for candidate in sorted(candidates, key=lambda item: (item["source_priority"], -item["support_tests"])):
        if any(
            abs(candidate["support_mid"] - existing["support_mid"])
            / existing["support_mid"] * 100 <= cluster_tolerance
            for existing in unique
        ):
            continue
        unique.append(candidate)
    return unique


def calculate_distance_to_support(current_price, support_mid, support_lower, support_upper):
    current = swing_breakout_number(current_price)
    mid = swing_breakout_number(support_mid)
    lower = swing_breakout_number(support_lower)
    upper = swing_breakout_number(support_upper)
    if current is None or mid is None or mid <= 0:
        return {"distance_pct": None, "bucket": "FAR", "in_zone": False}
    distance_pct = (current - mid) / mid * 100
    in_zone = lower is not None and upper is not None and lower <= current <= upper
    if in_zone:
        bucket = "TEST"
    elif distance_pct <= SUPPORT_PULLBACK_CONFIG["near_distance_pct"]:
        bucket = "NEAR"
    elif distance_pct <= SUPPORT_PULLBACK_CONFIG["far_distance_pct"]:
        bucket = "WATCH"
    else:
        bucket = "FAR"
    return {"distance_pct": distance_pct, "bucket": bucket, "in_zone": in_zone}


def detect_pullback(bars, zone, current_price):
    """Confirm that price is returning from above, not rising into resistance."""
    bars = list(bars or [])
    if not bars or not zone:
        return {"from_above": False, "orderly": False, "dump": False, "start_index": None, "previous_swing_high": None}
    last_index = len(bars) - 1
    above_indices = [
        index for index, bar in enumerate(bars[:last_index])
        if (bar.get("close") or 0) > zone["support_upper"]
    ]
    start_index = max(above_indices) if above_indices else None
    if start_index is None:
        return {"from_above": False, "orderly": False, "dump": False, "start_index": None, "previous_swing_high": None}
    path = bars[start_index:last_index + 1]
    peak = max((bar.get("high") or bar.get("close") or 0) for bar in path)
    current = swing_breakout_number(current_price)
    closes = [bar.get("close") for bar in path if bar.get("close")]
    down_steps = sum(1 for left, right in zip(closes, closes[1:]) if right < left)
    drops = [
        (bar["open"] - bar["close"]) / bar["open"] * 100
        for bar in path
        if bar.get("open") and bar.get("close") and bar["close"] < bar["open"]
    ]
    max_drop_pct = max(drops) if drops else 0
    orderly = down_steps > 0 and max_drop_pct <= 5.0
    dump = max_drop_pct > 6.0 or (current is not None and peak and (peak - current) / peak * 100 > 12)
    return {
        "from_above": bool(
            path[0].get("close") > zone["support_upper"]
            and current is not None
            and current < peak
        ),
        "orderly": orderly,
        "dump": dump,
        "start_index": start_index,
        "previous_swing_high": peak if peak > (current or 0) else None,
        "pullback_days": max(0, last_index - start_index),
        "down_steps": down_steps,
        "max_drop_pct": max_drop_pct,
    }


def detect_support_test(bar, zone):
    if not bar or not zone:
        return {"tested": False, "low": None, "close": None}
    low = swing_breakout_number(bar.get("low"))
    close = swing_breakout_number(bar.get("close"))
    tested = bool(
        low is not None and close is not None
        and low <= zone["support_upper"]
        and close >= zone["support_lower"]
    )
    return {"tested": tested, "low": low, "close": close}


def detect_rejection(candle, zone, previous_candle=None):
    """Detect rejection from OHLC structure; candle names are not required."""
    if not candle or not zone:
        return {"valid": False, "strength": 0, "rejection_high": None, "rejection_low": None}
    opened = swing_breakout_number(candle.get("open"))
    high = swing_breakout_number(candle.get("high"))
    low = swing_breakout_number(candle.get("low"))
    close = swing_breakout_number(candle.get("close"))
    if None in {opened, high, low, close} or high <= low:
        return {"valid": False, "strength": 0, "rejection_high": high, "rejection_low": low}
    candle_range = high - low
    lower_wick = min(opened, close) - low
    close_position = (close - low) / candle_range
    previous_open = swing_breakout_number((previous_candle or {}).get("open"))
    previous_close = swing_breakout_number((previous_candle or {}).get("close"))
    bullish_engulfing = bool(
        previous_open is not None and previous_close is not None
        and previous_close < previous_open and close > opened
        and opened <= previous_close and close >= previous_open
    )
    flags = {
        "in_zone": low <= zone["support_upper"] and close >= zone["support_lower"],
        "close_above_mid": close >= zone["support_mid"],
        "lower_wick": lower_wick / candle_range >= 0.35,
        "upper_close": close_position >= 0.65,
        "bullish_candle": close > opened,
        "bullish_engulfing": bullish_engulfing,
    }
    strength = round(sum(1 for key, value in flags.items() if value and key != "in_zone") / 5 * 100)
    valid = bool(
        flags["in_zone"] and flags["lower_wick"] and flags["upper_close"]
        and (flags["bullish_candle"] or flags["bullish_engulfing"])
    )
    return {
        "valid": valid,
        "strength": min(100, strength),
        "rejection_high": high,
        "rejection_low": low,
        "lower_wick_ratio": lower_wick / candle_range,
        "close_position": close_position,
        "flags": flags,
    }


def analyze_pullback_volume(bars, pullback_start_index):
    bars = list(bars or [])
    last_index = len(bars) - 1
    baseline = [
        bar.get("volume") for bar in bars[max(0, last_index - 20):last_index]
        if bar.get("volume") and bar["volume"] > 0
    ]
    average_volume = sum(baseline) / len(baseline) if baseline else None
    start = pullback_start_index + 1 if pullback_start_index is not None else max(0, last_index - 3)
    pullback = bars[start:last_index + 1]
    pullback_volumes = [bar.get("volume") for bar in pullback if bar.get("volume") and bar["volume"] > 0]
    pullback_average = sum(pullback_volumes) / len(pullback_volumes) if pullback_volumes else None
    pullback_ratio = pullback_average / average_volume if pullback_average and average_volume else None
    rebound_volume = bars[-1].get("volume") if bars else None
    rebound_ratio = rebound_volume / average_volume if rebound_volume and average_volume else None
    return {
        "average_volume_20": average_volume,
        "pullback_volume_ratio": pullback_ratio,
        "rebound_volume_ratio": rebound_ratio,
        "volume_contracting": bool(pullback_ratio is not None and pullback_ratio <= 1.0),
        "selling_distribution": bool(
            pullback_ratio is not None
            and pullback_ratio >= SUPPORT_PULLBACK_CONFIG["breakdown_volume_ratio"]
        ),
    }


def detect_breakdown(bar, zone, atr14, volume_ratio=None, bar_closed=True):
    if not bar or not zone or not bar_closed:
        return {
            "broken": False,
            "closed": bool(bar_closed),
            "reason": "Menunggu daily close untuk validasi breakdown.",
        }
    close = swing_breakout_number(bar.get("close"))
    atr = swing_breakout_number(atr14) or 0
    if close is None:
        return {"broken": False, "closed": True, "reason": "Close breakdown belum tersedia."}
    below = close < zone["support_lower"]
    deep_break = close < zone["support_lower"] - atr * SUPPORT_PULLBACK_CONFIG["breakdown_atr_factor"]
    heavy_volume = below and volume_ratio is not None and volume_ratio > SUPPORT_PULLBACK_CONFIG["breakdown_volume_ratio"]
    broken = bool(deep_break or heavy_volume)
    return {
        "broken": broken,
        "closed": True,
        "deep_break": deep_break,
        "heavy_volume": heavy_volume,
        "reason": "Close daily mematahkan support." if broken else "Support belum breakdown valid.",
    }


def detect_trigger(current_price, rejection_high, atr14):
    high = swing_breakout_number(rejection_high)
    current = swing_breakout_number(current_price)
    atr = swing_breakout_number(atr14) or 0
    if high is None:
        return {"triggered": False, "trigger_price": None, "current_price": current}
    trigger_price = high + atr * SUPPORT_PULLBACK_CONFIG["trigger_atr_factor"]
    return {
        "triggered": bool(current is not None and current > trigger_price),
        "trigger_price": trigger_price,
        "current_price": current,
    }


def calculate_risk_reward(entry_price, support_lower, rejection_low, atr14, target_price):
    entry = swing_breakout_number(entry_price)
    lower = swing_breakout_number(support_lower)
    rejection = swing_breakout_number(rejection_low)
    atr = swing_breakout_number(atr14) or 0
    target = swing_breakout_number(target_price)
    if entry is None or lower is None:
        return {"entry": entry, "stop_loss": None, "target": target, "rr": None}
    stop = lower - atr * SUPPORT_PULLBACK_CONFIG["breakdown_atr_factor"]
    if rejection is not None:
        stop = min(stop, rejection - atr * 0.1)
    risk = entry - stop if stop > 0 else None
    reward = target - entry if target is not None else None
    rr = reward / risk if risk and reward and risk > 0 and reward > 0 else None
    return {
        "entry": entry,
        "stop_loss": stop if stop and stop > 0 else None,
        "target": target,
        "risk_pct": risk / entry * 100 if risk and entry else None,
        "reward_pct": reward / entry * 100 if reward and entry else None,
        "rr": rr,
        "risk_amount": risk,
        "reward_amount": reward,
    }


def calculate_support_pullback_score(analysis):
    """Score support quality, pullback, reaction, confirmation, and RR."""
    analysis = analysis or {}
    support = 0
    source = str(analysis.get("support_source") or analysis.get("source") or "")
    if source == "BREAKOUT_FLIP":
        support += 10
    if (analysis.get("support_tests") or 0) >= 2:
        support += 5
    if analysis.get("breakout_level"):
        support += 5
    if (analysis.get("days_since_last_touch") or 999) <= 30:
        support += 5
    support = min(25, support)

    pullback = 0
    if analysis.get("pullback_from_above"):
        pullback += 5
    if analysis.get("pullback_orderly") and not analysis.get("pullback_dump"):
        pullback += 5
    if analysis.get("pullback_volume_contracting"):
        pullback += 10
    pullback = min(20, pullback)

    reaction = 0
    if analysis.get("support_test"):
        reaction += 5
    if analysis.get("close_above_support"):
        reaction += 10
    if analysis.get("rejection_valid"):
        reaction += 10
    if analysis.get("bullish_structure"):
        reaction += 5
    reaction = min(30, reaction)

    confirmation = 0
    if analysis.get("triggered"):
        confirmation += 10
    if (analysis.get("rebound_volume_ratio") or 0) >= SUPPORT_PULLBACK_CONFIG["rebound_volume_ratio"]:
        confirmation += 5
    confirmation = min(15, confirmation)

    rr = analysis.get("rr")
    risk_reward = (
        10 if rr is not None and rr >= 2
        else 5 if rr is not None and rr >= SUPPORT_PULLBACK_CONFIG["minimum_rr"]
        else 0
    )

    penalties = []
    penalty = 0
    if analysis.get("pullback_dump") or analysis.get("selling_distribution"):
        penalty += 10
        penalties.append("tekanan jual/distribusi besar")
    if (analysis.get("support_tests") or 0) > 5:
        penalty += 5
        penalties.append("support terlalu sering dites")
    if not analysis.get("pullback_from_above"):
        penalty += 15
        penalties.append("harga belum terbukti kembali dari atas")
    if analysis.get("breakdown"):
        penalty += 30
        penalties.append("support breakdown")
    if rr is not None and rr < SUPPORT_PULLBACK_CONFIG["minimum_rr"]:
        penalty += 5
        penalties.append("RR di bawah 1,5R")

    total = max(0, min(100, support + pullback + reaction + confirmation + risk_reward - penalty))
    return {
        "score": total,
        "support_quality": support,
        "pullback_quality": pullback,
        "reaction": reaction,
        "confirmation": confirmation,
        "risk_reward": risk_reward,
        "penalty": penalty,
        "penalties": penalties,
    }


def _support_pullback_reasons(analysis):
    reasons = []
    source = analysis.get("support_source") or analysis.get("source")
    if source == "BREAKOUT_FLIP":
        reasons.append("Breakout level lama berubah menjadi support")
    elif source == "MULTI_TEST":
        reasons.append(f"Support diuji {analysis.get('support_tests', 0)} kali")
    elif source:
        reasons.append(f"Support berasal dari {str(source).lower()}")
    if analysis.get("pullback_from_above"):
        reasons.append("Pullback datang dari atas support")
    if analysis.get("pullback_volume_contracting"):
        ratio = analysis.get("pullback_volume_ratio")
        reasons.append(
            f"Volume pullback mengecil ({ratio:.2f}x)"
            if ratio is not None else "Volume pullback mengecil"
        )
    if analysis.get("support_test"):
        reasons.append("Harga masuk/test zona support")
    if analysis.get("rejection_valid"):
        reasons.append("Lower wick dan close atas menunjukkan rejection bullish")
    if analysis.get("triggered"):
        reasons.append(f"Harga menembus high rejection di {round_idx_tick(analysis.get('trigger_price'))}")
    elif analysis.get("rejection_high") and analysis.get("trigger_price") is not None:
        reasons.append(f"Belum trigger; tunggu break {round_idx_tick(analysis.get('trigger_price'))}")
    if analysis.get("rebound_volume_ratio") is not None:
        reasons.append(f"Volume rebound {analysis['rebound_volume_ratio']:.2f}x")
    reasons.extend(f"Penalty: {item}" for item in analysis.get("score_penalties") or [])
    return reasons


def analyze_support_pullback(history, current_price, atr14, current_bar=None, current_volume=None, market_open=False):
    """Build the daily Support Pullback state without future-bar information."""
    bars = _support_pullback_completed_bars(history, market_open)
    empty = {
        "status": "WATCH",
        "distance_bucket": "FAR",
        "eligible": False,
        "data_complete": len(bars) >= SUPPORT_STRENGTH_MIN_HISTORY_DAYS,
        "lookahead_safe": True,
        "reason": "Data daily belum cukup untuk menilai Support Pullback.",
        "score": 0,
        "support_quality_score": 0,
        "score_components": {},
        "score_penalties": [],
        "support_lower": None,
        "support_mid": None,
        "support_upper": None,
        "distance_pct": None,
        "pullback_volume_ratio": None,
        "rebound_volume_ratio": None,
        "rejection_strength": 0,
        "trigger_price": None,
        "entry_price": None,
        "stop_loss": None,
        "target_price": None,
        "risk_pct": None,
        "rr": None,
        "reasons": [],
    }
    if len(bars) < SUPPORT_STRENGTH_MIN_HISTORY_DAYS:
        return empty
    candidates = detect_support(bars, atr14)
    if not candidates:
        empty["reason"] = "Belum ditemukan support yang dapat membentuk zona valid."
        return empty
    current = swing_breakout_number(current_price)
    if current is None:
        return empty

    last_bar = bars[-1]
    evaluated = []
    for candidate in candidates:
        distance = calculate_distance_to_support(
            current,
            candidate["support_mid"],
            candidate["support_lower"],
            candidate["support_upper"],
        )
        pullback = detect_pullback(bars, candidate, current)
        test = detect_support_test(current_bar or last_bar, candidate)
        bar_volume = current_volume if current_volume is not None else last_bar.get("volume")
        baseline = [
            bar.get("volume") for bar in bars[-21:-1]
            if bar.get("volume") and bar["volume"] > 0
        ]
        average_volume = sum(baseline) / len(baseline) if baseline else None
        current_volume_ratio = bar_volume / average_volume if bar_volume and average_volume else None
        breakdown = detect_breakdown(
            current_bar or last_bar,
            candidate,
            atr14,
            current_volume_ratio,
            bar_closed=not market_open,
        )
        pullback_volume = analyze_pullback_volume(bars, pullback.get("start_index"))
        daily_rejection = detect_rejection(last_bar, candidate)
        daily_trigger = detect_trigger(
            current,
            daily_rejection.get("rejection_high") if daily_rejection.get("valid") else None,
            atr14,
        )
        close_above_support = bool(
            last_bar.get("close") is not None
            and last_bar["close"] >= candidate["support_mid"]
        )
        plan = calculate_risk_reward(
            daily_trigger.get("trigger_price") if daily_rejection.get("valid") else None,
            candidate["support_lower"],
            daily_rejection.get("rejection_low"),
            atr14,
            pullback.get("previous_swing_high"),
        )
        analysis = {
            **candidate,
            "status": distance["bucket"],
            "distance_bucket": distance["bucket"],
            "eligible": bool(pullback.get("from_above") and not breakdown.get("broken")),
            "data_complete": True,
            "lookahead_safe": True,
            "distance_pct": distance.get("distance_pct"),
            "support_test": bool(test.get("tested")),
            "pullback_from_above": bool(pullback.get("from_above")),
            "pullback_orderly": bool(pullback.get("orderly")),
            "pullback_dump": bool(pullback.get("dump")),
            "pullback_volume_contracting": bool(pullback_volume.get("volume_contracting")),
            "selling_distribution": bool(pullback_volume.get("selling_distribution")),
            "pullback_volume_ratio": pullback_volume.get("pullback_volume_ratio"),
            "rebound_volume_ratio": pullback_volume.get("rebound_volume_ratio"),
            "rejection_valid": bool(daily_rejection.get("valid")),
            "rejection_strength": daily_rejection.get("strength", 0),
            "rejection_high": daily_rejection.get("rejection_high"),
            "rejection_low": daily_rejection.get("rejection_low"),
            "close_above_support": close_above_support,
            "bullish_structure": bool(daily_rejection.get("flags", {}).get("bullish_candle")),
            "breakdown": bool(breakdown.get("broken") or candidate.get("broken_history")),
            "breakdown_reason": breakdown.get("reason"),
            "days_since_last_touch": max(
                0,
                len(bars) - 1 - candidate.get("last_touch_index", len(bars) - 1),
            ),
            "previous_swing_high": pullback.get("previous_swing_high"),
            "triggered": bool(daily_trigger.get("triggered")),
            "trigger_price": daily_trigger.get("trigger_price"),
            "entry_price": plan.get("entry"),
            "stop_loss": plan.get("stop_loss"),
            "target_price": plan.get("target"),
            "risk_pct": plan.get("risk_pct"),
            "rr": plan.get("rr"),
            "current_volume_ratio": current_volume_ratio,
        }
        if analysis["breakdown"]:
            analysis["status"] = "INVALID"
            analysis["eligible"] = False
        elif analysis["triggered"] and analysis["eligible"]:
            analysis["status"] = "TRIGGER"
        elif analysis["rejection_valid"] and analysis["eligible"]:
            analysis["status"] = "REJECT"
        score = calculate_support_pullback_score(analysis)
        analysis["score"] = score["score"]
        analysis["support_quality_score"] = score["support_quality"]
        analysis["score_components"] = score
        analysis["score_penalties"] = score["penalties"]
        analysis["reasons"] = _support_pullback_reasons(analysis)
        evaluated.append(analysis)

    evaluated.sort(key=lambda item: (
        0 if item.get("eligible") else 1,
        0 if item.get("status") != "INVALID" else 1,
        item.get("source_priority", 9),
        -float(item.get("score") or 0),
        abs(float(item.get("distance_pct") or 999)),
    ))
    selected = evaluated[0]
    selected["current_price"] = current
    return selected


def _support_swing_action(raw, candidate, trend, close, sma20,
                          support_invalidation, resistance_20d, market_open):
    """Classify a swing setup using closed 1H/15m bars, never as SIAP ENTRY."""
    hourly = {
        "timestamp": raw.get("time[1]|60"),
        "close": swing_breakout_number(raw.get("close[1]|60")),
        "ema20": swing_breakout_number(raw.get("EMA20[1]|60")),
        "ema50": swing_breakout_number(raw.get("EMA50[1]|60")),
        "adx": swing_breakout_number(raw.get("ADX[1]|60")),
        "macd": swing_breakout_number(raw.get("MACD.macd[1]|60")),
        "macd_signal": swing_breakout_number(raw.get("MACD.signal[1]|60")),
    }
    minute15 = {
        "timestamp": raw.get("time[1]|15"),
        "close": swing_breakout_number(raw.get("close[1]|15")),
        "ema20": swing_breakout_number(raw.get("EMA20[1]|15")),
        "ema50": swing_breakout_number(raw.get("EMA50[1]|15")),
        "adx": swing_breakout_number(raw.get("ADX[1]|15")),
        "macd": swing_breakout_number(raw.get("MACD.macd[1]|15")),
        "macd_signal": swing_breakout_number(raw.get("MACD.signal[1]|15")),
    }
    daily_ok = (
        trend in {"UPTREND", "RECOVERING"}
        and close > 0
        and sma20 > 0
        and close > sma20
    )
    hourly_complete = (
        all(hourly[key] > 0 for key in ("close", "ema20", "ema50"))
        and raw.get("ADX[1]|60") is not None
    )
    minute15_complete = (
        all(minute15[key] > 0 for key in ("close", "ema20"))
        and raw.get("ADX[1]|15") is not None
        and raw.get("MACD.macd[1]|15") is not None
        and raw.get("MACD.signal[1]|15") is not None
    )
    hourly_ok = (
        hourly_complete
        and hourly["close"] > hourly["ema20"]
        and hourly["ema20"] >= hourly["ema50"]
        and hourly["adx"] >= 20
    )
    minute15_ok = (
        minute15_complete
        and minute15["close"] > minute15["ema20"]
        and minute15["macd"] > minute15["macd_signal"]
        and minute15["adx"] >= 20
    )

    invalidation = swing_breakout_number(support_invalidation)
    resistance = swing_breakout_number(resistance_20d)
    risk = close - invalidation if close > invalidation > 0 else 0
    reward = resistance - close if resistance > close > 0 else 0
    risk_pct = risk / close * 100 if risk > 0 and close > 0 else None
    reward_pct = reward / close * 100 if reward > 0 and close > 0 else None
    risk_reward = reward / risk if risk > 0 and reward > 0 else None
    rr_ok = risk_reward is not None and risk_reward >= 1.5
    plan_available = bool(
        candidate
        and not market_open
        and close > 0
        and invalidation > 0
        and resistance > close
    )

    missing = []
    if not hourly_complete:
        missing.append("data 1H tertutup")
    if not minute15_complete:
        missing.append("data 15m tertutup")

    if not candidate:
        status = "BELUM BELI"
        reason = "Belum lolos screening support."
    elif missing:
        status = "MENUNGGU KONFIRMASI"
        reason = f"Menunggu {' dan '.join(missing)}."
    elif market_open:
        pending = []
        if not daily_ok:
            pending.append("bias 1D")
        if not hourly_ok:
            pending.append("setup 1H")
        if not minute15_ok:
            pending.append("konfirmasi 15m")
        if pending:
            status = "MENUNGGU KONFIRMASI"
            reason = f"Menunggu {' + '.join(pending)} selaras."
        elif not rr_ok:
            status = "RR BELUM LAYAK"
            rr_text = f"{risk_reward:.2f}R" if risk_reward is not None else "belum tersedia"
            reason = f"1D/1H/15m sudah selaras, tetapi RR snapshot {rr_text}; minimal 1,5R."
        else:
            status = "MENUNGGU CLOSE DAILY"
            reason = f"1D/1H/15m dan RR snapshot {risk_reward:.2f}R sudah selaras. Tunggu close daily sebelum mengunci rencana swing."
    elif not rr_ok:
        status = "BELUM BELI"
        reason = "Ruang ke resistance belum memberi RR minimal 1,5."
    elif daily_ok and hourly_ok and minute15_ok:
        status = "SETUP SWING TERKONFIRMASI"
        reason = "Bias 1D, setup 1H, konfirmasi 15m, dan RR sudah selaras. Bukan SIAP ENTRY intraday."
    else:
        pending = []
        if not daily_ok:
            pending.append("bias 1D")
        if not hourly_ok:
            pending.append("setup 1H")
        if not minute15_ok:
            pending.append("konfirmasi 15m")
        status = "MENUNGGU KONFIRMASI"
        reason = f"Menunggu {' + '.join(pending)} selaras."

    return {
        "status": status,
        "reason": reason,
        "daily_ok": daily_ok,
        "hourly_ok": hourly_ok,
        "minute15_ok": minute15_ok,
        "risk_pct": round(risk_pct, 2) if risk_pct is not None else None,
        "reward_pct": round(reward_pct, 2) if reward_pct is not None else None,
        "risk_reward": round(risk_reward, 2) if risk_reward is not None else None,
        "plan_available": plan_available,
        "buy_price": round_idx_tick(close) if plan_available else None,
        "stop_loss": round_idx_tick(invalidation) if plan_available else None,
        "take_profit": round_idx_tick(resistance) if plan_available else None,
        "hourly": hourly,
        "minute15": minute15,
    }


def _support_incomplete_row(ticker, raw, retrieved_at, market_open, reason):
    close = swing_breakout_number(raw.get("close"))
    return {
        "ticker": str(ticker).upper(),
        "name": raw.get("name") or str(ticker).upper(),
        "phase": "DATA INCOMPLETE",
        "screening_status": "TIDAK LAYAK DITUNGGU",
        "swing_action_status": "BELUM BELI",
        "swing_action_reason": "Data belum lengkap untuk menilai aksi swing.",
        "swing_confirmation": None,
        "swing_plan": None,
        "verdict": reason,
        "quality_score": 0,
        "support_pullback": {
            "status": "WATCH",
            "distance_bucket": "FAR",
            "eligible": False,
            "data_complete": False,
            "lookahead_safe": True,
            "reason": reason,
            "score": 0,
            "support_quality_score": 0,
            "score_components": {},
            "score_penalties": [],
            "reasons": [reason],
        },
        "close": close,
        "change_pct": swing_breakout_number(raw.get("change")),
        "support_level": None,
        "support_zone_low": None,
        "support_zone_high": None,
        "support_invalidation": None,
        "touches_support": 0,
        "rejection_count": 0,
        "rejection_rate_pct": None,
        "last_touch": "",
        "days_since_last_touch": None,
        "distance_to_support_pct": None,
        "bounce_from_last_touch_pct": None,
        "near_limit_pct": None,
        "support_broken": False,
        "trend": _support_trend(close, swing_breakout_number(raw.get("SMA20")), swing_breakout_number(raw.get("SMA50"))),
        "rsi": swing_breakout_number(raw.get("RSI")),
        "adx": swing_breakout_number(raw.get("ADX")),
        "sma20": swing_breakout_number(raw.get("SMA20")),
        "sma50": swing_breakout_number(raw.get("SMA50")),
        "atr": swing_breakout_number(raw.get("ATR")),
        "atr_pct": None,
        "volume_ratio_20d": None,
        "waiting_for": "Data daily yang valid belum cukup untuk menilai zona support.",
        "data_source": "TradingView Scanner + Yahoo Finance daily OHLC",
        "candle_close_status": "running" if market_open else "closed",
        "retrieved_at": retrieved_at,
        "timeframe": {"bias": "1D", "setup": "1H", "confirm": "15m", "trigger": None},
    }


def build_support_strength_row(ticker, values, columns, market_open, retrieved_at, history=None):
    """Rank repeated daily support reactions without turning screening into entry."""
    if len(values) != len(columns):
        return None
    raw = dict(zip(columns, values))
    close = swing_breakout_number(raw.get("close"))
    if not close or close <= 0:
        return None

    records = []
    for record in list(history or [])[-SUPPORT_STRENGTH_LOOKBACK_DAYS:]:
        if not isinstance(record, dict):
            continue
        bar = {
            "date": str(record.get("date") or ""),
            "open": swing_breakout_number(record.get("open")),
            "high": swing_breakout_number(record.get("high")),
            "low": swing_breakout_number(record.get("low")),
            "close": swing_breakout_number(record.get("close")),
            "volume": swing_breakout_number(record.get("volume")),
        }
        if bar["high"] and bar["low"] and bar["close"]:
            records.append(bar)
    if len(records) < SUPPORT_STRENGTH_MIN_HISTORY_DAYS:
        return _support_incomplete_row(ticker, raw, retrieved_at, market_open, "Data daily kurang dari 35 sesi.")

    # The current scanner candle is not used as a completed support test while
    # IDX is open. Yahoo history is already closed-bar data in that situation.
    bars = records[-SUPPORT_STRENGTH_LOOKBACK_DAYS:]
    atr = swing_breakout_number(raw.get("ATR"))
    atr_pct = atr / close * 100 if atr and close else None
    tolerance_pct = max(0.8, min(2.5, (atr_pct * 0.55) if atr_pct else 1.25))
    pivots = _support_pivot_lows(bars)
    clusters = _support_cluster_pivots(pivots, tolerance_pct)
    if not clusters:
        return _support_incomplete_row(ticker, raw, retrieved_at, market_open, "Belum ditemukan swing-low support yang cukup jelas.")

    sma20 = swing_breakout_number(raw.get("SMA20"))
    sma50 = swing_breakout_number(raw.get("SMA50"))
    sma200 = swing_breakout_number(raw.get("SMA200"))
    trend = _support_trend(close, sma20, sma50)
    near_limit_pct = max(2.5, min(5.0, (atr_pct * 1.5) if atr_pct else 3.0))
    break_buffer = max((atr * 0.35) if atr else 0, close * 0.005)

    baseline = [bar.get("volume") for bar in bars[-21:-1] if bar.get("volume") and bar.get("volume") > 0]
    current_volume = swing_breakout_number(raw.get("volume"))
    volume_ratio = current_volume / _sideways_mean(baseline) if current_volume and _sideways_mean(baseline) else None
    current_bar = {
        "date": retrieved_at[:10],
        "open": raw.get("open"),
        "high": raw.get("high"),
        "low": raw.get("low"),
        "close": raw.get("close"),
        "volume": raw.get("volume"),
    }
    support_pullback = analyze_support_pullback(
        records,
        close,
        atr,
        current_bar=current_bar,
        current_volume=current_volume,
        market_open=market_open,
    )
    recent_highs = [bar.get("high") for bar in bars[-20:] if bar.get("high")]
    candidate_details = []
    for cluster in clusters:
        cluster = sorted(cluster, key=lambda item: item["index"])
        if not cluster:
            continue
        pivot_lows = [item["low"] for item in cluster]
        level = _support_median(pivot_lows)
        zone_low = min(pivot_lows)
        zone_pad = max((atr * 0.25) if atr else 0, level * 0.003)
        zone_high = max(pivot_lows) + zone_pad
        first_index = cluster[0]["index"]
        last_index = cluster[-1]["index"]
        post_touch_bars = bars[first_index:]
        broken_history = any(
            bar.get("close") and bar["close"] < zone_low - break_buffer
            for bar in post_touch_bars
        )
        rejection_count = 0
        for item in cluster:
            bar = bars[item["index"]]
            bar_range = (bar.get("high") or 0) - (bar.get("low") or 0)
            opened = bar.get("open")
            close_value = bar.get("close")
            body_low = min(opened or close_value or 0, close_value or 0)
            lower_wick = body_low - (bar.get("low") or 0)
            close_position = (
                (close_value - (bar.get("low") or 0)) / bar_range
                if bar_range > 0 and close_value is not None else 0
            )
            if (
                close_value is not None
                and close_value >= zone_low
                and bar_range > 0
                and lower_wick / bar_range >= 0.35
                and close_position >= 0.6
                and opened is not None
                and close_value >= opened
            ):
                rejection_count += 1
        last_low = cluster[-1]["low"]
        if close < zone_low:
            distance_pct = (close - zone_low) / zone_low * 100
        elif close <= zone_high:
            distance_pct = 0.0
        else:
            distance_pct = (close - zone_high) / zone_high * 100
        current_below = close < zone_low - break_buffer
        broken = broken_history or current_below
        bars_since = max(0, len(bars) - 1 - last_index)
        bounce_pct = (close - last_low) / last_low * 100 if last_low else None
        rejection_rate = rejection_count / len(cluster) * 100 if cluster else 0
        if len(cluster) < 3:
            touch_score = min(15, len(cluster) * 5)
        elif len(cluster) == 3:
            touch_score = 18
        elif len(cluster) == 4:
            touch_score = 23
        elif len(cluster) == 5:
            touch_score = 25
        else:
            # Many tests can mean the level is being absorbed; reward up to
            # five clean tests, then gradually reduce the score.
            touch_score = max(12, 25 - (len(cluster) - 5) * 3)
        rejection_score = round(min(20, rejection_rate / 100 * 20))
        integrity_score = 0 if broken else 25
        location_score = (
            15 if distance_pct is not None and 0 <= distance_pct <= 1
            else 11 if distance_pct is not None and 0 <= distance_pct <= 2.5
            else 6 if distance_pct is not None and near_limit_pct is not None and 0 <= distance_pct <= near_limit_pct
            else 0
        )
        recency_score = 5 if bars_since <= 15 else 3 if bars_since <= 30 else 0
        trend_score = 10 if trend == "UPTREND" else 7 if trend == "RECOVERING" else 3 if trend == "FLAT" else 0
        excess_touch_penalty = min(20, max(0, len(cluster) - 5) * 5)
        score = max(0, min(100, touch_score + rejection_score + integrity_score + location_score + recency_score + trend_score - excess_touch_penalty))
        candidate_details.append({
            "cluster": cluster,
            "level": level,
            "zone_low": zone_low,
            "zone_high": zone_high,
            "broken": broken,
            "touches": len(cluster),
            "rejection_count": rejection_count,
            "rejection_rate": rejection_rate,
            "last_index": last_index,
            "bars_since": bars_since,
            "distance_pct": distance_pct,
            "bounce_pct": bounce_pct,
            "score": score,
            "excess_touch_penalty": excess_touch_penalty,
            "near_limit_pct": near_limit_pct,
        })

    if not candidate_details:
        return _support_incomplete_row(ticker, raw, retrieved_at, market_open, "Tidak ada cluster support yang dapat dinilai.")
    # Prefer an unbroken zone near the current price, then quality and recency.
    detail = sorted(
        candidate_details,
        key=lambda item: (
            item["broken"],
            0 if (
                item.get("distance_pct") is not None
                and item.get("near_limit_pct") is not None
                and 0 <= item["distance_pct"] <= item["near_limit_pct"]
            ) else 1,
            -item["score"],
            item["distance_pct"] if item["distance_pct"] >= 0 else 999,
        ),
    )[0]
    touches = detail["touches"]
    distance_pct = detail["distance_pct"]
    daily_context_ok = trend == "UPTREND" and (not sma50 or close >= sma50 * 0.98)
    near_support = bool(
        not detail.get("broken")
        and distance_pct is not None
        and detail.get("near_limit_pct") is not None
        and distance_pct >= 0
        and distance_pct <= SUPPORT_PULLBACK_CONFIG["near_distance_pct"]
    )
    reaction_ok = detail["rejection_count"] >= max(2, math.ceil(touches * 0.5)) or (
        detail["bounce_pct"] is not None and detail["bounce_pct"] >= max(1.5, (atr_pct or 1.5) * 0.6)
    )
    candidate = touches >= 3 and near_support and daily_context_ok and reaction_ok and detail["score"] >= 60
    if detail["broken"]:
        phase = "SUPPORT PECAH"
        verdict = "Ada close daily di bawah zona support; jangan menunggu entry di level lama."
    elif touches < 3:
        phase = "SUPPORT LEMAH"
        verdict = f"Baru {touches} retest terpisah; minimal 3 retest untuk kandidat support kuat."
    elif not near_support:
        phase = "SUPPORT TERLALU JAUH"
        verdict = f"Zona valid, tetapi harga {distance_pct:.1f}% di atas zona; tunggu pullback yang sehat."
    elif not daily_context_ok:
        phase = "BIAS DAILY LEMAH"
        verdict = f"Support terlihat, tetapi konteks daily {trend.lower()} belum cukup aman."
    elif not reaction_ok:
        phase = "REAKSI BELUM KUAT"
        verdict = "Retest ada, tetapi rejection/bounce belum cukup meyakinkan."
    elif candidate:
        phase = "SUPPORT KUAT"
        verdict = "Support diretest beberapa kali dan belum ditembus; lanjutkan validasi swing 1H dan 15m."
    else:
        phase = "NEAR SUPPORT"
        verdict = "Zona support menarik, tetapi skor atau konfirmasi daily belum cukup untuk ditunggu."

    support_invalidation = detail["zone_low"] - break_buffer
    resistance_20d = max(recent_highs) if recent_highs else None
    swing_action = _support_swing_action(
        raw,
        candidate,
        trend,
        close,
        sma20,
        support_invalidation,
        resistance_20d,
        market_open,
    )
    reason_parts = [f"{touches} retest", f"rejection {detail['rejection_count']}/{touches}"]
    if detail["bounce_pct"] is not None:
        reason_parts.append(f"bounce {detail['bounce_pct']:.1f}%")
    if volume_ratio is not None and not market_open:
        reason_parts.append(f"volume {volume_ratio:.1f}x")
    if detail.get("excess_touch_penalty"):
        reason_parts.append(f"penalty {detail['excess_touch_penalty']} poin karena support diuji lebih dari 5 kali")
    return {
        "ticker": str(ticker).upper(),
        "name": raw.get("name") or str(ticker).upper(),
        "phase": phase,
        "screening_status": "KANDIDAT FULL PA" if candidate else "TIDAK LAYAK DITUNGGU",
        "swing_action_status": swing_action["status"],
        "swing_action_reason": swing_action["reason"],
        "swing_confirmation": swing_action,
        "swing_plan": {
            "available": swing_action["plan_available"],
            "buy_price": swing_action["buy_price"],
            "stop_loss": swing_action["stop_loss"],
            "take_profit": swing_action["take_profit"],
            "risk_pct": swing_action["risk_pct"],
            "reward_pct": swing_action["reward_pct"],
            "risk_reward": swing_action["risk_reward"],
            "basis": "harga close daily terakhir, SL invalidasi support, TP resistance 20D",
        },
        "verdict": verdict,
        "quality_score": detail["score"],
        "support_pullback": support_pullback,
        "close": close,
        "change_pct": swing_breakout_number(raw.get("change")),
        "support_level": round_idx_tick(detail["level"]),
        "support_zone_low": round_idx_tick(detail["zone_low"]),
        "support_zone_high": round_idx_tick(detail["zone_high"]),
        "support_invalidation": round_idx_tick(support_invalidation),
        "resistance_20d": round_idx_tick(resistance_20d) if resistance_20d else None,
        "high_1m": swing_breakout_number(raw.get("High.1M")),
        "high_3m": swing_breakout_number(raw.get("High.3M")),
        "high_52w": swing_breakout_number(raw.get("price_52_week_high")),
        "touches_support": touches,
        "rejection_count": detail["rejection_count"],
        "rejection_rate_pct": round(detail["rejection_rate"], 1),
        "last_touch": detail["cluster"][-1].get("index") is not None and bars[detail["last_index"]].get("date") or "",
        "days_since_last_touch": detail["bars_since"],
        "distance_to_support_pct": round(distance_pct, 2),
        "bounce_from_last_touch_pct": round(detail["bounce_pct"], 2) if detail["bounce_pct"] is not None else None,
        "near_limit_pct": round(detail["near_limit_pct"], 2),
        "support_broken": detail["broken"],
        "trend": trend,
        "rsi": swing_breakout_number(raw.get("RSI")),
        "adx": swing_breakout_number(raw.get("ADX")),
        "sma20": sma20,
        "sma50": sma50,
        "sma200": sma200,
        "atr": atr,
        "atr_pct": round(atr_pct, 2) if atr_pct is not None else None,
        "volume_ratio_20d": round(volume_ratio, 2) if volume_ratio is not None else None,
        "reason_parts": reason_parts,
        "waiting_for": "Validasi swing 1D dan setup 1H/15m. Dashboard ini bukan trigger intraday.",
        "data_source": "TradingView Scanner + Yahoo Finance daily OHLC",
        "candle_close_status": "running" if market_open else "closed",
        "retrieved_at": retrieved_at,
        "data_through": bars[-1].get("date") if bars else "",
        "timeframe": {"bias": "1D", "setup": "1H", "confirm": "15m", "trigger": None},
    }


def _strategy_number(value):
    try:
        number = float(value)
    except (TypeError, ValueError):
        return None
    return number if math.isfinite(number) else None


def _strategy_alignment_flags(snapshot):
    """Expose the canonical 1D/1H/15m gates without flattening their state."""
    snapshot = snapshot or {}
    daily = snapshot.get("1D") or {}
    hourly = snapshot.get("1H") or {}
    minute15 = snapshot.get("15m") or {}
    daily_close = _strategy_number(daily.get("close")) or 0
    daily_ema20 = _strategy_number(daily.get("EMA20")) or 0
    daily_rsi = _strategy_number(daily.get("RSI")) or 0
    hourly_close = _strategy_number(hourly.get("close")) or 0
    hourly_ema20 = _strategy_number(hourly.get("EMA20")) or 0
    hourly_ema50 = _strategy_number(hourly.get("EMA50")) or 0
    hourly_adx = _strategy_number(hourly.get("ADX")) or 0
    minute15_close = _strategy_number(minute15.get("close")) or 0
    minute15_ema20 = _strategy_number(minute15.get("EMA20")) or 0
    minute15_adx = _strategy_number(minute15.get("ADX")) or 0
    minute15_macd = _strategy_number(minute15.get("MACD.macd", minute15.get("macd"))) or 0
    minute15_signal = _strategy_number(minute15.get("MACD.signal", minute15.get("macd_signal"))) or 0
    return {
        "bias1d": bool(daily_close > daily_ema20 and 40 <= daily_rsi <= 70),
        "setup1h": bool(
            hourly_close > hourly_ema20
            and hourly_ema20 >= hourly_ema50
            and hourly_adx >= 20
        ),
        "confirm15m": bool(
            minute15_close > minute15_ema20
            and minute15_macd > minute15_signal
            and minute15_adx >= 20
        ),
    }


def _strategy_closed_pa_context(ticker, pa_payload, minimum_rvol=1.0):
    """Return closed-5m evidence for live execution or next-session planning."""
    ticker = str(ticker or "").strip().upper()
    snapshot = ((pa_payload or {}).get("snapshots") or {}).get(ticker) or {}
    quality = ((pa_payload or {}).get("quality") or {}).get(ticker) or {}
    candle = snapshot.get("5m_closed") or {}
    alignment_flags = _strategy_alignment_flags(snapshot)
    alignment = classify_snapshot(snapshot) == STATUS_WAIT
    market_open = bool((pa_payload or {}).get("market_open"))
    trigger_allowed, planning_from_stale = pa_trigger_candle_allowed(
        candle, quality, market_open
    )
    fresh_closed = quality.get("candle_close_status") == "closed"
    timestamp = candle.get("candle_timestamp")
    volume_ratio = _strategy_number(candle.get("relative_volume_10d_calc"))
    volume_supported = bool(volume_ratio is not None and volume_ratio >= minimum_rvol)
    timestamp_number = _strategy_number(timestamp)
    if timestamp_number is not None and timestamp_number > 1_000_000_000_000:
        timestamp_number /= 1000
    stale_age_seconds = (
        max(0, time.time() - timestamp_number)
        if timestamp_number is not None else None
    )
    stale_too_old = bool(
        quality.get("candle_close_status") == "stale"
        and stale_age_seconds is not None
        and stale_age_seconds > SUPPORT_NEXT_SESSION_MAX_STALE_SECONDS
    )
    complete = all(
        _strategy_number(candle.get(field)) is not None
        for field in ("open", "high", "low", "close")
    )
    executable = bool(
        alignment
        and trigger_allowed
        and timestamp not in (None, "")
        and complete
        and volume_supported
        and not stale_too_old
    )
    return {
        "snapshot": snapshot,
        "candle": candle,
        "quality": quality,
        "alignment": alignment,
        "alignment_flags": alignment_flags,
        "fresh_closed": fresh_closed,
        "closed_evidence": trigger_allowed,
        "market_open": market_open,
        "planning_only": bool(not market_open),
        "planning_from_stale": planning_from_stale,
        "stale_age_seconds": stale_age_seconds,
        "stale_too_old": stale_too_old,
        "volume_ratio": volume_ratio,
        "volume_supported": volume_supported,
        "executable": executable,
    }


def _strategy_rr(entry, stop, target):
    entry = _strategy_number(entry)
    stop = _strategy_number(stop)
    target = _strategy_number(target)
    if entry is None or stop is None or target is None or not (target > entry > stop > 0):
        return None
    # The levels shown and stored by the strategy are IDX-tick rounded. Use
    # those final levels for RR too, so the gate and the ledger agree.
    entry = round_idx_tick(entry)
    stop = round_idx_tick(stop)
    target = round_idx_tick(target)
    if not (target > entry > stop > 0):
        return None
    risk = entry - stop
    reward = target - entry
    rr = reward / risk
    return {
        "entry": entry,
        "sl": stop,
        "target": target,
        "rr": rr,
        "risk_pct": risk / entry * 100,
        "reward_pct": reward / entry * 100,
        "risk_amount": risk,
        "reward_amount": reward,
    }


def _bullish_rejection(candle):
    opened = _strategy_number((candle or {}).get("open"))
    high = _strategy_number((candle or {}).get("high"))
    low = _strategy_number((candle or {}).get("low"))
    close = _strategy_number((candle or {}).get("close"))
    if None in {opened, high, low, close} or high <= low or close <= opened:
        return False
    body_low = min(opened, close)
    lower_wick = body_low - low
    close_position = (close - low) / (high - low)
    return lower_wick / (high - low) >= 0.25 and close_position >= 0.6


def _support_pullback_reclaim_gate(row):
    """Require a real, healthy support pullback before EMA reclaim entry."""
    analysis = row.get("support_pullback") or {}
    status = str(analysis.get("status") or "").strip().upper()
    distance = _strategy_number(analysis.get("distance_pct"))
    failures = []

    if str(row.get("trend") or "").strip().upper() != "UPTREND":
        failures.append("trend daily bukan UPTREND")
    if status not in {"TEST", "REJECT", "TRIGGER"}:
        failures.append(f"status support {status or 'belum valid'}")
    if distance is None or not 0 <= distance <= SUPPORT_PULLBACK_CONFIG["near_distance_pct"]:
        distance_text = f"{distance:.2f}%" if distance is not None else "belum tersedia"
        failures.append(f"jarak support {distance_text} (maksimal 2%)")
    if analysis.get("data_complete") is not True:
        failures.append("data support belum lengkap")
    if analysis.get("eligible") is not True:
        failures.append("pullback belum terbukti datang dari atas support")
    if analysis.get("pullback_orderly") is not True:
        failures.append("pullback tidak orderly")
    if analysis.get("pullback_volume_contracting") is not True:
        failures.append("volume pullback belum berkontraksi")
    if analysis.get("pullback_dump") is True:
        failures.append("terdeteksi dump")
    if analysis.get("selling_distribution") is True:
        failures.append("terdeteksi distribusi")
    if analysis.get("breakdown") is True:
        failures.append("support breakdown")

    return not failures, "; ".join(failures)


def _latest_sideways_daily_confirmation(ticker, observations):
    ticker = str(ticker or "").strip().upper()
    candidates = [
        item for item in (observations or [])
        if isinstance(item, dict)
        and str(item.get("ticker") or "").strip().upper() == ticker
        and item.get("event") == "DAILY_BREAKOUT_CONFIRMED"
    ]
    return max(candidates, key=lambda item: str(item.get("signal_date") or ""), default=None)


def _sideways_confirmation_age_days(row, confirmation):
    """Return the calendar age of a persisted daily breakout confirmation."""
    signal_date = str((confirmation or {}).get("signal_date") or "").strip()[:10]
    current_date = str(
        (row or {}).get("bar_date")
        or (row or {}).get("retrieved_at")
        or ""
    ).strip()[:10]
    if not signal_date or not current_date:
        return None
    try:
        return (
            datetime.strptime(current_date, "%Y-%m-%d")
            - datetime.strptime(signal_date, "%Y-%m-%d")
        ).days
    except (TypeError, ValueError):
        return None


def _sideways_confirmation_age_sessions(row, confirmation):
    """Count completed IDX sessions after a persisted daily breakout."""
    signal_date = str((confirmation or {}).get("signal_date") or "").strip()[:10]
    current_date = str(
        (row or {}).get("bar_date")
        or (row or {}).get("retrieved_at")
        or ""
    ).strip()[:10]
    if not signal_date or not current_date:
        return None
    try:
        signal = datetime.strptime(signal_date, "%Y-%m-%d")
        current = datetime.strptime(current_date, "%Y-%m-%d")
    except (TypeError, ValueError):
        return None
    if current < signal:
        return -1
    if current == signal:
        return 0
    sessions = 0
    cursor = signal + timedelta(days=1)
    while cursor <= current:
        session_probe = cursor.replace(hour=10, minute=0, second=0, microsecond=0)
        if is_idx_market_open(session_probe):
            sessions += 1
        cursor += timedelta(days=1)
    return sessions


def _sideways_confirmation_is_fresh(row, confirmation):
    """Require a next-session, still-relevant daily breakout observation."""
    age_sessions = _sideways_confirmation_age_sessions(row, confirmation)
    return (
        age_sessions is not None
        and 0 < age_sessions <= SIDEWAYS_BREAKOUT_EARLY_MAX_SESSIONS
    )


def enrich_sideways_breakout_pullbacks(rows, observations=None):
    """Mark recent daily breakouts that have pulled back into their retest zone.

    This is a screening label only. It uses the separately persisted daily
    breakout observations, never the trade outcome ledger, and it does not
    promote a row to SIAP ENTRY.
    """
    observations = observations or []
    for row in rows or []:
        row.update({
            "breakout_pullback_candidate": False,
            "breakout_pullback_status": "",
            "breakout_pullback_reason": "",
            "breakout_pullback_date": "",
            "breakout_pullback_age_days": None,
            "breakout_pullback_age_sessions": None,
            "breakout_pullback_level": None,
            "breakout_pullback_distance_pct": None,
            "breakout_pullback_retest_low": None,
            "breakout_pullback_retest_high": None,
        })
        confirmation = _latest_sideways_daily_confirmation(row.get("ticker"), observations)
        if not confirmation:
            continue
        signal_date = str(confirmation.get("signal_date") or "").strip()[:10]
        current_date = _sideways_row_date(row)
        level = swing_breakout_number(confirmation.get("breakout_level"))
        close = swing_breakout_number(row.get("close"))
        atr = swing_breakout_number(row.get("atr"))
        if not signal_date or not current_date or level is None or close is None:
            continue
        age_days = _sideways_confirmation_age_days(row, confirmation)
        age_sessions = _sideways_confirmation_age_sessions(row, confirmation)
        if age_days is None or age_sessions is None:
            continue
        if age_sessions <= 0:
            continue
        row["breakout_pullback_date"] = signal_date
        row["breakout_pullback_age_days"] = age_days
        row["breakout_pullback_age_sessions"] = age_sessions
        row["breakout_pullback_level"] = level
        if age_sessions > SIDEWAYS_BREAKOUT_EARLY_MAX_SESSIONS:
            row["breakout_pullback_status"] = "BREAKOUT TERLAMBAT"
            row["breakout_pullback_reason"] = (
                f"Breakout sudah lewat D+{SIDEWAYS_BREAKOUT_EARLY_MAX_SESSIONS}; "
                "tidak dimasukkan ke radar breakout awal."
            )
            continue

        retest_buffer = max((atr * 0.75) if atr else 0, level * 0.01)
        if retest_buffer <= 0:
            continue
        retest_low = round_idx_tick(level - retest_buffer)
        retest_high = round_idx_tick(level + retest_buffer * 0.35)
        distance_pct = (close - level) / level * 100
        row["breakout_pullback_distance_pct"] = distance_pct
        row["breakout_pullback_retest_low"] = retest_low
        row["breakout_pullback_retest_high"] = retest_high

        if close < retest_low:
            row["breakout_pullback_status"] = "BREAKOUT GAGAL"
            row["breakout_pullback_reason"] = "Daily close sudah di bawah batas retest breakout."
        elif close > retest_high:
            row["breakout_pullback_status"] = "BREAKOUT CONTINUATION"
            row["breakout_pullback_reason"] = "Harga masih di atas zona pullback; belum masuk area retest."
        elif str(row.get("candle_close_status") or "").lower() != "closed":
            row["breakout_pullback_status"] = "PULLBACK CANDLE BERJALAN"
            row["breakout_pullback_reason"] = "Harga masuk zona pullback, tetapi daily candle belum closed."
        elif str(row.get("trend") or "") not in {"UPTREND", "RECOVERING"}:
            row["breakout_pullback_status"] = "PULLBACK BERISIKO"
            row["breakout_pullback_reason"] = "Harga masuk zona pullback, tetapi trend daily tidak mendukung."
        else:
            row["breakout_pullback_candidate"] = True
            row["breakout_pullback_status"] = "PULLBACK SEHAT"
            row["breakout_pullback_reason"] = "Breakout daily sebelumnya kembali menguji zona retest; tunggu reclaim 1H/15m dan trigger close 5m."


def enrich_sideways_strategy_entries(rows, pa_payload, observations=None):
    """Attach strategy-specific direct/retest evidence to daily sideways rows."""
    observations = observations or []
    for row in rows or []:
        row.update({
            "ready_entry": False,
            "entry_status": "",
            "entry_method": "",
            "ready_entry_reason": "Menunggu daily breakout confirmed dan trigger 5m sesi berikutnya.",
            "daily_signal_age_days": None,
            "daily_signal_age_sessions": None,
            "daily_signal_fresh": False,
            "daily_confirmation_eligible": False,
            "daily_screening_locked": "",
            "daily_quality_score_locked": None,
            "early_breakout": row.get("phase") == "BREAKOUT CONFIRMED",
            "early_breakout_session": 0 if row.get("phase") == "BREAKOUT CONFIRMED" else None,
            "breakout_late": False,
            "pa_alignment_1d_1h_15m": False,
            "trigger_5m_valid": False,
            "trigger_5m_close_status": "unavailable",
            "volume_5m_supported": False,
            "invalidation": "",
        })
        confirmation = _latest_sideways_daily_confirmation(row.get("ticker"), observations)
        if not confirmation:
            continue
        if (
            row.get("phase") == "BREAKOUT CONFIRMED"
            and str(confirmation.get("signal_date") or "")[:10] != _sideways_row_date(row)
        ):
            # A current D0 breakout must not inherit an older breakout cycle.
            continue
        row["daily_signal_key"] = confirmation.get("signal_key")
        row["daily_signal_date"] = confirmation.get("signal_date")
        row["daily_breakout_level"] = confirmation.get("breakout_level")
        # The persisted observation is created only after a closed D0 breakout
        # passed the sideways screening.  Keep that eligibility for D+1..D+3:
        # a successful breakout naturally stops looking like a sideways base.
        row["daily_confirmation_eligible"] = True
        row["daily_screening_locked"] = str(
            confirmation.get("screening_status") or "KANDIDAT FULL PA"
        )
        row["daily_quality_score_locked"] = confirmation.get("quality_score")
        age_days = _sideways_confirmation_age_days(row, confirmation)
        age_sessions = _sideways_confirmation_age_sessions(row, confirmation)
        row["daily_signal_age_days"] = age_days
        row["daily_signal_age_sessions"] = age_sessions
        row["daily_signal_fresh"] = bool(
            age_sessions is not None
            and 0 < age_sessions <= SIDEWAYS_BREAKOUT_EARLY_MAX_SESSIONS
        )
        if row["daily_signal_fresh"]:
            row["early_breakout"] = True
            row["early_breakout_session"] = age_sessions
        level = _strategy_number(confirmation.get("breakout_level"))
        close = _strategy_number(row.get("close"))
        row["breakout_late"] = bool(
            age_sessions is not None
            and age_sessions > SIDEWAYS_BREAKOUT_EARLY_MAX_SESSIONS
            and level is not None
            and close is not None
            and close >= level * 0.99
        )
        if age_sessions is None:
            row["ready_entry_reason"] = "Tanggal daily breakout atau current bar belum tersedia; entry tidak boleh dipromosikan."
            continue
        if age_sessions <= 0:
            row["ready_entry_reason"] = "Daily breakout baru tercatat pada bar yang sama; tunggu sesi berikutnya."
            continue
        if age_sessions > SIDEWAYS_BREAKOUT_EARLY_MAX_SESSIONS:
            row["ready_entry_reason"] = (
                f"Daily breakout sudah D+{age_sessions}; menunggu breakout awal baru."
            )
            continue
        if row.get("daily_confirmation_eligible") is not True:
            row["ready_entry_reason"] = "Eligibility daily breakout belum terkunci; entry tidak diproses."
            continue
        context = _strategy_closed_pa_context(row.get("ticker"), pa_payload, minimum_rvol=1.2)
        row["pa_alignment_1d_1h_15m"] = context["alignment"]
        row["trigger_5m_close_status"] = "closed" if context["fresh_closed"] else context["quality"].get("candle_close_status", "unknown")
        row["volume_5m_supported"] = context["volume_supported"]
        if not context["executable"]:
            row["ready_entry_reason"] = "Daily breakout tersimpan; menunggu alignment dan closed 5m fresh saat market buka."
            continue
        if context["planning_only"] or not context["fresh_closed"]:
            row["entry_status"] = "RENCANA SESI BERIKUTNYA"
            row["ready_entry_reason"] = "Bukti 5m hanya untuk planning; tunggu closed 5m fresh saat market buka."
            continue
        candle = context["candle"]
        opened = _strategy_number(candle.get("open"))
        low = _strategy_number(candle.get("low"))
        close = _strategy_number(candle.get("close"))
        level = _strategy_number(confirmation.get("breakout_level"))
        if None in {opened, low, close, level} or not context["volume_supported"]:
            row["ready_entry_reason"] = "Menunggu closed 5m di atas breakout dengan RVOL minimal 1,2x."
            continue
        retest = bool(low <= level * 1.005 and close >= level and _bullish_rejection(candle))
        direct = bool(opened > level and low > level and close > opened)
        method = "SIDEWAYS_RETEST" if retest else "SIDEWAYS_DIRECT" if direct else ""
        if not method:
            row["ready_entry_reason"] = "Daily breakout valid; closed 5m belum membentuk direct continuation atau retest-reclaim."
            continue
        stop = _strategy_number(row.get("stop_loss"))
        target = _strategy_number(row.get("target"))
        plan = _strategy_rr(close, stop, target)
        if not plan or not rr_is_valid(plan.get("rr")):
            row["ready_entry_reason"] = "Trigger 5m ada, tetapi level atau RR belum mencapai 1,5R."
            continue
        row.update({
            "ready_entry": True,
            "entry_status": "SIAP ENTRY",
            "entry_method": method,
            "entry_plan": plan["entry"],
            "stop_loss": plan["sl"],
            "target": plan["target"],
            "rr": plan["rr"],
            "risk_pct": plan["risk_pct"],
            "reward_pct": plan["reward_pct"],
            "risk_amount": plan["risk_amount"],
            "reward_amount": plan["reward_amount"],
            "trigger_5m_valid": True,
            "trigger_5m_timestamp": candle.get("candle_timestamp"),
            "invalidation": f"Closed 5m kembali di bawah {round_idx_tick(level)}",
            "ready_entry_reason": (
                "Retest range high bertahan dengan bullish rejection dan RVOL mendukung."
                if method == "SIDEWAYS_RETEST" else
                "Continuation 5m bertahan di atas range high dengan RVOL mendukung."
            ),
        })


def build_support_strategy_entry(row, pa_payload):
    """Build SUPPORT_REJECTION or PULLBACK_RECLAIM from closed PA evidence."""
    context = _strategy_closed_pa_context(row.get("ticker"), pa_payload, minimum_rvol=1.0)
    alignment_flags = context.get("alignment_flags") or {}
    result = {
        "ready": False,
        "method": "",
        "reason": "Menunggu alignment 1D/1H/15m dan trigger closed 5m.",
        "alignment": context["alignment"],
        "closed_5m": context["fresh_closed"],
        "planning_only": context["planning_only"],
        "entry_status": "",
        "volume_supported": context["volume_supported"],
        "candle_timestamp": context["candle"].get("candle_timestamp"),
        "plan": None,
        "gate": {
            "bias1d": bool(alignment_flags.get("bias1d")),
            "setup1h": bool(alignment_flags.get("setup1h")),
            "confirm15m": bool(alignment_flags.get("confirm15m")),
            "trigger5m": False,
            "volume": context["volume_supported"],
            "closed5m": context["closed_evidence"],
            "support": False,
            "rr": False,
        },
    }
    if row.get("screening_status") != "KANDIDAT FULL PA":
        result["reason"] = "Area support/pullback belum lolos screening."
        return result
    if not context["executable"]:
        if context.get("stale_too_old"):
            result["reason"] = "Candle 5m tertutup sudah lebih dari 72 jam; tunggu closed 5m baru pada sesi berikutnya."
        elif not context.get("alignment"):
            missing = [
                label for key, label in (
                    ("bias1d", "1D"),
                    ("setup1h", "1H"),
                    ("confirm15m", "15m"),
                )
                if not alignment_flags.get(key)
            ]
            result["reason"] = f"Alignment belum lengkap: {', '.join(missing) or '1D/1H/15m'}."
        elif context.get("market_open") and not context.get("fresh_closed"):
            result["reason"] = "Alignment tersedia, tetapi closed 5m masih berasal dari sesi sebelumnya; tunggu candle 5m fresh."
        elif not context.get("closed_evidence"):
            result["reason"] = "Closed 5m valid belum tersedia; trigger entry belum dapat dinilai."
        elif context.get("alignment") and context.get("closed_evidence") and not context.get("volume_supported"):
            result["reason"] = "Closed 5m ada, tetapi RVOL belum mencapai 1,0x; volume belum mendukung trigger."
        return result
    candle = context["candle"]
    snapshot = context["snapshot"]
    opened = _strategy_number(candle.get("open"))
    low = _strategy_number(candle.get("low"))
    close = _strategy_number(candle.get("close"))
    ema20_5m = _strategy_number(candle.get("EMA20"))
    zone_low = _strategy_number(row.get("support_zone_low"))
    zone_high = _strategy_number(row.get("support_zone_high"))
    atr_5m = max(_strategy_number(candle.get("ATR")) or 0, (close or 0) * 0.005)
    support_rejection = bool(
        None not in {low, close, zone_low, zone_high}
        and low <= zone_high * 1.003
        and close >= zone_low
        and _bullish_rejection(candle)
    )
    daily_sma20 = _strategy_number(row.get("sma20"))
    daily_atr = _strategy_number(row.get("atr")) or 0
    daily_close = _strategy_number(row.get("close"))
    near_daily_pullback = bool(
        daily_close and daily_sma20
        and abs(daily_close - daily_sma20) <= max(daily_atr * 0.8, daily_sma20 * 0.02)
    )
    pullback_reclaim = bool(
        near_daily_pullback
        and None not in {opened, low, close, ema20_5m}
        and low <= ema20_5m
        and close > ema20_5m
        and close > opened
    )
    method = "SUPPORT_REJECTION" if support_rejection else "PULLBACK_RECLAIM" if pullback_reclaim else ""
    if not method:
        result["reason"] = "Closed 5m belum membentuk rejection zona support atau reclaim EMA20."
        return result
    if method == "PULLBACK_RECLAIM":
        support_gate_ok, support_gate_reason = _support_pullback_reclaim_gate(row)
        if not support_gate_ok:
            result["reason"] = (
                "PULLBACK_RECLAIM ditolak: "
                f"{support_gate_reason}."
            )
            return result
    result["gate"]["support"] = True
    sr5 = (((pa_payload or {}).get("sr") or {}).get(str(row.get("ticker") or "").upper()) or {}).get("5m") or {}
    supports = [
        _strategy_number(row.get("support_invalidation")),
        _strategy_number(sr5.get("support")),
        _strategy_number((snapshot.get("15m") or {}).get("low")),
        _strategy_number((snapshot.get("1H") or {}).get("low")),
    ]
    supports = [value for value in supports if value is not None and close and 0 < value < close]
    if not supports:
        result["reason"] = "Trigger ada, tetapi invalidasi support belum tersedia."
        return result
    nearest_support = max(supports)
    stop = nearest_support if method == "SUPPORT_REJECTION" and nearest_support == _strategy_number(row.get("support_invalidation")) else nearest_support - 0.5 * atr_5m
    target = _strategy_number(row.get("resistance_20d"))
    plan = _strategy_rr(close, stop, target)
    if not plan or not rr_is_valid(plan.get("rr")):
        result["reason"] = "Trigger 5m ada, tetapi resistance terdekat tidak memberi RR minimal 1,5R."
        return result
    planning_only = bool(context["planning_only"])
    result.update({
        "ready": not planning_only,
        "method": method,
        "entry_status": (
            "RENCANA SESI BERIKUTNYA"
            if planning_only else "SIAP ENTRY"
        ),
        "reason": (
            "Bullish rejection closed 5m pada zona support kuat dengan volume mendukung."
            if method == "SUPPORT_REJECTION" else
            "Closed 5m reclaim EMA20 setelah pullback daily dengan volume mendukung."
        ),
        "plan": plan,
        "invalidation": (
            f"Closed 5m di bawah zona {round_idx_tick(zone_low)}"
            if method == "SUPPORT_REJECTION" else
            f"Closed 5m kembali di bawah {round_idx_tick(nearest_support)}"
        ),
    })
    if planning_only:
        result["reason"] += " Ini hanya rencana; status SIAP ENTRY menunggu validasi ulang saat market buka."
    result["gate"].update({"trigger5m": True, "rr": True})
    return result


def enrich_support_strategy_entries(rows, pa_payload):
    for row in rows or []:
        row["strategy_entry"] = build_support_strategy_entry(row, pa_payload)
        row["ready_entry"] = bool(row["strategy_entry"].get("ready"))
        row["entry_method"] = row["strategy_entry"].get("method") or ""
        confirmation = row.get("swing_confirmation")
        if row.get("screening_status") != "KANDIDAT FULL PA" or not isinstance(confirmation, dict):
            continue

        # The canonical PA snapshot is also the source used by SIAP ENTRY.
        # Keep the pre-entry status on the same 1D/1H/15m gates so the radar
        # cannot tell the user that alignment is missing while the entry
        # engine considers it complete (or the reverse).
        gate = row["strategy_entry"].get("gate") or {}
        confirmation["daily_ok"] = bool(gate.get("bias1d"))
        confirmation["hourly_ok"] = bool(gate.get("setup1h"))
        confirmation["minute15_ok"] = bool(gate.get("confirm15m"))
        confirmation["alignment_source"] = "canonical_pa_snapshot"
        missing = [
            label for key, label in (
                ("bias1d", "bias 1D"),
                ("setup1h", "setup 1H"),
                ("confirm15m", "konfirmasi 15m"),
            ) if not gate.get(key)
        ]
        rr = _strategy_number(confirmation.get("risk_reward"))
        rr_ok = rr is not None and rr >= 1.5
        market_open = bool((pa_payload or {}).get("market_open"))
        if missing:
            status = "MENUNGGU KONFIRMASI"
            reason = f"Menunggu {' + '.join(missing)} selaras (gate PA canonical)."
        elif not rr_ok:
            status = "RR BELUM LAYAK"
            rr_text = f"{rr:.2f}R" if rr is not None else "belum tersedia"
            reason = f"1D/1H/15m sudah selaras, tetapi RR snapshot {rr_text}; minimal 1,5R."
        elif market_open:
            status = "MENUNGGU CLOSE DAILY"
            reason = f"1D/1H/15m dan RR snapshot {rr:.2f}R sudah selaras. Tunggu close daily sebelum mengunci rencana swing."
        else:
            status = "SETUP SWING TERKONFIRMASI"
            reason = "Bias 1D, setup 1H, konfirmasi 15m, dan RR sudah selaras. Bukan SIAP ENTRY intraday."
        confirmation["status"] = status
        confirmation["reason"] = reason
        row["swing_action_status"] = status
        row["swing_action_reason"] = reason


def enrich_support_pullback_rows(rows, pa_payload):
    """Add closed-5m rejection/trigger evidence to the daily pullback state."""
    snapshots = (pa_payload or {}).get("snapshots") or {}
    market_open = bool((pa_payload or {}).get("market_open"))
    for row in rows or []:
        analysis = dict(row.get("support_pullback") or {})
        if not analysis:
            continue
        ticker = str(row.get("ticker") or "").strip().upper()
        snapshot = snapshots.get(ticker) or {}
        strategy = row.get("strategy_entry") or {}
        gate = strategy.get("gate") or {}
        candle = snapshot.get("5m_closed") or {}
        closed5m = bool(gate.get("closed5m") and candle)
        live_candle = snapshot.get("5m") or {}
        live_price = _strategy_number(live_candle.get("close")) or _strategy_number(row.get("close"))

        if closed5m and not analysis.get("breakdown"):
            rejection = detect_rejection(
                candle,
                {
                    "support_lower": analysis.get("support_lower"),
                    "support_mid": analysis.get("support_mid"),
                    "support_upper": analysis.get("support_upper"),
                },
            )
            if rejection.get("valid"):
                trigger = detect_trigger(
                    live_price,
                    rejection.get("rejection_high"),
                    row.get("atr"),
                )
                target = analysis.get("previous_swing_high") or row.get("resistance_20d")
                plan = calculate_risk_reward(
                    trigger.get("trigger_price"),
                    analysis.get("support_lower"),
                    rejection.get("rejection_low"),
                    row.get("atr"),
                    target,
                )
                analysis.update({
                    "support_test": True,
                    "rejection_valid": True,
                    "rejection_strength": rejection.get("strength", 0),
                    "rejection_high": rejection.get("rejection_high"),
                    "rejection_low": rejection.get("rejection_low"),
                    "rejection_flags": rejection.get("flags") or {},
                    "close_above_support": bool(
                        _strategy_number(candle.get("close")) is not None
                        and analysis.get("support_mid") is not None
                        and _strategy_number(candle.get("close")) >= analysis["support_mid"]
                    ),
                    "bullish_structure": bool(
                        (rejection.get("flags") or {}).get("bullish_candle")
                        or (rejection.get("flags") or {}).get("bullish_engulfing")
                    ),
                    "triggered": bool(trigger.get("triggered")),
                    "trigger_price": trigger.get("trigger_price"),
                    "entry_price": plan.get("entry"),
                    "stop_loss": plan.get("stop_loss"),
                    "target_price": plan.get("target"),
                    "risk_pct": plan.get("risk_pct"),
                    "rr": plan.get("rr"),
                    "rebound_volume_ratio": _strategy_number(
                        candle.get("relative_volume_10d_calc")
                        or candle.get("relative_volume_20d_calc")
                    ) or analysis.get("rebound_volume_ratio"),
                })
                analysis["status"] = "TRIGGER" if trigger.get("triggered") else "REJECT"
                analysis["trigger_candle_timestamp"] = candle.get("candle_timestamp")
                analysis["planning_only"] = not market_open

        if analysis.get("breakdown"):
            analysis["status"] = "INVALID"
            analysis["eligible"] = False
        score = calculate_support_pullback_score(analysis)
        analysis["score"] = score["score"]
        analysis["support_quality_score"] = score["support_quality"]
        analysis["score_components"] = score
        analysis["score_penalties"] = score["penalties"]
        analysis["reasons"] = _support_pullback_reasons(analysis)
        row["support_pullback"] = analysis
        row["support_pullback_status"] = analysis.get("status", "WATCH")
        row["support_pullback_score"] = analysis.get("score", 0)
        row["support_pullback_reason"] = " · ".join(analysis.get("reasons") or []) or analysis.get("reason", "")


def load_sideways_breakout_snapshot(tickers, watchlist="ALL_WATCHLISTS"):
    """Find long daily bases and rank their conditional breakout proximity."""
    raw_watchlist_key = str(watchlist or "ALL_WATCHLISTS").strip().upper() or "ALL_WATCHLISTS"
    # Keep the legacy aliases on the three curated watchlists. The explicit
    # all-IDX option is separate so users can choose it without changing the
    # meaning of the existing combined universe.
    watchlist_key = (
        "ALL_WATCHLISTS"
        if raw_watchlist_key in {"ALL", "ALL_REGISTERED", "SEMUA"}
        else raw_watchlist_key
    )
    market_open_now = is_idx_market_open()
    universe_meta = (
        load_sideways_breakout_idx_universe()
        if watchlist_key == SIDEWAYS_BREAKOUT_ALL_IDX_WATCHLIST
        else None
    )
    requested_tickers = {str(ticker).strip().upper() for ticker in tickers if str(ticker).strip()}
    if universe_meta is not None:
        allowed = set(universe_meta.get("tickers") or [])
        normalized = tuple(sorted(requested_tickers & allowed))
    else:
        normalized = tuple(sorted(requested_tickers))
    universe_label = (
        "Semua IDX · FCA dikecualikan"
        if watchlist_key == SIDEWAYS_BREAKOUT_ALL_IDX_WATCHLIST
        else "Semua saham · Konglo + LQ45 + Kompas100"
        if watchlist_key == "ALL_WATCHLISTS"
        else watchlist_key
    )
    if not normalized:
        return {
            "status": "ok",
            "watchlist": watchlist_key,
            "rows": [],
            "as_of": now_jakarta().isoformat(timespec="seconds"),
            "market_open": market_open_now,
            "lookback_days": SIDEWAYS_BREAKOUT_LOOKBACK_DAYS,
            "minimum_sideways_days": SIDEWAYS_BREAKOUT_MIN_BASE_DAYS,
            "confirmation_max_age_sessions": SIDEWAYS_BREAKOUT_EARLY_MAX_SESSIONS,
            "minimum_screening_quality_score": SIDEWAYS_BREAKOUT_MIN_SCREENING_SCORE,
            "universe": {
                "label": universe_label,
                "listed_count": (universe_meta or {}).get("listed_count", 0),
                "eligible_count": len((universe_meta or {}).get("tickers") or []),
                "loaded_count": 0,
                "fca_excluded_count": (universe_meta or {}).get("fca_in_universe_count", 0),
                "fca_registry_count": (universe_meta or {}).get("fca_count", 0),
                "fca_as_of": (universe_meta or {}).get("fca_as_of", ""),
                "source": (universe_meta or {}).get("universe_source", SIDEWAYS_BREAKOUT_UNIVERSE_FILE),
                "retrieved_at": (universe_meta or {}).get("universe_retrieved_at", ""),
            },
        }
    cache_key = (watchlist_key, normalized)
    now_mono = time.monotonic()
    with SIDEWAYS_BREAKOUT_CACHE_LOCK:
        cached = SIDEWAYS_BREAKOUT_CACHE.get(cache_key)
        cached_payload = cached.get("payload") if isinstance(cached, dict) else None
        cached_market_open = bool((cached_payload or {}).get("market_open"))
        closed_snapshot = not market_open_now and cached_payload and not cached_market_open
        fresh_open_snapshot = market_open_now and cached and now_mono - cached["created_at"] < SIDEWAYS_BREAKOUT_CACHE_TTL
        if closed_snapshot or fresh_open_snapshot:
            cached_payload = cached["payload"]
            auto_track_sideways_breakout_snapshot(cached_payload)
            return cached_payload

    columns = [
        "name", "close", "open", "high", "low", "change", "volume",
        "SMA20", "SMA50", "SMA200", "ATR", "RSI", "ADX",
        "High.1M", "High.3M", "price_52_week_high", "Value.Traded",
    ]
    retrieved_at = now_jakarta().isoformat(timespec="seconds")
    market_open = market_open_now
    data = tradingview_scan(list(normalized), columns)
    historical = fetch_sideways_breakout_ohlcv(normalized)
    rows = {}
    for item in data.get("data", []):
        symbol = item.get("s", "").split(":")[-1].upper()
        if symbol not in normalized:
            continue
        row = build_sideways_breakout_row(symbol, item.get("d") or [], columns, market_open, retrieved_at, historical.get(symbol))
        if row:
            rows[symbol] = row

    priority = {
        "BREAKOUT CONFIRMED": 0,
        "BREAKOUT IN PROGRESS": 1,
        "NEAR BREAKOUT": 2,
        "BASE SIDEWAYS": 3,
        "BREAKOUT WEAK": 4,
        "NOT SIDEWAYS": 5,
        "DATA INCOMPLETE": 6,
    }
    ordered = [rows[ticker] for ticker in normalized if ticker in rows]
    ordered.sort(key=lambda row: (priority.get(row["phase"], 9), -row["quality_score"], -(row.get("sideways_days") or 0), row["ticker"]))
    history_payload = load_sideways_breakout_history()
    observations = history_payload.get("observations") or [] if history_payload.get("status") == "ok" else []
    pa_tickers = [
        row["ticker"] for row in ordered
        if market_open and _latest_sideways_daily_confirmation(row.get("ticker"), observations)
    ]
    pa_payload = load_pa_snapshot_payload(pa_tickers) if pa_tickers else {
        "status": "ok", "snapshots": {}, "quality": {}, "sr": {}, "market_open": market_open,
    }
    enrich_sideways_strategy_entries(ordered, pa_payload, observations)
    enrich_sideways_breakout_pullbacks(ordered, observations)
    payload = {
        "status": "ok",
        "watchlist": watchlist_key,
        "rows": ordered,
        "lookback_days": SIDEWAYS_BREAKOUT_LOOKBACK_DAYS,
        "minimum_sideways_days": SIDEWAYS_BREAKOUT_MIN_BASE_DAYS,
        "confirmation_max_age_sessions": SIDEWAYS_BREAKOUT_EARLY_MAX_SESSIONS,
        "minimum_screening_quality_score": SIDEWAYS_BREAKOUT_MIN_SCREENING_SCORE,
        "timeframes": {"base": "1D", "setup": "1H", "confirm": "15m", "trigger": "5m"},
        "rules": {
            "base": "trailing 30–60 daily bars with adaptive ATR-normalized range, flat slope, and at least two resistance/support touches",
            "near_breakout": "price within adaptive 1.5–3.0% of the base high",
            "breakout": "daily close above base high with volume >= 1.5x; breakout-awal window is D0 through D+3 IDX sessions",
            "execution": "Eligibility KANDIDAT FULL PA dikunci saat breakout D0; D+1 sampai D+3 memakai SIDEWAYS_DIRECT atau SIDEWAYS_RETEST setelah alignment 1D/1H/15m, closed 5m fresh saat market buka, volume, invalidasi, dan RR valid",
        },
        "as_of": retrieved_at,
        "market_open": market_open,
        "data_source": "TradingView Scanner + Yahoo Finance daily OHLC",
        "entry_methods": ["SIDEWAYS_DIRECT", "SIDEWAYS_RETEST"],
        "universe": {
            "label": universe_label,
            "listed_count": (universe_meta or {}).get("listed_count", len(normalized)),
            "eligible_count": len((universe_meta or {}).get("tickers") or normalized),
            "loaded_count": len(normalized),
            "fca_excluded_count": (universe_meta or {}).get("fca_in_universe_count", 0),
            "fca_registry_count": (universe_meta or {}).get("fca_count", 0),
            "fca_as_of": (universe_meta or {}).get("fca_as_of", ""),
            "fca_effective_from": (universe_meta or {}).get("fca_effective_from", ""),
            "source": (universe_meta or {}).get("universe_source", "watchlist"),
            "fca_source": (universe_meta or {}).get("fca_source", ""),
            "retrieved_at": (universe_meta or {}).get("universe_retrieved_at", ""),
            "discovery_fallback": bool((universe_meta or {}).get("universe_error")),
        },
    }
    with SIDEWAYS_BREAKOUT_CACHE_LOCK:
        SIDEWAYS_BREAKOUT_CACHE[cache_key] = {"created_at": time.monotonic(), "payload": payload}
    auto_track_sideways_breakout_snapshot(payload)
    return payload


def load_support_strength_snapshot(tickers, watchlist="ALL_WATCHLISTS", universe_meta=None):
    """Scan repeated daily support reactions for the dedicated radar page."""
    raw_watchlist_key = str(watchlist or "ALL_WATCHLISTS").strip().upper() or "ALL_WATCHLISTS"
    # Keep legacy ALL aliases scoped to the curated lists.  The explicit
    # ALL_IDX_EX_FCA key is required for the broad universe so old clients do
    # not silently change meaning.
    watchlist_key = "ALL_WATCHLISTS" if raw_watchlist_key in {"ALL", "ALL_REGISTERED", "SEMUA"} else raw_watchlist_key
    market_open_now = is_idx_market_open()
    universe_meta = universe_meta or (
        load_sideways_breakout_idx_universe()
        if watchlist_key == SIDEWAYS_BREAKOUT_ALL_IDX_WATCHLIST else None
    )
    if universe_meta is not None:
        allowed = set((universe_meta or {}).get("tickers") or [])
        normalized = tuple(sorted({
            str(ticker).strip().upper()
            for ticker in tickers
            if str(ticker).strip().upper() in allowed
        }))
    else:
        normalized = tuple(sorted({str(ticker).strip().upper() for ticker in tickers if str(ticker).strip()}))

    universe_labels = {
        "ALL_WATCHLISTS": "Semua saham · Konglo + LQ45 + Kompas100",
        SIDEWAYS_BREAKOUT_ALL_IDX_WATCHLIST: "Semua IDX · FCA dikecualikan",
        "KONGLO": "Konglo",
        "LQ45": "LQ45",
        "KOMPAS100": "Kompas100",
    }
    universe = {
        "label": universe_labels.get(watchlist_key, "Semua IDX · FCA dikecualikan" if universe_meta else watchlist_key),
        "listed_count": (universe_meta or {}).get("listed_count", len(normalized)),
        "eligible_count": len((universe_meta or {}).get("tickers") or normalized),
        "loaded_count": 0,
        "fca_excluded_count": (universe_meta or {}).get("fca_in_universe_count", 0),
        "fca_registry_count": (universe_meta or {}).get("fca_count", 0),
        "fca_as_of": (universe_meta or {}).get("fca_as_of", ""),
        "fca_effective_from": (universe_meta or {}).get("fca_effective_from", ""),
        "source": (universe_meta or {}).get("universe_source", "watchlist"),
        "fca_source": (universe_meta or {}).get("fca_source", ""),
        "retrieved_at": (universe_meta or {}).get("universe_retrieved_at", ""),
        "discovery_fallback": bool((universe_meta or {}).get("universe_error")),
    }
    if not normalized:
        return {
            "status": "ok",
            "watchlist": watchlist_key,
            "rows": [],
            "as_of": now_jakarta().isoformat(timespec="seconds"),
            "market_open": market_open_now,
            "universe": universe,
        }

    cache_key = (watchlist_key, normalized)
    now_mono = time.monotonic()
    with SUPPORT_STRENGTH_CACHE_LOCK:
        cached = SUPPORT_STRENGTH_CACHE.get(cache_key)
        cached_payload = cached.get("payload") if isinstance(cached, dict) else None
        cached_market_open = bool((cached_payload or {}).get("market_open"))
        closed_snapshot = not market_open_now and cached_payload and not cached_market_open
        fresh_open_snapshot = market_open_now and cached and now_mono - cached["created_at"] < SUPPORT_STRENGTH_CACHE_TTL
        if closed_snapshot or fresh_open_snapshot:
            return cached["payload"]

    columns = [
        "name", "close", "open", "high", "low", "change", "volume",
        "SMA20", "SMA50", "SMA200", "ATR", "RSI", "ADX", "Value.Traded",
        "High.1M", "High.3M", "price_52_week_high",
        "time[1]|60", "close[1]|60", "EMA20[1]|60", "EMA50[1]|60",
        "ADX[1]|60", "MACD.macd[1]|60", "MACD.signal[1]|60",
        "time[1]|15", "close[1]|15", "EMA20[1]|15", "EMA50[1]|15",
        "ADX[1]|15", "MACD.macd[1]|15", "MACD.signal[1]|15",
    ]
    retrieved_at = now_jakarta().isoformat(timespec="seconds")
    market_open = market_open_now
    data = tradingview_scan(list(normalized), columns)
    historical = fetch_sideways_breakout_ohlcv(normalized)
    rows = {}
    for item in data.get("data", []):
        symbol = str(item.get("s") or "").split(":")[-1].upper()
        if symbol not in normalized:
            continue
        row = build_support_strength_row(
            symbol,
            item.get("d") or [],
            columns,
            market_open,
            retrieved_at,
            historical.get(symbol),
        )
        if row:
            rows[symbol] = row

    priority = {
        "SUPPORT KUAT": 0,
        "NEAR SUPPORT": 1,
        "REAKSI BELUM KUAT": 2,
        "SUPPORT TERLALU JAUH": 3,
        "BIAS DAILY LEMAH": 4,
        "SUPPORT LEMAH": 5,
        "SUPPORT PECAH": 6,
        "DATA INCOMPLETE": 7,
    }
    ordered = [rows[ticker] for ticker in normalized if ticker in rows]
    action_priority = {
        "SETUP SWING TERKONFIRMASI": 0,
        "MENUNGGU KONFIRMASI": 1,
        "BELUM BELI": 2,
    }
    ordered.sort(key=lambda row: (
        action_priority.get(row.get("swing_action_status"), 9),
        priority.get(row.get("phase"), 9),
        -(row.get("quality_score") or 0),
        row.get("distance_to_support_pct") if row.get("distance_to_support_pct") is not None else 999,
        row.get("ticker", ""),
    ))
    pa_tickers = [
        row["ticker"] for row in ordered
        if row.get("screening_status") == "KANDIDAT FULL PA"
    ]
    pa_payload = load_pa_snapshot_payload(pa_tickers) if pa_tickers else {
        "status": "ok", "snapshots": {}, "quality": {}, "sr": {}, "market_open": market_open,
    }
    enrich_support_strategy_entries(ordered, pa_payload)
    enrich_support_pullback_rows(ordered, pa_payload)
    ordered.sort(key=lambda row: (
        SUPPORT_PULLBACK_STATUS_PRIORITY.get(
            (row.get("support_pullback") or {}).get("status"),
            9,
        ),
        -float((row.get("support_pullback") or {}).get("score") or 0),
        -float((row.get("support_pullback") or {}).get("rr") or 0),
        float((row.get("support_pullback") or {}).get("distance_pct") or 999),
        row.get("ticker", ""),
    ))
    universe["loaded_count"] = len(ordered)
    payload = {
        "status": "ok",
        "watchlist": watchlist_key,
        "rows": ordered,
        "lookback_days": SUPPORT_STRENGTH_LOOKBACK_DAYS,
        "minimum_history_days": SUPPORT_STRENGTH_MIN_HISTORY_DAYS,
        "timeframes": {"bias": "1D", "setup": "1H", "confirm": "15m", "trigger": "5m closed"},
        "rules": {
            "support": "minimal 3 swing-low retest terpisah dalam 90 daily bars",
            "zone": "cluster low dengan toleransi ATR; wick rejection boleh, close daily di bawah zona membatalkan",
            "screening": "hanya KANDIDAT FULL PA atau TIDAK LAYAK DITUNGGU",
            "execution": "SUPPORT_REJECTION atau PULLBACK_RECLAIM hanya setelah 1D/1H/15m selaras, closed 5m fresh, volume, invalidasi, target resistance, dan RR minimal 1,5; PULLBACK_RECLAIM wajib support TEST/REJECT/TRIGGER yang sehat, jarak maksimal 2%, pullback dari atas yang orderly, volume pullback berkontraksi, tanpa dump/distribusi/breakdown",
            "support_pullback": {
                "flow": ["WATCH", "NEAR", "TEST", "REJECT", "TRIGGER"],
                "invalid": "close < support_lower - ATR14*0.25 atau close di bawah support dengan volume > 1.3x average 20D",
                "distance": "FAR >5%; WATCH 2–5%; NEAR 0–2%; TEST saat harga masuk zona",
                "trigger": "harga menembus high candle rejection + ATR14*0.05",
                "volume": "pullback volume dibanding average 20D; rebound bonus mulai RVOL 1.2x",
                "score": "Support Quality 25 + Pullback Quality 20 + Reaction 30 + Confirmation 15 + RR 10, dikurangi penalty anti-false-signal",
                "lookahead": "hanya bar yang tersedia sampai saat scan; daily candle berjalan tidak dipakai sebagai evidence tertutup",
            },
        },
        "support_pullback_config": SUPPORT_PULLBACK_CONFIG,
        "as_of": retrieved_at,
        "market_open": market_open,
        "data_source": "TradingView Scanner + Yahoo Finance daily OHLC",
        "entry_methods": ["SUPPORT_REJECTION", "PULLBACK_RECLAIM"],
        "universe": universe,
    }
    with SUPPORT_STRENGTH_CACHE_LOCK:
        SUPPORT_STRENGTH_CACHE[cache_key] = {"created_at": time.monotonic(), "payload": payload}
    return payload


def load_sideways_breakout_history():
    """Read the isolated sideways-breakout paper ledger."""
    path = os.path.join(os.getcwd(), SIDEWAYS_BREAKOUT_HISTORY_FILE)
    if not os.path.exists(path):
        return {
            "status": "ok",
            "history": [],
            "observations": [],
            "source": SIDEWAYS_BREAKOUT_HISTORY_FILE,
            "message": "Belum ada trade sideways yang tercatat.",
        }
    try:
        with open(path, "r", encoding="utf-8") as handle:
            data = json.load(handle)
    except (OSError, json.JSONDecodeError) as exc:
        return {
            "status": "error",
            "history": [],
            "observations": [],
            "source": SIDEWAYS_BREAKOUT_HISTORY_FILE,
            "message": f"Ledger sideways tidak dapat dibaca: {exc}",
        }
    if not isinstance(data, dict):
        data = {}
    return {
        "status": "ok",
        "history": data.get("history") if isinstance(data.get("history"), list) else [],
        "observations": data.get("observations") if isinstance(data.get("observations"), list) else [],
        "source": SIDEWAYS_BREAKOUT_HISTORY_FILE,
        "message": data.get("message") or "Ledger sideways terpisah dari swing dan PA scalping.",
    }


def save_sideways_breakout_history(history, observations=None):
    """Persist the isolated sideways-breakout paper ledger atomically."""
    path = os.path.join(os.getcwd(), SIDEWAYS_BREAKOUT_HISTORY_FILE)
    atomic_write_json(path, {
        "schema_version": 1,
        "history": [item for item in (history or []) if isinstance(item, dict)],
        "observations": [item for item in (observations or []) if isinstance(item, dict)],
        "updated_at": now_jakarta().isoformat(timespec="seconds"),
        "message": "Ledger sideways terpisah dari swing dan PA scalping.",
    }, indent=2)


def sideways_ready_entry_gate(row):
    """Validate the full entry evidence before a sideways trade is recorded."""
    if not isinstance(row, dict):
        return False, "baris setup tidak valid"
    ready = row.get("ready_entry") is True or str(row.get("ready_entry")).lower() == "true"
    if not ready:
        return False, str(
            row.get("ready_entry_reason")
            or "scanner sideways hanya menyediakan screening 1D"
        )
    failures = []
    if row.get("daily_confirmation_eligible") is not True:
        failures.append("eligibility daily breakout belum terkunci")
    daily_signal_key = str(row.get("daily_signal_key") or "").strip()
    if not daily_signal_key:
        failures.append("daily breakout closed belum tersimpan")
    daily_confirmation = {"signal_date": row.get("daily_signal_date")}
    daily_age_sessions = _sideways_confirmation_age_sessions(row, daily_confirmation)
    if daily_age_sessions is None:
        failures.append("umur daily breakout tidak tersedia")
    elif daily_age_sessions <= 0:
        failures.append("daily breakout belum memasuki sesi berikutnya")
    elif daily_age_sessions > SIDEWAYS_BREAKOUT_EARLY_MAX_SESSIONS:
        failures.append(
            f"daily breakout sudah lewat D+{SIDEWAYS_BREAKOUT_EARLY_MAX_SESSIONS}"
        )
    if row.get("pa_alignment_1d_1h_15m") is not True:
        failures.append("1D/1H/15m belum selaras")
    if row.get("trigger_5m_valid") is not True:
        failures.append("trigger 5m belum valid")
    if str(row.get("trigger_5m_close_status") or "").lower() != "closed":
        failures.append("candle trigger 5m belum close")
    if row.get("volume_5m_supported") is not True:
        failures.append("volume 5m belum mendukung")
    if str(row.get("entry_method") or "").upper() not in {"SIDEWAYS_DIRECT", "SIDEWAYS_RETEST"}:
        failures.append("metode entry sideways belum valid")
    if not str(row.get("invalidation") or "").strip():
        failures.append("invalidation belum jelas")
    entry = swing_breakout_number(row.get("entry_plan", row.get("entry")))
    stop = swing_breakout_number(row.get("stop_loss", row.get("sl")))
    target = swing_breakout_number(row.get("target"))
    rr = swing_breakout_number(row.get("rr"))
    if entry is None or stop is None or target is None or rr is None:
        failures.append("entry, SL, target, atau RR belum tersedia")
    elif entry <= stop or target <= entry:
        failures.append("hubungan entry, SL, dan target tidak valid")
    elif rr < 1.5 - 1e-9:
        failures.append("RR di bawah 1.5R")
    return not failures, "; ".join(failures)


def record_sideways_breakout_trade(params):
    """Record one fully validated sideways setup as an open paper trade."""
    candidate = dict(params or {})
    candidate.setdefault("phase", candidate.get("status"))
    candidate.setdefault("entry_plan", candidate.get("entry"))
    candidate.setdefault("stop_loss", candidate.get("sl"))
    candidate.setdefault("target", candidate.get("target_resistance"))
    valid, reason = sideways_ready_entry_gate(candidate)
    if not valid:
        raise ValueError(f"hanya setup SIAP ENTRY sideways yang dapat dicatat: {reason}")

    ticker = str(candidate.get("ticker") or "").strip().upper()
    entry_status = str(candidate.get("entry_status") or "").strip().upper()
    if not ticker:
        raise ValueError("ticker sideways kosong")
    if entry_status not in {"", "SIAP ENTRY", "RETEST CONFIRMED"}:
        raise ValueError("status entry sideways tidak valid")
    entry = swing_breakout_number(candidate.get("entry_plan"))
    stop = swing_breakout_number(candidate.get("stop_loss"))
    target = swing_breakout_number(candidate.get("target"))
    rr = swing_breakout_number(candidate.get("rr"))
    signal_date = str(candidate.get("snapshot_at") or candidate.get("retrieved_at") or "").strip()
    entry_method = str(candidate.get("entry_method") or "").strip().upper()
    signal_key = str(candidate.get("signal_key") or "").strip()
    if not signal_key:
        signal_key = f"{ticker}|{signal_date[:10]}|{entry:g}|{stop:g}|{target:g}"
    now_text = now_jakarta().strftime("%Y-%m-%d %H:%M:%S")
    trade = {
        "id": f"SDB_{ticker}_{int(time.time() * 1000)}",
        "date": now_text,
        "entry_date": now_text,
        "ticker": ticker,
        "setup": entry_method,
        "entry_method": entry_method,
        "entry": entry,
        "sl": stop,
        "target": target,
        "rr": rr,
        "status": "open",
        "type": "buy",
        "action": "SIAP ENTRY",
        "reason": str(candidate.get("reason") or candidate.get("ready_entry_reason") or "Full PA sideways valid"),
        "trigger": (
            "Daily breakout confirmed + closed 5m continuation above range with volume"
            if entry_method == "SIDEWAYS_DIRECT" else
            "Daily breakout confirmed + closed 5m retest-reclaim range high with volume"
        ),
        "signal_key": signal_key,
        "signal_status": str(candidate.get("phase") or "BREAKOUT CONFIRMED"),
        "entry_status": entry_status or "SIAP ENTRY",
        "signal_date": signal_date,
        "snapshot_at": signal_date,
        "daily_signal_key": str(candidate.get("daily_signal_key") or ""),
        "daily_signal_date": str(candidate.get("daily_signal_date") or ""),
        "entry_candle_timestamp": candidate.get("trigger_5m_timestamp"),
        "candle_close_status": str(candidate.get("candle_close_status") or "closed"),
        "reference_close": swing_breakout_number(candidate.get("close")),
        "risk_pct": swing_breakout_number(candidate.get("risk_pct")),
        "reward_pct": swing_breakout_number(candidate.get("reward_pct")),
        "watchlist": str(candidate.get("watchlist") or "ALL").upper(),
        "pa_alignment_1d_1h_15m": True,
        "trigger_5m_valid": True,
        "trigger_5m_close_status": "closed",
        "volume_5m_supported": True,
        "invalidation": str(candidate.get("invalidation") or ""),
        "retest_confirmed": entry_method == "SIDEWAYS_RETEST",
        "planning_only": False,
        "paper_trade": True,
        "execution_mode": "auto-paper",
        "source": "sideways_breakout_dashboard",
        "cost_pct": 0.0,
    }
    with SIDEWAYS_BREAKOUT_HISTORY_LOCK:
        payload = load_sideways_breakout_history()
        if payload.get("status") != "ok":
            raise ValueError(payload.get("message") or "ledger sideways tidak tersedia")
        history = [item for item in (payload.get("history") or []) if isinstance(item, dict)]
        existing = next((item for item in history if str(item.get("signal_key") or "") == signal_key), None)
        if existing:
            return {"inserted": False, "trade": existing, "history": history}
        save_sideways_breakout_history(history + [trade], payload.get("observations") or [])
        return {"inserted": True, "trade": trade, "history": history + [trade]}


def _sideways_row_date(row):
    raw = str((row or {}).get("bar_date") or (row or {}).get("retrieved_at") or "").strip()
    return raw[:10]


def _sideways_trade_signal_key(row):
    row = row or {}
    return "|".join([
        str(row.get("ticker") or "").strip().upper(),
        str(row.get("daily_signal_key") or _sideways_row_date(row) or "unknown"),
        str(row.get("entry_method") or ""),
        str(row.get("trigger_5m_timestamp") or ""),
        str(row.get("entry_plan") if row.get("entry_plan") is not None else ""),
        str(row.get("stop_loss") if row.get("stop_loss") is not None else ""),
        str(row.get("target") if row.get("target") is not None else ""),
    ])


def _sideways_trade_active(history, ticker):
    ticker = str(ticker or "").strip().upper()
    return any(
        str(item.get("ticker") or "").strip().upper() == ticker
        and str(item.get("status") or "").lower() in {"open", "pending"}
        for item in history
        if isinstance(item, dict)
    )


def _sideways_close_trade(trade, outcome, exit_price, exit_time, reason):
    entry = swing_breakout_number(trade.get("entry"))
    exit_value = swing_breakout_number(exit_price)
    if entry is None or entry <= 0 or exit_value is None or exit_value <= 0:
        return False
    cost_pct = swing_breakout_number(trade.get("cost_pct")) or 0.0
    gross = (exit_value - entry) / entry * 100
    trade.update({
        "status": outcome,
        "exit_time": exit_time,
        "exit_date": exit_time,
        "exit_price": exit_value,
        "gross_return_pct": gross,
        "net_return_pct": gross - cost_pct,
        "profit_pct": gross - cost_pct,
        "outcome": outcome,
        "outcome_reason": reason,
    })
    return True


def auto_track_sideways_breakout_snapshot(payload):
    """Auto-record valid sideways SIAP ENTRY rows and resolve TP/SL."""
    if not isinstance(payload, dict) or payload.get("status") != "ok":
        return {"inserted": 0, "closed": 0, "history_count": 0}
    rows = [row for row in (payload.get("rows") or []) if isinstance(row, dict)]
    now_text = now_jakarta().strftime("%Y-%m-%d %H:%M:%S")
    inserted = 0
    closed = 0
    with SIDEWAYS_BREAKOUT_HISTORY_LOCK:
        history_payload = load_sideways_breakout_history()
        if history_payload.get("status") != "ok":
            return {"inserted": 0, "closed": 0, "history_count": 0, "error": history_payload.get("message")}
        history = [item for item in (history_payload.get("history") or []) if isinstance(item, dict)]
        observations = [item for item in (history_payload.get("observations") or []) if isinstance(item, dict)]
        rows_by_ticker = {
            str(row.get("ticker") or "").strip().upper(): row for row in rows
        }
        changed = False

        if not payload.get("market_open"):
            existing_observation_keys = {str(item.get("signal_key") or "") for item in observations}
            for row in rows:
                if row.get("phase") != "BREAKOUT CONFIRMED" or row.get("screening_status") != "KANDIDAT FULL PA":
                    continue
                level = swing_breakout_number(row.get("breakout_level"))
                signal_date = _sideways_row_date(row)
                if level is None or not signal_date:
                    continue
                signal_key = f"{str(row.get('ticker') or '').upper()}|{signal_date}|{level:g}"
                if signal_key in existing_observation_keys:
                    continue
                observations.append({
                    "event": "DAILY_BREAKOUT_CONFIRMED",
                    "ticker": str(row.get("ticker") or "").upper(),
                    "signal_key": signal_key,
                    "signal_date": signal_date,
                    "breakout_level": level,
                    "volume_ratio_20d": row.get("volume_ratio_20d"),
                    "quality_score": row.get("quality_score"),
                    "screening_status": row.get("screening_status"),
                    "recorded_at": now_text,
                })
                existing_observation_keys.add(signal_key)
                changed = True

        for trade in history:
            if str(trade.get("status") or "").lower() not in {"open", "pending"}:
                continue
            ticker = str(trade.get("ticker") or "").strip().upper()
            row = rows_by_ticker.get(ticker)
            if not row:
                continue
            observation_date = _sideways_row_date(row)
            signal_date = str(trade.get("signal_date") or trade.get("snapshot_at") or "")[:10]
            if not observation_date or not signal_date or observation_date <= signal_date:
                continue
            high = swing_breakout_number(row.get("bar_high")) or swing_breakout_number(row.get("bar_close"))
            low = swing_breakout_number(row.get("bar_low")) or swing_breakout_number(row.get("bar_close"))
            target = swing_breakout_number(trade.get("target"))
            stop = swing_breakout_number(trade.get("sl"))
            if high is None or low is None or target is None or stop is None:
                continue
            if low <= stop:
                reason = "SL tersentuh pada daily bar berikutnya"
                if high >= target:
                    reason += "; TP juga tersentuh, SL didahulukan"
                if _sideways_close_trade(trade, "loss", stop, now_text, reason):
                    closed += 1
                    changed = True
            elif high >= target and _sideways_close_trade(
                trade, "win", target, now_text, "TP tersentuh pada daily bar berikutnya"
            ):
                closed += 1
                changed = True

        for row in rows:
            valid, _ = sideways_ready_entry_gate(row)
            if not valid:
                continue
            ticker = str(row.get("ticker") or "").strip().upper()
            if not ticker or _sideways_trade_active(history, ticker):
                continue
            daily_signal_key = str(row.get("daily_signal_key") or "")
            if daily_signal_key and any(str(item.get("daily_signal_key") or "") == daily_signal_key for item in history):
                continue
            signal_key = _sideways_trade_signal_key(row)
            if any(str(item.get("signal_key") or "") == signal_key for item in history):
                continue
            try:
                result = record_sideways_breakout_trade({
                    **row,
                    "watchlist": payload.get("watchlist") or "ALL",
                    "signal_key": signal_key,
                    "snapshot_at": row.get("retrieved_at"),
                    "reason": row.get("ready_entry_reason") or row.get("verdict"),
                })
            except ValueError:
                continue
            if result.get("inserted"):
                history.append(result.get("trade"))
                inserted += 1
                changed = True

        if changed:
            save_sideways_breakout_history(history, observations)
    return {"inserted": inserted, "closed": closed, "history_count": len(history)}


def sideways_breakout_auto_loop():
    while True:
        try:
            raw_watchlist = SIDEWAYS_BREAKOUT_AUTO_WATCHLIST
            watchlist = "ALL_WATCHLISTS" if raw_watchlist in {"ALL", "ALL_REGISTERED", "SEMUA"} else raw_watchlist
            tickers = swing_breakout_watchlist_tickers(watchlist)
            if tickers:
                payload = load_sideways_breakout_snapshot(tickers, watchlist)
                result = auto_track_sideways_breakout_snapshot(payload)
                if result.get("inserted") or result.get("closed"):
                    print(
                        f"[SIDEWAYS_AUTO] inserted={result.get('inserted', 0)} "
                        f"closed={result.get('closed', 0)}"
                    )
        except Exception as exc:
            print(f"[SIDEWAYS_AUTO] {type(exc).__name__}: {exc}")
        time.sleep(SIDEWAYS_BREAKOUT_AUTO_INTERVAL_SEC)


def _swing_trade_plan_key(trade):
    """Identify one swing paper position by ticker and planned entry price.

    A daily snapshot can produce a new signal_key on every scan even when the
    planned trade has not changed.  For the swing ledger that is the same
    paper position, so the entry plan is the stable identity.
    """
    trade = trade or {}
    ticker = str(trade.get("ticker") or "").strip().upper()
    entry = swing_breakout_number(
        trade.get("entry") if trade.get("entry") is not None else trade.get("entry_price")
    )
    return f"{ticker}|{entry:g}" if ticker and entry is not None else ""


def _swing_trade_preference_score(trade):
    """Prefer the most informative copy when migrating duplicate history."""
    trade = trade or {}
    status = str(trade.get("status") or "").strip().lower()
    closed = status not in {"", "open", "pending", "active"}
    outcome = any(
        trade.get(key) not in (None, "")
        for key in ("exit_price", "exit_date", "exit_time", "net_return_pct", "outcome")
    )
    return (int(closed), int(outcome), int(bool(trade.get("setup_key"))))


def _deduplicate_swing_trade_history(history):
    """Collapse repeated snapshots of the same ticker and entry plan."""
    unique = []
    positions = {}
    removed = 0
    for trade in history or []:
        if not isinstance(trade, dict):
            continue
        key = _swing_trade_plan_key(trade)
        if not key:
            unique.append(trade)
            continue
        existing_index = positions.get(key)
        if existing_index is None:
            positions[key] = len(unique)
            unique.append(trade)
            continue
        removed += 1
        existing = unique[existing_index]
        if _swing_trade_preference_score(trade) > _swing_trade_preference_score(existing):
            unique[existing_index] = trade
    return unique, removed


def load_swing_breakout_history():
    """Read and migrate the separate swing ledger without PA history."""
    path = os.path.join(os.getcwd(), SWING_BREAKOUT_HISTORY_FILE)
    if not os.path.exists(path):
        return {
            "status": "ok",
            "history": [],
            "observations": [],
            "source": SWING_BREAKOUT_HISTORY_FILE,
            "message": "Belum ada trade swing yang tercatat.",
        }
    try:
        with open(path, "r", encoding="utf-8") as handle:
            data = json.load(handle)
    except (OSError, json.JSONDecodeError) as exc:
        return {
            "status": "error",
            "history": [],
            "observations": [],
            "source": SWING_BREAKOUT_HISTORY_FILE,
            "message": f"Ledger swing tidak dapat dibaca: {exc}",
        }
    if not isinstance(data, dict):
        data = {}
    raw_history = data.get("history") if isinstance(data.get("history"), list) else []
    observations = data.get("observations") if isinstance(data.get("observations"), list) else []
    history, deduplicated_count = _deduplicate_swing_trade_history(raw_history)
    if deduplicated_count:
        save_swing_breakout_history(history, observations)
    return {
        "status": "ok",
        "history": history,
        "observations": observations,
        "source": SWING_BREAKOUT_HISTORY_FILE,
        "message": data.get("message") or "Ledger swing terpisah dari PA scalping.",
        "deduplicated_count": deduplicated_count,
    }


def save_swing_breakout_history(history, observations=None):
    """Persist the separate swing ledger atomically."""
    path = os.path.join(os.getcwd(), SWING_BREAKOUT_HISTORY_FILE)
    history, _ = _deduplicate_swing_trade_history(history)
    atomic_write_json(path, {
        "schema_version": 2,
        "history": history,
        "observations": [item for item in (observations or []) if isinstance(item, dict)],
        "updated_at": now_jakarta().isoformat(timespec="seconds"),
        "message": "Ledger swing terpisah dari PA scalping.",
    }, indent=2)


def record_swing_breakout_trade(params):
    """Automatically record one valid ready setup as an open paper trade.

    This is simulated execution at the deterministic entry plan.  It does
    not submit an order to a broker and never fabricates an outcome.
    """
    ticker = str(params.get("ticker") or "").strip().upper()
    status = str(params.get("status") or "").strip().upper()
    entry_status = str(params.get("entry_status") or "").strip().upper()
    ready_entry = params.get("ready_entry") is True or str(params.get("ready_entry")).lower() == "true"
    retest_confirmed = params.get("retest_confirmed") is True or str(params.get("retest_confirmed")).lower() == "true"
    candle_close_status = str(params.get("candle_close_status") or "").strip().lower()
    entry = swing_breakout_number(params.get("entry"))
    stop = swing_breakout_number(params.get("sl"))
    target = swing_breakout_number(params.get("target"))
    rr = swing_breakout_number(params.get("rr"))
    if not ticker or not ready_entry or not retest_confirmed:
        raise ValueError("hanya setup SIAP ENTRY dengan retest valid yang dapat dicatat")
    if status not in {"CONFIRMED BREAKOUT", "NEAR BREAKOUT", "NO SIGNAL"}:
        raise ValueError("status breakout tidak valid untuk pencatatan")
    if entry_status not in {"", "RETEST CONFIRMED"}:
        raise ValueError("status entry tidak valid")
    if candle_close_status != "closed":
        raise ValueError("candle daily belum close")
    if entry is None or stop is None or target is None or rr is None:
        raise ValueError("entry, SL, target, dan RR wajib tersedia")
    if entry <= stop or target <= entry or rr < 1.5 - 1e-9:
        raise ValueError("rencana entry tidak memenuhi hubungan harga atau RR minimal 1.5R")

    signal_date = str(params.get("snapshot_at") or params.get("retrieved_at") or "").strip()
    signal_key = str(params.get("signal_key") or "").strip()
    if not signal_key:
        signal_key = f"{ticker}|{signal_date[:10]}|{entry:g}|{stop:g}|{target:g}"
    setup_key = str(params.get("setup_key") or "").strip()
    if not setup_key:
        setup_key = f"{ticker}|BREAKOUT_RETEST|timestamp-missing|{entry:g}"
    legacy_plan_key = _swing_legacy_plan_key(ticker, entry)
    now_text = now_jakarta().strftime("%Y-%m-%d %H:%M:%S")
    trade = {
        "id": f"SB_{ticker}_{int(time.time() * 1000)}",
        "date": now_text,
        "entry_date": now_text,
        "ticker": ticker,
        "setup": "BREAKOUT/RETEST",
        "entry": entry,
        "sl": stop,
        "target": target,
        "rr": rr,
        "status": "open",
        "type": "buy",
        "action": "SIAP ENTRY",
        "reason": str(params.get("reason") or "Retest resistance bertahan; volume, trend, target, dan RR valid"),
        "trigger": "Daily breakout confirmed + retest resistance; auto-paper entry pada entry plan",
        "signal_key": signal_key,
        "setup_key": setup_key,
        "signal_status": status,
        "entry_status": entry_status or "RETEST CONFIRMED",
        "signal_date": signal_date,
        "snapshot_at": signal_date,
        "candle_close_status": candle_close_status,
        "reference_close": swing_breakout_number(params.get("reference_close")),
        "risk_pct": swing_breakout_number(params.get("risk_pct")),
        "reward_pct": swing_breakout_number(params.get("reward_pct")),
        "volume_ratio_10d": swing_breakout_number(params.get("volume_ratio_10d")),
        "trend": str(params.get("trend") or "UNKNOWN"),
        "retest_confirmed": True,
        "planning_only": False,
        "paper_trade": True,
        "execution_mode": "auto-paper",
        "source": "swing_breakout_dashboard",
        "cost_pct": 0.0,
    }
    with SWING_BREAKOUT_HISTORY_LOCK:
        payload = load_swing_breakout_history()
        if payload.get("status") != "ok":
            raise ValueError(payload.get("message") or "ledger swing tidak tersedia")
        history = [item for item in (payload.get("history") or []) if isinstance(item, dict)]
        existing = next((
            item for item in history
            if str(item.get("signal_key") or "") == signal_key
            or _swing_trade_matches_setup(item, setup_key, legacy_plan_key)
            or _swing_trade_plan_key(item) == _swing_trade_plan_key(trade)
        ), None)
        if existing:
            return {"inserted": False, "trade": existing, "history": history}
        save_swing_breakout_history(history + [trade], payload.get("observations") or [])
        return {"inserted": True, "trade": trade, "history": history + [trade]}


def _swing_row_date(row):
    raw = str((row or {}).get("bar_date") or (row or {}).get("retrieved_at") or "").strip()
    return raw[:10]


def _swing_legacy_plan_key(ticker, entry):
    ticker = str(ticker or "").strip().upper()
    entry = swing_breakout_number(entry)
    return f"{ticker}|BREAKOUT_RETEST|{entry:g}" if ticker and entry is not None else ""


def _swing_row_setup_key(row):
    row = row or {}
    explicit = str(row.get("breakout_signal_key") or row.get("setup_key") or "").strip()
    if explicit:
        return explicit
    ticker = str(row.get("ticker") or "").strip().upper()
    entry = swing_breakout_number(row.get("entry_plan"))
    timestamp = swing_breakout_number(row.get("breakout_bar_timestamp"))
    if not ticker or entry is None:
        return ""
    event_part = str(int(timestamp)) if timestamp is not None else "timestamp-missing"
    return f"{ticker}|BREAKOUT_RETEST|{event_part}|{entry:g}"


def _swing_trade_matches_setup(trade, setup_key, legacy_plan_key):
    stored_setup = str((trade or {}).get("setup_key") or "").strip()
    if stored_setup:
        return bool(setup_key and stored_setup == setup_key)
    stored_legacy = _swing_legacy_plan_key(
        (trade or {}).get("ticker"), (trade or {}).get("entry")
    )
    return bool(legacy_plan_key and stored_legacy == legacy_plan_key)


def _swing_trade_signal_key(row):
    row = row or {}
    return "|".join([
        str(row.get("ticker") or "").strip().upper(),
        _swing_row_date(row) or "unknown",
        str(row.get("entry_plan") if row.get("entry_plan") is not None else ""),
        str(row.get("stop_loss") if row.get("stop_loss") is not None else ""),
        str(row.get("target_resistance") if row.get("target_resistance") is not None else ""),
    ])


def _swing_trade_active(history, ticker):
    ticker = str(ticker or "").strip().upper()
    return any(
        str(item.get("ticker") or "").strip().upper() == ticker
        and str(item.get("status") or "").lower() in {"open", "pending"}
        for item in history
        if isinstance(item, dict)
    )


def _swing_close_trade(trade, outcome, exit_price, exit_time, reason):
    entry = swing_breakout_number(trade.get("entry"))
    exit_value = swing_breakout_number(exit_price)
    if entry is None or entry <= 0 or exit_value is None or exit_value <= 0:
        return False
    cost_pct = swing_breakout_number(trade.get("cost_pct")) or 0.0
    gross = (exit_value - entry) / entry * 100
    trade.update({
        "status": outcome,
        "exit_time": exit_time,
        "exit_date": exit_time,
        "exit_price": exit_value,
        "gross_return_pct": gross,
        "net_return_pct": gross - cost_pct,
        "profit_pct": gross - cost_pct,
        "outcome": outcome,
        "outcome_reason": reason,
    })
    return True


def auto_track_swing_breakout_snapshot(payload):
    """Auto-enter ready setups and resolve open paper trades at TP/SL.

    The tracker uses the current daily bar high/low.  For a same-day signal,
    resolution starts on the next bar so the setup candle is not reused as
    fake forward performance.  If both levels are touched in one bar, SL is
    conservatively applied first.
    """
    if not isinstance(payload, dict) or payload.get("status") != "ok":
        return {"inserted": 0, "closed": 0, "history_count": 0}
    rows = [row for row in (payload.get("rows") or []) if isinstance(row, dict)]
    now_text = now_jakarta().strftime("%Y-%m-%d %H:%M:%S")
    inserted = 0
    closed = 0
    new_trades = []
    closed_trades = []
    with SWING_BREAKOUT_HISTORY_LOCK:
        history_payload = load_swing_breakout_history()
        if history_payload.get("status") != "ok":
            return {"inserted": 0, "closed": 0, "history_count": 0, "error": history_payload.get("message")}
        history = [item for item in (history_payload.get("history") or []) if isinstance(item, dict)]
        rows_by_ticker = {
            str(row.get("ticker") or "").strip().upper(): row for row in rows
        }
        changed = False

        # Resolve existing trades first, but never on the same bar that created them.
        for trade in history:
            if str(trade.get("status") or "").lower() not in {"open", "pending"}:
                continue
            ticker = str(trade.get("ticker") or "").strip().upper()
            row = rows_by_ticker.get(ticker)
            if not row:
                continue
            observation_date = _swing_row_date(row)
            signal_date = str(trade.get("signal_date") or trade.get("snapshot_at") or "")[:10]
            if not observation_date or not signal_date or observation_date <= signal_date:
                continue
            high = swing_breakout_number(row.get("bar_high")) or swing_breakout_number(row.get("bar_close"))
            low = swing_breakout_number(row.get("bar_low")) or swing_breakout_number(row.get("bar_close"))
            target = swing_breakout_number(trade.get("target"))
            stop = swing_breakout_number(trade.get("sl"))
            if high is None or low is None or target is None or stop is None:
                continue
            if low <= stop:
                reason = "SL tersentuh pada daily bar berikutnya"
                if high >= target:
                    reason += "; TP juga tersentuh, SL didahulukan"
                if _swing_close_trade(trade, "loss", stop, now_text, reason):
                    closed += 1
                    changed = True
                    closed_trades.append(dict(trade))
            elif high >= target and _swing_close_trade(
                trade, "win", target, now_text, "TP tersentuh pada daily bar berikutnya"
            ):
                closed += 1
                changed = True
                closed_trades.append(dict(trade))

        # Create one automatic paper entry per ticker/signal while no trade is active.
        for row in rows:
            if not row.get("ready_entry") or row.get("status") != "CONFIRMED BREAKOUT":
                continue
            if str(row.get("candle_close_status") or "").lower() != "closed":
                continue
            ticker = str(row.get("ticker") or "").strip().upper()
            if not ticker or _swing_trade_active(history, ticker):
                continue
            plan_key = _swing_trade_plan_key({"ticker": ticker, "entry": row.get("entry_plan")})
            if plan_key and any(_swing_trade_plan_key(item) == plan_key for item in history):
                continue
            setup_key = _swing_row_setup_key(row)
            legacy_plan_key = _swing_legacy_plan_key(ticker, row.get("entry_plan"))
            if any(
                _swing_trade_matches_setup(item, setup_key, legacy_plan_key)
                for item in history
            ):
                continue
            signal_key = _swing_trade_signal_key(row)
            if any(str(item.get("signal_key") or "") == signal_key for item in history):
                continue
            result = record_swing_breakout_trade({
                "ticker": ticker,
                "status": row.get("status"),
                "entry_status": row.get("entry_status") or "RETEST CONFIRMED",
                "ready_entry": True,
                "retest_confirmed": row.get("retest_confirmed"),
                "candle_close_status": row.get("candle_close_status"),
                "signal_key": signal_key,
                "setup_key": setup_key,
                "snapshot_at": row.get("retrieved_at"),
                "entry": row.get("entry_plan"),
                "sl": row.get("stop_loss"),
                "target": row.get("target_resistance"),
                "rr": row.get("rr"),
                "risk_pct": row.get("risk_pct"),
                "reward_pct": row.get("reward_pct"),
                "reference_close": row.get("close"),
                "volume_ratio_10d": row.get("volume_ratio_10d"),
                "trend": row.get("trend"),
                "reason": row.get("ready_entry_reason") or row.get("verdict"),
            })
            if result.get("inserted"):
                new_trade = result.get("trade")
                history.append(new_trade)
                new_trades.append(new_trade)
                inserted += 1
                changed = True

        if changed:
            save_swing_breakout_history(history, history_payload.get("observations") or [])
    notifications = 0
    for trade in new_trades:
        ok, _ = send_swing_breakout_telegram_alert(trade)
        notifications += int(ok)
    outcome_notifications = 0
    for trade in closed_trades:
        ok, _ = send_swing_breakout_outcome_telegram_alert(trade)
        outcome_notifications += int(ok)
    return {
        "inserted": inserted,
        "closed": closed,
        "history_count": len(history),
        "notifications_scheduled": notifications,
        "outcome_notifications_scheduled": outcome_notifications,
    }


def swing_breakout_auto_loop():
    while True:
        try:
            watchlist = SWING_BREAKOUT_AUTO_WATCHLIST
            universe_meta = load_sideways_breakout_idx_universe() if watchlist == "ALL" else None
            tickers = (
                tuple(universe_meta.get("tickers") or [])
                if universe_meta is not None
                else swing_breakout_watchlist_tickers(watchlist)
            )
            if tickers:
                payload = load_swing_breakout_snapshot(tickers, watchlist, universe_meta)
                result = auto_track_swing_breakout_snapshot(payload)
                if result.get("inserted") or result.get("closed"):
                    print(
                        f"[SWING_AUTO] inserted={result.get('inserted', 0)} "
                        f"closed={result.get('closed', 0)}"
                    )
        except Exception as exc:
            print(f"[SWING_AUTO] {type(exc).__name__}: {exc}")
        time.sleep(SWING_BREAKOUT_AUTO_INTERVAL_SEC)


def support_pullback_history_summary(history):
    """Calculate audit-only performance for the support/pullback ledger."""
    records = [item for item in (history or []) if isinstance(item, dict)]
    closed = [item for item in records if str(item.get("status", "")).lower() in {"win", "loss"}]
    wins = sum(1 for item in closed if str(item.get("status", "")).lower() == "win")
    losses = sum(1 for item in closed if str(item.get("status", "")).lower() == "loss")
    returns = []
    for item in closed:
        value = item.get("net_return_pct", item.get("profit_pct"))
        try:
            value = float(value)
        except (TypeError, ValueError):
            continue
        if value == value and value not in (float("inf"), float("-inf")):
            returns.append(value)
    return {
        "total_trades": len(records),
        "open_trades": sum(1 for item in records if str(item.get("status", "")).lower() in {"open", "pending"}),
        "closed_trades": len(closed),
        "wins": wins,
        "losses": losses,
        "winrate_pct": (wins / len(closed) * 100) if closed else None,
        "net_return_pct": sum(returns) if returns else None,
        "ready": bool(closed),
        "message": (
            "Belum ada trade closed; winrate akan muncul setelah paper trade ditutup sebagai WIN atau LOSS."
            if not closed else
            "Winrate dihitung dari trade support/pullback yang sudah closed."
        ),
    }


def load_support_pullback_history():
    """Read the support/pullback ledger without falling back to another strategy."""
    path = os.path.join(os.getcwd(), SUPPORT_PULLBACK_HISTORY_FILE)
    if not os.path.exists(path):
        history = []
        return {
            "status": "ok",
            "history": history,
            "source": SUPPORT_PULLBACK_HISTORY_FILE,
            "performance_summary": support_pullback_history_summary(history),
            "message": "Belum ada trade support/pullback yang tercatat.",
        }
    try:
        with open(path, "r", encoding="utf-8") as handle:
            data = json.load(handle)
    except (OSError, json.JSONDecodeError) as exc:
        return {
            "status": "error",
            "history": [],
            "source": SUPPORT_PULLBACK_HISTORY_FILE,
            "performance_summary": support_pullback_history_summary([]),
            "message": f"Ledger support/pullback tidak dapat dibaca: {exc}",
        }
    history = data.get("history") if isinstance(data, dict) and isinstance(data.get("history"), list) else []
    history = [item for item in history if isinstance(item, dict)]
    return {
        "status": "ok",
        "history": history,
        "source": SUPPORT_PULLBACK_HISTORY_FILE,
        "performance_summary": support_pullback_history_summary(history),
        "message": data.get("message") if isinstance(data, dict) and data.get("message") else "Ledger support/pullback terpisah dari Breakout Swing dan PA.",
    }


def support_pullback_number(value):
    try:
        number = float(value)
    except (TypeError, ValueError):
        return None
    return number if number == number and number not in (float("inf"), float("-inf")) else None


def save_support_pullback_history(history):
    path = os.path.join(os.getcwd(), SUPPORT_PULLBACK_HISTORY_FILE)
    atomic_write_json(path, {
        "schema_version": 2,
        "history": history,
        "updated_at": now_jakarta().isoformat(timespec="seconds"),
    }, indent=2)


def record_support_pullback_trade(params):
    """Append one validated strategy trade to the isolated support ledger."""
    ticker = str(params.get("ticker") or "").strip().upper()
    entry = support_pullback_number(params.get("entry"))
    stop = support_pullback_number(params.get("sl"))
    target = support_pullback_number(params.get("target"))
    if not ticker or entry is None or stop is None or target is None:
        raise ValueError("ticker, entry, sl, dan target wajib diisi")
    if entry <= stop or target <= entry:
        raise ValueError("entry harus di atas SL dan target harus di atas entry")
    if params.get("planning_only") or params.get("market_open") is False:
        raise ValueError("rencana sesi berikutnya belum boleh dicatat sebagai trade aktif")
    calculated_rr = (target - entry) / (entry - stop)
    if not rr_is_valid(calculated_rr):
        raise ValueError("paper trade support/pullback wajib memiliki R:R minimal 1.5R")
    rr = calculated_rr
    raw_method = str(params.get("entry_method") or params.get("setup") or "").strip().upper()
    entry_method = (
        raw_method if raw_method in {"SUPPORT_REJECTION", "PULLBACK_RECLAIM"}
        else "PULLBACK_RECLAIM" if "PULLBACK" in raw_method
        else "SUPPORT_REJECTION"
    )
    with SUPPORT_PULLBACK_HISTORY_LOCK:
        payload = load_support_pullback_history()
        if payload.get("status") != "ok":
            raise ValueError(payload.get("message") or "ledger support/pullback tidak tersedia")
        history = payload.get("history") or []
        candle_key = str(params.get("entry_candle_timestamp") or "")
        signal_key = str(params.get("signal_key") or f"{ticker}|{entry_method}|{candle_key}")
        if any(
            str(item.get("ticker", "")).upper() == ticker
            and str(item.get("status") or "").lower() in {"open", "pending"}
            for item in history
        ):
            existing = next(item for item in history if str(item.get("ticker", "")).upper() == ticker and str(item.get("status") or "").lower() in {"open", "pending"})
            return {"inserted": False, "trade": existing, "history": history}
        if signal_key and any(
            str(item.get("signal_key") or "") == signal_key
            for item in history
        ):
            existing = next(item for item in history if str(item.get("signal_key") or "") == signal_key)
            return {"inserted": False, "trade": existing, "history": history}
        if candle_key and any(
            str(item.get("ticker", "")).upper() == ticker
            and str(item.get("entry_method") or "") == entry_method
            and str(item.get("entry_candle_timestamp", "")) == candle_key
            for item in history
        ):
            return {"inserted": False, "trade": next(item for item in history if str(item.get("ticker", "")).upper() == ticker and str(item.get("entry_method") or "") == entry_method and str(item.get("entry_candle_timestamp", "")) == candle_key), "history": history}
        now_text = now_jakarta().strftime("%Y-%m-%d %H:%M:%S")
        trade_id = f"SP_{ticker}_{candle_key or int(time.time() * 1000)}"
        trade = {
            "id": trade_id,
            "date": now_text,
            "entry_date": now_text,
            "ticker": ticker,
            "setup": entry_method,
            "entry_method": entry_method,
            "entry": entry,
            "sl": stop,
            "target": target,
            "rr": rr,
            "status": "open",
            "type": "buy",
            "action": "SIAP ENTRY",
            "reason": str(params.get("reason") or "Support/pullback PA confirmed"),
            "trigger": str(params.get("trigger") or "Closed 5m trigger + volume"),
            "entry_candle_timestamp": params.get("entry_candle_timestamp"),
            "signal_key": signal_key,
            "invalidation": str(params.get("invalidation") or ""),
            "source_signals": list(params.get("source_signals") or []),
            "planning_only": bool(params.get("planning_only")),
            "market_open": bool(params.get("market_open")),
            "source": str(params.get("source") or "support_strategy_engine"),
            "cost_pct": 0.0,
        }
        history.append(trade)
        save_support_pullback_history(history)
        return {"inserted": True, "trade": trade, "history": history}


def close_support_pullback_trade(params):
    """Close one isolated paper trade as WIN or LOSS for audit purposes."""
    trade_id = str(params.get("id") or "").strip()
    outcome = str(params.get("outcome") or params.get("status") or "").strip().lower()
    if outcome not in {"win", "loss"}:
        raise ValueError("outcome harus WIN atau LOSS")
    with SUPPORT_PULLBACK_HISTORY_LOCK:
        payload = load_support_pullback_history()
        if payload.get("status") != "ok":
            raise ValueError(payload.get("message") or "ledger support/pullback tidak tersedia")
        history = payload.get("history") or []
        trade = next((item for item in history if str(item.get("id")) == trade_id), None)
        if not trade:
            raise ValueError("trade support/pullback tidak ditemukan")
        if str(trade.get("status", "")).lower() in {"win", "loss"}:
            return {"updated": False, "trade": trade, "history": history}
        entry = support_pullback_number(trade.get("entry")) or 0
        default_exit = trade.get("target") if outcome == "win" else trade.get("sl")
        exit_price = support_pullback_number(params.get("exit")) or support_pullback_number(default_exit)
        if entry <= 0 or exit_price is None or exit_price <= 0:
            raise ValueError("harga exit tidak valid")
        gross = (exit_price - entry) / entry * 100
        cost_pct = support_pullback_number(trade.get("cost_pct")) or 0.0
        exit_time = now_jakarta().strftime("%Y-%m-%d %H:%M:%S")
        trade.update({
            "status": outcome,
            "exit_time": exit_time,
            "exit_price": exit_price,
            "gross_return_pct": gross,
            "net_return_pct": gross - cost_pct,
            "profit_pct": gross - cost_pct,
        })
        save_support_pullback_history(history)
        return {"updated": True, "trade": trade, "history": history}


def _support_close_trade(trade, outcome, exit_price, exit_time, reason):
    entry = support_pullback_number(trade.get("entry"))
    exit_value = support_pullback_number(exit_price)
    if entry is None or entry <= 0 or exit_value is None or exit_value <= 0:
        return False
    cost_pct = support_pullback_number(trade.get("cost_pct")) or 0.0
    gross = (exit_value - entry) / entry * 100
    trade.update({
        "status": outcome,
        "exit_time": exit_time,
        "exit_date": exit_time,
        "exit_price": exit_value,
        "gross_return_pct": gross,
        "net_return_pct": gross - cost_pct,
        "profit_pct": gross - cost_pct,
        "outcome": outcome,
        "outcome_reason": reason,
    })
    return True


def auto_track_support_pullback_snapshot(payload):
    """Record strategy-specific support entries and resolve them on later 5m bars."""
    if not isinstance(payload, dict) or payload.get("status") != "ok":
        return {"inserted": 0, "closed": 0, "history_count": 0}
    rows = [row for row in (payload.get("rows") or []) if isinstance(row, dict)]
    now_text = now_jakarta().strftime("%Y-%m-%d %H:%M:%S")
    inserted = 0
    closed = 0
    with SUPPORT_PULLBACK_HISTORY_LOCK:
        history_payload = load_support_pullback_history()
        if history_payload.get("status") != "ok":
            return {"inserted": 0, "closed": 0, "history_count": 0, "error": history_payload.get("message")}
        history = [item for item in (history_payload.get("history") or []) if isinstance(item, dict)]

        for row in rows:
            entry_state = row.get("strategy_entry") or {}
            plan = entry_state.get("plan") or {}
            if not entry_state.get("ready") or entry_state.get("planning_only") or not plan:
                continue
            ticker = str(row.get("ticker") or "").strip().upper()
            method = str(entry_state.get("method") or "").strip().upper()
            candle_key = str(entry_state.get("candle_timestamp") or "")
            signal_key = f"{ticker}|{method}|{candle_key}"
            try:
                result = record_support_pullback_trade({
                    "ticker": ticker,
                    "entry_method": method,
                    "entry": plan.get("entry"),
                    "sl": plan.get("sl"),
                    "target": plan.get("target"),
                    "rr": plan.get("rr"),
                    "reason": entry_state.get("reason"),
                    "trigger": "Closed 5m strategy trigger + volume",
                    "entry_candle_timestamp": entry_state.get("candle_timestamp"),
                    "signal_key": signal_key,
                    "invalidation": entry_state.get("invalidation"),
                    "planning_only": False,
                    "market_open": payload.get("market_open"),
                    "source_signals": [row.get("phase"), row.get("swing_action_status")],
                    "source": "support_strategy_engine",
                })
            except ValueError:
                continue
            if result.get("inserted"):
                history.append(result.get("trade"))
                inserted += 1

        active = [item for item in history if str(item.get("status") or "").lower() in {"open", "pending"}]
        active_tickers = sorted({str(item.get("ticker") or "").strip().upper() for item in active if item.get("ticker")})
        if active_tickers:
            pa_payload = load_pa_snapshot_payload(active_tickers)
            snapshots = pa_payload.get("snapshots") or {}
            qualities = pa_payload.get("quality") or {}
            for trade in active:
                ticker = str(trade.get("ticker") or "").strip().upper()
                candle = (snapshots.get(ticker) or {}).get("5m_closed") or {}
                quality = qualities.get(ticker) or {}
                candle_timestamp = _strategy_number(candle.get("candle_timestamp"))
                entry_timestamp = _strategy_number(trade.get("entry_candle_timestamp"))
                if quality.get("candle_close_status") not in {"closed", "stale"}:
                    continue
                if candle_timestamp is None or entry_timestamp is None or candle_timestamp <= entry_timestamp:
                    continue
                high = support_pullback_number(candle.get("high"))
                low = support_pullback_number(candle.get("low"))
                target = support_pullback_number(trade.get("target"))
                stop = support_pullback_number(trade.get("sl"))
                if None in {high, low, target, stop}:
                    continue
                if low <= stop:
                    reason = "SL tersentuh pada closed 5m setelah entry"
                    if high >= target:
                        reason += "; TP juga tersentuh, SL didahulukan"
                    if _support_close_trade(trade, "loss", stop, now_text, reason):
                        closed += 1
                elif high >= target and _support_close_trade(trade, "win", target, now_text, "TP tersentuh pada closed 5m setelah entry"):
                    closed += 1
        if inserted or closed:
            save_support_pullback_history(history)
    return {"inserted": inserted, "closed": closed, "history_count": len(history)}


def run_support_pullback_auto_cycle(watchlist=None):
    """Run one cached, tiered support scan for the configured universe."""
    raw_watchlist = str(watchlist or SUPPORT_PULLBACK_AUTO_WATCHLIST).strip().upper()
    resolved_watchlist = (
        "ALL_WATCHLISTS"
        if raw_watchlist in {"ALL", "ALL_REGISTERED", "SEMUA"}
        else raw_watchlist
    )
    universe_meta = (
        load_sideways_breakout_idx_universe()
        if resolved_watchlist == SIDEWAYS_BREAKOUT_ALL_IDX_WATCHLIST
        else None
    )
    tickers = (
        tuple(universe_meta.get("tickers") or [])
        if universe_meta is not None
        else swing_breakout_watchlist_tickers(resolved_watchlist)
    )
    if not tickers:
        return {"inserted": 0, "closed": 0, "history_count": 0, "scanned": 0}
    # load_support_strength_snapshot performs the broad daily scan first and
    # requests PA/5m evidence only for KANDIDAT FULL PA. Its 180-second cache is
    # shared with the dashboard, avoiding a second full scan in the same cycle.
    payload = load_support_strength_snapshot(tickers, resolved_watchlist, universe_meta)
    result = auto_track_support_pullback_snapshot(payload)
    result["scanned"] = len(tickers)
    result["watchlist"] = resolved_watchlist
    return result


def support_pullback_auto_loop():
    while True:
        try:
            result = run_support_pullback_auto_cycle()
            if result.get("inserted") or result.get("closed"):
                print(
                    f"[SUPPORT_AUTO] inserted={result.get('inserted', 0)} "
                    f"closed={result.get('closed', 0)} "
                    f"scanned={result.get('scanned', 0)}"
                )
        except Exception as exc:
            print(f"[SUPPORT_AUTO] {type(exc).__name__}: {exc}")
        time.sleep(SUPPORT_PULLBACK_AUTO_INTERVAL_SEC)


def fetch_tradingview_relative_metrics(tickers):
    """Fetch persistent performance windows for within-group leader ranking."""
    columns = ["name", "Perf.W", "Perf.1M", "Perf.3M"]
    data = tradingview_scan(tickers, columns)
    metrics = {}
    for item in data.get("data", []):
        vals = item.get("d") or []
        if len(vals) != len(columns):
            continue
        sym = item.get("s", "").split(":")[-1].upper()
        if not sym:
            continue
        metrics[sym] = {
            "perf_week_pct": vals[1],
            "perf_month_pct": vals[2],
            "perf_3m_pct": vals[3],
            "data_source": "TradingView Scanner",
        }
    return metrics


_RELATIVE_CACHE = {}
_RELATIVE_CACHE_LOCK = threading.Lock()
RELATIVE_CACHE_TTL = 300


def fetch_relative_metrics_cached(tickers):
    now = time.time()
    missing = []
    with _RELATIVE_CACHE_LOCK:
        for ticker in tickers:
            hit = _RELATIVE_CACHE.get(ticker)
            if not hit or now - hit["ts"] > RELATIVE_CACHE_TTL:
                missing.append(ticker)
    if missing:
        fresh = fetch_tradingview_relative_metrics(missing)
        with _RELATIVE_CACHE_LOCK:
            for ticker in missing:
                _RELATIVE_CACHE[ticker] = {
                    "ts": now,
                    "metrics": fresh.get(ticker),
                }
    with _RELATIVE_CACHE_LOCK:
        metrics = {
            ticker: _RELATIVE_CACHE[ticker]["metrics"]
            for ticker in tickers
            if _RELATIVE_CACHE.get(ticker) and _RELATIVE_CACHE[ticker]["metrics"]
        }
    return metrics


def detect_daily_structure(highs, lows, closes, dates=None, lookback=4):
    """Classify HH/HL/LH/LL from confirmed daily swing points."""
    size = min(len(highs), len(lows), len(closes))
    if size < lookback * 2 + 8:
        return {
            "structure": "UNCLEAR",
            "bos": "NONE",
            "reason": "candle historis belum cukup",
        }
    high_values = [float(value) for value in highs[:size]]
    low_values = [float(value) for value in lows[:size]]
    close_values = [float(value) for value in closes[:size]]
    swing_highs = []
    swing_lows = []
    for index in range(lookback, size - lookback):
        high_window = high_values[index - lookback:index + lookback + 1]
        low_window = low_values[index - lookback:index + lookback + 1]
        if high_values[index] == max(high_window) and high_window.count(high_values[index]) == 1:
            swing_highs.append({"index": index, "value": high_values[index]})
        if low_values[index] == min(low_window) and low_window.count(low_values[index]) == 1:
            swing_lows.append({"index": index, "value": low_values[index]})

    if len(swing_highs) < 2 or len(swing_lows) < 2:
        return {
            "structure": "UNCLEAR",
            "bos": "NONE",
            "reason": "dua swing high/low terkonfirmasi belum tersedia",
            "swing_highs": swing_highs[-2:],
            "swing_lows": swing_lows[-2:],
        }

    higher_high = swing_highs[-1]["value"] > swing_highs[-2]["value"]
    higher_low = swing_lows[-1]["value"] > swing_lows[-2]["value"]
    lower_high = swing_highs[-1]["value"] < swing_highs[-2]["value"]
    lower_low = swing_lows[-1]["value"] < swing_lows[-2]["value"]
    if higher_high and higher_low:
        structure = "UPTREND"
    elif lower_high and lower_low:
        structure = "DOWNTREND"
    elif higher_high or higher_low:
        structure = "RECOVERING"
    else:
        structure = "WEAKENING"

    latest_close = close_values[-1]
    if latest_close > swing_highs[-2]["value"]:
        bos = "BULLISH"
    elif latest_close < swing_lows[-2]["value"]:
        bos = "BEARISH"
    else:
        bos = "NONE"
    data_date = ""
    if dates:
        raw_date = dates[-1]
        data_date = raw_date.strftime("%Y-%m-%d") if hasattr(raw_date, "strftime") else str(raw_date)
    return {
        "structure": structure,
        "bos": bos,
        "latest_close": latest_close,
        "last_swing_high": swing_highs[-1]["value"],
        "previous_swing_high": swing_highs[-2]["value"],
        "last_swing_low": swing_lows[-1]["value"],
        "previous_swing_low": swing_lows[-2]["value"],
        "data_date": data_date,
        "lookback": lookback,
        "data_source": "Yahoo Finance daily OHLC",
    }


def fetch_yahoo_daily_structures(tickers):
    """Fetch current daily OHLC in one batch and calculate confirmed structure."""
    import yfinance as yf

    symbols = [f"{ticker}.JK" for ticker in tickers]
    frame = yf.download(
        symbols,
        period="4mo",
        interval="1d",
        group_by="ticker",
        auto_adjust=False,
        progress=False,
        threads=True,
    )
    structures = {}
    for ticker, symbol in zip(tickers, symbols):
        try:
            ticker_frame = frame[symbol] if len(symbols) > 1 else frame
            ticker_frame = ticker_frame.dropna(subset=["High", "Low", "Close"])
            result = detect_daily_structure(
                ticker_frame["High"].tolist(),
                ticker_frame["Low"].tolist(),
                ticker_frame["Close"].tolist(),
                ticker_frame.index.tolist(),
            )
            structures[ticker] = result
        except Exception as error:
            structures[ticker] = {
                "structure": "UNCLEAR",
                "bos": "NONE",
                "reason": str(error),
                "data_source": "Yahoo Finance daily OHLC",
            }
    return structures


_STRUCTURE_CACHE = {}
_STRUCTURE_CACHE_LOCK = threading.Lock()
STRUCTURE_CACHE_TTL = 1800


def fetch_daily_structures_cached(tickers):
    now = time.time()
    missing = []
    with _STRUCTURE_CACHE_LOCK:
        for ticker in tickers:
            hit = _STRUCTURE_CACHE.get(ticker)
            if not hit or now - hit["ts"] > STRUCTURE_CACHE_TTL:
                missing.append(ticker)
    if missing:
        fresh = fetch_yahoo_daily_structures(missing)
        with _STRUCTURE_CACHE_LOCK:
            for ticker in missing:
                _STRUCTURE_CACHE[ticker] = {
                    "ts": now,
                    "structure": fresh.get(ticker),
                }
    with _STRUCTURE_CACHE_LOCK:
        return {
            ticker: _STRUCTURE_CACHE[ticker]["structure"]
            for ticker in tickers
            if _STRUCTURE_CACHE.get(ticker) and _STRUCTURE_CACHE[ticker]["structure"]
        }


def fetch_tradingview_sr(tickers, periods=10):
    def tf_columns(tf):
        cols = []
        for i in range(periods):
            offset = "" if i == 0 else f"[{i}]"
            cols.extend([f"low{offset}|{tf}", f"high{offset}|{tf}"])
        return cols

    columns = (
        ["close", "low", "high", "low[1]", "high[1]"]
        + tf_columns("1")
        + tf_columns("5")
        + [
            "Pivot.M.Classic.S1|1", "Pivot.M.Classic.R1|1",
            "Pivot.M.Classic.S1|5", "Pivot.M.Classic.R1|5",
        ]
    )
    data = tradingview_scan(tickers, columns)
    sr = {}
    for item in data.get("data", []):
        vals = item.get("d") or []
        sym = item.get("s", "").split(":")[-1].upper()
        if not sym:
            continue
        close = float(vals[0] or 0) if len(vals) > 0 and vals[0] is not None else 0.0
        day_low = float(vals[1] or 0) if len(vals) > 1 and vals[1] is not None else 0.0
        day_high = float(vals[2] or 0) if len(vals) > 2 and vals[2] is not None else 0.0
        prev_low = float(vals[3] or 0) if len(vals) > 3 and vals[3] is not None else 0.0
        prev_high = float(vals[4] or 0) if len(vals) > 4 and vals[4] is not None else 0.0
        tf_map = {}
        pivot_start = 5 + periods * 4
        pivot = {
            "1m": {
                "support": float(vals[pivot_start] or 0) if len(vals) > pivot_start and vals[pivot_start] is not None else 0.0,
                "resistance": float(vals[pivot_start + 1] or 0) if len(vals) > pivot_start + 1 and vals[pivot_start + 1] is not None else 0.0,
            },
            "5m": {
                "support": float(vals[pivot_start + 2] or 0) if len(vals) > pivot_start + 2 and vals[pivot_start + 2] is not None else 0.0,
                "resistance": float(vals[pivot_start + 3] or 0) if len(vals) > pivot_start + 3 and vals[pivot_start + 3] is not None else 0.0,
            },
        }
        for label, start in (("1m", 5), ("5m", 5 + periods * 2)):
            chunk = vals[start:start + periods * 2]
            lows = [float(chunk[i]) for i in range(0, len(chunk), 2) if chunk[i] is not None]
            highs = [float(chunk[i]) for i in range(1, len(chunk), 2) if chunk[i] is not None]
            support = min(lows) if lows else 0.0
            resistance = max(highs) if highs else 0.0
            source = "TV candle"

            if not support or not resistance or support == resistance:
                p_support = pivot[label]["support"]
                p_resistance = pivot[label]["resistance"]
                if p_support > 0:
                    support = p_support
                if p_resistance > close > 0:
                    resistance = p_resistance
                elif label == "1m" and day_high > close:
                    resistance = day_high
                elif label == "5m" and prev_high > close:
                    resistance = prev_high
                elif day_high > 0:
                    resistance = max(day_high, resistance)
                source = "TV pivot"

            if label == "5m":
                # 5m dibuat sedikit lebih lebar: pakai range harian/kemarin sebagai konteks
                # kalau candle 5m TradingView masih flat di luar jam market.
                if day_low > 0 and (not support or day_low < support):
                    support = day_low
                if day_high > 0 and day_high > resistance:
                    resistance = day_high
                if prev_low > 0 and close > 0 and prev_low <= close and prev_low < support:
                    support = prev_low
                if prev_high > 0 and prev_high >= close and prev_high > resistance:
                    resistance = prev_high

            if support and resistance:
                tf_map[label] = {"support": support, "resistance": resistance, "source": source}
        if tf_map:
            sr[sym] = tf_map
    return sr


def fetch_tradingview_technical(tickers):
    columns = [
        "Recommend.All",
        "Recommend.MA",
        "Recommend.Other",
        "RSI",
        "MACD.macd",
        "MACD.signal",
        "ADX",
        "CCI20",
        "Stoch.K",
        "Stoch.D",
        "SMA5",
        "SMA10",
        "SMA20",
        "close",
        "change",
    ]
    data = tradingview_scan(tickers, columns)
    technical = {}
    for item in data.get("data", []):
        vals = item.get("d") or []
        if len(vals) != len(columns):
            continue
        sym = item.get("s", "").split(":")[-1].upper()
        technical[sym] = {
            "recommend_all": vals[0],
            "recommend_ma": vals[1],
            "recommend_other": vals[2],
            "rsi": vals[3],
            "macd": vals[4],
            "macd_signal": vals[5],
            "adx": vals[6],
            "cci20": vals[7],
            "stoch_k": vals[8],
            "stoch_d": vals[9],
            "sma5": vals[10],
            "sma10": vals[11],
            "sma20": vals[12],
            "close": vals[13],
            "change_pct": vals[14],
        }
    return technical


def fetch_pa_snapshot(tickers):
    """Fetch PA fields for the 1D -> 1H -> 15m -> 5m hierarchy."""
    retrieved_at = now_jakarta().isoformat()
    base = [
        "close", "change", "relative_volume_10d_calc", "RSI",
        "MACD.macd", "MACD.signal", "EMA20", "EMA50", "VWAP",
        "ADX", "ADX+DI", "ADX-DI", "ATR", "high", "low", "volume",
    ]
    timeframes = {"1D": "", "1H": "|60", "15m": "|15", "5m": "|5"}
    columns = [f"{name}{suffix}" for suffix in timeframes.values() for name in base]
    data = tradingview_scan(tickers, columns)
    # Some TradingView Scanner entitlements expose bar timestamps as `time`.
    # Probe it separately so an unsupported optional field cannot break PA.
    bar_times = {}
    try:
        time_columns = ["time", "time|60", "time|15", "time|5"]
        time_data = tradingview_scan(tickers, time_columns)
        for item in time_data.get("data", []):
            sym = item.get("s", "").split(":")[-1].upper()
            vals = item.get("d") or []
            if len(vals) == len(time_columns):
                bar_times[sym] = dict(zip(("1D", "1H", "15m", "5m"), vals))
    except Exception:
        bar_times = {}
    # TradingView Scanner offset [1] is the previous closed intraday bar.
    closed_intraday = {"1m": {}, "5m": {}}
    closed_names = (
        "candle_timestamp", "open", "high", "low", "close", "volume",
        "relative_volume_10d_calc", "EMA20", "EMA50", "ATR",
    )
    for label, suffix in (("1m", "1"), ("5m", "5")):
        try:
            closed_columns = [f"{name}[1]|{suffix}" for name in ("time", "open", "high", "low", "close", "volume", "relative_volume_10d_calc", "EMA20", "EMA50", "ATR")]
            closed_data = tradingview_scan(tickers, closed_columns)
            for item in closed_data.get("data", []):
                sym = item.get("s", "").split(":")[-1].upper(); vals = item.get("d") or []
                if len(vals) == len(closed_columns): closed_intraday[label][sym] = dict(zip(closed_names, vals))
        except Exception:
            closed_intraday[label] = {}
    snapshots = {}
    for item in data.get("data", []):
        vals = item.get("d") or []
        if len(vals) != len(columns):
            continue
        sym = item.get("s", "").split(":")[-1].upper()
        snapshots[sym] = {}
        cursor = 0
        for label in timeframes:
            snapshots[sym][label] = dict(zip(base, vals[cursor:cursor + len(base)]))
            # Scanner response is a realtime snapshot, but does not expose the
            # source candle's close timestamp. Keep this explicit and avoid
            # falsely claiming that the candle is closed.
            snapshots[sym][label]["data_source"] = "TradingView Scanner"
            snapshots[sym][label]["retrieved_at"] = retrieved_at
            candle_time = (bar_times.get(sym) or {}).get(label)
            snapshots[sym][label]["candle_timestamp"] = candle_time
            snapshots[sym][label]["candle_close_status"] = "unknown" if candle_time is None else "timestamp_available"
            cursor += len(base)
        for label, suffix in (("1m", "1"), ("5m", "5")):
            if sym in closed_intraday[label]:
                snapshots[sym][f"{label}_closed"] = closed_intraday[label][sym]
                snapshots[sym][f"{label}_closed"].update({"data_source": f"TradingView Scanner time[1]|{suffix}", "retrieved_at": retrieved_at, "candle_close_status": "closed"})
    return snapshots


def pa_trigger_candle_allowed(candle, quality, market_open):
    """Allow a real closed bar to prepare the next-session plan.

    During an open session a stale 5m bar is never executable. When IDX is
    closed, the latest TradingView ``time[1]|5`` bar is still useful for a
    next-session plan. It must have an exact timestamp and an explicit
    TradingView source; missing data may not be promoted.
    """
    close_status = (quality or {}).get("candle_close_status")
    if close_status == "closed":
        return True, False
    if market_open or close_status != "stale":
        return False, False
    source = str((candle or {}).get("data_source") or "")
    timestamp = (candle or {}).get("candle_timestamp")
    planning_allowed = bool(timestamp not in (None, "") and "TradingView" in source)
    return planning_allowed, planning_allowed


def update_pa_trigger_states(tickers, snapshots, sr, qualities):
    """Build conservative 5m break-retest evidence from successive snapshots.

    This is trigger evidence only; it is deliberately separate from outcome,
    TP/SL, or learning history. State is persisted in the canonical PA history
    so a server restart cannot erase an observed TradingView break.
    """
    global PA_TRIGGER_STATE_LOADED
    try:
        from pa_tracker import load_dashboard_history, save_history, trigger_event
    except Exception:
        return {}
    result = {}
    market_open = is_market_open()
    with PA_TRIGGER_LOCK, PA_HISTORY_LOCK:
        history_data = load_dashboard_history()
        from pa_watchlists import registered_pa_tickers
        allowed = set(registered_pa_tickers())
        if not PA_TRIGGER_STATE_LOADED:
            PA_TRIGGER_STATES.update({
                str(ticker).strip().upper(): state
                for ticker, state in (history_data.get("break_retests") or {}).items()
                if str(ticker).strip().upper() in allowed
            })
            PA_TRIGGER_STATE_LOADED = True
        changed = False
        for ticker in tickers:
            snapshot = snapshots.get(ticker) or {}
            candle = snapshot.get("5m_closed") or {}
            quality = qualities.get(ticker) or {}
            previous = dict(PA_TRIGGER_STATES.get(ticker) or {})
            if classify_snapshot(snapshot) != STATUS_WAIT:
                result[ticker] = {
                    "state": previous,
                    "reason": "setup 1D/1H/15m belum LAYAK DITUNGGU",
                    "ready": bool(previous.get("ready")),
                    "trigger_valid": bool(previous.get("trigger_valid")),
                    "planning_only": False,
                }
                continue
            candle_allowed, planning_only = pa_trigger_candle_allowed(
                candle, quality, market_open)
            if not candle_allowed:
                result[ticker] = {
                    "state": previous,
                    "reason": f"TradingView closed 5m {quality.get('candle_close_status', 'unknown')}",
                    "ready": bool(previous.get("ready")),
                    "trigger_valid": bool(previous.get("trigger_valid")),
                    "planning_only": False,
                }
                continue
            state, reason = trigger_event(
                previous, candle,
                (sr.get(ticker) or {}).get("5m"), ticker)
            if planning_only:
                reason = f"Rencana sesi berikutnya: candle 5m terakhir sudah tutup — {reason}"
            PA_TRIGGER_STATES[ticker] = state
            changed = changed or state != previous
            result[ticker] = {"state": state, "reason": reason,
                              "ready": bool(state.get("ready")),
                              "trigger_valid": bool(state.get("trigger_valid")),
                              "retest": bool(state.get("retest")),
                              "volume_supported": bool(state.get("volume_supported")),
                              "planning_only": planning_only}
        if changed:
            history_data["break_retests"] = {
                ticker: state for ticker, state in PA_TRIGGER_STATES.items()
                if ticker in allowed
            }
            history_data["trigger_state_updated_at"] = now_jakarta().isoformat()
            save_history(history_data)
    return result


# Both PA pages are views of the same live dataset.  Keep one short-lived
# payload per ticker set so two open tabs do not make separate TradingView
# reads at the same moment and then classify different candles.
_PA_SNAPSHOT_CACHE = {}
_PA_SNAPSHOT_CACHE_LOCK = threading.Lock()
# A page refresh can spend several seconds waiting on four TradingView
# timeframe requests.  Keep the payload long enough for a second open view to
# receive the exact same result while preserving a bounded freshness window.
PA_SNAPSHOT_CACHE_TTL = 30


def load_pa_snapshot_payload(tickers):
    normalized = tuple(sorted({str(ticker).strip().upper() for ticker in tickers if str(ticker).strip()}))
    cache_key = normalized
    now = time.monotonic()
    with _PA_SNAPSHOT_CACHE_LOCK:
        cached = _PA_SNAPSHOT_CACHE.get(cache_key)
        if cached and now - cached["created_at"] < PA_SNAPSHOT_CACHE_TTL:
            return cached["payload"]

        snapshots = fetch_pa_snapshot(list(normalized))
        # PA membutuhkan level trigger terpisah dari harga live.
        # Jika gagal, snapshot indikator tetap bisa dipakai dengan sr={}
        # dan frontend wajib menandai trigger sebagai belum tersedia.
        try:
            sr = fetch_tradingview_sr(list(normalized))
        except Exception:
            sr = {}
        qualities = {ticker: snapshot_quality(snapshot, time.time()) for ticker, snapshot in snapshots.items()}
        trigger_states = update_pa_trigger_states(list(normalized), snapshots, sr, qualities)
        active_trades = load_active_pa_trades(list(normalized))
        closed_5m_timestamps = []
        for snapshot in snapshots.values():
            timestamp = (snapshot.get("5m_closed") or {}).get("candle_timestamp")
            try:
                numeric_timestamp = float(timestamp)
            except (TypeError, ValueError):
                continue
            if numeric_timestamp == numeric_timestamp and numeric_timestamp not in (float("inf"), float("-inf")):
                closed_5m_timestamps.append(numeric_timestamp)
        as_of = now_jakarta().isoformat(timespec="seconds")
        payload = {
            "status": "ok",
            "snapshots": snapshots,
            "quality": qualities,
            "sr": sr,
            "trigger_states": trigger_states,
            "active_trades": active_trades,
            "timeframes": ["1D", "1H", "15m", "5m"],
            # `as_of`/`retrieved_at` are fetch times. The closed 5m timestamp
            # is exposed separately so a UI cannot present retrieval time as
            # the time of the candle that drives the trigger plan.
            "as_of": as_of,
            "retrieved_at": as_of,
            "closed_5m_latest_timestamp": max(closed_5m_timestamps) if closed_5m_timestamps else None,
            "market_open": is_market_open(),
        }
        _PA_SNAPSHOT_CACHE[cache_key] = {"created_at": time.monotonic(), "payload": payload}
        return payload


def _vcp_pa_number(value, default=0.0):
    try:
        number = float(value) if value is not None else default
    except (TypeError, ValueError):
        return default
    return number if math.isfinite(number) else default


def _vcp_pa_calc_rr(snapshot, sr5=None, status=STATUS_CANDIDATE, execution_candle=None):
    """Mirror the PA dashboard's conditional RR calculation for VCP display."""
    snapshot = snapshot or {}
    sr5 = sr5 or {}
    live_5m = snapshot.get("5m") or {}
    execution = execution_candle or live_5m
    live = _vcp_pa_number(live_5m.get("close"))
    if live <= 0 or status in {STATUS_CANDIDATE, STATUS_SKIP}:
        return None

    entry = _vcp_pa_number(execution.get("close"))
    if entry <= 0:
        return None
    if status == STATUS_WAIT:
        resistance = _vcp_pa_number(sr5.get("resistance"))
        if resistance <= live:
            return None
        entry = resistance

    atr = max(_vcp_pa_number(execution.get("ATR")), entry * 0.005)
    minute_15 = snapshot.get("15m") or {}
    hourly = snapshot.get("1H") or {}
    supports = [
        _vcp_pa_number(sr5.get("support")),
        _vcp_pa_number(execution.get("low")),
        _vcp_pa_number(minute_15.get("low")),
        _vcp_pa_number(hourly.get("low")),
    ]
    supports = [value for value in supports if 0 < value < entry]
    nearest_support = max(supports) if supports else entry - atr
    stop = _vcp_pa_number(execution.get("EMA20"))
    if stop <= 0 or stop >= entry:
        stop = nearest_support - 0.5 * atr
    if stop <= 0 or stop >= entry:
        stop = entry - atr
    risk = entry - stop
    if risk <= 0:
        return None

    ema50 = _vcp_pa_number(execution.get("EMA50"))
    target = ema50 if ema50 > entry else entry + risk * 1.5
    reward = target - entry
    rr = reward / risk if reward > 0 else 0.0
    return {
        "entry": entry,
        "sl": stop,
        "target": target,
        "rr": rr,
        "risk_pct": (risk / entry) * 100,
        "target_pct": (reward / entry) * 100,
    }


def _vcp_pa_active_trade_rr(trade):
    if not trade:
        return None
    entry = _vcp_pa_number(trade.get("entry"))
    stop = _vcp_pa_number(trade.get("sl"))
    target = _vcp_pa_number(trade.get("target"))
    if entry <= 0 or stop <= 0 or stop >= entry or target <= entry:
        return None
    calculated = (target - entry) / (entry - stop)
    rr = _vcp_pa_number(trade.get("rr"), calculated)
    if not rr_is_valid(rr):
        return None
    return {
        "entry": entry,
        "sl": stop,
        "target": target,
        "rr": rr,
        "risk_pct": (entry - stop) / entry * 100,
        "target_pct": (target - entry) / entry * 100,
    }


def _vcp_pa_ready_consumed(ticker, evidence):
    candle = evidence.get("ready_candle") or {}
    candle_key = str(candle.get("candle_timestamp") or evidence.get("ready_candle_key") or "")
    if not candle_key:
        return False
    try:
        import pa_tracker
        history = pa_tracker.load_dashboard_history().get("history") or []
    except Exception:
        return False
    for trade in history:
        if str(trade.get("ticker") or "").strip().upper() != ticker:
            continue
        if str(trade.get("status") or "").lower() not in {"win", "loss", "paper_rejected"}:
            continue
        if str(trade.get("entry_candle_timestamp") or "") == candle_key:
            return True
    return False


def _vcp_pa_status_for_ticker(ticker, pa_payload, registered_tickers=None):
    """Return a conservative PA status for one daily VCP candidate."""
    ticker = str(ticker or "").strip().upper()
    snapshots = pa_payload.get("snapshots") or {}
    snapshot = snapshots.get(ticker)
    if not snapshot:
        return {
            "status": "BELUM VALIDASI PA",
            "reason": "Snapshot TradingView 1D/1H/15m/5m belum tersedia.",
            "candle_close_status": "unknown",
            "planning_only": False,
        }

    quality = (pa_payload.get("quality") or {}).get(ticker) or {}
    base_status = classify_snapshot(snapshot)
    trigger_payload = (pa_payload.get("trigger_states") or {}).get(ticker) or {}
    evidence = trigger_payload.get("state") or {}
    active_trade = (pa_payload.get("active_trades") or {}).get(ticker)
    active_rr = _vcp_pa_active_trade_rr(active_trade)
    sr5 = ((pa_payload.get("sr") or {}).get(ticker) or {}).get("5m") or {}
    planning_only = bool(trigger_payload.get("planning_only"))
    candle_status = quality.get("candle_close_status") or "unknown"
    missing = quality.get("missing") or {}
    missing_note = ""
    if missing:
        missing_note = "Data PA belum lengkap: " + "; ".join(
            f"{timeframe} ({', '.join(fields)})"
            for timeframe, fields in missing.items()
        )

    registered = ticker in set(registered_tickers or ())
    scope_note = ""
    if not registered:
        scope_note = "Ticker belum masuk watchlist PA paper resmi; status ini hanya validasi live di VCP."

    def result(status, reason, rr_obj=None):
        notes = [note for note in (reason, missing_note, scope_note) if note]
        if planning_only and status == STATUS_READY:
            notes.append("Rencana sesi berikutnya; validasi ulang saat market buka.")
        rr_entry = _strategy_number(rr_obj.get("entry")) if rr_obj else None
        rr_sl = _strategy_number(rr_obj.get("sl")) if rr_obj else None
        gates = {
            "1D bias": "OK" if base_status in {STATUS_WAIT, STATUS_CANDIDATE} else "BLOCK",
            "1H setup": "OK" if base_status == STATUS_WAIT else "WAIT",
            "15m confirm": "OK" if base_status == STATUS_WAIT else "WAIT",
            "5m closed": "OK" if candle_status == "closed" else str(candle_status).upper(),
            "5m trigger": "OK" if bool(evidence.get("trigger_valid")) else "WAIT",
            "5m volume": "OK" if bool(evidence.get("volume_supported")) else "WAIT",
            "invalidation": "OK" if rr_entry is not None and rr_sl is not None and rr_sl < rr_entry else "WAIT",
            "risk/reward": "OK" if rr_obj and rr_is_valid(rr_obj.get("rr")) else "WAIT",
        }
        return {
            "status": status,
            "reason": " ".join(notes),
            "candle_close_status": candle_status,
            "planning_only": planning_only,
            "rr": rr_obj.get("rr") if rr_obj else None,
            "entry": rr_obj.get("entry") if rr_obj else None,
            "sl": rr_obj.get("sl") if rr_obj else None,
            "target": rr_obj.get("target") if rr_obj else None,
            "gates": gates,
        }

    if active_rr:
        return result(STATUS_READY, "SIAP ENTRY dikunci oleh trade PA paper yang masih aktif.", active_rr)

    if base_status != STATUS_WAIT:
        return result(base_status, "Bias/setup PA belum memenuhi seluruh alignment entry.")

    ready_evidence = bool(
        evidence.get("ready")
        and evidence.get("trigger_valid")
        and evidence.get("retest")
        and evidence.get("volume_supported")
        and evidence.get("ready_candle")
        and not evidence.get("entered")
        and not evidence.get("consumed")
        and not _vcp_pa_ready_consumed(ticker, evidence)
    )
    if ready_evidence:
        ready_rr = _vcp_pa_calc_rr(
            snapshot,
            sr5,
            STATUS_READY,
            evidence.get("ready_candle"),
        )
        if ready_rr and rr_is_valid(ready_rr.get("rr")):
            return result(STATUS_READY, "Break-retest closed 5m + RVOL mendukung + RR valid.", ready_rr)
        return result(STATUS_SKIP, "Trigger PA ada, tetapi RR di bawah 1.5R atau level tidak valid.")

    pending_rr = _vcp_pa_calc_rr(snapshot, sr5, STATUS_WAIT)
    if pending_rr and not rr_is_valid(pending_rr.get("rr")):
        return result(STATUS_SKIP, "Setup PA tersedia, tetapi RR di bawah 1.5R.")

    reason = trigger_payload.get("reason") or "1D/1H/15m selaras; menunggu break-retest closed 5m + volume."
    if candle_status in {"stale", "unknown"}:
        reason = f"Candle 5m {candle_status}; {reason}"
    return result(STATUS_WAIT, reason, pending_rr)


def _load_vcp_pa_status_context(tickers):
    """Load VCP PA evidence and statuses without changing the public payload."""
    normalized = tuple(sorted({str(ticker).strip().upper() for ticker in tickers if str(ticker).strip()}))
    if not normalized:
        return normalized, {}, {}

    payload = load_pa_snapshot_payload(list(normalized))
    try:
        from pa_watchlists import registered_pa_tickers
        registered = registered_pa_tickers()
    except Exception:
        registered = ()
    statuses = {
        ticker: _vcp_pa_status_for_ticker(ticker, payload, registered)
        for ticker in normalized
    }
    return normalized, payload, statuses


def load_vcp_pa_status_payload(tickers):
    """Validate only non-Rejected VCP rows against the canonical PA gates."""
    normalized, payload, statuses = _load_vcp_pa_status_context(tickers)
    if not normalized:
        return {
            "status": "ok",
            "statuses": {},
            "as_of": now_jakarta().isoformat(timespec="seconds"),
            "market_open": is_market_open(),
        }
    return {
        "status": "ok",
        "statuses": statuses,
        "as_of": payload.get("as_of"),
        "retrieved_at": payload.get("retrieved_at"),
        "closed_5m_latest_timestamp": payload.get("closed_5m_latest_timestamp"),
        "market_open": payload.get("market_open"),
        "timeframes": payload.get("timeframes", ["1D", "1H", "15m", "5m"]),
        "source": "TradingView Scanner",
    }


def _read_vcp_pa_alert_state():
    try:
        with open(VCP_PA_ALERT_STATE_FILE, "r", encoding="utf-8") as handle:
            raw = json.load(handle)
    except (FileNotFoundError, json.JSONDecodeError, OSError, TypeError):
        raw = {}
    events = raw.get("events") if isinstance(raw, dict) else {}
    return {
        "version": 1,
        "events": events if isinstance(events, dict) else {},
    }


def _write_vcp_pa_alert_state(state):
    directory = os.path.dirname(VCP_PA_ALERT_STATE_FILE)
    if directory:
        os.makedirs(directory, exist_ok=True)
    atomic_write_json(VCP_PA_ALERT_STATE_FILE, state, indent=2)


def _vcp_pa_alert_number(value):
    try:
        number = float(value)
    except (TypeError, ValueError):
        return None
    return number if math.isfinite(number) else None


def _vcp_pa_alert_price(value):
    number = _vcp_pa_alert_number(value)
    return f"{number:,.2f}" if number is not None else "N/A"


def _vcp_pa_alert_candle_label(candle):
    timestamp = _vcp_pa_alert_number((candle or {}).get("candle_timestamp"))
    if timestamp is None:
        return "timestamp tidak tersedia"
    if timestamp > 1_000_000_000_000:
        timestamp /= 1000
    try:
        return datetime.fromtimestamp(timestamp, MARKET_TZ).strftime(
            "%Y-%m-%d %H:%M:%S WIB"
        )
    except (OSError, OverflowError, ValueError):
        return "timestamp tidak valid"


def _vcp_pa_candidate_key(row, data_through=""):
    row = row or {}
    ticker = str(row.get("ticker") or "").strip().upper()
    day = str(
        data_through
        or row.get("signal_date")
        or row.get("breakout_date")
        or "latest"
    ).strip()
    setup = str(row.get("setup_stage") or row.get("status") or "").strip()
    pivot = str(row.get("pivot_price") or "").strip()
    entry = str(row.get("harga_entry_rencana") or "").strip()
    return "|".join((ticker, day, setup, pivot, entry))


def _vcp_pa_alert_schedule(key, message, alert_mode="ALL"):
    config = load_pa_telegram_config()
    if not config.get("enabled"):
        return False
    if not config.get("token") or not config.get("chat_id"):
        return False

    now_ts = time.time()
    with VCP_PA_ALERT_LOCK:
        state = _read_vcp_pa_alert_state()
        existing = state["events"].get(key)
        if isinstance(existing, dict):
            if existing.get("status") == "sent":
                return False
            try:
                pending_age = now_ts - float(existing.get("created_ts") or 0)
            except (TypeError, ValueError):
                pending_age = VCP_PA_ALERT_POLL_SEC + 1
            if existing.get("status") == "pending" and pending_age < 600:
                return False
        state["events"][key] = {
            "status": "pending",
            "created_ts": now_ts,
            "created_at": now_jakarta().isoformat(timespec="seconds"),
        }
        if len(state["events"]) > 1000:
            ordered = sorted(
                state["events"].items(),
                key=lambda item: float((item[1] or {}).get("created_ts") or 0),
            )
            state["events"] = dict(ordered[-1000:])
        _write_vcp_pa_alert_state(state)

    def deliver():
        ok = notify_telegram(
            config["token"],
            config["chat_id"],
            message,
            enabled=config["enabled"],
            alert_mode=alert_mode,
        )
        with VCP_PA_ALERT_LOCK:
            latest = _read_vcp_pa_alert_state()
            if ok:
                latest["events"][key] = {
                    "status": "sent",
                    "sent_ts": time.time(),
                    "sent_at": now_jakarta().isoformat(timespec="seconds"),
                }
            else:
                latest["events"].pop(key, None)
            _write_vcp_pa_alert_state(latest)

    threading.Thread(
        target=deliver,
        name="vcp-pa-telegram-alert",
        daemon=True,
    ).start()
    return True


def _format_vcp_pa_trigger_alert(
    ticker, trigger, live_price, status, detected_at, candidate_day
):
    return (
        "[VCP PA] 🔔 TRIGGER TERSENTUH\n"
        f"📌 Ticker: <b>{html_escape(ticker)}</b>\n"
        f"🗓️ Kandidat daily: {html_escape(candidate_day)}\n"
        f"🔔 Trigger PA 5m: <b>{_vcp_pa_alert_price(trigger)}</b>\n"
        f"💰 Harga live: {_vcp_pa_alert_price(live_price)}\n"
        f"📐 RR rencana: {_vcp_pa_alert_number(status.get('rr')) or 0:.2f}R\n"
        f"🕒 Terdeteksi: {html_escape(detected_at)} WIB\n\n"
        "⚠️ Ini belum SIAP ENTRY. Tunggu candle 5m close, retest, volume, "
        "dan validasi PA/RR sebelum eksekusi manual."
    )


def _format_vcp_pa_ready_alert(ticker, status, evidence, candidate_day):
    candle = evidence.get("ready_candle") or {}
    candle_label = _vcp_pa_alert_candle_label(candle)
    reason = html_escape(status.get("reason") or "Break-retest closed 5m + RR valid.")
    return (
        "[READY BUY VCP PA] 🚨 SIAP ENTRY\n"
        f"📌 Ticker: <b>{html_escape(ticker)}</b>\n"
        f"🗓️ Kandidat daily: {html_escape(candidate_day)}\n\n"
        f"💰 Entry PA: <b>{_vcp_pa_alert_price(status.get('entry'))}</b>\n"
        f"🛑 Stop Loss: {_vcp_pa_alert_price(status.get('sl'))}\n"
        f"🎯 Target: {_vcp_pa_alert_price(status.get('target'))}\n"
        f"📐 Risk/Reward: {_vcp_pa_alert_number(status.get('rr')) or 0:.2f}R\n\n"
        f"✅ Trigger: {reason}\n"
        f"🕯️ Candle 5m closed: {html_escape(candle_label)}\n\n"
        "⚠️ Verifikasi ulang harga dan status sesi sebelum entry manual. "
        "Dashboard tidak mengirim order broker."
    )


def _vcp_pa_live_price(ticker, quotes, pa_payload):
    quote = (quotes or {}).get(ticker) or {}
    price = _vcp_pa_alert_number(quote.get("price"))
    if price is not None and price > 0:
        return price
    snapshot = ((pa_payload.get("snapshots") or {}).get(ticker) or {}).get("5m") or {}
    price = _vcp_pa_alert_number(snapshot.get("close"))
    return price if price is not None and price > 0 else None


def vcp_pa_alert_tick(now=None):
    """Monitor VCP candidates and notify the dedicated PA Telegram channel."""
    current = now or now_jakarta()
    if not VCP_PA_ALERT_ENABLED or not is_idx_market_open(current):
        return {"status": "market_closed", "alerts": 0}
    config = load_pa_telegram_config()
    if not config.get("enabled") or not config.get("token") or not config.get("chat_id"):
        return {"status": "telegram_not_configured", "alerts": 0}

    results = load_vcp_results_payload()
    rows = results.get("rows") if results.get("status") == "ok" else []
    rows = [row for row in (rows or []) if isinstance(row, dict)]
    if not rows:
        return {"status": "no_candidates", "alerts": 0}

    tickers = sorted({
        str(row.get("ticker") or "").strip().upper()
        for row in rows
        if str(row.get("ticker") or "").strip()
    })
    normalized, pa_payload, statuses = _load_vcp_pa_status_context(tickers)
    if not normalized:
        return {"status": "no_candidates", "alerts": 0}
    try:
        quotes, quote_as_of = fetch_quotes_cached(list(normalized))
    except Exception:
        quotes, quote_as_of = {}, ""

    alerts = 0
    data_through = str(results.get("data_through") or "latest")
    for row in rows:
        ticker = str(row.get("ticker") or "").strip().upper()
        status = statuses.get(ticker) or {}
        evidence = (
            ((pa_payload.get("trigger_states") or {}).get(ticker) or {}).get("state")
            or {}
        )
        candidate_key = _vcp_pa_candidate_key(row, data_through)
        candidate_day = data_through if data_through != "latest" else "terbaru"
        status_name = status.get("status")

        if status_name == STATUS_READY and not status.get("planning_only"):
            ready_candle = evidence.get("ready_candle") or {}
            candle_key = str(
                ready_candle.get("candle_timestamp")
                or evidence.get("ready_candle_key")
                or ""
            ).strip()
            if candle_key and evidence.get("ready"):
                key = f"READY|{candidate_key}|{candle_key}"
                if _vcp_pa_alert_schedule(
                    key,
                    _format_vcp_pa_ready_alert(
                        ticker, status, evidence, candidate_day
                    ),
                ):
                    alerts += 1
            continue

        if status_name != STATUS_WAIT:
            continue
        trigger = _vcp_pa_alert_number(evidence.get("trigger"))
        if trigger is None or trigger <= 0:
            trigger = _vcp_pa_alert_number(status.get("entry"))
        live_price = _vcp_pa_live_price(ticker, quotes, pa_payload)
        if trigger is None or trigger <= 0 or live_price is None or live_price < trigger:
            continue
        key = f"TRIGGER|{candidate_key}|{trigger:.6f}"
        if _vcp_pa_alert_schedule(
            key,
            _format_vcp_pa_trigger_alert(
                ticker,
                trigger,
                live_price,
                status,
                quote_as_of or now_jakarta().strftime("%H:%M:%S"),
                candidate_day,
            ),
            alert_mode="ALL",
        ):
            alerts += 1
    return {"status": "ok", "alerts": alerts, "candidates": len(rows)}


def vcp_pa_alert_loop():
    while True:
        try:
            vcp_pa_alert_tick()
        except Exception as exc:
            print(f"[VCP_PA_ALERT] {type(exc).__name__}: {exc}", flush=True)
        time.sleep(VCP_PA_ALERT_POLL_SEC)


def _load_trend_break_support_snapshot():
    """Reuse the Support Reaction snapshot for the combined PA queue."""
    try:
        tickers = swing_breakout_watchlist_tickers("ALL_WATCHLISTS")
        if not tickers:
            return {
                "status": "error",
                "rows": [],
                "as_of": None,
                "market_open": is_idx_market_open(),
                "message": "Universe Support Reaction kosong.",
            }
        return load_support_strength_snapshot(tickers, "ALL_WATCHLISTS")
    except Exception:
        # Keep the combined dashboard usable when the separate support scan is
        # unavailable. Do not pass through provider/session details to the UI.
        return {
            "status": "error",
            "rows": [],
            "as_of": None,
            "market_open": is_idx_market_open(),
            "message": "Snapshot Support Reaction belum tersedia.",
        }


def _normalize_support_queue_row(row):
    """Adapt one Support Reaction row to the Trend Break queue schema."""
    if not isinstance(row, dict):
        return None
    screening_status = str(row.get("screening_status") or "").strip().upper()
    if screening_status != STATUS_CANDIDATE:
        return None

    def number(value):
        try:
            parsed = float(value)
        except (TypeError, ValueError):
            return None
        return parsed if math.isfinite(parsed) else None

    def first_number(sources, keys):
        for source in sources:
            if not isinstance(source, dict):
                continue
            for key in keys:
                value = number(source.get(key))
                if value is not None:
                    return value
        return None

    strategy = row.get("strategy_entry") if isinstance(row.get("strategy_entry"), dict) else {}
    ready_entry = bool(strategy.get("ready"))
    action_status = str(
        strategy.get("entry_status")
        or row.get("swing_action_status")
        or ""
    ).strip().upper()
    if not ready_entry and action_status not in {
        "MENUNGGU KONFIRMASI",
        "SETUP SWING TERKONFIRMASI",
    }:
        return None

    strategy_plan = strategy.get("plan") if isinstance(strategy.get("plan"), dict) else {}
    swing_plan = row.get("swing_plan") if isinstance(row.get("swing_plan"), dict) else {}
    confirmation = row.get("swing_confirmation") if isinstance(row.get("swing_confirmation"), dict) else {}
    plan_sources = [strategy_plan, swing_plan, confirmation, row]
    entry = first_number(plan_sources, ("entry", "buy_price", "buy_reference", "reference_close"))
    stop = first_number(plan_sources, ("sl", "stop_loss", "support_invalidation"))
    target = first_number(plan_sources, ("target", "take_profit", "resistance_20d"))
    rr = first_number(plan_sources, ("rr", "risk_reward"))
    risk_pct = first_number(plan_sources, ("risk_pct",))
    reward_pct = first_number(plan_sources, ("reward_pct",))
    if risk_pct is None and entry and stop is not None:
        risk_pct = ((entry - stop) / entry) * 100
    if reward_pct is None and entry and target is not None:
        reward_pct = ((target - entry) / entry) * 100
    plan = None
    if any(value is not None for value in (entry, stop, target, rr, risk_pct, reward_pct)):
        plan = {
            "entry": entry,
            "sl": stop,
            "target": target,
            "rr": rr,
            "risk_pct": risk_pct,
            "reward_pct": reward_pct,
        }

    snapshot_at = str(row.get("retrieved_at") or row.get("data_through") or "").strip()
    detected_date = str(row.get("data_through") or snapshot_at[:10] or "").strip()
    detected_time = snapshot_at[11:19] if len(snapshot_at) >= 19 and "T" in snapshot_at else ""
    ticker = str(row.get("ticker") or "").strip().upper()
    if not ticker:
        return None
    reason = str(
        strategy.get("reason")
        or row.get("swing_action_reason")
        or row.get("verdict")
        or row.get("waiting_for")
        or "Support Reaction menunggu validasi PA."
    ).strip()
    return {
        "source": "SUPPORT REACTION",
        "ticker": ticker,
        "trend_status": "SUPPORT",
        "setup_status": str(row.get("phase") or "SUPPORT").strip(),
        "screening_status": screening_status,
        "entry_status": action_status or "MENUNGGU KONFIRMASI",
        "entry_ready": ready_entry,
        "planning_only": bool(strategy.get("planning_only")),
        "reason": reason,
        "plan": plan,
        "trend_price": number(row.get("close")),
        "detected_price": number(row.get("close")),
        "swing_high": None,
        "break_margin_pct": None,
        "score": number(row.get("quality_score")),
        "vol_ratio": number(row.get("volume_ratio_20d")),
        "detected_at": snapshot_at,
        "detected_date": detected_date,
        "detected_time": detected_time,
        "time_context": "snapshot",
        "screening_reasons": [],
        "quality_protocol": {},
        "support_phase": str(row.get("phase") or "SUPPORT").strip(),
        "support_level": number(row.get("support_level")),
        "support_zone_low": number(row.get("support_zone_low")),
        "support_zone_high": number(row.get("support_zone_high")),
        "touches_support": number(row.get("touches_support")),
        "rejection_count": number(row.get("rejection_count")),
        "rejection_rate_pct": number(row.get("rejection_rate_pct")),
        "distance_to_support_pct": number(row.get("distance_to_support_pct")),
        "entry_method": str(strategy.get("method") or row.get("entry_method") or "").strip(),
    }


def load_trend_break_ready_snapshot():
    """Build the combined Trend Break + Support Reaction PA queue.

    The daily Trend Break result is a screening source.  Only rows whose
    daily status is ``CONFIRM`` are sent through the canonical PA readiness
    validator; a daily confirmation by itself can never become an entry.
    Support Reaction rows use their own strategy entry gate and are kept as
    separate setup records so different entry techniques are not conflated.
    """
    result_path = os.path.join(os.getcwd(), "trend_break_results.json")
    try:
        with open(result_path, "r", encoding="utf-8") as handle:
            trend_payload = json.load(handle)
    except FileNotFoundError:
        return {
            "status": "not_ready",
            "message": "trend_break_results.json belum tersedia.",
            "rows": [],
            "confirmed": [],
            "ready": [],
            "as_of": "",
            "market_open": is_market_open(),
        }
    except (json.JSONDecodeError, OSError) as exc:
        return {
            "status": "error",
            "message": f"Snapshot Trend Break tidak dapat dibaca: {exc}",
            "rows": [],
            "confirmed": [],
            "ready": [],
            "as_of": "",
            "market_open": is_market_open(),
        }

    signals = [item for item in (trend_payload.get("signals") or []) if isinstance(item, dict)]
    confirmed_signals = [
        item for item in signals
        if str(item.get("status") or "").strip().upper() == "CONFIRM"
    ]
    confirmed_tickers = sorted({
        str(item.get("ticker") or "").strip().upper()
        for item in confirmed_signals
        if str(item.get("ticker") or "").strip()
    })

    pa_status_payload = load_vcp_pa_status_payload(confirmed_tickers) if confirmed_tickers else {
        "status": "ok",
        "statuses": {},
        "as_of": None,
        "retrieved_at": None,
        "closed_5m_latest_timestamp": None,
        "market_open": is_market_open(),
        "timeframes": ["1D", "1H", "15m", "5m"],
        "source": "TradingView Scanner",
    }
    pa_statuses = pa_status_payload.get("statuses") or {}

    def number(value):
        try:
            parsed = float(value)
        except (TypeError, ValueError):
            return None
        return parsed if math.isfinite(parsed) else None

    def build_plan(pa_status):
        if not isinstance(pa_status, dict):
            return None
        entry = number(pa_status.get("entry"))
        stop = number(pa_status.get("sl"))
        target = number(pa_status.get("target"))
        rr = number(pa_status.get("rr"))
        if entry is None or stop is None or target is None or rr is None:
            return None
        risk_pct = ((entry - stop) / entry) * 100 if entry > 0 else None
        reward_pct = ((target - entry) / entry) * 100 if entry > 0 else None
        return {
            "entry": entry,
            "sl": stop,
            "target": target,
            "rr": rr,
            "risk_pct": risk_pct,
            "reward_pct": reward_pct,
        }

    rows = []
    for signal in signals:
        ticker = str(signal.get("ticker") or "").strip().upper()
        if not ticker:
            continue
        trend_status = str(signal.get("status") or "").strip().upper() or "UNKNOWN"
        screening_status = str(signal.get("screening_status") or "").strip().upper() or STATUS_CANDIDATE
        pa_status = None
        if trend_status == "CONFIRM":
            pa_status = pa_statuses.get(ticker) or {
                "status": STATUS_SKIP,
                "reason": "Validasi PA belum menghasilkan status.",
                "planning_only": False,
            }
            # A Trend Break confirm that is not a legal full-PA screening
            # candidate must not be promoted by the PA endpoint.
            if screening_status != STATUS_CANDIDATE:
                pa_status = {
                    "status": STATUS_SKIP,
                    "reason": f"Screening Trend Break berstatus {screening_status}; bukan kandidat full PA.",
                    "planning_only": False,
                }
        else:
            pa_status = {
                "status": "BELUM CONFIRM",
                "reason": "Trend Break daily masih NEAR; belum masuk validasi PA entry.",
                "planning_only": False,
            }

        raw_entry_status = str(pa_status.get("status") or "").strip().upper()
        planning_only = bool(pa_status.get("planning_only"))
        entry_status = raw_entry_status
        if raw_entry_status == STATUS_READY and planning_only:
            entry_status = "SIAP ENTRY SESI BERIKUTNYA"
        ready_entry = raw_entry_status == STATUS_READY
        plan = build_plan(pa_status) if ready_entry else None
        rows.append({
            "ticker": ticker,
            "trend_status": trend_status,
            "screening_status": screening_status,
            "entry_status": entry_status,
            "entry_ready": ready_entry,
            "planning_only": planning_only,
            "reason": str(pa_status.get("reason") or "").strip(),
            "plan": plan,
            "trend_price": number(signal.get("price")),
            "detected_price": number(signal.get("detected_price")),
            "swing_high": number(signal.get("swing_high")),
            "break_margin_pct": number(signal.get("break_margin_pct")),
            "score": number(signal.get("score")),
            "vol_ratio": number(signal.get("vol_ratio")),
            "detected_at": signal.get("detected_at") or "",
            "detected_date": signal.get("detected_date") or "",
            "detected_time": signal.get("detected_time") or "",
            "screening_reasons": signal.get("screening_reasons") or [],
            "quality_protocol": signal.get("quality_protocol") or {},
        })

    rows.sort(key=lambda row: (
        0 if row.get("entry_ready") else (1 if row.get("trend_status") == "CONFIRM" else 2),
        -(row.get("score") or 0),
        row.get("ticker") or "",
    ))
    confirmed_rows = [row for row in rows if row.get("trend_status") == "CONFIRM"]
    support_payload = _load_trend_break_support_snapshot()
    support_rows = [
        normalized
        for normalized in (
            _normalize_support_queue_row(item)
            for item in (support_payload.get("rows") or [])
        )
        if normalized is not None
    ]
    queue_rows = rows + support_rows
    queue_rows.sort(key=lambda row: (
        0 if row.get("entry_ready") else (
            1 if row.get("entry_status") == "MENUNGGU KONFIRMASI" else 2
        ),
        0 if row.get("source") == "SUPPORT REACTION" else 1,
        -(row.get("score") or 0),
        row.get("ticker") or "",
    ))
    ready_rows = [row for row in queue_rows if row.get("entry_ready")]
    support_ready_rows = [row for row in support_rows if row.get("entry_ready")]
    waiting_confirmation_rows = [
        row for row in queue_rows
        if row.get("entry_status") == "MENUNGGU KONFIRMASI"
    ]
    live = trend_break_live_payload()
    return {
        "status": "ok",
        "rows": rows,
        "support_rows": support_rows,
        "queue_rows": queue_rows,
        "confirmed": confirmed_rows,
        "ready": ready_rows,
        "support_ready": support_ready_rows,
        "counts": {
            "trend_break_rows": len(rows),
            "support_rows": len(support_rows),
            "support_candidates": sum(
                1 for item in (support_payload.get("rows") or [])
                if isinstance(item, dict) and str(item.get("screening_status") or "").upper() == STATUS_CANDIDATE
            ),
            "queue_rows": len(queue_rows),
            "confirmed": len(confirmed_rows),
            "ready": len(ready_rows),
            "trend_ready": sum(1 for row in confirmed_rows if row.get("entry_ready")),
            "support_ready": len(support_ready_rows),
            "waiting_confirmation": len(waiting_confirmation_rows),
            "waiting_pa": sum(1 for row in confirmed_rows if not row.get("entry_ready")),
            "near": sum(1 for row in rows if row.get("trend_status") != "CONFIRM"),
        },
        "trend_break": {
            "timestamp": trend_payload.get("timestamp"),
            "timestamp_iso": trend_payload.get("timestamp_iso"),
            "market_open": trend_payload.get("market_open"),
            "snapshot_phase": trend_payload.get("snapshot_phase"),
            "universe_count": trend_payload.get("universe_count"),
            "candidate_count": trend_payload.get("candidate_count"),
            "source": trend_payload.get("source") or "trend_break_results.json",
        },
        "pa": {
            "as_of": pa_status_payload.get("as_of"),
            "retrieved_at": pa_status_payload.get("retrieved_at"),
            "closed_5m_latest_timestamp": pa_status_payload.get("closed_5m_latest_timestamp"),
            "market_open": pa_status_payload.get("market_open"),
            "timeframes": pa_status_payload.get("timeframes", ["1D", "1H", "15m", "5m"]),
            "source": pa_status_payload.get("source") or "TradingView Scanner",
        },
        "support": {
            "status": support_payload.get("status") or "error",
            "as_of": support_payload.get("as_of"),
            "market_open": support_payload.get("market_open"),
            "watchlist": support_payload.get("watchlist") or "ALL_WATCHLISTS",
            "data_source": support_payload.get("data_source") or "Support Reaction snapshot",
            "universe": support_payload.get("universe") or {},
            "message": support_payload.get("message") or "",
        },
        "live": live,
        "as_of": now_jakarta().isoformat(timespec="seconds"),
        "market_open": is_market_open(),
        "data_source": "Trend Break daily snapshot + Support Reaction snapshot + TradingView Scanner PA",
        "rules": {
            "trend_break": "hanya status daily CONFIRM yang masuk antrean PA",
            "support_reaction": "hanya KANDIDAT FULL PA dengan SIAP ENTRY atau MENUNGGU KONFIRMASI yang masuk antrean",
            "ready_entry": "1D/1H/15m selaras + closed 5m valid + volume mendukung + invalidasi/SL/target + RR minimal 1,5",
            "planning": "saat market tutup, SIAP ENTRY SESI BERIKUTNYA tetap berupa rencana; validasi ulang saat market buka",
        },
    }


class CustomHandler(http.server.SimpleHTTPRequestHandler):
    def end_headers(self):
        # Bypass ngrok browser warning page untuk semua response
        self.send_header('ngrok-skip-browser-warning', '1')
        self.send_header('Access-Control-Allow-Origin', '*')
        # Disable caching for real-time updates
        self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate')
        self.send_header('Pragma', 'no-cache')
        self.send_header('Expires', '0')
        super().end_headers()

    def do_OPTIONS(self):
        """Allow the file-opened dashboard to POST its separate paper ledger."""
        self.send_response(204)
        self.send_header('Access-Control-Allow-Origin', '*')
        self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
        self.send_header('Access-Control-Allow-Headers', 'Content-Type')
        self.end_headers()


    def do_GET(self):
        parsed = urlparse(self.path)
        if parsed.path == '/dashboard.html' and parse_qs(parsed.query).get('technical') == ['1']:
            self.send_error(404, "Technical dashboard removed")
            return
        if parsed.path == '/api/dmas/dates':
            try:
                send_json(self, 200, {
                    "status": "ok",
                    "dates": dmas_available_dates(),
                })
            except Exception as exc:
                send_json(self, 500, {
                    "status": "error",
                    "message": f"Tanggal DMAS tidak terbaca: {exc}",
                    "dates": [],
                })
            return
        if parsed.path == '/api/dmas/data':
            try:
                date = (parse_qs(parsed.query).get('date') or [''])[0]
                payload = build_dmas_payload(date or None)
                send_json(self, 400 if payload.get("status") == "error" else 200, payload)
            except Exception as exc:
                send_json(self, 500, {
                    "status": "error",
                    "message": f"Data DMAS tidak terbaca: {exc}",
                    "results": [],
                })
            return
        if parsed.path == '/api/dmas/job':
            try:
                send_json(self, 200, {
                    "status": "ok",
                    "job": dmas_job_status(),
                })
            except Exception as exc:
                send_json(self, 500, {
                    "status": "error",
                    "message": f"Status job DMAS tidak terbaca: {exc}",
                })
            return
        if parsed.path == '/api/vcp_scan_status':
            send_json(self, 200, {"status": "ok", **vcp_scan_status_payload()})
            return
        if parsed.path == '/api/vcp_results':
            payload = load_vcp_results_payload()
            send_json(self, 200 if payload.get("status") != "error" else 500, payload)
            return
        if parsed.path == '/api/vcp_performance':
            payload = load_vcp_performance_payload()
            send_json(self, 200 if payload.get("status") != "error" else 500, payload)
            return
        if parsed.path == '/api/vcp_pa_status':
            tickers = [
                ticker.strip().upper()
                for ticker in (parse_qs(parsed.query).get('tickers', [''])[0]).split(',')
                if ticker.strip()
            ]
            if not tickers:
                send_json(self, 400, {"status": "error", "message": "tickers kosong"})
                return
            try:
                send_json(self, 200, load_vcp_pa_status_payload(tickers))
            except Exception:
                send_json(self, 502, {
                    "status": "error",
                    "message": "Snapshot PA VCP belum tersedia; kolom PA Entry tidak boleh dianggap siap.",
                })
            return
        if parsed.path == '/api/broker_summary_date':
            qs = parse_qs(parsed.query)
            ticker = (qs.get('ticker') or [''])[0].strip().upper()
            start_date = (qs.get('start_date') or qs.get('date') or [''])[0].strip()
            end_date = (qs.get('end_date') or qs.get('date') or [''])[0].strip()
            if not ticker:
                send_json(self, 400, {"status": "error", "message": "Ticker wajib diisi."})
                return
            try:
                payload = fetch_broker_summary_date_cached(ticker, start_date=start_date, end_date=end_date)
                send_json(self, 200, payload)
            except Exception as exc:
                send_json(self, 500, {"status": "error", "message": str(exc)})
            return
        if parsed.path == '/api/screener_status':
            server_now = now_jakarta()
            payload = read_job_status()
            payload["server_time"] = server_now.isoformat(timespec="seconds")
            payload["server_date"] = server_now.date().isoformat()
            payload["market_open"] = is_market_open(server_now)
            payload["auto_scan"] = {
                "enabled": AUTO_SCAN_ENABLED,
                "source": AUTO_SCAN_SOURCE,
                "interval_min": AUTO_SCAN_INTERVAL_MIN,
                "market_open": payload["market_open"],
                "state": read_auto_scan_state(),
            }
            payload["trend_break_live"] = trend_break_live_payload()
            payload["background_automation"] = background_automation_payload()
            send_json(self, 200, payload)
            return
        if parsed.path == '/api/background_automation_status':
            send_json(self, 200, background_automation_payload())
            return
        if parsed.path == '/api/trend_break_live_status':
            send_json(self, 200, {"status": "ok", **trend_break_live_payload()})
            return
        if parsed.path == '/api/trend_break_ready_snapshot':
            try:
                send_json(self, 200, load_trend_break_ready_snapshot())
            except Exception as exc:
                send_json(self, 502, {
                    "status": "error",
                    "message": f"Trend Break Ready gagal dihitung: {exc}",
                    "rows": [],
                    "confirmed": [],
                    "ready": [],
                })
            return
        if parsed.path == '/api/trending_radar':
            try:
                send_json(self, 200, load_trending_radar_snapshot())
            except Exception as exc:
                send_json(self, 502, {
                    "status": "error",
                    "message": f"Radar trending gagal dihitung: {exc}",
                    "rows": [],
                })
            return
        if parsed.path == '/api/pb1m_radar':
            try:
                send_json(self, 200, load_pb1m_radar_snapshot())
            except Exception as exc:
                send_json(self, 502, {
                    "status": "error",
                    "message": f"Radar 1M Pullback Breakout gagal dihitung: {exc}",
                    "rows": [],
                })
            return
        if parsed.path == '/api/pb1m_trade_history':
            try:
                payload = pb1m.load_pb1m_trade_history()
                send_json(self, 200 if payload.get("status") == "ok" else 500, payload)
            except Exception as exc:
                send_json(self, 500, {
                    "status": "error",
                    "message": f"Trade history 1M Pullback Breakout tidak terbaca: {exc}",
                    "history": [],
                })
            return
        if parsed.path == '/api/pb1m_v2_radar':
            try:
                send_json(self, 200, load_pb1m_v2_radar_snapshot())
            except Exception as exc:
                send_json(self, 502, {
                    "status": "error",
                    "message": f"Radar PB1M V2 gagal dihitung: {exc}",
                    "rows": [],
                })
            return
        if parsed.path == '/api/pb1m_v2_trade_history':
            try:
                payload = pb1m_v2.load_pb1m_v2_trade_history()
                send_json(self, 200 if payload.get("status") == "ok" else 500, payload)
            except Exception as exc:
                send_json(self, 500, {
                    "status": "error",
                    "message": f"Trade history PB1M V2 tidak terbaca: {exc}",
                    "history": [],
                })
            return
        if parsed.path == '/api/ara_hunter_v2':
            try:
                send_json(self, 200, load_ara_hunter_v2_snapshot())
            except Exception as exc:
                send_json(self, 502, {"status": "error", "message": f"ARA Hunter V2 gagal dihitung: {exc}", "rows": []})
            return
        if parsed.path == '/api/ara_hunter_v2_history':
            try:
                payload = ara_hunter_v2.load_trade_history()
                send_json(self, 200 if payload.get("status") == "ok" else 500, payload)
            except Exception as exc:
                send_json(self, 500, {"status": "error", "message": f"History ARA Hunter V2 tidak terbaca: {exc}", "history": []})
            return
        if parsed.path == '/api/momentum_ignition':
            try:
                send_json(self, 200, load_momentum_ignition_snapshot())
            except Exception as exc:
                send_json(self, 502, {"status": "error", "message": f"Momentum Ignition gagal dihitung: {exc}", "rows": []})
            return
        if parsed.path == '/api/momentum_ignition_history':
            try:
                payload = momentum_ignition.load_history()
                send_json(self, 200 if payload.get("status") == "ok" else 500, payload)
            except Exception as exc:
                send_json(self, 500, {"status": "error", "message": f"History Momentum Ignition tidak terbaca: {exc}", "history": []})
            return
        if parsed.path == '/api/pb1m_research':
            try:
                send_json(self, 200, pb1m_research.load_pb1m_research())
            except Exception as exc:
                send_json(self, 500, {
                    "status": "error",
                    "message": f"Riset PB1M tidak terbaca: {exc}",
                    "events": [],
                })
            return
        if parsed.path == '/api/pb1m_telegram_status':
            try:
                send_json(self, 200, {
                    "status": "ok",
                    **pb1m_telegram_alert.pb1m_telegram_alert_status(),
                })
            except Exception as exc:
                send_json(self, 500, {
                    "status": "error",
                    "message": f"Status Telegram PB1M tidak terbaca: {exc}",
                })
            return
        if parsed.path == '/api/trending_radar_history':
            try:
                send_json(self, 200, load_trending_radar_history())
            except Exception as exc:
                send_json(self, 500, {
                    "status": "error",
                    "message": f"Riwayat radar trending tidak terbaca: {exc}",
                    "sessions": {},
                })
            return
        if parsed.path == '/api/trending_pa_trade_history':
            try:
                payload = load_trending_pa_trade_history()
                send_json(self, 200 if payload.get('status') == 'ok' else 500, payload)
            except Exception as exc:
                send_json(self, 500, {"status": "error", "message": f"Trade history Trending PA tidak terbaca: {exc}", "history": []})
            return
        if parsed.path == '/api/trending_pa_telegram_status':
            try:
                send_json(self, 200, {
                    "status": "ok",
                    **trending_pa_telegram_alert.trending_pa_telegram_alert_status(),
                })
            except Exception as exc:
                send_json(self, 500, {
                    "status": "error",
                    "message": f"Status Telegram Trending PA tidak terbaca: {exc}",
                })
            return
        if parsed.path == '/api/trending_pa_ab_history':
            try:
                payload = trending_pa_ab_payload()
                send_json(self, 200 if payload.get('status') == 'ok' else 500, payload)
            except Exception as exc:
                send_json(self, 500, {"status": "error", "message": str(exc), "trades": [], "summary": {}})
            return
        if parsed.path == '/api/trend_break_watchlist':
            try:
                send_json(self, 200, trend_break_watchlist_payload())
            except Exception as exc:
                send_json(self, 500, {
                    "status": "error",
                    "message": f"Watchlist Trend Break tidak terbaca: {exc}",
                })
            return
        if parsed.path == '/api/quotes':
            tickers = [t.strip().upper() for t in (parse_qs(parsed.query).get('tickers', [''])[0]).split(',') if t.strip()]
            if not tickers:
                send_json(self, 400, {"status": "error", "message": "tickers kosong"})
                return
            try:
                quotes, as_of = fetch_quotes_cached(tickers)
                retrieved_at = now_jakarta().isoformat(timespec="seconds")
                quote_retrieved_at = max(
                    (
                        str(quote.get("retrieved_at") or "")
                        for quote in quotes.values()
                        if quote.get("retrieved_at")
                    ),
                    default="",
                )
                send_json(self, 200, {
                    "status": "ok",
                    "quotes": quotes,
                    "as_of": as_of,
                    "retrieved_at": retrieved_at,
                    "quote_retrieved_at": quote_retrieved_at,
                    "market_open": is_market_open(),
                    "source": "TradingView Scanner",
                    "freshness_note": "data_as_of adalah waktu bar harga; retrieved_at adalah waktu endpoint dibaca",
                })
            except Exception as e:
                send_json(self, 502, {"status": "error", "message": str(e)})
            return
        if parsed.path == '/api/konglo_leadership':
            tickers = [t.strip().upper() for t in (parse_qs(parsed.query).get('tickers', [''])[0]).split(',') if t.strip()]
            if not tickers:
                send_json(self, 400, {"status": "error", "message": "tickers kosong"})
                return
            try:
                quotes, quote_as_of = fetch_quotes_cached(tickers)
                current = now_jakarta()
                market_open = is_market_open(current)
                fetched_at = current.isoformat(timespec="seconds")
                payload = load_konglo_leadership_payload(
                    tickers,
                    quotes,
                    market_open=market_open,
                    now=current,
                    as_of=fetched_at,
                    quote_as_of=quote_as_of,
                )
                session_complete = market_elapsed_minutes(current) >= market_total_minutes(current)
                payload["performance"] = update_flight_performance(
                    payload,
                    quotes,
                    market_open=market_open,
                    session_complete=session_complete,
                    now=current,
                )
                payload["performance_audit_only"] = True
                send_json(self, 200, payload)
            except Exception as e:
                send_json(self, 502, {"status": "error", "message": str(e)})
            return
        if parsed.path == '/api/konglo_flight_performance':
            try:
                send_json(self, 200, load_flight_performance_summary())
            except Exception as e:
                send_json(self, 500, {"status": "error", "message": str(e)})
            return
        if parsed.path == '/api/konglo_relative':
            tickers = [t.strip().upper() for t in (parse_qs(parsed.query).get('tickers', [''])[0]).split(',') if t.strip()]
            if not tickers:
                send_json(self, 400, {"status": "error", "message": "tickers kosong"})
                return
            try:
                metrics = fetch_relative_metrics_cached(tickers)
                send_json(self, 200, {
                    "status": "ok",
                    "metrics": metrics,
                    "windows": ["1W", "1M", "3M"],
                    "as_of": now_jakarta().isoformat(timespec="seconds"),
                    "market_open": is_market_open(),
                })
            except Exception as e:
                send_json(self, 502, {"status": "error", "message": str(e)})
            return
        if parsed.path == '/api/konglo_structure':
            tickers = [t.strip().upper() for t in (parse_qs(parsed.query).get('tickers', [''])[0]).split(',') if t.strip()]
            if not tickers:
                send_json(self, 400, {"status": "error", "message": "tickers kosong"})
                return
            try:
                structures = fetch_daily_structures_cached(tickers)
                send_json(self, 200, {
                    "status": "ok",
                    "structures": structures,
                    "timeframe": "1D",
                    "lookback": 4,
                    "as_of": now_jakarta().isoformat(timespec="seconds"),
                    "source": "Yahoo Finance daily OHLC",
                })
            except Exception as e:
                send_json(self, 502, {"status": "error", "message": str(e)})
            return
        if parsed.path == '/api/tv_sr':
            tickers = [t.strip().upper() for t in (parse_qs(parsed.query).get('tickers', [''])[0]).split(',') if t.strip()]
            if not tickers:
                send_json(self, 400, {"status": "error", "message": "tickers kosong"})
                return
            try:
                sr = fetch_tradingview_sr(tickers)
                send_json(self, 200, {
                    "status": "ok",
                    "sr": sr,
                    "as_of": now_jakarta().isoformat(timespec="seconds"),
                })
            except Exception as e:
                send_json(self, 502, {"status": "error", "message": str(e)})
            return
        if parsed.path == '/api/tv_technical':
            tickers = [t.strip().upper() for t in (parse_qs(parsed.query).get('tickers', [''])[0]).split(',') if t.strip()]
            if not tickers:
                send_json(self, 400, {"status": "error", "message": "tickers kosong"})
                return
            try:
                technical = fetch_tradingview_technical(tickers)
                send_json(self, 200, {
                    "status": "ok",
                    "technical": technical,
                    "timeframe": "D",
                    "as_of": now_jakarta().isoformat(timespec="seconds"),
                })
            except Exception as e:
                send_json(self, 502, {"status": "error", "message": str(e)})
            return
        if parsed.path == '/api/multibagger_research':
            try:
                raw_limit = (parse_qs(parsed.query).get('limit') or ['500'])[0]
                limit = max(25, min(int(raw_limit), 500))
            except (TypeError, ValueError):
                send_json(self, 400, {"status": "error", "message": "limit harus berupa angka 25–500"})
                return
            try:
                # This endpoint is an explicit user-triggered research refresh;
                # it is deliberately not part of the live trading/PA loop.
                from multibagger_research import run_research
                send_json(self, 200, run_research(limit=limit, persist=True))
            except Exception as e:
                send_json(self, 502, {"status": "error", "message": str(e)})
            return
        if parsed.path == '/api/pa_snapshot':
            tickers = [t.strip().upper() for t in (parse_qs(parsed.query).get('tickers', [''])[0]).split(',') if t.strip()]
            if not tickers:
                send_json(self, 400, {"status": "error", "message": "tickers kosong"})
                return
            try:
                # load_pa_snapshot_payload owns these canonical calculations
                # for both cached and uncached requests:
                # qualities = {ticker: snapshot_quality(snapshot, time.time())
                # trigger_states = update_pa_trigger_states(tickers, snapshots, sr, qualities)
                send_json(self, 200, load_pa_snapshot_payload(tickers))
            except Exception as e:
                send_json(self, 502, {"status": "error", "message": str(e)})
            return
        if parsed.path == '/api/sideways_breakout_snapshot':
            query = parse_qs(parsed.query)
            raw_watchlist = (query.get('watchlist') or ['ALL_WATCHLISTS'])[0].strip().upper() or 'ALL_WATCHLISTS'
            watchlist = 'ALL_WATCHLISTS' if raw_watchlist in {'ALL', 'ALL_REGISTERED', 'SEMUA'} else raw_watchlist
            requested = [t.strip().upper() for t in (query.get('tickers') or [''])[0].split(',') if t.strip()]
            if requested:
                tickers = tuple(sorted(set(requested)))
                if watchlist in {'ALL_WATCHLISTS', SIDEWAYS_BREAKOUT_ALL_IDX_WATCHLIST}:
                    allowed = set(swing_breakout_watchlist_tickers(watchlist))
                    tickers = tuple(ticker for ticker in tickers if ticker in allowed)
            elif watchlist in {'ALL_WATCHLISTS', SIDEWAYS_BREAKOUT_ALL_IDX_WATCHLIST}:
                tickers = swing_breakout_watchlist_tickers(watchlist)
            else:
                tickers = swing_breakout_watchlist_tickers(watchlist)
            if not tickers:
                send_json(self, 400, {"status": "error", "message": "watchlist/tickers kosong"})
                return
            try:
                send_json(self, 200, load_sideways_breakout_snapshot(tickers, watchlist))
            except Exception as e:
                send_json(self, 502, {"status": "error", "message": str(e)})
            return
        if parsed.path == '/api/sideways_breakout_history':
            try:
                payload = load_sideways_breakout_history()
                send_json(self, 200 if payload.get('status') == 'ok' else 500, payload)
            except Exception as e:
                send_json(self, 500, {"status": "error", "message": str(e), "history": []})
            return
        if parsed.path == '/api/support_strength_snapshot':
            query = parse_qs(parsed.query)
            raw_watchlist = (query.get('watchlist') or ['ALL_WATCHLISTS'])[0].strip().upper() or 'ALL_WATCHLISTS'
            watchlist = 'ALL_WATCHLISTS' if raw_watchlist in {'ALL', 'ALL_REGISTERED', 'SEMUA'} else raw_watchlist
            requested = [t.strip().upper() for t in (query.get('tickers') or [''])[0].split(',') if t.strip()]
            universe_meta = load_sideways_breakout_idx_universe() if watchlist == SIDEWAYS_BREAKOUT_ALL_IDX_WATCHLIST else None
            if requested:
                if universe_meta is not None:
                    allowed = set(universe_meta.get('tickers') or [])
                    tickers = tuple(sorted(set(requested) & allowed))
                else:
                    tickers = tuple(sorted(set(requested)))
            elif universe_meta is not None:
                tickers = tuple(universe_meta.get('tickers') or [])
            else:
                tickers = swing_breakout_watchlist_tickers(watchlist)
            if not tickers:
                send_json(self, 400, {"status": "error", "message": "watchlist/tickers kosong"})
                return
            try:
                send_json(self, 200, load_support_strength_snapshot(tickers, watchlist, universe_meta))
            except Exception as e:
                send_json(self, 502, {"status": "error", "message": str(e)})
            return
        if parsed.path == '/api/sr_trend_snapshot':
            query = parse_qs(parsed.query)
            raw_tickers = (query.get('tickers') or [''])[0]
            if not raw_tickers.strip():
                send_json(self, 400, {"status": "error", "message": "tickers kosong"})
                return
            source = (query.get('source') or [''])[0]
            reason = (query.get('reason') or [''])[0]
            raw_items = [
                {"ticker": ticker, "source": source, "reason": reason}
                for ticker in raw_tickers.split(',')
                if ticker.strip()
            ]
            normalized = normalize_input_items(raw_items=raw_items)
            if not normalized["items"]:
                send_json(self, 400, {
                    "status": "error",
                    "message": "Tidak ada ticker valid.",
                    "input": normalized,
                })
                return
            if len(normalized["items"]) > 100:
                send_json(self, 413, {
                    "status": "error",
                    "message": "Maksimal 100 ticker per batch.",
                    "input": normalized,
                })
                return
            try:
                payload = load_sr_trend_snapshot(normalized["items"])
                payload["input"] = normalized
                send_json(self, 200, payload)
            except Exception as exc:
                send_json(self, 502, {
                    "status": "error",
                    "message": f"Analisis S/R + Trend gagal: {exc}",
                    "input": normalized,
                })
            return
        if parsed.path == '/api/swing_breakout_snapshot':
            query = parse_qs(parsed.query)
            watchlist = (query.get('watchlist') or ['KONGLO'])[0].strip().upper() or 'KONGLO'
            requested = [t.strip().upper() for t in (query.get('tickers') or [''])[0].split(',') if t.strip()]
            universe_meta = load_sideways_breakout_idx_universe() if watchlist == 'ALL' else None
            if requested:
                if universe_meta is not None:
                    allowed = set(universe_meta.get('tickers') or [])
                    tickers = tuple(sorted(set(requested) & allowed))
                else:
                    tickers = tuple(sorted(set(requested)))
            else:
                tickers = tuple(universe_meta.get('tickers') or []) if universe_meta is not None else swing_breakout_watchlist_tickers(watchlist)
            if not tickers:
                send_json(self, 400, {"status": "error", "message": "watchlist/tickers kosong"})
                return
            try:
                send_json(self, 200, load_swing_breakout_snapshot(tickers, watchlist, universe_meta))
            except Exception as e:
                send_json(self, 502, {"status": "error", "message": str(e)})
            return
        if parsed.path == '/api/swing_breakout_chart':
            query = parse_qs(parsed.query)
            ticker = (query.get('ticker') or [''])[0].strip().upper()
            if not ticker:
                send_json(self, 400, {"status": "error", "message": "ticker kosong"})
                return
            try:
                payload = load_swing_breakout_chart(ticker)
                send_json(self, 200 if payload.get('status') == 'ok' else 502, payload)
            except Exception as e:
                send_json(self, 502, {"status": "error", "message": str(e)})
            return
        if parsed.path == '/api/swing_breakout_history':
            try:
                payload = load_swing_breakout_history()
                send_json(self, 200 if payload.get('status') == 'ok' else 500, payload)
            except Exception as e:
                send_json(self, 500, {"status": "error", "message": str(e), "history": []})
            return
        if parsed.path == '/api/support_pullback_history':
            try:
                payload = load_support_pullback_history()
                send_json(self, 200 if payload.get('status') == 'ok' else 500, payload)
            except Exception as e:
                send_json(self, 500, {"status": "error", "message": str(e), "history": []})
            return
        if parsed.path == '/api/swing_entry_compare':
            query = parse_qs(parsed.query)
            watchlist = (query.get('watchlist') or ['KONGLO'])[0].strip().upper() or 'KONGLO'
            period = (query.get('period') or ['1y'])[0].strip().lower()
            if period not in {'1y', '2y', '5y'}:
                period = '1y'
            tickers = swing_breakout_watchlist_tickers(watchlist)
            if not tickers:
                send_json(self, 400, {"status": "error", "message": "watchlist kosong"})
                return
            cache_key = (watchlist, period)
            now_mono = time.monotonic()
            with SWING_ENTRY_COMPARE_CACHE_LOCK:
                cached = SWING_ENTRY_COMPARE_CACHE.get(cache_key)
                if cached and now_mono - cached["created_at"] < SWING_ENTRY_COMPARE_CACHE_TTL:
                    send_json(self, 200, cached["payload"])
                    return
            try:
                from swing_entry_compare import run_comparison
                payload = run_comparison(tickers, period=period)
                payload["watchlist"] = watchlist
                payload["as_of"] = now_jakarta().isoformat(timespec="seconds")
                if watchlist == 'ALL':
                    universe_meta = load_sideways_breakout_idx_universe()
                    payload["universe"] = {
                        "label": "Semua IDX · FCA dikecualikan",
                        "listed_count": int(universe_meta.get("listed_count") or 0),
                        "eligible_count": len(universe_meta.get("tickers") or []),
                        "fca_excluded_count": int(universe_meta.get("fca_in_universe_count") or 0),
                        "fca_registry_count": int(universe_meta.get("fca_count") or 0),
                        "fca_as_of": universe_meta.get("fca_as_of") or "",
                        "fca_effective_from": universe_meta.get("fca_effective_from") or "",
                        "universe_source": universe_meta.get("universe_source") or "",
                        "fca_source": universe_meta.get("fca_source") or "",
                    }
                with SWING_ENTRY_COMPARE_CACHE_LOCK:
                    SWING_ENTRY_COMPARE_CACHE[cache_key] = {
                        "created_at": time.monotonic(),
                        "payload": payload,
                    }
                send_json(self, 200, payload)
            except Exception as e:
                send_json(self, 502, {"status": "error", "message": str(e)})
            return
        if parsed.path == '/api/pa_watchlists':
            try:
                from pa_watchlists import get_pa_watchlists
                send_json(self, 200, {
                    "status": "ok",
                    "watchlists": get_pa_watchlists(),
                    "source": "PA watchlists terpilih",
                })
            except Exception as e:
                send_json(self, 500, {"status": "error", "message": str(e)})
            return
        if parsed.path == '/api/pa_history':
            try:
                import pa_tracker
                data = pa_tracker.load_dashboard_history()
                history = data.get("history") or []
                closed = [
                    item for item in history
                    if str(item.get("status", "")).lower() in {"win", "loss"}
                ]
                wins = sum(1 for item in closed if str(item.get("status", "")).lower() == "win")
                losses = sum(1 for item in closed if str(item.get("status", "")).lower() == "loss")
                net_values = []
                for item in closed:
                    value = item.get("net_return_pct", item.get("profit_pct"))
                    try:
                        value = float(value)
                    except (TypeError, ValueError):
                        continue
                    if value == value and value not in (float("inf"), float("-inf")):
                        net_values.append(value)
                data["performance_summary"] = {
                    "closed_trades": len(closed),
                    "wins": wins,
                    "losses": losses,
                    "winrate_pct": (wins / len(closed) * 100) if closed else None,
                    "net_return_pct": sum(net_values) if net_values else None,
                    "ready": bool(closed),
                    "message": (
                        "Histori PA belum memiliki trade selesai; winrate akan muncul "
                        "setelah entry paper dan exit candle terpantau."
                        if not closed else
                        "Winrate dihitung dari trade PA paper yang sudah selesai."
                    ),
                }
                data["collector_state"] = read_pa_paper_state()
                send_json(self, 200, data)
            except Exception as e:
                send_json(self, 500, {"status": "error", "message": str(e)})
            return
        if parsed.path == '/api/trend_break_history':
            try:
                from trend_break_history import load_range
                params = parse_qs(parsed.query)
                start = str((params.get("start") or [""])[0])[:10]
                end = str((params.get("end") or [""])[0])[:10]
                summary = str((params.get("summary") or [""])[0]).lower() in {"1", "true", "yes"}
                payload = load_range(start or None, end or None, compact=summary)
                send_json(self, 200, {
                    "status": "ok",
                    **payload,
                })
            except Exception as exc:
                send_json(self, 500, {"status": "error", "message": str(exc)})
            return
        if parsed.path == '/api/trend_break_daily_closes':
            try:
                tickers = [
                    ticker.strip().upper()
                    for ticker in (parse_qs(parsed.query).get('tickers', [''])[0]).split(',')
                    if ticker.strip()
                ]
                if not tickers:
                    send_json(self, 400, {"status": "error", "message": "tickers kosong"})
                    return
                closes = fetch_trend_break_daily_closes(tickers)
                send_json(self, 200, {
                    "status": "ok",
                    "closes": closes,
                    "source": "Yahoo Finance daily OHLC",
                    "retrieved_at": now_jakarta().isoformat(timespec="seconds"),
                })
            except Exception as exc:
                send_json(self, 502, {"status": "error", "message": str(exc), "closes": {}})
            return
        if parsed.path == '/api/data_status':
            # Returns mtime (Unix timestamp) of all screener result files.
            # Dashboards poll this cheaply every 10s and reload only when mtime changes.
            DATA_FILES = {
                "screening":    "screening_results.json",
                "trend_break":  "trend_break_results.json",
                "trend_break_audit": "trend_break_audit.json",
                "trend_break_outcomes": "trend_break_outcomes.json",
                "trend_break_history_index": "trend_break_history/index.json",
                "bullish_ma":   "bullish_ma_results.json",
                "breakout":     "breakout_results.json",
                "pre_pump":     "pre_pump_results.json",
                "anomaly":      "anomaly_results.json",
                "anomaly_history": "anomaly_history.json",
                "recommendation_history": "recommendation_history.json",
                "broker":       "broker_summary_results.json",
                "broker_trend_break": "broker_summary_results_trendbreak.json",
                "audit":        "neobdm_audit.json",
                "machine_learning": "ml_model_state.json",
            }
            mtimes = {}
            for key, fname in DATA_FILES.items():
                fpath = os.path.join(os.getcwd(), fname)
                try:
                    mtimes[key] = int(os.path.getmtime(fpath))
                except OSError:
                    mtimes[key] = 0
            send_json(self, 200, {"status": "ok", "mtimes": mtimes, "ts": int(time.time())})
            return
        if parsed.path == '/api/external_signals':
            try:
                fpath = os.path.join(os.getcwd(), "external_signals.json")
                if os.path.exists(fpath):
                    with open(fpath, "r") as f:
                        data = json.load(f)
                else:
                    data = {"signals": []}
                if isinstance(data, dict):
                    data.setdefault("rules", EXTERNAL_SIGNAL_RULES)
                send_json(self, 200, data)
            except Exception as e:
                send_json(self, 500, {"status": "error", "message": str(e)})
            return
        if parsed.path == '/api/signal_performance':
            try:
                fpath = os.path.join(os.getcwd(), "signal_performance.json")
                if os.path.exists(fpath):
                    with open(fpath, "r") as f:
                        data = json.load(f)
                else:
                    data = {}
                send_json(self, 200, data)
            except Exception as e:
                send_json(self, 500, {"status": "error", "message": str(e)})
            return
        super().do_GET()


    def do_POST(self):
        if urlparse(self.path).path == '/api/dmas/run':
            content_length = int(self.headers.get('Content-Length', 0))
            if content_length > 16 * 1024:
                send_json(self, 413, {
                    "status": "error",
                    "message": "Payload pipeline DMAS terlalu besar.",
                })
                return
            try:
                params = json.loads(self.rfile.read(content_length).decode('utf-8') or "{}")
                if not isinstance(params, dict):
                    raise ValueError("Payload harus berupa object JSON.")
                started, payload = start_dmas_pipeline_job(
                    date=params.get("date"),
                    trigger=str(params.get("trigger") or "dashboard"),
                )
                if started:
                    send_json(self, 202, payload)
                elif payload.get("status") == "running":
                    send_json(self, 409, payload)
                else:
                    send_json(self, 400, payload)
            except (ValueError, json.JSONDecodeError) as exc:
                send_json(self, 400, {"status": "error", "message": str(exc)})
            except Exception as exc:
                send_json(self, 500, {
                    "status": "error",
                    "message": f"Pipeline DMAS gagal dimulai: {exc}",
                })
            return
        if self.path == '/api/sr_trend_snapshot':
            content_length = int(self.headers.get('Content-Length', 0))
            if content_length > 128 * 1024:
                send_json(self, 413, {
                    "status": "error",
                    "message": "payload analisis terlalu besar",
                })
                return
            try:
                params = json.loads(self.rfile.read(content_length).decode('utf-8') or "{}")
                if isinstance(params.get("text"), str):
                    normalized = normalize_input_items(raw_text=params.get("text"))
                else:
                    raw_items = params.get("items")
                    if raw_items is None:
                        raw_items = params.get("tickers") or []
                    if not isinstance(raw_items, list):
                        raw_items = [raw_items]
                    normalized = normalize_input_items(raw_items=raw_items)
                if not normalized["items"]:
                    send_json(self, 400, {
                        "status": "error",
                        "message": "Tidak ada ticker valid.",
                        "input": normalized,
                    })
                    return
                if len(normalized["items"]) > 100:
                    send_json(self, 413, {
                        "status": "error",
                        "message": "Maksimal 100 ticker per batch.",
                        "input": normalized,
                    })
                    return
                payload = load_sr_trend_snapshot(normalized["items"])
                payload["input"] = normalized
                send_json(self, 200, payload)
            except (ValueError, json.JSONDecodeError) as exc:
                send_json(self, 400, {"status": "error", "message": str(exc)})
            except Exception as exc:
                send_json(self, 502, {
                    "status": "error",
                    "message": f"Analisis S/R + Trend gagal: {exc}",
                })
            return

        if self.path == '/api/trend_break_watchlist':
            content_length = int(self.headers.get('Content-Length', 0))
            if content_length > 64 * 1024:
                send_json(self, 413, {
                    "status": "error",
                    "message": "payload watchlist terlalu besar",
                })
                return
            try:
                params = json.loads(
                    self.rfile.read(content_length).decode('utf-8') or "{}"
                )
                result = update_trend_break_watchlist(
                    params.get("action"),
                    ticker=params.get("ticker", ""),
                    tickers=params.get("tickers"),
                )
                send_json(self, 200, result)
            except (ValueError, json.JSONDecodeError) as exc:
                send_json(self, 400, {"status": "error", "message": str(exc)})
            except Exception as exc:
                send_json(self, 500, {
                    "status": "error",
                    "message": f"Watchlist Trend Break gagal disimpan: {exc}",
                })
            return

        if self.path == '/api/support_pullback_history':
            content_length = int(self.headers.get('Content-Length', 0))
            if content_length > 128 * 1024:
                send_json(self, 413, {"status": "error", "message": "payload terlalu besar"})
                return
            try:
                params = json.loads(self.rfile.read(content_length).decode('utf-8') or "{}")
                action = str(params.get("action") or "record").strip().lower()
                if action == "record":
                    result = record_support_pullback_trade(params)
                    status_code = 201 if result.get("inserted") else 200
                elif action == "close":
                    result = close_support_pullback_trade(params)
                    status_code = 200
                else:
                    raise ValueError("action harus record atau close")
                history = result.get("history") or []
                send_json(self, status_code, {
                    "status": "ok",
                    **result,
                    "performance_summary": support_pullback_history_summary(history),
                    "source": SUPPORT_PULLBACK_HISTORY_FILE,
                })
            except (ValueError, json.JSONDecodeError) as exc:
                send_json(self, 400, {"status": "error", "message": str(exc)})
            except Exception as exc:
                send_json(self, 500, {"status": "error", "message": str(exc)})
            return

        if self.path == '/api/pa_alert':
            content_length = int(self.headers.get('Content-Length', 0))
            try:
                params = json.loads(self.rfile.read(content_length).decode('utf-8') or "{}")
                ok, message = send_pa_telegram_alert(params)
                send_json(self, 200 if ok else 503, {"status": "ok" if ok else "error", "message": message})
            except Exception as exc:
                send_json(self, 400, {"status": "error", "message": str(exc)})
            return

        if self.path == '/api/konglo_haka_alert':
            content_length = int(self.headers.get('Content-Length', 0))
            try:
                params = json.loads(self.rfile.read(content_length).decode('utf-8') or "{}")
                ok, message = send_konglo_telegram_alert(params)
                send_json(self, 200 if ok else 503, {"status": "ok" if ok else "error", "message": message})
            except Exception as exc:
                send_json(self, 400, {"status": "error", "message": str(exc)})
            return

        if self.path == '/api/konglo_leader_alert':
            content_length = int(self.headers.get('Content-Length', 0))
            try:
                params = json.loads(self.rfile.read(content_length).decode('utf-8') or "{}")
                ok, message = send_konglo_leader_alert(params)
                send_json(self, 200 if ok else 503, {"status": "ok" if ok else "error", "message": message})
            except Exception as exc:
                send_json(self, 400, {"status": "error", "message": str(exc)})
            return

        if self.path == '/api/run_screener':
            content_length = int(self.headers.get('Content-Length', 0))
            post_data = self.rfile.read(content_length)
            try:
                params = json.loads(post_data.decode('utf-8') or "{}")
                source = params.get("source", "")
                started, payload = start_screener_job(source, trigger="manual")
                if started:
                    send_json(self, 202, payload)
                elif payload.get("status") == "running":
                    send_json(self, 409, payload)
                else:
                    send_json(self, 400, payload)
            except Exception as e:
                send_json(self, 500, {"status": "error", "message": str(e)})
            return

        if self.path == '/api/run_vcp_scan':
            try:
                content_length = int(self.headers.get('Content-Length', 0))
                params = json.loads(self.rfile.read(content_length).decode('utf-8') or "{}")
                started, payload = start_vcp_scan_job(trigger=str(params.get("trigger") or "manual"))
                if started:
                    send_json(self, 202, payload)
                elif payload.get("status") == "running":
                    send_json(self, 409, payload)
                else:
                    send_json(self, 500, payload)
            except Exception as exc:
                send_json(self, 400, {"status": "error", "message": str(exc)})
            return

        if self.path == '/api/run_broker_summary':
            content_length = int(self.headers.get('Content-Length', 0))
            post_data = self.rfile.read(content_length)
            try:
                params = json.loads(post_data.decode('utf-8') or "{}")
                source = params.get("source", "trendbreak")
                started, payload = start_broker_summary_job(source, trigger="manual")
                if started:
                    send_json(self, 202, payload)
                elif payload.get("status") == "running":
                    send_json(self, 409, payload)
                else:
                    send_json(self, 400, payload)
            except Exception as e:
                send_json(self, 500, {"status": "error", "message": str(e)})
            return

        if self.path == '/api/run_broker_ticker':
            content_length = int(self.headers.get('Content-Length', 0))
            post_data = self.rfile.read(content_length)
            try:
                params = json.loads(post_data.decode('utf-8') or "{}")
                started, payload = start_broker_ticker_job(
                    params.get("ticker", ""),
                    params.get("source", "trendbreak"),
                    trigger="manual-dashboard",
                )
                if started:
                    send_json(self, 202, payload)
                elif payload.get("status") == "running":
                    send_json(self, 409, payload)
                else:
                    send_json(self, 400, payload)
            except Exception as e:
                send_json(self, 500, {"status": "error", "message": str(e)})
            return

        if self.path == '/api/edit_levels':

            content_length = int(self.headers['Content-Length'])
            post_data = self.rfile.read(content_length)
            try:
                params = json.loads(post_data.decode('utf-8'))
                ticker = params.get('ticker')
                entry_date = params.get('entry_date')
                new_entry = params.get('entry_price')
                new_entry_low = params.get('entry_low')
                new_entry_high = params.get('entry_high')
                new_support = params.get('support_price')
                new_resistance = params.get('resistance_price')
                new_sl = params.get('sl_price')
                new_t1 = params.get('t1_price')
                new_t2 = params.get('t2_price')
                new_status = params.get('status')
                
                history_path = "./recommendation_history.json"
                js_path = "./recommendation_history.js"
                
                if os.path.exists(history_path):
                    with open(history_path, 'r') as f:
                        data = json.load(f)
                    
                    recs = data.get("recommendations", [])
                    updated = False
                    for r in recs:
                        if r.get("ticker") == ticker and r.get("entry_date") == entry_date:
                            if new_entry is not None:
                                r["entry_price"] = float(new_entry)
                                cur_price = r.get("current_price", 0.0)
                                if float(new_entry) > 0:
                                    r["return_pct"] = round(((cur_price - float(new_entry)) / float(new_entry)) * 100, 2)
                            if new_entry_low is not None:
                                r["entry_low"] = float(new_entry_low)
                            if new_entry_high is not None:
                                r["entry_high"] = float(new_entry_high)
                            if new_support is not None:
                                r["support_price"] = float(new_support)
                            if new_resistance is not None:
                                r["resistance_price"] = float(new_resistance)
                            if new_sl is not None:
                                r["sl_price"] = float(new_sl)
                            if new_t1 is not None:
                                r["t1_price"] = float(new_t1)
                            if new_t2 is not None:
                                r["t2_price"] = float(new_t2)
                            if new_status is not None:
                                if new_status == "ACTIVE":
                                    rr = reward_risk_ratio(
                                        r.get("current_price") or r.get("entry_price"),
                                        r.get("sl_price"),
                                        r.get("t1_price"),
                                    )
                                    min_rr = min_rr_for_setup(r.get("setup_type", "PULLBACK"))
                                    if rr < min_rr:
                                        send_json(self, 400, {
                                            "status": "error",
                                            "message": f"Live R:R dari harga sekarang baru 1:{rr:.2f}, minimal 1:{min_rr:.0f}. Status Buka tidak disimpan.",
                                        })
                                        return
                                r["status"] = new_status
                                if new_status == "TAKE_PROFIT_T1":
                                    r["t1_hit_price"] = r.get("t1_price") or r.get("current_price", 0.0)
                                    r["t1_date"] = datetime.now().strftime("%Y-%m-%d")
                                    r["close_date"] = ""
                                    r["exit_price"] = 0.0
                                elif new_status in ["TAKE_PROFIT", "CUT_LOSS"]:
                                    if not r.get("close_date"):
                                        r["close_date"] = datetime.now().strftime("%Y-%m-%d")
                                    if new_status == "TAKE_PROFIT":
                                        r["exit_price"] = r.get("t2_price") or r.get("t1_price") or r.get("current_price", 0.0)
                                    elif new_status == "CUT_LOSS":
                                        r["exit_price"] = r.get("sl_price") or r.get("current_price", 0.0)
                                else:
                                    r["close_date"] = ""
                                    r["exit_price"] = 0.0
                            updated = True
                            break
                            
                    if updated:
                        atomic_write_json(history_path, data, indent=2)
                        atomic_write_text(js_path, f"var RECOMMENDATION_HISTORY = {json.dumps(data, indent=2)};\n")
                            
                        self.send_response(200)
                        self.send_header('Content-Type', 'application/json')
                        self.send_header('Access-Control-Allow-Origin', '*')
                        self.end_headers()
                        self.wfile.write(json.dumps({"status": "success", "message": "Levels updated successfully"}).encode())
                        return
                
                self.send_response(400)
                self.end_headers()
                self.wfile.write(b"Error updating levels")
            except Exception as e:
                self.send_response(500)
                self.end_headers()
                self.wfile.write(str(e).encode())
        else:
            self.send_response(404)
            self.end_headers()

if __name__ == '__main__':
    if AUTO_SCAN_ENABLED:
        threading.Thread(target=auto_scan_loop, daemon=True).start()
        labels = ", ".join(SCREENER_SOURCES.get(s, s) for s in AUTO_SCAN_SOURCES)
        print(
            f"⏱️ Auto-scan rotasi [{labels}] aktif "
            f"setiap {AUTO_SCAN_INTERVAL_MIN} menit/source saat market buka."
        )
    if TREND_BREAK_LIVE_ENABLED:
        threading.Thread(target=trend_break_live_loop, daemon=True).start()
        print(
            "📈 Trend Break auto realtime aktif: "
            f"scan market-wide setiap {TREND_BREAK_LIVE_INTERVAL_SEC} detik "
            "saat sesi IDX buka."
        )
    write_background_automation_state(
        status="disabled",
        worker="background_market_automation",
        updated_at=now_jakarta().isoformat(timespec="seconds"),
        message="Fokus proyek: Trend Break screener & dashboard.",
    )
    server_address = ('', PORT)
    httpd = http.server.ThreadingHTTPServer(server_address, CustomHandler)
    print(f"🚀 Custom Server running on port {PORT}...")
    httpd.serve_forever()
