import os
import sys
import time
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo

import requests
import pandas as pd
import numpy as np
import yfinance as yf
import matplotlib
matplotlib.use("Agg")  # biar bisa jalan tanpa display/GUI (server/headless)
import mplfinance as mpf

# ============================================================
# NEUROBRO SCALPING & LONG SYSTEM — V1.0
# Implementasi lengkap resep 3-layer filter (Likuiditas -> Teknikal 3/5 -> Sentimen)
# + entry/SL/support-resistance sesuai dokumen resep.
# ============================================================

# ============ KONFIGURASI DASAR ============
TELEGRAM_BOT_TOKEN = "ISI_TOKEN_BOT_TELEGRAM_KAMU"
TELEGRAM_CHAT_ID = "ISI_CHAT_ID_KAMU"

TICKER_FILE = "tickers_idx.txt"

MIN_VOLUME = 100_000     # pre-filter cepat: skip saham yg volume bar terakhirnya receh banget
BATCH_SIZE = 50
BATCH_DELAY_SEC = 2

# --- TIMEFRAME ---
# "1h"  -> MODE SCALP. Dipakai sebagai proksi "30M chart" di resep (yfinance IDX
#          gak selalu nyediain 30m yg stabil, 1h paling deket & reliable).
# "1d"  -> MODE LONG/SWING harian.
# "1wk" -> MODE LONG/POSITION mingguan.
# Trend filter (SMA50) SELALU dihitung dari data HARIAN sesuai resep ("SMA 50 -
# Daily Chart"), terlepas dari TIMEFRAME primary di atas. Kalau primary != "1d",
# script otomatis fetch tambahan data harian khusus buat trend filter + cek
# likuiditas Layer 1 (volume/value traded harus dihitung dari basis harian).
TIMEFRAME = "1d"

TIMEFRAME_CONFIG = {
    "1h":  {"interval": "60m", "period": "1mo"},
    "1d":  {"interval": "1d",  "period": "2y"},
    "1wk": {"interval": "1wk", "period": "5y"},
}
DAILY_REF_PERIOD = "1y"   # period buat fetch tambahan data harian (trend filter + layer1)

MODE = "scalp" if TIMEFRAME == "1h" else "long"

# --- FILTER LIKUIDITAS & FREE FLOAT (Layer 1) ---
MIN_AVG_VALUE_TRADED = 1_000_000_000   # rata-rata nilai transaksi 20 hari (Rupiah)
MIN_FREE_FLOAT_PCT = 25                # minimal free float (%), dari cache free_float_idx.csv
FREE_FLOAT_FILE = "free_float_idx.csv"
REQUIRE_FREE_FLOAT_DATA = False        # True = saham tanpa data free float otomatis di-skip

SCALP_MIN_VOLUME_MULT = 5   # volume hari ini vs rata-rata 20 hari (scalp)
LONG_MIN_VOLUME_MULT = 3    # volume hari ini vs rata-rata 20 hari (long)
VOLUME_AVG_PERIOD = 20

SCALP_PRICE_CEILING = None  # contoh: 200 -> cuma scalp saham di bawah Rp200. None = disable (bebas)

CORP_ACTION_LOOKBACK_SCALP_DAYS = 30   # scalp: gak boleh ada stock split dlm 30 hari terakhir
CORP_ACTION_LOOKBACK_LONG_DAYS = 5     # long: boleh split asal udah lewat 5 hari

CHECK_LIVE_SPREAD = False   # True -> nambah 1 API call/ticker buat cek bid-ask spread real-time

# --- INDIKATOR TEKNIKAL (Layer 2) ---
EMA_FAST = 9
EMA_SLOW = 21
RSI_PERIOD = 14
SMA_TREND_PERIOD = 50
VOLUME_SPIKE_LOOKBACK_BARS = 3 # spike harus terjadi dlm 3 bar terakhir, kalau lebih -> kadaluarsa

# --- BOBOT SKOR (Layer 2) — total 100, minimal 70 buat lolos ---
MAX_SCORE_TOTAL = 100
MIN_SCORE_TOTAL = 70

# Trend Filter - SMA50, dihitung dari data harian (bobot 25)
TREND_MAX_POINTS = 25
TREND_STRONG_PCT = 3       # >3% di atas SMA50
TREND_MODERATE_PCT = 1     # 1-3% di atas SMA50
TREND_AT_SMA_BAND_PCT = 1  # |diff| <= ini dianggap "tepat di SMA50"
TREND_POINTS = {"strong": 25, "moderate": 20, "at_sma": 10, "below": 0}

# Entry Timing - EMA9/21 (bobot 20)
ENTRY_MAX_POINTS = 20
EMA_SLOPE_LOOKBACK_BARS = 3     # buat cek EMA "naik" vs "flat"
EMA_FLAT_THRESHOLD_PCT = 0.15   # slope di bawah ini dianggap flat/datar
ENTRY_POINTS = {"full_cross_rising": 20, "consolidation": 10, "flat_or_weak": 5, "bearish": 0}

# Volume Spike (bobot 20) — list (min_spike_pct, poin), urut menurun
VOLUME_MAX_POINTS = 20
VOLUME_TIERS = [(80, 20), (60, 17), (40, 14), (20, 8)]

# RSI (bobot 20) — list ((low, high), poin)
RSI_MAX_POINTS = 20
RSI_TIERS = [((46, 55), 20), ((56, 65), 15), ((30, 45), 10), ((66, 75), 8)]

# Price Action - 3 bar terakhir (bobot 15)
PRICE_ACTION_MAX_POINTS = 15
PRICE_ACTION_POINTS = {"full_higher_low_green": 15, "partial_higher_low": 12,
                        "higher_low_doji": 8, "no_pattern": 3, "bearish_or_lower_high": 0}

SR_LOOKBACK_BARS = 90
SR_PIVOT_LR = 3            # left/right bar buat deteksi pivot high/low
SR_CLUSTER_TOL_PCT = 1.5   # toleransi gabungin level yg berdekatan jadi satu zona
SR_MIN_TOUCH_USABLE = 2    # minimal disentuh 2x biar valid dipakai entry (sesuai resep entry hierarchy)
SR_STRONG_TOUCH = 3        # >=3x disentuh -> klasifikasi "kuat" (sesuai resep S&R)

FIB_LOOKBACK_BARS = 60
FIB_SCALP_LEVEL = 0.382
FIB_LONG_LEVELS = (0.5, 0.618)

SL_SCALP_LOOKBACK_BARS = 10
SL_SCALP_MAX_PCT = 2.0        # jarak SL scalp maksimal wajar 1.5-2% dari entry
SL_LONG_PCT_RANGE = (4.0, 6.0)  # jarak SL long wajar 4-6% dari entry
SL_LONG_ZONE_BUFFER_PCT = 1.0   # 1% di bawah zona demand

IHSG_TICKER = "^JKSE"

# --- JADWAL: kapan robot boleh jalan ---
RUN_DAYS = [0, 1, 2, 3, 4]     # 0=Senin ... 4=Jumat
RUN_HOUR_START = 9             # jam mulai boleh screening (WIB)
RUN_HOUR_END = 16              # jam terakhir boleh screening (WIB)
ENFORCE_SCHEDULE = False  # False = boleh jalan walau market lagi tutup (weekend/di luar jam bursa),
                          # tetep pake data terakhir yang ke-fetch dari yfinance (last close/last candle)

# --- SELF-LOOP MODE ---
SELF_LOOP = True

# --- CHART OTOMATIS ---
CHARTS_DIR = "charts"
SEND_CHART_TO_TELEGRAM = True
MAX_CHARTS_PER_RUN = 15
CHART_LOOKBACK_CANDLES = 90
# =======================================


def is_within_schedule() -> bool:
    if not ENFORCE_SCHEDULE:
        return True
    now = datetime.now(ZoneInfo("Asia/Jakarta"))
    if now.weekday() not in RUN_DAYS:
        print(f"[SKIP] Hari ini ({now.strftime('%A')}) di luar RUN_DAYS.")
        return False
    if not (RUN_HOUR_START <= now.hour < RUN_HOUR_END):
        print(f"[SKIP] Jam sekarang ({now.hour}:00 WIB) di luar jadwal.")
        return False
    return True


def load_tickers() -> list[str]:
    with open(TICKER_FILE) as f:
        codes = [line.strip() for line in f if line.strip()]
    return [f"{code}.JK" for code in codes]


def load_free_float() -> dict:
    if not os.path.exists(FREE_FLOAT_FILE):
        print(f"[WARN] {FREE_FLOAT_FILE} belum ada - jalankan update_free_float.py dulu "
              f"kalau mau filter free float aktif penuh.")
        return {}

    free_float_map = {}
    with open(FREE_FLOAT_FILE) as f:
        next(f)  # skip header
        for line in f:
            parts = line.strip().split(",")
            if len(parts) == 2 and parts[1]:
                try:
                    free_float_map[parts[0]] = float(parts[1])
                except ValueError:
                    continue
    return free_float_map


def chunk(lst: list, size: int):
    for i in range(0, len(lst), size):
        yield lst[i:i + size]


# ============ INDIKATOR DASAR ============

def calc_rsi(close: pd.Series, period: int = RSI_PERIOD) -> pd.Series:
    delta = close.diff()
    gain = delta.clip(lower=0)
    loss = -delta.clip(upper=0)
    avg_gain = gain.ewm(alpha=1 / period, min_periods=period, adjust=False).mean()
    avg_loss = loss.ewm(alpha=1 / period, min_periods=period, adjust=False).mean()
    rs = avg_gain / avg_loss
    return 100 - (100 / (1 + rs))


def calc_ema(series: pd.Series, period: int) -> pd.Series:
    return series.ewm(span=period, adjust=False).mean()


def estimate_tick_size(price: float) -> float:
    """Fraksi harga IDX (aturan BEI)."""
    if price < 200:
        return 1
    elif price < 500:
        return 2
    elif price < 2000:
        return 5
    elif price < 5000:
        return 10
    else:
        return 25


def compute_common_indicators(df: pd.DataFrame) -> pd.DataFrame:
    out = df.copy()
    out["ema_fast"] = calc_ema(out["Close"], EMA_FAST)
    out["ema_slow"] = calc_ema(out["Close"], EMA_SLOW)
    out["rsi"] = calc_rsi(out["Close"], RSI_PERIOD)
    out["vol_avg20"] = out["Volume"].rolling(VOLUME_AVG_PERIOD).mean()
    out["vol_ratio"] = out["Volume"] / out["vol_avg20"]
    out["is_green"] = out["Close"] > out["Open"]
    out["body"] = (out["Close"] - out["Open"]).abs()
    out["range"] = (out["High"] - out["Low"]).replace(0, np.nan)
    out["body_ratio"] = out["body"] / out["range"]
    return out


def compute_trend_sma(daily_df: pd.DataFrame, period: int = SMA_TREND_PERIOD) -> pd.DataFrame:
    out = daily_df.copy()
    out["sma_trend"] = out["Close"].rolling(period).mean()
    return out


def get_days_since_last_split(daily_df: pd.DataFrame):
    if daily_df is None or "Stock Splits" not in daily_df.columns:
        return None
    splits = daily_df[daily_df["Stock Splits"].fillna(0) != 0]
    if splits.empty:
        return None
    last_split_date = splits.index[-1]
    last_date = daily_df.index[-1]
    return (last_date - last_split_date).days


# ============ LAYER 1 — LIKUIDITAS & KELAYAKAN ============

def check_layer1(daily_df: pd.DataFrame, mode: str, ticker: str, free_float_map: dict,
                  price_ceiling: float | None = None) -> dict:
    checks = {}
    warnings = []
    fails = []

    # --- volume hari ini vs rata-rata 20 hari (basis harian) ---
    if daily_df is None or len(daily_df) < VOLUME_AVG_PERIOD + 1:
        checks["volume_multiple"] = {"status": "unknown"}
        warnings.append("volume_multiple: data harian belum cukup buat dihitung")
    else:
        avg_vol20 = daily_df["Volume"].iloc[-(VOLUME_AVG_PERIOD + 1):-1].mean()
        today_vol = daily_df["Volume"].iloc[-1]
        if not avg_vol20 or pd.isna(avg_vol20):
            checks["volume_multiple"] = {"status": "unknown"}
            warnings.append("volume_multiple: rata-rata volume 0/NaN")
        else:
            ratio = today_vol / avg_vol20
            threshold = SCALP_MIN_VOLUME_MULT if mode == "scalp" else LONG_MIN_VOLUME_MULT
            ok = ratio >= threshold
            checks["volume_multiple"] = {"status": "pass" if ok else "fail",
                                          "ratio": round(float(ratio), 2), "threshold": threshold}
            if not ok:
                fails.append(f"volume hari ini {ratio:.2f}x rata-rata 20 hari (min {threshold}x)")

    # --- rata-rata nilai transaksi 20 hari (Rupiah) ---
    if daily_df is not None and len(daily_df) >= VOLUME_AVG_PERIOD:
        avg_value = (daily_df["Close"] * daily_df["Volume"]).iloc[-VOLUME_AVG_PERIOD:].mean()
        ok = avg_value >= MIN_AVG_VALUE_TRADED
        checks["avg_value_traded"] = {"status": "pass" if ok else "fail", "value": round(float(avg_value))}
        if not ok:
            fails.append(f"rata-rata nilai transaksi 20 hari Rp{avg_value:,.0f} (min Rp{MIN_AVG_VALUE_TRADED:,.0f})")
    else:
        checks["avg_value_traded"] = {"status": "unknown"}
        warnings.append("avg_value_traded: data harian belum cukup")

    # --- free float ---
    code = ticker.replace(".JK", "")
    ff = free_float_map.get(code)
    if ff is None:
        checks["free_float"] = {"status": "unknown"}
        warnings.append("free_float: data gak ada di cache")
        if REQUIRE_FREE_FLOAT_DATA:
            fails.append("free_float: data wajib tapi gak tersedia (REQUIRE_FREE_FLOAT_DATA=True)")
    else:
        ok = ff >= MIN_FREE_FLOAT_PCT
        checks["free_float"] = {"status": "pass" if ok else "fail", "value": ff}
        if not ok:
            fails.append(f"free float {ff}% (min {MIN_FREE_FLOAT_PCT}%)")

    # --- harga (opsional, scalp murah) ---
    if mode == "scalp" and price_ceiling is not None and daily_df is not None and len(daily_df) > 0:
        close = float(daily_df["Close"].iloc[-1])
        ok = close < price_ceiling
        checks["price_ceiling"] = {"status": "pass" if ok else "fail", "close": round(close, 2)}
        if not ok:
            fails.append(f"harga Rp{close:,.0f} di atas batas scalp murah Rp{price_ceiling:,.0f}")

    # --- aksi korporasi: stock split ---
    split_days = get_days_since_last_split(daily_df) if daily_df is not None else None
    if split_days is not None:
        lookback = CORP_ACTION_LOOKBACK_SCALP_DAYS if mode == "scalp" else CORP_ACTION_LOOKBACK_LONG_DAYS
        ok = split_days > lookback
        checks["corp_action_split"] = {"status": "pass" if ok else "fail", "days_since_split": split_days}
        if not ok:
            fails.append(f"ada stock split {split_days} hari lalu, belum aman buat entry (min >{lookback} hari)")
    else:
        checks["corp_action_split"] = {"status": "pass", "days_since_split": None}

    # --- gak otomatis-able dari data OHLCV batch, ditandain manual check ---
    checks["trading_status_suspend"] = {"status": "not_checked"}
    checks["right_issue_waran"] = {"status": "not_checked"}
    checks["bid_ask_spread"] = {"status": "not_checked" if not CHECK_LIVE_SPREAD else "see_live_check"}
    warnings.append("trading_status_suspend & right_issue_waran butuh cek manual (data gak tersedia di yfinance)")
    if not CHECK_LIVE_SPREAD:
        warnings.append("bid_ask_spread gak dihitung dari data historis batch (aktifkan CHECK_LIVE_SPREAD kalau perlu)")

    passed = len(fails) == 0
    return {"passed": passed, "checks": checks, "fails": fails, "warnings": warnings}


# ============ SUPPORT & RESISTANCE ============

def find_pivot_lows(df: pd.DataFrame, left: int = SR_PIVOT_LR, right: int = SR_PIVOT_LR) -> list:
    low = df["Low"]
    n = len(low)
    idxs = []
    for i in range(left, n - right):
        window = low.iloc[i - left:i + right + 1]
        if low.iloc[i] == window.min():
            idxs.append(i)
    return idxs


def find_pivot_highs(df: pd.DataFrame, left: int = SR_PIVOT_LR, right: int = SR_PIVOT_LR) -> list:
    high = df["High"]
    n = len(high)
    idxs = []
    for i in range(left, n - right):
        window = high.iloc[i - left:i + right + 1]
        if high.iloc[i] == window.max():
            idxs.append(i)
    return idxs


def cluster_levels(levels_with_vol: list, tol_pct: float = SR_CLUSTER_TOL_PCT) -> list:
    if not levels_with_vol:
        return []
    items = sorted(levels_with_vol, key=lambda x: x[0])
    clusters = []
    current = [items[0]]
    for item in items[1:]:
        if abs(item[0] - current[-1][0]) / current[-1][0] * 100 <= tol_pct:
            current.append(item)
        else:
            clusters.append(current)
            current = [item]
    clusters.append(current)

    result = []
    for c in clusters:
        prices = [x[0] for x in c]
        vols = [x[1] for x in c]
        result.append({
            "level": float(np.mean(prices)),
            "low_bound": float(min(prices)),
            "high_bound": float(max(prices)),
            "touches": len(c),
            "avg_touch_volume": float(np.mean(vols)),
        })
    return result


def volume_profile_levels(df: pd.DataFrame, bins: int = 20, top_n: int = 3) -> list:
    prices = df["Close"]
    volumes = df["Volume"]
    price_min, price_max = prices.min(), prices.max()
    if price_max <= price_min:
        return []
    bin_edges = np.linspace(price_min, price_max, bins + 1)
    bin_vol = np.zeros(bins)
    bin_idx = np.clip(np.digitize(prices, bin_edges) - 1, 0, bins - 1)
    for idx, vol in zip(bin_idx, volumes):
        bin_vol[idx] += vol
    top_idx = np.argsort(bin_vol)[::-1][:top_n]
    levels = [{"level": float((bin_edges[i] + bin_edges[i + 1]) / 2), "volume": float(bin_vol[i])}
              for i in top_idx]
    return sorted(levels, key=lambda x: -x["volume"])


def classic_pivot_points(daily_df: pd.DataFrame):
    if daily_df is None or len(daily_df) < 2:
        return None
    prev = daily_df.iloc[-2]
    H, L, C = float(prev["High"]), float(prev["Low"]), float(prev["Close"])
    P = (H + L + C) / 3
    return {"pivot": round(P, 2), "r1": round(2 * P - L, 2), "r2": round(P + (H - L), 2),
            "s1": round(2 * P - H, 2), "s2": round(P - (H - L), 2)}


def nearest_round_numbers(price: float) -> dict:
    if price >= 5000:
        step = 500
    elif price >= 1000:
        step = 100
    elif price >= 200:
        step = 50
    else:
        step = 25
    lower = (price // step) * step
    return {"lower": lower, "upper": lower + step, "step": step}


def previous_day_high_low(daily_df: pd.DataFrame):
    if daily_df is None or len(daily_df) < 2:
        return None
    prev = daily_df.iloc[-2]
    return {"high": float(prev["High"]), "low": float(prev["Low"])}


def detect_support_resistance(df: pd.DataFrame, daily_df: pd.DataFrame,
                               lookback: int = SR_LOOKBACK_BARS) -> dict:
    recent = df.iloc[-lookback:] if len(df) > lookback else df
    low_idxs = find_pivot_lows(recent)
    high_idxs = find_pivot_highs(recent)

    support_raw = [(float(recent["Low"].iloc[i]), float(recent["Volume"].iloc[i])) for i in low_idxs]
    resistance_raw = [(float(recent["High"].iloc[i]), float(recent["Volume"].iloc[i])) for i in high_idxs]

    support_zones = cluster_levels(support_raw)
    resistance_zones = cluster_levels(resistance_raw)

    for z in support_zones + resistance_zones:
        z["strength"] = "kuat" if z["touches"] >= SR_STRONG_TOUCH else (
            "cukup" if z["touches"] >= SR_MIN_TOUCH_USABLE else "lemah")

    return {
        "support_zones": sorted(support_zones, key=lambda z: -z["level"]),
        "resistance_zones": sorted(resistance_zones, key=lambda z: z["level"]),
        "volume_profile": volume_profile_levels(recent),
        "pivot_points": classic_pivot_points(daily_df),
        "prev_day": previous_day_high_low(daily_df),
        "round_numbers": nearest_round_numbers(float(df["Close"].iloc[-1])),
    }


# ============ LAYER 2 — STRUKTUR TEKNIKAL (3 dari 5) ============

def score_trend(pdf, trend_strong_pct=3, trend_moderate_pct=1, trend_at_sma_band_pct=1, trend_max_points=25):
    """Score trend berdasarkan posisi harga terhadap SMA50 (standalone function)."""
    if pdf is None or "sma_trend" not in pdf.columns or pd.isna(pdf["sma_trend"].iloc[-1]):
        return {"points": 0, "max_points": trend_max_points, "reason": "SMA50 data not enough"}

    sma50 = float(pdf["sma_trend"].iloc[-1])
    close = float(pdf["Close"].iloc[-1])
    diff_pct = (close - sma50) / sma50 * 100

    # 5 zone system dengan poin berbeda
    if diff_pct < -trend_at_sma_band_pct:
        points = 0
        zone = "below_sma50"
    elif -trend_at_sma_band_pct <= diff_pct <= trend_at_sma_band_pct:
        points = 15
        zone = "at_sma50_band"
    elif trend_at_sma_band_pct < diff_pct <= trend_moderate_pct:
        points = 20
        zone = "near_sma50_above"
    elif trend_moderate_pct < diff_pct <= trend_strong_pct:
        points = 25
        zone = "healthy_uptrend"
    else:  # diff_pct > trend_strong_pct
        points = 12
        zone = "extended_above_sma50"

    return {
        "points": points,
        "max_points": trend_max_points,
        "sma50": round(sma50, 2),
        "close": round(close, 2),
        "diff_pct": round(diff_pct, 2),
        "zone": zone
    }


def score_entry_timing(pdf, ema_slope_lookback=3, ema_flat_threshold_pct=0.15, entry_max_points=20):
    """Score entry timing berdasarkan EMA9/21 cross & slope (standalone function)."""
    if len(pdf) < ema_slope_lookback + 1:
        return {"points": 0, "max_points": entry_max_points, "reason": "EMA data not enough"}

    ema9 = float(pdf["ema_fast"].iloc[-1])
    ema21 = float(pdf["ema_slow"].iloc[-1])
    close = float(pdf["Close"].iloc[-1])
    if pd.isna(ema9) or pd.isna(ema21):
        return {"points": 0, "max_points": entry_max_points, "reason": "EMA data not enough"}

    ema9_prev = float(pdf["ema_fast"].iloc[-1 - ema_slope_lookback])
    ema21_prev = float(pdf["ema_slow"].iloc[-1 - ema_slope_lookback])

    ema9_slope = ((ema9 - ema9_prev) / ema9_prev * 100) if ema9_prev else 0
    ema21_slope = ((ema21 - ema21_prev) / ema21_prev * 100) if ema21_prev else 0

    # 5 setup system dengan poin berbeda
    if close < ema21 and ema9 < ema21:
        points = 0
        setup = "below_ema21"
    elif close <= ema21 * (1 + 0.01) and ema9 > ema9_prev and ema21_slope >= 0:
        points = 20
        setup = "pullback_to_ema21_bounce"
    elif ema21 < close <= ema9 and ema9_slope > ema_flat_threshold_pct:
        points = 18
        setup = "between_ema21_ema9"
    elif close > ema9 and ema9_slope > ema_flat_threshold_pct and ema21_slope > ema_flat_threshold_pct:
        points = 14
        setup = "chasing_above_ema9"
    else:
        points = 6
        setup = "weak_or_flat"

    return {
        "points": points,
        "max_points": entry_max_points,
        "ema9": round(ema9, 2),
        "ema21": round(ema21, 2),
        "close": round(close, 2),
        "ema9_slope": round(ema9_slope, 2),
        "ema21_slope": round(ema21_slope, 2),
        "setup": setup
    }


def score_volume_spike(pdf, volume_lookback_bars=3, volume_tiers=None, volume_max_points=20):
    """Score volume spike (standalone function)."""
    if volume_tiers is None:
        volume_tiers = [(80, 20), (60, 17), (40, 14), (20, 8)]

    recent = pdf.iloc[-volume_lookback_bars:]
    if recent["vol_ratio"].isna().all():
        return {"points": 0, "max_points": volume_max_points, "reason": "volume data not enough"}

    best_i = recent["vol_ratio"].idxmax()
    ratio = recent.loc[best_i, "vol_ratio"]
    if pd.isna(ratio):
        return {"points": 0, "max_points": volume_max_points, "reason": "volume data not enough"}

    is_green = bool(recent.loc[best_i, "is_green"])
    spike_pct = float((ratio - 1) * 100)

    if not is_green:
        points = 0  # candle merah = distribusi
    else:
        for min_pct, pts in volume_tiers:
            if spike_pct >= min_pct:
                points = pts
                break
        else:
            points = 0

    return {
        "points": points,
        "max_points": volume_max_points,
        "spike_pct": round(spike_pct, 1)
    }


def score_rsi(pdf, rsi_tiers=None, rsi_max_points=20):
    """Score RSI momentum (standalone function)."""
    if rsi_tiers is None:
        rsi_tiers = [((46, 55), 20), ((56, 65), 15), ((30, 45), 10), ((66, 75), 8)]

    rsi_val = pdf["rsi"].iloc[-1]
    if pd.isna(rsi_val):
        return {"points": 0, "max_points": rsi_max_points, "reason": "RSI data not enough"}
    rsi_val = float(rsi_val)

    for (lo, hi), pts in rsi_tiers:
        if lo <= rsi_val <= hi:
            points = pts
            break
    else:
        points = 0

    return {
        "points": points,
        "max_points": rsi_max_points,
        "rsi": round(rsi_val, 1)
    }


def score_price_action(pdf, price_action_max_points=15):
    """Score price action 3 bar terakhir (standalone function)."""
    if len(pdf) < 4:
        return {"points": 0, "max_points": price_action_max_points, "reason": "price action data not enough"}

    last3 = pdf.iloc[-3:]
    lows = last3["Low"].values
    highs = last3["High"].values
    greens = last3["is_green"].values

    asc_count = int(lows[0] < lows[1]) + int(lows[1] < lows[2])
    desc_count = int(highs[0] > highs[1]) + int(highs[1] > highs[2])

    cur = pdf.iloc[-1]
    prev = pdf.iloc[-2]
    bearish_engulf = bool(prev["is_green"]) and (not bool(cur["is_green"])) and \
        cur["Open"] >= prev["Close"] and cur["Close"] <= prev["Open"]
    is_doji = pd.notna(cur["body_ratio"]) and cur["body_ratio"] < 0.15
    last_bar_green = bool(greens[-1])

    if asc_count == 2 and bool(greens.all()):
        points = 15
        pattern = "3bar_higher_low_all_green"
    elif desc_count == 2 or bearish_engulf:
        points = 0
        pattern = "lower_high_or_bearish_engulfing"
    elif asc_count >= 1 and last_bar_green:
        points = 12
        pattern = "partial_higher_low_last_green"
    elif asc_count >= 1 and is_doji:
        points = 8
        pattern = "higher_low_with_doji"
    else:
        points = 3
        pattern = "no_clear_pattern"

    return {
        "points": points,
        "max_points": price_action_max_points,
        "pattern": pattern
    }


# Fungsi-fungsi lama sudah dihapus, diganti dengan versi baru di atas


def score_action_label(total_score: float, mode: str) -> dict:
    if total_score >= 85:
        return {"tier": "85-100", "action": "Eksekusi penuh"}
    elif total_score >= 70:
        action = "Hati-hati — pastikan bid spread ≤1%" if mode == "scalp" else "Boleh entry, TP lebih dekat"
        return {"tier": "70-84", "action": action}
    elif total_score >= 55:
        action = ("Mungkin kalau 3 indikator bobot tinggi + kamu expert — tapi disaranin skip"
                  if mode == "scalp" else "Skip kecuali daily trend sangat bullish")
        return {"tier": "55-69", "action": action}
    else:
        return {"tier": "<55", "action": "Jangan entry"}


def scoring_teknikal(
    pdf,
    mode="long",
    max_score=100,
    min_score=70,
    trend_strong_pct=3,
    trend_moderate_pct=1,
    trend_at_sma_band_pct=1,
    trend_max_points=25,
    ema_slope_lookback=3,
    ema_flat_threshold_pct=0.15,
    entry_max_points=20,
    volume_lookback_bars=3,
    volume_tiers=None,
    volume_max_points=20,
    rsi_tiers=None,
    rsi_max_points=20,
    price_action_max_points=15,
    min_layer1_pass=True
):
    """
    Layer 2 — Weighted Scoring dengan Hard Gates & Penalties.

    Sistem yang lebih ketat:
    - Hard gates: indikator yang 0 poin = automatic fail
    - Penalties: pengurangan skor untuk setup yang kurang ideal
    - Total score dikap di 0-100

    Returns:
    - dict: {total_score, max_score, min_required, passed, hard_fails, indicators, action_label}
    """
    if volume_tiers is None:
        volume_tiers = [(80, 20), (60, 17), (40, 14), (20, 8)]

    if rsi_tiers is None:
        rsi_tiers = [((46, 55), 20), ((56, 65), 15), ((30, 45), 10), ((66, 75), 8)]

    indicators = {}

    # ---- layer 2: trend / entry / volume / rsi / price action ----
    indicators["trend_sma50"] = score_trend(
        pdf,
        trend_strong_pct=trend_strong_pct,
        trend_moderate_pct=trend_moderate_pct,
        trend_at_sma_band_pct=trend_at_sma_band_pct,
        trend_max_points=trend_max_points,
    )

    indicators["entry_timing_ema"] = score_entry_timing(
        pdf,
        ema_slope_lookback=ema_slope_lookback,
        ema_flat_threshold_pct=ema_flat_threshold_pct,
        entry_max_points=entry_max_points,
    )

    indicators["volume_spike"] = score_volume_spike(
        pdf,
        volume_lookback_bars=volume_lookback_bars,
        volume_tiers=volume_tiers,
        volume_max_points=volume_max_points,
    )

    indicators["rsi_momentum"] = score_rsi(
        pdf,
        rsi_tiers=rsi_tiers,
        rsi_max_points=rsi_max_points,
    )

    indicators["price_action"] = score_price_action(
        pdf,
        price_action_max_points=price_action_max_points,
    )

    # ---- hard gates ----
    hard_fails = []

    if indicators["trend_sma50"]["points"] <= 0:
        hard_fails.append("trend_fail")

    if indicators["entry_timing_ema"]["points"] <= 0:
        hard_fails.append("entry_fail")

    if indicators["volume_spike"]["points"] <= 0:
        hard_fails.append("volume_fail")

    if indicators["price_action"]["points"] <= 0:
        hard_fails.append("price_action_fail")

    if indicators["rsi_momentum"]["points"] <= 0:
        hard_fails.append("rsi_fail")

    layer1_ok = bool(min_layer1_pass)

    # ---- total score ----
    total_score = sum(v["points"] for v in indicators.values())

    # ---- penalties for weak setups ----
    if len(hard_fails) >= 2:
        total_score -= 15
    elif len(hard_fails) == 1:
        total_score -= 8

    # if trend is extended above sma50, cap the score a bit
    if indicators["trend_sma50"].get("zone") == "extended_above_sma50":
        total_score -= 5

    # if entry is chasing above ema9, cap it
    if indicators["entry_timing_ema"].get("setup") == "chasing_above_ema9":
        total_score -= 4

    # clamp score
    total_score = max(0, min(max_score, int(round(total_score))))

    passed = layer1_ok and total_score >= min_score and len(hard_fails) == 0
    action_label = score_action_label(total_score, mode)

    return {
        "total_score": total_score,
        "max_score": max_score,
        "min_required": min_score,
        "passed": passed,
        "hard_fails": hard_fails,
        "indicators": indicators,
        "action_label": action_label,
    }


def debug_result_to_text(result, symbol=None, mode="long"):
    """Generate debug text output dari hasil scoring."""
    lines = []

    header = f"[DEBUG SCORE] {symbol or '-'} | mode={mode}"
    lines.append(header)
    lines.append(f"total_score: {result.get('total_score')} / {result.get('max_score')} | min_required: {result.get('min_required')} | passed: {result.get('passed')}")
    lines.append(f"action: {result.get('action_label', {}).get('tier')} - {result.get('action_label', {}).get('action')}")

    hard_fails = result.get("hard_fails", [])
    if hard_fails:
        lines.append(f"hard_fails: {', '.join(hard_fails)}")
    else:
        lines.append("hard_fails: none")

    lines.append("")

    indicators = result.get("indicators", {})
    order = ["trend_sma50", "entry_timing_ema", "volume_spike", "rsi_momentum", "price_action"]

    for key in order:
        item = indicators.get(key, {})
        points = item.get("points", 0)
        max_points = item.get("max_points", 0)
        status = "PASS" if points > 0 else "FAIL"

        lines.append(f"- {key}: {status} | {points}/{max_points}")

        if key == "trend_sma50":
            lines.append(f"  close={item.get('close')} sma50={item.get('sma50')} diff_pct={item.get('diff_pct')} zone={item.get('zone')}")
        elif key == "entry_timing_ema":
            lines.append(f"  close={item.get('close')} ema9={item.get('ema9')} ema21={item.get('ema21')} ema9_slope={item.get('ema9_slope')} ema21_slope={item.get('ema21_slope')} setup={item.get('setup')}")
        elif key == "volume_spike":
            lines.append(f"  spike_pct={item.get('spike_pct')}")
        elif key == "rsi_momentum":
            lines.append(f"  rsi={item.get('rsi')}")
        elif key == "price_action":
            lines.append(f"  pattern={item.get('pattern')}")

    return "\n".join(lines)


# Legacy alias for backward compatibility
def layer2_weighted_score(pdf: pd.DataFrame, daily_df: pd.DataFrame, mode: str) -> dict:
    """Legacy function - use scoring_teknikal instead."""
    return scoring_teknikal(pdf, daily_df, mode)


def debug_checklist(result):
    """
    Generate debug checklist dari hasil analisa untuk troubleshooting.

    Parameters:
    - result: dict output dari fungsi analyze()

    Returns:
    - dict: checklist dengan detail setiap layer dan indikator
    """
    checklist = {
        "layer1_liquidity": {
            "pass": result.get("layer1", {}).get("passed", False),
            "checks": result.get("layer1", {}).get("checks", {}),
            "fails": result.get("layer1", {}).get("fails", []),
            "warnings": result.get("layer1", {}).get("warnings", [])
        },
        "layer2_trend": {
            "pass": result.get("layer2", {}).get("indicators", {}).get("trend_sma50", {}).get("points", 0) > 0,
            "points": result.get("layer2", {}).get("indicators", {}).get("trend_sma50", {}).get("points", 0),
            "zone": result.get("layer2", {}).get("indicators", {}).get("trend_sma50", {}).get("zone", "")
        },
        "layer2_entry": {
            "pass": result.get("layer2", {}).get("indicators", {}).get("entry_timing_ema", {}).get("points", 0) >= 15,
            "points": result.get("layer2", {}).get("indicators", {}).get("entry_timing_ema", {}).get("points", 0),
            "setup": result.get("layer2", {}).get("indicators", {}).get("entry_timing_ema", {}).get("setup", "")
        },
        "layer2_volume": {
            "pass": result.get("layer2", {}).get("indicators", {}).get("volume_spike", {}).get("points", 0) >= 14,
            "points": result.get("layer2", {}).get("indicators", {}).get("volume_spike", {}).get("points", 0),
            "spike_pct": result.get("layer2", {}).get("indicators", {}).get("volume_spike", {}).get("spike_pct", None)
        },
        "layer2_rsi": {
            "pass": result.get("layer2", {}).get("indicators", {}).get("rsi_momentum", {}).get("points", 0) >= 15,
            "points": result.get("layer2", {}).get("indicators", {}).get("rsi_momentum", {}).get("points", 0),
            "rsi": result.get("layer2", {}).get("indicators", {}).get("rsi_momentum", {}).get("rsi", None)
        },
        "layer2_price_action": {
            "pass": result.get("layer2", {}).get("indicators", {}).get("price_action", {}).get("points", 0) >= 8,
            "points": result.get("layer2", {}).get("indicators", {}).get("price_action", {}).get("points", 0),
            "pattern": result.get("layer2", {}).get("indicators", {}).get("price_action", {}).get("pattern", "")
        },
        "layer3_total_score": {
            "pass": result.get("status") == "candidate",
            "total_score": result.get("score", 0),
            "min_score": result.get("min_required", 70),
            "action": result.get("action_label", {}).get("action", "")
        }
    }
    return checklist


# ============ LAYER 3 — SENTIMEN (IHSG otomatis, sisanya manual) ============

def fetch_ihsg_change_pct():
    try:
        idx = yf.download(IHSG_TICKER, period="5d", interval="1d", progress=False)
        if idx is None or len(idx) < 2:
            return None
        prev_close = float(idx["Close"].iloc[-2])
        last_close = float(idx["Close"].iloc[-1])
        return (last_close - prev_close) / prev_close * 100
    except Exception as e:
        print(f"[WARN] gagal ambil data IHSG: {e}")
        return None


def layer3_context(ihsg_change_pct) -> dict:
    if ihsg_change_pct is None:
        return {"ihsg_checked": False, "note": "data IHSG gak tersedia",
                "warning": "Berita negatif, right issue/waran, saham viral tetap butuh cek manual."}
    is_red = ihsg_change_pct < 0
    return {
        "ihsg_checked": True,
        "ihsg_change_pct": round(ihsg_change_pct, 2),
        "ihsg_red": is_red,
        "note": "IHSG merah - ketatin SL / cari saham defensif" if is_red else "IHSG hijau/flat",
        "warning": "Berita negatif, right issue/waran, saham viral tetap butuh cek manual.",
    }


# ============ ENTRY & STOP LOSS ============

def suggest_entry(pdf: pd.DataFrame, ddf: pd.DataFrame, sr_info: dict, mode: str):
    close = float(pdf["Close"].iloc[-1])
    tick = estimate_tick_size(close)

    # Prioritas 1: support zone teruji (minimal 2x touch)
    for z in sr_info.get("support_zones", []):
        if z["touches"] >= SR_MIN_TOUCH_USABLE and z["low_bound"] * 0.98 <= close <= z["high_bound"] * 1.02:
            entry_price = z["high_bound"] + tick
            return {"priority": 1, "method": "support_zone",
                    "entry_price": round(entry_price, 2), "zone": z}

    # Prioritas 2: MA pullback (EMA21 scalp / SMA50 daily long)
    if mode == "scalp":
        ma_value = pdf["ema_slow"].iloc[-1]
    else:
        ma_value = ddf["sma_trend"].iloc[-1] if ddf is not None and "sma_trend" in ddf.columns else None

    rsi_now = pdf["rsi"].iloc[-1]
    rsi_prev = pdf["rsi"].iloc[-2] if len(pdf) > 1 else np.nan
    rsi_rising = pd.notna(rsi_now) and pd.notna(rsi_prev) and rsi_now > rsi_prev

    if ma_value is not None and pd.notna(ma_value):
        near_ma = abs(close - float(ma_value)) / float(ma_value) * 100 <= 1.0
        if near_ma and rsi_rising:
            return {"priority": 2, "method": "ma_pullback",
                    "entry_price": round(close, 2), "ma_value": round(float(ma_value), 2)}

    # Prioritas 3: Fibonacci retracement
    lookback = pdf.iloc[-FIB_LOOKBACK_BARS:] if len(pdf) > FIB_LOOKBACK_BARS else pdf
    swing_low = float(lookback["Low"].min())
    swing_high = float(lookback["High"].max())
    if swing_high > swing_low:
        diff = swing_high - swing_low
        if mode == "scalp":
            fib_level = swing_high - diff * FIB_SCALP_LEVEL
            if abs(close - fib_level) / fib_level * 100 <= 1.5:
                return {"priority": 3, "method": f"fibonacci_{FIB_SCALP_LEVEL}",
                        "entry_price": round(close, 2), "fib_level": round(fib_level, 2)}
        else:
            for r in FIB_LONG_LEVELS:
                fib_level = swing_high - diff * r
                if abs(close - fib_level) / fib_level * 100 <= 1.5:
                    return {"priority": 3, "method": f"fibonacci_{r}",
                            "entry_price": round(close, 2), "fib_level": round(fib_level, 2)}

    return None


def suggest_stop_loss(pdf: pd.DataFrame, sr_info: dict, mode: str, entry_price: float):
    close_price = entry_price if entry_price else float(pdf["Close"].iloc[-1])
    tick = estimate_tick_size(close_price)

    if mode == "scalp":
        lookback = pdf.iloc[-SL_SCALP_LOOKBACK_BARS:]
        swing_low_idx = lookback["Low"].idxmin()
        wick_low = float(lookback.loc[swing_low_idx, "Low"])
        sl = wick_low - tick
        dist_pct = (close_price - sl) / close_price * 100
        too_far = dist_pct > SL_SCALP_MAX_PCT
        return {"sl_price": round(sl, 2), "basis": "wick_low_swing",
                "distance_pct": round(dist_pct, 2), "max_allowed_pct": SL_SCALP_MAX_PCT,
                "too_far": too_far,
                "note": "jarak SL > 2%, tunggu pullback lebih deket dulu" if too_far else None}
    else:
        zones = sr_info.get("support_zones", [])
        if not zones:
            return None
        nearest = min(zones, key=lambda z: abs(close_price - z["level"]))
        sl = nearest["low_bound"] * (1 - SL_LONG_ZONE_BUFFER_PCT / 100)
        dist_pct = (close_price - sl) / close_price * 100
        lo, hi = SL_LONG_PCT_RANGE
        out_of_range = not (lo <= dist_pct <= hi)
        return {"sl_price": round(sl, 2), "basis": "demand_zone", "zone": nearest,
                "distance_pct": round(dist_pct, 2), "normal_range_pct": SL_LONG_PCT_RANGE,
                "out_of_normal_range": out_of_range}


# ============ ANALYZE — INTI SISTEM ============

def analyze(primary_df: pd.DataFrame, daily_df: pd.DataFrame, ticker: str, mode: str,
            free_float_map: dict, ihsg_change_pct=None, price_ceiling: float | None = None) -> dict | None:
    """
    primary_df : OHLCV di timeframe utama (30M-proxy buat scalp, 1D/1W buat long)
    daily_df   : OHLCV harian (dipakai buat trend filter SMA50 & Layer 1),
                 sama dengan primary_df kalau TIMEFRAME sudah "1d"
    """
    if primary_df is None or primary_df.empty or daily_df is None or daily_df.empty:
        return None

    primary_df = primary_df.dropna(subset=["Open", "High", "Low", "Close", "Volume"])
    daily_df = daily_df.dropna(subset=["Open", "High", "Low", "Close", "Volume"])
    if len(primary_df) < 60 or len(daily_df) < 60:
        return None

    if primary_df["Volume"].iloc[-1] < MIN_VOLUME:
        return None

    pdf = compute_common_indicators(primary_df)
    ddf = compute_trend_sma(daily_df)

    # Gabungkan SMA50 dari daily_df ke pdf untuk scoring
    pdf["sma_trend"] = ddf["sma_trend"]

    # --- LAYER 1: gerbang pertama, gak lolos -> berhenti ---
    layer1 = check_layer1(ddf, mode, ticker, free_float_map, price_ceiling)
    if not layer1["passed"]:
        return {"ticker": ticker, "mode": mode, "status": "rejected_layer1", "layer1": layer1}

    # --- LAYER 2: bobot skor 0-100, minimal 70 buat lolos (dengan hard gates & penalties) ---
    sr_info = detect_support_resistance(pdf, ddf)
    layer2 = scoring_teknikal(pdf, mode, min_layer1_pass=layer1["passed"])
    if not layer2["passed"]:
        return {"ticker": ticker, "mode": mode, "status": "rejected_layer2",
                "close": round(float(pdf["Close"].iloc[-1]), 2),
                "layer1": layer1, "layer2": layer2}

    # --- LAYER 3: sentimen (IHSG otomatis, sisanya flag manual) ---
    layer3 = layer3_context(ihsg_change_pct)

    # --- entry & SL ---
    entry = suggest_entry(pdf, ddf, sr_info, mode)
    entry_price = entry["entry_price"] if entry else float(pdf["Close"].iloc[-1])
    stop_loss = suggest_stop_loss(pdf, sr_info, mode, entry_price)

    return {
        "ticker": ticker,
        "mode": mode,
        "status": "candidate",
        "close": round(float(pdf["Close"].iloc[-1]), 2),
        "rsi": layer2["indicators"]["rsi_momentum"].get("rsi"),
        "score": layer2["total_score"],
        "max_score": layer2["max_score"],
        "min_required": layer2["min_required"],
        "action_label": layer2["action_label"],
        "layer1": layer1,
        "layer2": layer2,
        "layer3": layer3,
        "support_resistance": sr_info,
        "entry": entry,
        "stop_loss": stop_loss,
        "last_updated": str(pdf.index[-1]),
        "hard_fails": layer2.get("hard_fails", []),
        "filters": layer1,  # Alias untuk consistency dengan debug_checklist
    }


# ============ FETCH & SCREENING ============

def extract_ticker_df(raw: pd.DataFrame, ticker: str):
    if raw is None or raw.empty:
        return None
    try:
        if isinstance(raw.columns, pd.MultiIndex):
            if ticker not in raw.columns.get_level_values(0):
                return None
            df = raw[ticker].copy()
        else:
            df = raw.copy()
        return df.dropna(how="all")
    except Exception:
        return None


def make_chart(pdf_raw: pd.DataFrame, ticker: str, result: dict):
    os.makedirs(CHARTS_DIR, exist_ok=True)
    plot_df = pdf_raw.iloc[-CHART_LOOKBACK_CANDLES:].copy()
    plot_df = compute_common_indicators(plot_df)

    apds = [
        mpf.make_addplot(plot_df["ema_fast"], color="dodgerblue", width=1.0),
        mpf.make_addplot(plot_df["ema_slow"], color="orange", width=1.0),
    ]

    hlines_prices, hlines_colors = [], []
    entry = result.get("entry")
    sl = result.get("stop_loss")
    if entry and entry.get("entry_price"):
        hlines_prices.append(entry["entry_price"])
        hlines_colors.append("green")
    if sl and sl.get("sl_price"):
        hlines_prices.append(sl["sl_price"])
        hlines_colors.append("red")

    fname = f"{ticker.replace('.JK', '')}_{result['mode']}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png"
    fpath = os.path.join(CHARTS_DIR, fname)

    kwargs = dict(
        type="candle", style="yahoo", addplot=apds, volume=True,
        title=f"{ticker} | score {result['score']}/{result['max_score']} | {result['mode']}",
        savefig=dict(fname=fpath, dpi=110, bbox_inches="tight"),
    )
    if hlines_prices:
        kwargs["hlines"] = dict(hlines=hlines_prices, colors=hlines_colors,
                                 linestyle="--", linewidths=0.8)

    mpf.plot(plot_df, **kwargs)
    return fpath


def screen_batch(tickers: list, interval: str, period: str, mode: str,
                  free_float_map: dict, ihsg_change_pct, price_ceiling=None) -> list:
    results = []
    try:
        raw = yf.download(tickers, period=period, interval=interval, group_by="ticker",
                           threads=True, progress=False, auto_adjust=False, actions=False)
    except Exception as e:
        print(f"[ERROR] gagal fetch batch primary ({interval}): {e}")
        return results

    need_daily_fetch = interval != "1d"
    daily_raw = None
    if need_daily_fetch:
        try:
            daily_raw = yf.download(tickers, period=DAILY_REF_PERIOD, interval="1d", group_by="ticker",
                                     threads=True, progress=False, auto_adjust=False, actions=True)
        except Exception as e:
            print(f"[ERROR] gagal fetch batch harian (trend filter): {e}")

    for ticker in tickers:
        try:
            pdf_raw = extract_ticker_df(raw, ticker)
            if pdf_raw is None or pdf_raw.empty:
                continue

            ddf_raw = extract_ticker_df(daily_raw, ticker) if need_daily_fetch else pdf_raw
            if ddf_raw is None or ddf_raw.empty:
                continue

            result = analyze(pdf_raw, ddf_raw, ticker, mode, free_float_map, ihsg_change_pct, price_ceiling)
            if result is None or result.get("status") != "candidate":
                continue

            try:
                result["chart_path"] = make_chart(pdf_raw, ticker, result)
            except Exception as e:
                print(f"[WARN] gagal bikin chart {ticker}: {e}")
                result["chart_path"] = None

            results.append(result)
        except Exception as e:
            print(f"[ERROR] analisa {ticker} gagal: {e}")

    return results


def screen_all(tickers: list, timeframe: str, free_float_map: dict) -> list:
    cfg = TIMEFRAME_CONFIG[timeframe]
    mode = "scalp" if timeframe == "1h" else "long"
    ihsg_change_pct = fetch_ihsg_change_pct()

    all_results = []
    batches = list(chunk(tickers, BATCH_SIZE))
    for i, batch in enumerate(batches, 1):
        print(f"[INFO] Batch {i}/{len(batches)} ({len(batch)} ticker, mode={mode})...")
        all_results.extend(screen_batch(batch, cfg["interval"], cfg["period"], mode,
                                         free_float_map, ihsg_change_pct, SCALP_PRICE_CEILING))
        if i < len(batches):
            time.sleep(BATCH_DELAY_SEC)

    return sorted(all_results, key=lambda x: (-x["score"], x["rsi"] if x["rsi"] is not None else 999))


# ============ TELEGRAM ============

def send_telegram_photo(photo_path: str, caption: str):
    if not TELEGRAM_BOT_TOKEN or "ISI_TOKEN" in TELEGRAM_BOT_TOKEN:
        print("[WARN] Telegram token belum diisi, skip kirim chart.")
        return
    url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendPhoto"
    try:
        with open(photo_path, "rb") as f:
            resp = requests.post(url, data={"chat_id": TELEGRAM_CHAT_ID, "caption": caption,
                                             "parse_mode": "Markdown"},
                                  files={"photo": f}, timeout=30)
        if resp.status_code != 200:
            print(f"[WARN] Telegram sendPhoto gagal ({resp.status_code}): {resp.text}")
    except Exception as e:
        print(f"[WARN] Telegram sendPhoto error: {e}")


def send_telegram_message(text: str):
    if not TELEGRAM_BOT_TOKEN or "ISI_TOKEN" in TELEGRAM_BOT_TOKEN:
        return
    url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage"
    try:
        requests.post(url, data={"chat_id": TELEGRAM_CHAT_ID, "text": text, "parse_mode": "Markdown"}, timeout=30)
    except Exception as e:
        print(f"[WARN] Telegram sendMessage error: {e}")


def format_caption(r: dict) -> str:
    lines = [f"*{r['ticker']}* ({r['mode']}) — Skor {r['score']}/{r['max_score']} "
             f"({r['action_label']['action']})",
             f"Close: Rp{r['close']:,.0f} | RSI: {r['rsi']}"]
    if r.get("entry"):
        lines.append(f"Entry: Rp{r['entry']['entry_price']:,.0f} ({r['entry']['method']})")
    else:
        lines.append("Entry: belum ada setup valid (support/MA pullback/fib)")
    if r.get("stop_loss"):
        sl = r["stop_loss"]
        flag = " ⚠️" if sl.get("too_far") or sl.get("out_of_normal_range") else ""
        lines.append(f"SL: Rp{sl['sl_price']:,.0f} ({sl['distance_pct']}%){flag}")
    if r.get("layer3", {}).get("ihsg_red"):
        lines.append("⚠️ IHSG merah — ketatin risk")
    lines.append("_Cek manual: berita, right issue/waran, status suspensi._")
    return "\n".join(lines)


# ============ ORKESTRASI ============

def run_screening():
    tickers = load_tickers()
    free_float_map = load_free_float()
    mode = "scalp" if TIMEFRAME == "1h" else "long"
    print(f"[INFO] Scanning {len(tickers)} saham, timeframe={TIMEFRAME} (mode={mode}), "
          f"free_float_data={len(free_float_map)} saham, min_avg_value={MIN_AVG_VALUE_TRADED:,.0f}...")

    results = screen_all(tickers, TIMEFRAME, free_float_map)
    print(f"[DONE] {len(results)} saham lolos Layer 1+2. Chart tersimpan di folder {CHARTS_DIR}/.")

    if not results:
        return

    sent = 0
    for r in results:
        caption = format_caption(r)
        print("\n" + caption)
        if SEND_CHART_TO_TELEGRAM and r.get("chart_path") and sent < MAX_CHARTS_PER_RUN:
            send_telegram_photo(r["chart_path"], caption)
            sent += 1
        elif sent >= MAX_CHARTS_PER_RUN:
            print(f"[INFO] Sudah kirim {MAX_CHARTS_PER_RUN} chart, sisanya cek folder {CHARTS_DIR}/ lokal.")


def main_once():
    if not is_within_schedule():
        return
    run_screening()


def seconds_until_next_hour_mark() -> float:
    now = datetime.now(ZoneInfo("Asia/Jakarta"))
    next_hour = (now.replace(minute=0, second=0, microsecond=0) + pd.Timedelta(hours=1))
    return (next_hour - now).total_seconds()


def main_loop():
    print(f"[INFO] Self-loop aktif (align ke jam bulat). "
          f"Jendela jalan: {RUN_HOUR_START}:00-{RUN_HOUR_END}:00 WIB, hari {RUN_DAYS}.")

    while True:
        now = datetime.now(ZoneInfo("Asia/Jakarta"))
        if is_within_schedule():
            try:
                run_screening()
            except Exception as e:
                print(f"[ERROR] Screening gagal: {e}")
        else:
            print(f"[IDLE] {now.strftime('%Y-%m-%d %H:%M:%S')} WIB - di luar jendela jadwal.")

        sleep_sec = seconds_until_next_hour_mark()
        next_run = now + pd.Timedelta(seconds=sleep_sec)
        print(f"[INFO] Tidur sampai {next_run.strftime('%H:%M:%S')} WIB ({int(sleep_sec)}s)...")
        time.sleep(sleep_sec)


if __name__ == "__main__":
    if SELF_LOOP:
        main_loop()
    else:
        main_once()